ecmaVersion: 'latest',
sourceType: 'module',
},
+ "globals": {
+ JSX: "readonly"
+ },
plugins: [
'react',
'@typescript-eslint'
"browser-encrypt-attachment": "0.3.0",
"classnames": "2.3.2",
"dateformat": "5.0.3",
+ "dayjs": "1.11.10",
"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.3.0",
+ "folds": "1.5.0",
"formik": "2.2.9",
- "html-react-parser": "3.0.4",
+ "html-react-parser": "4.2.0",
"immer": "9.0.16",
"is-hotkey": "0.2.0",
"jotai": "1.12.0",
"katex": "0.16.4",
"linkify-html": "4.0.2",
+ "linkify-react": "4.1.1",
"linkifyjs": "4.0.2",
"matrix-js-sdk": "24.1.0",
"millify": "6.1.0",
+ "pdfjs-dist": "3.10.111",
+ "prismjs": "1.29.0",
"prop-types": "15.8.1",
"react": "17.0.2",
"react-autosize-textarea": "7.1.0",
"react-dnd": "15.1.2",
"react-dnd-html5-backend": "15.1.3",
"react-dom": "17.0.2",
+ "react-error-boundary": "4.0.10",
"react-google-recaptcha": "2.1.0",
"react-modal": "3.16.1",
+ "react-range": "1.8.14",
"sanitize-html": "2.8.0",
"slate": "0.90.0",
"slate-history": "0.93.0",
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1",
+ "@types/file-saver": "2.0.5",
"@types/node": "18.11.18",
+ "@types/prismjs": "1.26.0",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.9",
+ "@types/sanitize-html": "2.9.0",
"@types/ua-parser-js": "0.7.36",
"@typescript-eslint/eslint-plugin": "5.46.1",
"@typescript-eslint/parser": "5.46.1",
"react-dom": "16.14.0"
}
},
+ "node_modules/@mapbox/node-pre-gyp": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
+ "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "https-proxy-agent": "^5.0.0",
+ "make-dir": "^3.1.0",
+ "node-fetch": "^2.6.7",
+ "nopt": "^5.0.0",
+ "npmlog": "^5.0.1",
+ "rimraf": "^3.0.2",
+ "semver": "^7.3.5",
+ "tar": "^6.1.11"
+ },
+ "bin": {
+ "node-pre-gyp": "bin/node-pre-gyp"
+ }
+ },
+ "node_modules/@mapbox/node-pre-gyp/node_modules/semver": {
+ "version": "7.5.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
+ "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
+ "optional": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/@matrix-org/matrix-sdk-crypto-js": {
"version": "0.1.0-alpha.5",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.5.tgz",
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz",
"integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g=="
},
+ "node_modules/@types/file-saver": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz",
+ "integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==",
+ "dev": true
+ },
"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-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==",
"dev": true
},
+ "node_modules/@types/prismjs": {
+ "version": "1.26.0",
+ "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.0.tgz",
+ "integrity": "sha512-ZTaqn/qSqUuAq1YwvOFQfVW1AR/oQJlLSZVustdjwI+GZ8kr0MSHBj0tsXPW1EqHubx50gtBEjbPGsdZwQwCjQ==",
+ "dev": true
+ },
"node_modules/@types/prop-types": {
"version": "15.7.5",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="
},
+ "node_modules/@types/sanitize-html": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.9.0.tgz",
+ "integrity": "sha512-4fP/kEcKNj2u39IzrxWYuf/FnCCwwQCpif6wwY6ROUS1EPRIfWJjGkY3HIowY1EX/VbX5e86yq8AAE7UPMgATg==",
+ "dev": true,
+ "dependencies": {
+ "htmlparser2": "^8.0.0"
+ }
+ },
"node_modules/@types/scheduler": {
"version": "0.16.2",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
"vite": "^4.0.0"
}
},
+ "node_modules/abbrev": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
+ "optional": true
+ },
"node_modules/acorn": {
"version": "8.8.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.1.tgz",
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "optional": true,
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
"node_modules/ahocorasick": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/ahocorasick/-/ahocorasick-1.0.2.tgz",
"node": ">= 8"
}
},
+ "node_modules/aproba": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
+ "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==",
+ "optional": true
+ },
+ "node_modules/are-we-there-yet": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
+ "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
+ "optional": true,
+ "dependencies": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
- "dev": true
+ "devOptional": true
},
"node_modules/base-x": {
"version": "4.0.0",
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
]
},
+ "node_modules/canvas": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz",
+ "integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==",
+ "hasInstallScript": true,
+ "optional": true,
+ "dependencies": {
+ "@mapbox/node-pre-gyp": "^1.0.0",
+ "nan": "^2.17.0",
+ "simple-get": "^3.0.3"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"node": ">= 6"
}
},
+ "node_modules/chownr": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+ "optional": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/classnames": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
},
+ "node_modules/color-support": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+ "optional": true,
+ "bin": {
+ "color-support": "bin.js"
+ }
+ },
"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",
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
- "dev": true
+ "devOptional": true
},
"node_modules/confusing-browser-globals": {
"version": "1.0.11",
"integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==",
"dev": true
},
+ "node_modules/console-control-strings": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
+ "optional": true
+ },
"node_modules/content-type": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
"node": ">=12.20"
}
},
+ "node_modules/dayjs": {
+ "version": "1.11.10",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
+ "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
+ },
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
}
}
},
+ "node_modules/decompress-response": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz",
+ "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==",
+ "optional": true,
+ "dependencies": {
+ "mimic-response": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/delegates": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
+ "optional": true
+ },
+ "node_modules/detect-libc": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz",
+ "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
}
},
"node_modules/domutils": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz",
- "integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
+ "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
- "domhandler": "^5.0.1"
+ "domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/entities": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz",
- "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==",
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"engines": {
"node": ">=0.12"
},
"node": "^10.12.0 || >=12.0.0"
}
},
- "node_modules/flat-cache/node_modules/rimraf": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
- "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
- "dev": true,
- "dependencies": {
- "glob": "^7.1.3"
- },
- "bin": {
- "rimraf": "bin.js"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/flatted": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
}
},
"node_modules/folds": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/folds/-/folds-1.3.0.tgz",
- "integrity": "sha512-Jcv6xN9woJWaTaATDGCD9xFqUhjuSw+afvChYoUt4UsAyY351hfpkGNYzglN+gA5fvJw6N9oa6Ogjj2p84kFfA==",
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/folds/-/folds-1.5.0.tgz",
+ "integrity": "sha512-1QNHzD57OxFZT5SOe0nWcrKQvWmfMRv1f5sTF8xhGtwx9rajjv36T9SwCcj9Fh58PbERqOdBiwvpdhu+BQTVjg==",
"peerDependencies": {
"@vanilla-extract/css": "^1.9.2",
"@vanilla-extract/recipes": "^0.3.0",
"graceful-fs": "^4.1.6"
}
},
+ "node_modules/fs-minipass": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/fs-minipass/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "optional": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
- "dev": true
+ "devOptional": true
},
"node_modules/fsevents": {
"version": "2.3.2",
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gauge": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
+ "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
+ "optional": true,
+ "dependencies": {
+ "aproba": "^1.0.3 || ^2.0.0",
+ "color-support": "^1.1.2",
+ "console-control-strings": "^1.0.0",
+ "has-unicode": "^2.0.1",
+ "object-assign": "^4.1.1",
+ "signal-exit": "^3.0.0",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1",
+ "wide-align": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-unicode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
+ "optional": true
+ },
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
}
},
"node_modules/html-dom-parser": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-3.1.2.tgz",
- "integrity": "sha512-mLTtl3pVn3HnqZSZzW3xVs/mJAKrG1yIw3wlp+9bdoZHHLaBRvELdpfShiPVLyjPypq1Fugv2KMDoGHW4lVXnw==",
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-4.0.0.tgz",
+ "integrity": "sha512-TUa3wIwi80f5NF8CVWzkopBVqVAtlawUzJoLwVLHns0XSJGynss4jiY0mTWpiDOsuyw+afP+ujjMgRh9CoZcXw==",
"dependencies": {
"domhandler": "5.0.3",
- "htmlparser2": "8.0.1"
+ "htmlparser2": "9.0.0"
+ }
+ },
+ "node_modules/html-dom-parser/node_modules/htmlparser2": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.0.0.tgz",
+ "integrity": "sha512-uxbSI98wmFT/G4P2zXx4OVx04qWUmyFPrD2/CNepa2Zo3GPNaCaaxElDgwUrwYWkK1nr9fft0Ya8dws8coDLLQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.1.0",
+ "entities": "^4.5.0"
}
},
"node_modules/html-react-parser": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-3.0.4.tgz",
- "integrity": "sha512-va68PSmC7uA6PbOEc9yuw5Mu3OHPXmFKUpkLGvUPdTuNrZ0CJZk1s/8X/FaHjswK/6uZghu2U02tJjussT8+uw==",
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-4.2.0.tgz",
+ "integrity": "sha512-gzU55AS+FI6qD7XaKe5BLuLFM2Xw0/LodfMWZlxV9uOHe7LCD5Lukx/EgYuBI3c0kLu0XlgFXnSzO0qUUn3Vrg==",
"dependencies": {
"domhandler": "5.0.3",
- "html-dom-parser": "3.1.2",
+ "html-dom-parser": "4.0.0",
"react-property": "2.0.0",
- "style-to-js": "1.1.1"
+ "style-to-js": "1.1.3"
},
"peerDependencies": {
"react": "0.14 || 15 || 16 || 17 || 18"
"entities": "^4.3.0"
}
},
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "optional": true,
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true
+ "devOptional": true
},
"node_modules/inline-style-parser": {
"version": "0.1.1",
"linkifyjs": "^4.0.0"
}
},
+ "node_modules/linkify-react": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/linkify-react/-/linkify-react-4.1.1.tgz",
+ "integrity": "sha512-2K9Y1cUdvq40dFWqCJ//X+WP19nlzIVITFGI93RjLnA0M7KbnxQ/ffC3AZIZaEIrLangF9Hjt3i0GQ9/anEG5A==",
+ "peerDependencies": {
+ "linkifyjs": "^4.0.0",
+ "react": ">= 15.0.0"
+ }
+ },
"node_modules/linkifyjs": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.0.2.tgz",
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"yallist": "^4.0.0"
},
"node": ">=12"
}
},
+ "node_modules/make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "optional": true,
+ "dependencies": {
+ "semver": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/matrix-events-sdk": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz",
"millify": "bin/millify"
}
},
+ "node_modules/mimic-response": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz",
+ "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/mini-svg-data-uri": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/minipass": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
+ "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+ "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+ "optional": true,
+ "dependencies": {
+ "minipass": "^3.0.0",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minizlib/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "optional": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "optional": true,
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"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=="
},
+ "node_modules/nan": {
+ "version": "2.17.0",
+ "resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz",
+ "integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==",
+ "optional": true
+ },
"node_modules/nanoid": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
"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/nopt": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+ "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
+ "optional": true,
+ "dependencies": {
+ "abbrev": "1"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"node": ">=0.10.0"
}
},
+ "node_modules/npmlog": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
+ "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
+ "optional": true,
+ "dependencies": {
+ "are-we-there-yet": "^2.0.0",
+ "console-control-strings": "^1.1.0",
+ "gauge": "^3.0.0",
+ "set-blocking": "^2.0.0"
+ }
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"wrappy": "1"
}
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
- "dev": true,
+ "devOptional": true,
"engines": {
"node": ">=0.10.0"
}
"node": ">=8"
}
},
+ "node_modules/path2d-polyfill": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz",
+ "integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pdfjs-dist": {
+ "version": "3.10.111",
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.10.111.tgz",
+ "integrity": "sha512-+SXXGN/3YTNQSK5Ae7EyqQuR+4IAsNunJq/Us5ByOkRJ45qBXXOwkiWi3RIDU+CyF+ak5eSWXl2FQW2PKBrsRA==",
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "canvas": "^2.11.2",
+ "path2d-polyfill": "^2.0.1"
+ }
+ },
"node_modules/picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
+ "node_modules/prismjs": {
+ "version": "1.29.0",
+ "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz",
+ "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/promise": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
"react": "17.0.2"
}
},
+ "node_modules/react-error-boundary": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.0.10.tgz",
+ "integrity": "sha512-pvVKdi77j2OoPHo+p3rorgE43OjDWiqFkaqkJz8sJKK6uf/u8xtzuaVfj5qJ2JnDLIgF1De3zY5AJDijp+LVPA==",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "peerDependencies": {
+ "react": ">=16.13.1"
+ }
+ },
"node_modules/react-fast-compare": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
"resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.0.tgz",
"integrity": "sha512-kzmNjIgU32mO4mmH5+iUyrqlpFQhF8K2k7eZ4fdLSOPFrD1XgEuSBv9LDEgxRXTMBqMd8ppT0x6TIzqE5pdGdw=="
},
+ "node_modules/react-range": {
+ "version": "1.8.14",
+ "resolved": "https://registry.npmjs.org/react-range/-/react-range-1.8.14.tgz",
+ "integrity": "sha512-v2nyD5106rHf9dwHzq+WRlhCes83h1wJRHIMFjbZsYYsO6LF4mG/mR3cH7Cf+dkeHq65DItuqIbLn/3jjYjsHg==",
+ "peerDependencies": {
+ "react": "^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0",
+ "react-dom": "^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0"
+ }
+ },
"node_modules/react-refresh": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz",
"node": ">=0.10.0"
}
},
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "optional": true,
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"node": ">=0.10.0"
}
},
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "devOptional": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/rollup": {
"version": "3.25.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.25.1.tgz",
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "optional": true
+ },
"node_modules/safe-regex-test": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz",
"semver": "bin/semver.js"
}
},
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "optional": true
+ },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "optional": true
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "optional": true
+ },
+ "node_modules/simple-get": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz",
+ "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==",
+ "optional": true,
+ "dependencies": {
+ "decompress-response": "^4.2.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
"node": ">=0.10.0"
}
},
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "optional": true,
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
}
},
"node_modules/style-to-js": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.1.tgz",
- "integrity": "sha512-RJ18Z9t2B02sYhZtfWKQq5uplVctgvjTfLWT7+Eb1zjUjIrWzX5SdlkwLGQozrqarTmEzJJ/YmdNJCUNI47elg==",
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.3.tgz",
+ "integrity": "sha512-zKI5gN/zb7LS/Vm0eUwjmjrXWw8IMtyA8aPBJZdYiQTXj4+wQ3IucOLIOnF7zCHxvW8UhIGh/uZh/t9zEHXNTQ==",
"dependencies": {
- "style-to-object": "0.3.0"
+ "style-to-object": "0.4.1"
}
},
"node_modules/style-to-object": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz",
- "integrity": "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==",
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.1.tgz",
+ "integrity": "sha512-HFpbb5gr2ypci7Qw+IOhnP2zOU7e77b+rzM+wTzXzfi1PrtBCX0E7Pk4wL4iTLnhzZ+JgEGAhX81ebTg/aYjQw==",
"dependencies": {
"inline-style-parser": "0.1.1"
}
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.0.1.tgz",
"integrity": "sha512-SYJSIgeyXW7EuX1ytdneO5e8jip42oHWg9xl/o3oTYhmXusZVgiA+VlPvjIN+kHii9v90AmzTZEBcsEvuAY+TA=="
},
+ "node_modules/tar": {
+ "version": "6.1.15",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz",
+ "integrity": "sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==",
+ "optional": true,
+ "dependencies": {
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "minipass": "^5.0.0",
+ "minizlib": "^2.1.1",
+ "mkdirp": "^1.0.3",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
"punycode": "^2.1.0"
}
},
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "optional": true
+ },
"node_modules/uuid": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/wide-align": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+ "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
+ "optional": true,
+ "dependencies": {
+ "string-width": "^1.0.2 || 2 || 3 || 4"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
- "dev": true
+ "devOptional": true
},
"node_modules/y18n": {
"version": "5.0.8",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "dev": true
+ "devOptional": true
},
"node_modules/yaml": {
"version": "1.10.2",
"browser-encrypt-attachment": "0.3.0",
"classnames": "2.3.2",
"dateformat": "5.0.3",
+ "dayjs": "1.11.10",
"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.3.0",
+ "folds": "1.5.0",
"formik": "2.2.9",
- "html-react-parser": "3.0.4",
+ "html-react-parser": "4.2.0",
"immer": "9.0.16",
"is-hotkey": "0.2.0",
"jotai": "1.12.0",
"katex": "0.16.4",
"linkify-html": "4.0.2",
+ "linkify-react": "4.1.1",
"linkifyjs": "4.0.2",
"matrix-js-sdk": "24.1.0",
"millify": "6.1.0",
+ "pdfjs-dist": "3.10.111",
+ "prismjs": "1.29.0",
"prop-types": "15.8.1",
"react": "17.0.2",
"react-autosize-textarea": "7.1.0",
"react-dnd": "15.1.2",
"react-dnd-html5-backend": "15.1.3",
"react-dom": "17.0.2",
+ "react-error-boundary": "4.0.10",
"react-google-recaptcha": "2.1.0",
"react-modal": "3.16.1",
+ "react-range": "1.8.14",
"sanitize-html": "2.8.0",
"slate": "0.90.0",
"slate-history": "0.93.0",
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1",
+ "@types/file-saver": "2.0.5",
"@types/node": "18.11.18",
+ "@types/prismjs": "1.26.0",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.9",
+ "@types/sanitize-html": "2.9.0",
"@types/ua-parser-js": "0.7.36",
"@typescript-eslint/eslint-plugin": "5.46.1",
"@typescript-eslint/parser": "5.46.1",
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config } from 'folds';
+
+export const PdfViewer = style([
+ DefaultReset,
+ {
+ height: '100%',
+ },
+]);
+
+export const PdfViewerHeader = style([
+ DefaultReset,
+ {
+ paddingLeft: config.space.S200,
+ paddingRight: config.space.S200,
+ borderBottomWidth: config.borderWidth.B300,
+ flexShrink: 0,
+ gap: config.space.S200,
+ },
+]);
+export const PdfViewerFooter = style([
+ PdfViewerHeader,
+ {
+ borderTopWidth: config.borderWidth.B300,
+ borderBottomWidth: 0,
+ },
+]);
+
+export const PdfViewerContent = style([
+ DefaultReset,
+ {
+ margin: 'auto',
+ display: 'inline-block',
+ backgroundColor: color.Surface.Container,
+ color: color.Surface.OnContainer,
+ },
+]);
--- /dev/null
+/* eslint-disable no-param-reassign */
+/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
+import React, { FormEventHandler, useEffect, useRef, useState } from 'react';
+import classNames from 'classnames';
+import {
+ Box,
+ Button,
+ Chip,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Input,
+ Menu,
+ PopOut,
+ Scroll,
+ Spinner,
+ Text,
+ as,
+ config,
+} from 'folds';
+import FocusTrap from 'focus-trap-react';
+import FileSaver from 'file-saver';
+import * as css from './PdfViewer.css';
+import { AsyncStatus } from '../../hooks/useAsyncCallback';
+import { useZoom } from '../../hooks/useZoom';
+import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
+
+export type PdfViewerProps = {
+ name: string;
+ src: string;
+ requestClose: () => void;
+};
+
+export const PdfViewer = as<'div', PdfViewerProps>(
+ ({ className, name, src, requestClose, ...props }, ref) => {
+ const containerRef = useRef<HTMLDivElement>(null);
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
+
+ const [pdfJSState, loadPdfJS] = usePdfJSLoader();
+ const [docState, loadPdfDocument] = usePdfDocumentLoader(
+ pdfJSState.status === AsyncStatus.Success ? pdfJSState.data : undefined,
+ src
+ );
+ const isLoading =
+ pdfJSState.status === AsyncStatus.Loading || docState.status === AsyncStatus.Loading;
+ const isError =
+ pdfJSState.status === AsyncStatus.Error || docState.status === AsyncStatus.Error;
+ const [pageNo, setPageNo] = useState(1);
+ const [openJump, setOpenJump] = useState(false);
+
+ useEffect(() => {
+ loadPdfJS();
+ }, [loadPdfJS]);
+ useEffect(() => {
+ if (pdfJSState.status === AsyncStatus.Success) {
+ loadPdfDocument();
+ }
+ }, [pdfJSState, loadPdfDocument]);
+
+ useEffect(() => {
+ if (docState.status === AsyncStatus.Success) {
+ const doc = docState.data;
+ if (pageNo < 0 || pageNo > doc.numPages) return;
+ createPage(doc, pageNo, { scale: zoom }).then((canvas) => {
+ const container = containerRef.current;
+ if (!container) return;
+ container.textContent = '';
+ container.append(canvas);
+ scrollRef.current?.scrollTo({
+ top: 0,
+ });
+ });
+ }
+ }, [docState, pageNo, zoom]);
+
+ const handleDownload = () => {
+ FileSaver.saveAs(src, name);
+ };
+
+ const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+ if (docState.status !== AsyncStatus.Success) return;
+ const jumpInput = evt.currentTarget.jumpInput as HTMLInputElement;
+ if (!jumpInput) return;
+ const jumpTo = parseInt(jumpInput.value, 10);
+ setPageNo(Math.max(1, Math.min(docState.data.numPages, jumpTo)));
+ setOpenJump(false);
+ };
+
+ const handlePrevPage = () => {
+ setPageNo((n) => Math.max(n - 1, 1));
+ };
+
+ const handleNextPage = () => {
+ if (docState.status !== AsyncStatus.Success) return;
+ setPageNo((n) => Math.min(n + 1, docState.data.numPages));
+ };
+
+ return (
+ <Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
+ <Header className={css.PdfViewerHeader} size="400">
+ <Box grow="Yes" alignItems="Center" gap="200">
+ <IconButton size="300" radii="300" onClick={requestClose}>
+ <Icon size="50" src={Icons.ArrowLeft} />
+ </IconButton>
+ <Text size="T300" truncate>
+ {name}
+ </Text>
+ </Box>
+ <Box shrink="No" alignItems="Center" gap="200">
+ <IconButton
+ variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
+ outlined={zoom < 1}
+ size="300"
+ radii="Pill"
+ onClick={zoomOut}
+ aria-label="Zoom Out"
+ >
+ <Icon size="50" src={Icons.Minus} />
+ </IconButton>
+ <Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
+ <Text size="B300">{Math.round(zoom * 100)}%</Text>
+ </Chip>
+ <IconButton
+ variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
+ outlined={zoom > 1}
+ size="300"
+ radii="Pill"
+ onClick={zoomIn}
+ aria-label="Zoom In"
+ >
+ <Icon size="50" src={Icons.Plus} />
+ </IconButton>
+ <Chip
+ variant="Primary"
+ onClick={handleDownload}
+ radii="300"
+ before={<Icon size="50" src={Icons.Download} />}
+ >
+ <Text size="B300">Download</Text>
+ </Chip>
+ </Box>
+ </Header>
+ <Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
+ {isLoading && <Spinner variant="Secondary" size="600" />}
+ {isError && (
+ <>
+ <Text>Failed to load PDF</Text>
+ <Button
+ variant="Critical"
+ fill="Soft"
+ size="300"
+ radii="300"
+ before={<Icon src={Icons.Warning} size="50" />}
+ onClick={loadPdfJS}
+ >
+ <Text size="B300">Retry</Text>
+ </Button>
+ </>
+ )}
+ {docState.status === AsyncStatus.Success && (
+ <Scroll
+ ref={scrollRef}
+ size="300"
+ direction="Both"
+ variant="Surface"
+ visibility="Hover"
+ >
+ <Box>
+ <div className={css.PdfViewerContent} ref={containerRef} />
+ </Box>
+ </Scroll>
+ )}
+ </Box>
+ {docState.status === AsyncStatus.Success && (
+ <Header as="footer" className={css.PdfViewerFooter} size="400">
+ <Chip
+ variant="Secondary"
+ radii="300"
+ before={<Icon size="50" src={Icons.ChevronLeft} />}
+ onClick={handlePrevPage}
+ aria-disabled={pageNo <= 1}
+ >
+ <Text size="B300">Previous</Text>
+ </Chip>
+ <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
+ <PopOut
+ open={openJump}
+ align="Center"
+ position="Top"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setOpenJump(false),
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Menu variant="Surface">
+ <Box
+ as="form"
+ onSubmit={handleJumpSubmit}
+ style={{ padding: config.space.S200 }}
+ direction="Column"
+ gap="200"
+ >
+ <Input
+ name="jumpInput"
+ size="300"
+ variant="Background"
+ defaultValue={pageNo}
+ min={1}
+ max={docState.data.numPages}
+ step={1}
+ outlined
+ type="number"
+ radii="300"
+ aria-label="Page Number"
+ />
+ <Button type="submit" size="300" variant="Primary" radii="300">
+ <Text size="B300">Jump To Page</Text>
+ </Button>
+ </Box>
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ {(anchorRef) => (
+ <Chip
+ onClick={() => setOpenJump(!openJump)}
+ ref={anchorRef}
+ variant="SurfaceVariant"
+ radii="300"
+ aria-pressed={openJump}
+ >
+ <Text size="B300">{`${pageNo}/${docState.data.numPages}`}</Text>
+ </Chip>
+ )}
+ </PopOut>
+ </Box>
+ <Chip
+ variant="Primary"
+ radii="300"
+ after={<Icon size="50" src={Icons.ChevronRight} />}
+ onClick={handleNextPage}
+ aria-disabled={pageNo >= docState.data.numPages}
+ >
+ <Text size="B300">Next</Text>
+ </Chip>
+ </Header>
+ )}
+ </Box>
+ );
+ }
+);
--- /dev/null
+export * from './PdfViewer';
return editor;
};
-export type EditorChangeHandler = ((value: Descendant[]) => void) | undefined;
+export type EditorChangeHandler = (value: Descendant[]) => void;
type CustomEditorProps = {
top?: ReactNode;
bottom?: ReactNode;
editor: Editor;
placeholder?: string;
onKeyDown?: KeyboardEventHandler;
+ onKeyUp?: KeyboardEventHandler;
onChange?: EditorChangeHandler;
onPaste?: ClipboardEventHandler;
};
editor,
placeholder,
onKeyDown,
+ onKeyUp,
onChange,
onPaste,
},
renderElement={renderElement}
renderLeaf={renderLeaf}
onKeyDown={handleKeydown}
+ onKeyUp={onKeyUp}
onPaste={onPaste}
/>
</Scroll>
+++ /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',
- },
-]);
import React from 'react';
import { RenderElementProps, RenderLeafProps, useFocused, useSelected } from 'slate-react';
-import * as css from './Elements.css';
+import * as css from '../../styles/CustomHtml.css';
import { EmoticonElement, LinkElement, MentionElement } from './slate';
import { useMatrixClient } from '../../hooks/useMatrixClient';
case BlockType.CodeBlock:
return (
<Text as="pre" className={css.CodeBlock} {...attributes}>
- <Scroll direction="Horizontal" variant="Warning" size="300" visibility="Hover" hideTrack>
+ <Scroll
+ direction="Horizontal"
+ variant="Secondary"
+ size="300"
+ visibility="Hover"
+ hideTrack
+ >
<div className={css.CodeBlockInternal}>{children}</div>
</Scroll>
</Text>
);
if (leaf.spoiler)
child = (
- <span className={css.Spoiler} {...attributes}>
+ <span className={css.Spoiler()} {...attributes}>
<InlineChromiumBugfix />
{child}
</span>
return;
}
const rId = autoCompleteRoomIds[0];
- const name = mx.getRoom(rId)?.name ?? rId;
- handleAutocomplete(rId, name);
+ const r = mx.getRoom(rId);
+ const name = r?.name ?? rId;
+ handleAutocomplete(r?.getCanonicalAlias() ?? rId, name);
});
});
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
onTabPress(evt, () => handleAutocomplete(rId, room.name))
}
- onClick={() => handleAutocomplete(rId, room.name)}
+ onClick={() => handleAutocomplete(room.getCanonicalAlias() ?? rId, room.name)}
after={
<Text size="T200" priority="300" truncate>
{room.getCanonicalAlias() ?? ''}
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 { editableActiveElement, isIntersectingScrollView, targetFromEvent } from '../../utils/dom';
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
import { useDebounce } from '../../hooks/useDebounce';
import { useThrottle } from '../../hooks/useThrottle';
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 groupEl = groupEls.find((el) => isIntersectingScrollView(targetEl, el));
const groupId = groupEl?.getAttribute('data-group-id') ?? undefined;
setActiveGroupId(groupId);
}, [setActiveGroupId]);
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, config } from 'folds';
+
+export const EventReaders = style([
+ DefaultReset,
+ {
+ height: '100%',
+ },
+]);
+
+export const Header = style({
+ paddingLeft: config.space.S400,
+ paddingRight: config.space.S300,
+
+ flexShrink: 0,
+});
+
+export const Content = style({
+ paddingLeft: config.space.S200,
+ paddingBottom: config.space.S400,
+});
--- /dev/null
+import React from 'react';
+import classNames from 'classnames';
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+ Box,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ MenuItem,
+ Scroll,
+ Text,
+ as,
+ config,
+} from 'folds';
+import { Room, RoomMember } from 'matrix-js-sdk';
+import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
+import { getMemberDisplayName } from '../../utils/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import * as css from './EventReaders.css';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import colorMXID from '../../../util/colorMXID';
+import { openProfileViewer } from '../../../client/action/navigation';
+
+export type EventReadersProps = {
+ room: Room;
+ eventId: string;
+ requestClose: () => void;
+};
+export const EventReaders = as<'div', EventReadersProps>(
+ ({ className, room, eventId, requestClose, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const latestEventReaders = useRoomEventReaders(room, eventId);
+ const followingMembers = latestEventReaders
+ .map((readerId) => room.getMember(readerId))
+ .filter((member) => member) as RoomMember[];
+
+ const getName = (member: RoomMember) =>
+ getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
+
+ return (
+ <Box
+ className={classNames(css.EventReaders, className)}
+ direction="Column"
+ {...props}
+ ref={ref}
+ >
+ <Header className={css.Header} variant="Surface" size="600">
+ <Box grow="Yes">
+ <Text size="H3">Seen by</Text>
+ </Box>
+ <IconButton size="300" onClick={requestClose}>
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Header>
+ <Box grow="Yes">
+ <Scroll visibility="Hover" hideTrack size="300">
+ <Box className={css.Content} direction="Column">
+ {followingMembers.map((member) => {
+ const name = getName(member);
+ const avatarUrl = member.getAvatarUrl(
+ mx.baseUrl,
+ 100,
+ 100,
+ 'crop',
+ undefined,
+ false
+ );
+
+ return (
+ <MenuItem
+ key={member.userId}
+ style={{ padding: `0 ${config.space.S200}` }}
+ radii="400"
+ onClick={() => {
+ requestClose();
+ openProfileViewer(member.userId, room.roomId);
+ }}
+ before={
+ <Avatar size="200">
+ {avatarUrl ? (
+ <AvatarImage src={avatarUrl} />
+ ) : (
+ <AvatarFallback
+ style={{
+ background: colorMXID(member.userId),
+ color: 'white',
+ }}
+ >
+ <Text size="H6">{name[0]}</Text>
+ </AvatarFallback>
+ )}
+ </Avatar>
+ }
+ >
+ <Text size="T400" truncate>
+ {name}
+ </Text>
+ </MenuItem>
+ );
+ })}
+ </Box>
+ </Scroll>
+ </Box>
+ </Box>
+ );
+ }
+);
--- /dev/null
+export * from './EventReaders';
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config } from 'folds';
+
+export const ImageViewer = style([
+ DefaultReset,
+ {
+ height: '100%',
+ },
+]);
+
+export const ImageViewerHeader = style([
+ DefaultReset,
+ {
+ paddingLeft: config.space.S200,
+ paddingRight: config.space.S200,
+ borderBottomWidth: config.borderWidth.B300,
+ flexShrink: 0,
+ gap: config.space.S200,
+ },
+]);
+
+export const ImageViewerContent = style([
+ DefaultReset,
+ {
+ backgroundColor: color.Background.Container,
+ color: color.Background.OnContainer,
+ overflow: 'hidden',
+ },
+]);
+
+export const ImageViewerImg = style([
+ DefaultReset,
+ {
+ objectFit: 'contain',
+ width: '100%',
+ height: '100%',
+ backgroundColor: color.Surface.Container,
+ transition: 'transform 100ms linear',
+ },
+]);
--- /dev/null
+/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
+import React from 'react';
+import FileSaver from 'file-saver';
+import classNames from 'classnames';
+import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
+import * as css from './ImageViewer.css';
+import { useZoom } from '../../hooks/useZoom';
+import { usePan } from '../../hooks/usePan';
+
+export type ImageViewerProps = {
+ alt: string;
+ src: string;
+ requestClose: () => void;
+};
+
+export const ImageViewer = as<'div', ImageViewerProps>(
+ ({ className, alt, src, requestClose, ...props }, ref) => {
+ const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
+ const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
+
+ const handleDownload = () => {
+ FileSaver.saveAs(src, alt);
+ };
+
+ return (
+ <Box
+ className={classNames(css.ImageViewer, className)}
+ direction="Column"
+ {...props}
+ ref={ref}
+ >
+ <Header className={css.ImageViewerHeader} size="400">
+ <Box grow="Yes" alignItems="Center" gap="200">
+ <IconButton size="300" radii="300" onClick={requestClose}>
+ <Icon size="50" src={Icons.ArrowLeft} />
+ </IconButton>
+ <Text size="T300" truncate>
+ {alt}
+ </Text>
+ </Box>
+ <Box shrink="No" alignItems="Center" gap="200">
+ <IconButton
+ variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
+ outlined={zoom < 1}
+ size="300"
+ radii="Pill"
+ onClick={zoomOut}
+ aria-label="Zoom Out"
+ >
+ <Icon size="50" src={Icons.Minus} />
+ </IconButton>
+ <Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
+ <Text size="B300">{Math.round(zoom * 100)}%</Text>
+ </Chip>
+ <IconButton
+ variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
+ outlined={zoom > 1}
+ size="300"
+ radii="Pill"
+ onClick={zoomIn}
+ aria-label="Zoom In"
+ >
+ <Icon size="50" src={Icons.Plus} />
+ </IconButton>
+ <Chip
+ variant="Primary"
+ onClick={handleDownload}
+ radii="300"
+ before={<Icon size="50" src={Icons.Download} />}
+ >
+ <Text size="B300">Download</Text>
+ </Chip>
+ </Box>
+ </Header>
+ <Box
+ grow="Yes"
+ className={css.ImageViewerContent}
+ justifyContent="Center"
+ alignItems="Center"
+ >
+ <img
+ className={css.ImageViewerImg}
+ style={{
+ cursor,
+ transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
+ }}
+ src={src}
+ alt={alt}
+ onMouseDown={onMouseDown}
+ />
+ </Box>
+ </Box>
+ );
+ }
+);
--- /dev/null
+export * from './ImageViewer';
--- /dev/null
+import React, { ImgHTMLAttributes, forwardRef } from 'react';
+import classNames from 'classnames';
+import * as css from './media.css';
+
+export const Image = forwardRef<HTMLImageElement, ImgHTMLAttributes<HTMLImageElement>>(
+ ({ className, alt, ...props }, ref) => (
+ <img className={classNames(css.Image, className)} alt={alt} {...props} ref={ref} />
+ )
+);
--- /dev/null
+import React, { ReactNode } from 'react';
+import { Box, as } from 'folds';
+
+export type MediaControlProps = {
+ before?: ReactNode;
+ after?: ReactNode;
+ leftControl?: ReactNode;
+ rightControl?: ReactNode;
+};
+export const MediaControl = as<'div', MediaControlProps>(
+ ({ before, after, leftControl, rightControl, children, ...props }, ref) => (
+ <Box grow="Yes" direction="Column" gap="300" {...props} ref={ref}>
+ {before && <Box direction="Column">{before}</Box>}
+ <Box alignItems="Center" gap="200">
+ <Box alignItems="Center" grow="Yes" gap="Inherit">
+ {leftControl}
+ </Box>
+
+ <Box justifyItems="End" alignItems="Center" gap="Inherit">
+ {rightControl}
+ </Box>
+ </Box>
+ {after && <Box direction="Column">{after}</Box>}
+ {children}
+ </Box>
+ )
+);
--- /dev/null
+import React, { VideoHTMLAttributes, forwardRef } from 'react';
+import classNames from 'classnames';
+import * as css from './media.css';
+
+export const Video = forwardRef<HTMLVideoElement, VideoHTMLAttributes<HTMLVideoElement>>(
+ ({ className, ...props }, ref) => (
+ // eslint-disable-next-line jsx-a11y/media-has-caption
+ <video className={classNames(css.Image, className)} {...props} ref={ref} />
+ )
+);
--- /dev/null
+export * from './Image';
+export * from './Video';
+export * from './MediaControls';
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { DefaultReset } from 'folds';
+
+export const Image = style([
+ DefaultReset,
+ {
+ objectFit: 'cover',
+ width: '100%',
+ height: '100%',
+ },
+]);
+
+export const Video = style([
+ DefaultReset,
+ {
+ objectFit: 'cover',
+ width: '100%',
+ height: '100%',
+ },
+]);
--- /dev/null
+import { Box, Icon, Icons, Text, as, color, config } from 'folds';
+import React from 'react';
+
+const warningStyle = { color: color.Warning.Main, opacity: config.opacity.P300 };
+const criticalStyle = { color: color.Critical.Main, opacity: config.opacity.P300 };
+
+export const MessageDeletedContent = as<'div', { children?: never; reason?: string }>(
+ ({ reason, ...props }, ref) => (
+ <Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
+ <Icon size="50" src={Icons.Delete} />
+ {reason ? (
+ <i>This message has been deleted. {reason}</i>
+ ) : (
+ <i>This message has been deleted</i>
+ )}
+ </Box>
+ )
+);
+
+export const MessageUnsupportedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+ <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
+ <Icon size="50" src={Icons.Warning} />
+ <i>Unsupported message</i>
+ </Box>
+));
+
+export const MessageFailedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+ <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
+ <Icon size="50" src={Icons.Warning} />
+ <i>Failed to load message</i>
+ </Box>
+));
+
+export const MessageBadEncryptedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+ <Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
+ <Icon size="50" src={Icons.Lock} />
+ <i>Unable to decrypt message</i>
+ </Box>
+));
+
+export const MessageNotDecryptedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+ <Box as="span" alignItems="Center" gap="100" style={warningStyle} {...props} ref={ref}>
+ <Icon size="50" src={Icons.Lock} />
+ <i>This message is not decrypted yet</i>
+ </Box>
+));
+
+export const MessageBrokenContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+ <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
+ <Icon size="50" src={Icons.Warning} />
+ <i>Broken message</i>
+ </Box>
+));
+
+export const MessageEmptyContent = as<'div', { children?: never }>(({ ...props }, ref) => (
+ <Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
+ <Icon size="50" src={Icons.Warning} />
+ <i>Empty message</i>
+ </Box>
+));
+
+export const MessageEditedContent = as<'span', { children?: never }>(({ ...props }, ref) => (
+ <Text as="span" size="T200" priority="300" {...props} ref={ref}>
+ {' (edited)'}
+ </Text>
+));
--- /dev/null
+import { createVar, style } from '@vanilla-extract/css';
+import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
+
+const Container = createVar();
+const ContainerHover = createVar();
+const ContainerActive = createVar();
+const ContainerLine = createVar();
+const OnContainer = createVar();
+
+export const Reaction = style([
+ FocusOutline,
+ {
+ vars: {
+ [Container]: color.SurfaceVariant.Container,
+ [ContainerHover]: color.SurfaceVariant.ContainerHover,
+ [ContainerActive]: color.SurfaceVariant.ContainerActive,
+ [ContainerLine]: color.SurfaceVariant.ContainerLine,
+ [OnContainer]: color.SurfaceVariant.OnContainer,
+ },
+ padding: `${toRem(2)} ${config.space.S200} ${toRem(2)} ${config.space.S100}`,
+ backgroundColor: Container,
+ border: `${config.borderWidth.B300} solid ${ContainerLine}`,
+ borderRadius: config.radii.R300,
+
+ selectors: {
+ 'button&': {
+ cursor: 'pointer',
+ },
+ '&[aria-pressed=true]': {
+ vars: {
+ [Container]: color.Primary.Container,
+ [ContainerHover]: color.Primary.ContainerHover,
+ [ContainerActive]: color.Primary.ContainerActive,
+ [ContainerLine]: color.Primary.ContainerLine,
+ [OnContainer]: color.Primary.OnContainer,
+ },
+ backgroundColor: Container,
+ },
+ '&[aria-selected=true]': {
+ borderColor: color.Secondary.Main,
+ borderWidth: config.borderWidth.B400,
+ },
+ '&:hover, &:focus-visible': {
+ backgroundColor: ContainerHover,
+ },
+ '&:active': {
+ backgroundColor: ContainerActive,
+ },
+ '&[aria-disabled=true], &:disabled': {
+ cursor: 'not-allowed',
+ },
+ },
+ },
+]);
+
+export const ReactionText = style([
+ DefaultReset,
+ {
+ minWidth: 0,
+ maxWidth: toRem(150),
+ display: 'inline-flex',
+ alignItems: 'center',
+ lineHeight: toRem(20),
+ },
+]);
+
+export const ReactionImg = style([
+ DefaultReset,
+ {
+ height: '1em',
+ minWidth: 0,
+ maxWidth: toRem(150),
+ objectFit: 'contain',
+ },
+]);
--- /dev/null
+import React from 'react';
+import { Box, Text, as } from 'folds';
+import classNames from 'classnames';
+import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
+import * as css from './Reaction.css';
+import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
+import { getMemberDisplayName } from '../../utils/room';
+import { eventWithShortcode, getMxIdLocalPart } from '../../utils/matrix';
+
+export const Reaction = as<
+ 'button',
+ {
+ mx: MatrixClient;
+ count: number;
+ reaction: string;
+ }
+>(({ className, mx, count, reaction, ...props }, ref) => (
+ <Box
+ as="button"
+ className={classNames(css.Reaction, className)}
+ alignItems="Center"
+ shrink="No"
+ gap="200"
+ {...props}
+ ref={ref}
+ >
+ <Text className={css.ReactionText} as="span" size="T400">
+ {reaction.startsWith('mxc://') ? (
+ <img
+ className={css.ReactionImg}
+ src={mx.mxcUrlToHttp(reaction) ?? reaction}
+ alt={reaction}
+ />
+ ) : (
+ <Text as="span" size="Inherit" truncate>
+ {reaction}
+ </Text>
+ )}
+ </Text>
+ <Text as="span" size="T300">
+ {count}
+ </Text>
+ </Box>
+));
+
+type ReactionTooltipMsgProps = {
+ room: Room;
+ reaction: string;
+ events: MatrixEvent[];
+};
+
+export function ReactionTooltipMsg({ room, reaction, events }: ReactionTooltipMsgProps) {
+ const shortCodeEvt = events.find(eventWithShortcode);
+ const shortcode =
+ shortCodeEvt?.getContent().shortcode ??
+ getShortcodeFor(getHexcodeForEmoji(reaction)) ??
+ reaction;
+ const names = events.map(
+ (ev: MatrixEvent) =>
+ getMemberDisplayName(room, ev.getSender() ?? 'Unknown') ??
+ getMxIdLocalPart(ev.getSender() ?? 'Unknown') ??
+ 'Unknown'
+ );
+
+ return (
+ <>
+ {names.length === 1 && <b>{names[0]}</b>}
+ {names.length === 2 && (
+ <>
+ <b>{names[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' and '}
+ </Text>
+ <b>{names[1]}</b>
+ </>
+ )}
+ {names.length === 3 && (
+ <>
+ <b>{names[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {', '}
+ </Text>
+ <b>{names[1]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' and '}
+ </Text>
+ <b>{names[2]}</b>
+ </>
+ )}
+ {names.length > 3 && (
+ <>
+ <b>{names[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {', '}
+ </Text>
+ <b>{names[1]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {', '}
+ </Text>
+ <b>{names[2]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' and '}
+ </Text>
+ <b>{names.length - 3} others</b>
+ </>
+ )}
+ <Text as="span" size="Inherit" priority="300">
+ {' reacted with '}
+ </Text>
+ :<b>{shortcode}</b>:
+ </>
+ );
+}
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { config, toRem } from 'folds';
+
+export const Reply = style({
+ padding: `0 ${config.space.S100}`,
+ marginBottom: toRem(1),
+ cursor: 'pointer',
+ minWidth: 0,
+ maxWidth: '100%',
+ minHeight: config.lineHeight.T300,
+});
+
+export const ReplyContent = style({
+ opacity: config.opacity.P300,
+
+ selectors: {
+ [`${Reply}:hover &`]: {
+ opacity: config.opacity.P500,
+ },
+ },
+});
+
+export const ReplyContentText = style({
+ paddingRight: config.space.S100,
+});
--- /dev/null
+import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
+import { EventTimelineSet, MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
+import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
+import React, { useEffect, useState } from 'react';
+import to from 'await-to-js';
+import classNames from 'classnames';
+import colorMXID from '../../../util/colorMXID';
+import { getMemberDisplayName } from '../../utils/room';
+import { getMxIdLocalPart, trimReplyFromBody } from '../../utils/matrix';
+import { LinePlaceholder } from './placeholder';
+import { randomNumberBetween } from '../../utils/common';
+import * as css from './Reply.css';
+import {
+ MessageBadEncryptedContent,
+ MessageDeletedContent,
+ MessageFailedContent,
+} from './MessageContentFallback';
+
+type ReplyProps = {
+ mx: MatrixClient;
+ room: Room;
+ timelineSet: EventTimelineSet;
+ eventId: string;
+};
+
+export const Reply = as<'div', ReplyProps>(
+ ({ className, mx, room, timelineSet, eventId, ...props }, ref) => {
+ const [replyEvent, setReplyEvent] = useState<MatrixEvent | null | undefined>(
+ timelineSet.findEventById(eventId)
+ );
+
+ const { body } = replyEvent?.getContent() ?? {};
+ const sender = replyEvent?.getSender();
+
+ const fallbackBody = replyEvent?.isRedacted() ? (
+ <MessageDeletedContent />
+ ) : (
+ <MessageFailedContent />
+ );
+
+ useEffect(() => {
+ let disposed = false;
+ const loadEvent = async () => {
+ const [err, evt] = await to(mx.fetchRoomEvent(room.roomId, eventId));
+ const mEvent = new MatrixEvent(evt);
+ if (disposed) return;
+ if (err) {
+ setReplyEvent(null);
+ return;
+ }
+ if (mEvent.isEncrypted() && mx.getCrypto()) {
+ await to(mEvent.attemptDecryption(mx.getCrypto() as CryptoBackend));
+ }
+ setReplyEvent(mEvent);
+ };
+ if (replyEvent === undefined) loadEvent();
+ return () => {
+ disposed = true;
+ };
+ }, [replyEvent, mx, room, eventId]);
+
+ return (
+ <Box
+ className={classNames(css.Reply, className)}
+ alignItems="Center"
+ gap="100"
+ {...props}
+ ref={ref}
+ >
+ <Box style={{ color: colorMXID(sender ?? eventId) }} alignItems="Center" shrink="No">
+ <Icon src={Icons.ReplyArrow} size="50" />
+ {sender && (
+ <Text size="T300" truncate>
+ {getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}
+ </Text>
+ )}
+ </Box>
+ <Box grow="Yes" className={css.ReplyContent}>
+ {replyEvent !== undefined ? (
+ <Text className={css.ReplyContentText} size="T300" truncate>
+ {replyEvent?.getContent().msgtype === 'm.bad.encrypted' ? (
+ <MessageBadEncryptedContent />
+ ) : (
+ (body && trimReplyFromBody(body)) ?? fallbackBody
+ )}
+ </Text>
+ ) : (
+ <LinePlaceholder
+ style={{
+ backgroundColor: color.SurfaceVariant.ContainerActive,
+ maxWidth: toRem(randomNumberBetween(40, 400)),
+ width: '100%',
+ }}
+ />
+ )}
+ </Box>
+ </Box>
+ );
+ }
+);
--- /dev/null
+import React from 'react';
+import { Text, as } from 'folds';
+import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
+
+export type TimeProps = {
+ compact?: boolean;
+ ts: number;
+};
+
+export const Time = as<'span', TimeProps>(({ compact, ts, ...props }, ref) => {
+ let time = '';
+ if (compact) {
+ time = timeHourMinute(ts);
+ } else if (today(ts)) {
+ time = timeHourMinute(ts);
+ } else if (yesterday(ts)) {
+ time = `Yesterday ${timeHourMinute(ts)}`;
+ } else {
+ time = `${timeDayMonYear(ts)} ${timeHourMinute(ts)}`;
+ }
+
+ return (
+ <Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
+ {time}
+ </Text>
+ );
+});
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
+import { DefaultReset, color, config, toRem } from 'folds';
+
+export const Attachment = recipe({
+ base: {
+ backgroundColor: color.SurfaceVariant.Container,
+ color: color.SurfaceVariant.OnContainer,
+ borderRadius: config.radii.R400,
+ overflow: 'hidden',
+ maxWidth: '100%',
+ width: toRem(400),
+ },
+ variants: {
+ outlined: {
+ true: {
+ boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
+ },
+ },
+ },
+});
+
+export type AttachmentVariants = RecipeVariants<typeof Attachment>;
+
+export const AttachmentHeader = style({
+ padding: config.space.S300,
+});
+
+export const AttachmentBox = style([
+ DefaultReset,
+ {
+ maxWidth: '100%',
+ maxHeight: toRem(600),
+ width: toRem(400),
+ overflow: 'hidden',
+ },
+]);
+
+export const AttachmentContent = style({
+ padding: config.space.S300,
+ paddingTop: 0,
+});
--- /dev/null
+import React from 'react';
+import { Box, as } from 'folds';
+import classNames from 'classnames';
+import * as css from './Attachment.css';
+
+export const Attachment = as<'div', css.AttachmentVariants>(
+ ({ className, outlined, ...props }, ref) => (
+ <Box
+ display="InlineFlex"
+ direction="Column"
+ className={classNames(css.Attachment({ outlined }), className)}
+ {...props}
+ ref={ref}
+ />
+ )
+);
+
+export const AttachmentHeader = as<'div'>(({ className, ...props }, ref) => (
+ <Box
+ shrink="No"
+ gap="200"
+ className={classNames(css.AttachmentHeader, className)}
+ {...props}
+ ref={ref}
+ />
+));
+
+export const AttachmentBox = as<'div'>(({ className, ...props }, ref) => (
+ <Box
+ direction="Column"
+ className={classNames(css.AttachmentBox, className)}
+ {...props}
+ ref={ref}
+ />
+));
+
+export const AttachmentContent = as<'div'>(({ className, ...props }, ref) => (
+ <Box
+ direction="Column"
+ className={classNames(css.AttachmentContent, className)}
+ {...props}
+ ref={ref}
+ />
+));
--- /dev/null
+export * from './Attachment';
--- /dev/null
+export * from './layout';
+export * from './placeholder';
+export * from './Reaction';
+export * from './attachment';
+export * from './Reply';
+export * from './MessageContentFallback';
+export * from './Time';
--- /dev/null
+import React from 'react';
+import { as } from 'folds';
+import classNames from 'classnames';
+import * as css from './layout.css';
+
+export const MessageBase = as<'div', css.MessageBaseVariants>(
+ ({ className, highlight, selected, collapse, autoCollapse, space, ...props }, ref) => (
+ <div
+ className={classNames(
+ css.MessageBase({ highlight, selected, collapse, autoCollapse, space }),
+ className
+ )}
+ {...props}
+ ref={ref}
+ />
+ )
+);
+
+export const AvatarBase = as<'span'>(({ className, ...props }, ref) => (
+ <span className={classNames(css.AvatarBase, className)} {...props} ref={ref} />
+));
+
+export const Username = as<'span'>(({ as: AsUsername = 'span', className, ...props }, ref) => (
+ <AsUsername className={classNames(css.Username, className)} {...props} ref={ref} />
+));
--- /dev/null
+import React, { ReactNode } from 'react';
+import { Box, as } from 'folds';
+import * as css from './layout.css';
+
+type BubbleLayoutProps = {
+ before?: ReactNode;
+};
+
+export const BubbleLayout = as<'div', BubbleLayoutProps>(({ before, children, ...props }, ref) => (
+ <Box gap="300" {...props} ref={ref}>
+ <Box className={css.BubbleBefore} shrink="No">
+ {before}
+ </Box>
+ <Box className={css.BubbleContent} direction="Column">
+ {children}
+ </Box>
+ </Box>
+));
--- /dev/null
+import React, { ReactNode } from 'react';
+import { Box, as } from 'folds';
+import * as css from './layout.css';
+
+type CompactLayoutProps = {
+ before?: ReactNode;
+};
+
+export const CompactLayout = as<'div', CompactLayoutProps>(
+ ({ before, children, ...props }, ref) => (
+ <Box gap="200" {...props} ref={ref}>
+ <Box className={css.CompactHeader} gap="200" shrink="No">
+ {before}
+ </Box>
+ {children}
+ </Box>
+ )
+);
--- /dev/null
+import React, { ReactNode } from 'react';
+import { Box, as } from 'folds';
+import * as css from './layout.css';
+
+type ModernLayoutProps = {
+ before?: ReactNode;
+};
+
+export const ModernLayout = as<'div', ModernLayoutProps>(({ before, children, ...props }, ref) => (
+ <Box gap="300" {...props} ref={ref}>
+ <Box className={css.ModernBefore} shrink="No">
+ {before}
+ </Box>
+ <Box grow="Yes" direction="Column">
+ {children}
+ </Box>
+ </Box>
+));
--- /dev/null
+export * from './Modern';
+export * from './Compact';
+export * from './Bubble';
+export * from './Base';
--- /dev/null
+import { createVar, keyframes, style, styleVariants } from '@vanilla-extract/css';
+import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
+import { DefaultReset, color, config, toRem } from 'folds';
+
+export const StickySection = style({
+ position: 'sticky',
+ top: config.space.S100,
+});
+
+const SpacingVar = createVar();
+const SpacingVariant = styleVariants({
+ '0': {
+ vars: {
+ [SpacingVar]: config.space.S0,
+ },
+ },
+ '100': {
+ vars: {
+ [SpacingVar]: config.space.S100,
+ },
+ },
+ '200': {
+ vars: {
+ [SpacingVar]: config.space.S200,
+ },
+ },
+ '300': {
+ vars: {
+ [SpacingVar]: config.space.S300,
+ },
+ },
+ '400': {
+ vars: {
+ [SpacingVar]: config.space.S400,
+ },
+ },
+ '500': {
+ vars: {
+ [SpacingVar]: config.space.S500,
+ },
+ },
+});
+
+const highlightAnime = keyframes({
+ '0%': {
+ backgroundColor: color.Primary.Container,
+ },
+ '25%': {
+ backgroundColor: color.Primary.ContainerActive,
+ },
+ '50%': {
+ backgroundColor: color.Primary.Container,
+ },
+ '75%': {
+ backgroundColor: color.Primary.ContainerActive,
+ },
+ '100%': {
+ backgroundColor: color.Primary.Container,
+ },
+});
+const HighlightVariant = styleVariants({
+ true: {
+ animation: `${highlightAnime} 2000ms ease-in-out`,
+ },
+});
+
+const SelectedVariant = styleVariants({
+ true: {
+ backgroundColor: color.Surface.ContainerActive,
+ },
+});
+
+const AutoCollapse = style({
+ selectors: {
+ [`&+&`]: {
+ marginTop: 0,
+ },
+ },
+});
+
+export const MessageBase = recipe({
+ base: [
+ DefaultReset,
+ {
+ marginTop: SpacingVar,
+ padding: `${config.space.S100} ${config.space.S200} ${config.space.S100} ${config.space.S400}`,
+ borderRadius: `0 ${config.radii.R400} ${config.radii.R400} 0`,
+ },
+ ],
+ variants: {
+ space: SpacingVariant,
+ collapse: {
+ true: {
+ marginTop: 0,
+ },
+ },
+ autoCollapse: {
+ true: AutoCollapse,
+ },
+ highlight: HighlightVariant,
+ selected: SelectedVariant,
+ },
+ defaultVariants: {
+ space: '400',
+ },
+});
+
+export type MessageBaseVariants = RecipeVariants<typeof MessageBase>;
+
+export const CompactHeader = style([
+ DefaultReset,
+ StickySection,
+ {
+ maxWidth: toRem(170),
+ width: '100%',
+ },
+]);
+
+export const AvatarBase = style({
+ paddingTop: toRem(4),
+ cursor: 'pointer',
+ transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
+
+ selectors: {
+ '&:hover': {
+ transform: `translateY(${toRem(-4)})`,
+ },
+ },
+});
+
+export const ModernBefore = style({
+ minWidth: toRem(36),
+});
+
+export const BubbleBefore = style([ModernBefore]);
+
+export const BubbleContent = style({
+ maxWidth: toRem(800),
+ padding: config.space.S200,
+ backgroundColor: color.SurfaceVariant.Container,
+ color: color.SurfaceVariant.OnContainer,
+ borderRadius: config.radii.R400,
+});
+
+export const Username = style({
+ cursor: 'pointer',
+ overflow: 'hidden',
+ whiteSpace: 'nowrap',
+ textOverflow: 'ellipsis',
+ selectors: {
+ '&:hover, &:focus-visible': {
+ textDecoration: 'underline',
+ },
+ },
+});
--- /dev/null
+import React from 'react';
+import { as, toRem } from 'folds';
+import { randomNumberBetween } from '../../../utils/common';
+import { LinePlaceholder } from './LinePlaceholder';
+import { CompactLayout, MessageBase } from '../layout';
+
+export const CompactPlaceholder = as<'div'>(({ ...props }, ref) => (
+ <MessageBase>
+ <CompactLayout
+ {...props}
+ ref={ref}
+ before={
+ <>
+ <LinePlaceholder style={{ maxWidth: toRem(50) }} />
+ <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(40, 100)) }} />
+ </>
+ }
+ >
+ <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(120, 500)) }} />
+ </CompactLayout>
+ </MessageBase>
+));
--- /dev/null
+import React, { CSSProperties } from 'react';
+import { Avatar, Box, as, color, toRem } from 'folds';
+import { randomNumberBetween } from '../../../utils/common';
+import { LinePlaceholder } from './LinePlaceholder';
+import { MessageBase, ModernLayout } from '../layout';
+
+const contentMargin: CSSProperties = { marginTop: toRem(3) };
+const avatarBg: CSSProperties = { backgroundColor: color.SurfaceVariant.Container };
+
+export const DefaultPlaceholder = as<'div'>(({ ...props }, ref) => (
+ <MessageBase>
+ <ModernLayout {...props} ref={ref} before={<Avatar style={avatarBg} size="300" />}>
+ <Box style={contentMargin} grow="Yes" direction="Column" gap="200">
+ <Box grow="Yes" gap="200" alignItems="Center" justifyContent="SpaceBetween">
+ <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(40, 100)) }} />
+ <LinePlaceholder style={{ maxWidth: toRem(50) }} />
+ </Box>
+ <Box grow="Yes" gap="200" wrap="Wrap">
+ <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(80, 200)) }} />
+ <LinePlaceholder style={{ maxWidth: toRem(randomNumberBetween(80, 200)) }} />
+ </Box>
+ </Box>
+ </ModernLayout>
+ </MessageBase>
+));
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config, toRem } from 'folds';
+
+export const LinePlaceholder = style([
+ DefaultReset,
+ {
+ width: '100%',
+ height: toRem(16),
+ borderRadius: config.radii.R300,
+ backgroundColor: color.SurfaceVariant.Container,
+ },
+]);
--- /dev/null
+import React from 'react';
+import { Box, as } from 'folds';
+import classNames from 'classnames';
+import * as css from './LinePlaceholder.css';
+
+export const LinePlaceholder = as<'div'>(({ className, ...props }, ref) => (
+ <Box className={classNames(css.LinePlaceholder, className)} shrink="No" {...props} ref={ref} />
+));
--- /dev/null
+export * from './LinePlaceholder';
+export * from './CompactPlaceholder';
+export * from './DefaultPlaceholder';
--- /dev/null
+import React, { useCallback } from 'react';
+import { Avatar, AvatarFallback, AvatarImage, Box, Button, Spinner, Text, as, color } from 'folds';
+import { Room } from 'matrix-js-sdk';
+import { openInviteUser, selectRoom } from '../../../client/action/navigation';
+import { useStateEvent } from '../../hooks/useStateEvent';
+import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
+import { getMemberDisplayName, getStateEvent } from '../../utils/room';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
+
+export type RoomIntroProps = {
+ room: Room;
+};
+
+export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const createEvent = getStateEvent(room, StateEvent.RoomCreate);
+ const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
+ const nameEvent = useStateEvent(room, StateEvent.RoomName);
+ const topicEvent = useStateEvent(room, StateEvent.RoomTopic);
+ const createContent = createEvent?.getContent<IRoomCreateContent>();
+
+ const ts = createEvent?.getTs();
+ const creatorId = createEvent?.getSender();
+ const creatorName =
+ creatorId && (getMemberDisplayName(room, creatorId) ?? getMxIdLocalPart(creatorId));
+ const prevRoomId = createContent?.predecessor?.room_id;
+ const avatarMxc = (avatarEvent?.getContent().url as string) || undefined;
+ const avatarHttpUrl = avatarMxc ? mx.mxcUrlToHttp(avatarMxc) : undefined;
+ const name = (nameEvent?.getContent().name || room.name) as string;
+ const topic = (topicEvent?.getContent().topic as string) || undefined;
+
+ const [prevRoomState, joinPrevRoom] = useAsyncCallback(
+ useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx])
+ );
+
+ return (
+ <Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
+ <Box>
+ <Avatar size="500">
+ {avatarHttpUrl ? (
+ <AvatarImage src={avatarHttpUrl} alt={name} />
+ ) : (
+ <AvatarFallback
+ style={{
+ backgroundColor: color.SurfaceVariant.Container,
+ color: color.SurfaceVariant.OnContainer,
+ }}
+ >
+ <Text size="H2">{name[0]}</Text>
+ </AvatarFallback>
+ )}
+ </Avatar>
+ </Box>
+ <Box direction="Column" gap="300">
+ <Box direction="Column" gap="100">
+ <Text size="H3" priority="500">
+ {name}
+ </Text>
+ <Text size="T400" priority="400">
+ {typeof topic === 'string' ? topic : 'This is the beginning of conversation.'}
+ </Text>
+ {creatorName && ts && (
+ <Text size="T200" priority="300">
+ {'Created by '}
+ <b>@{creatorName}</b>
+ {` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts)}`}
+ </Text>
+ )}
+ </Box>
+ <Box gap="200" wrap="Wrap">
+ <Button
+ onClick={() => openInviteUser(room.roomId)}
+ variant="Secondary"
+ size="300"
+ radii="300"
+ >
+ <Text size="B300">Invite Member</Text>
+ </Button>
+ {typeof prevRoomId === 'string' &&
+ (mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (
+ <Button
+ onClick={() => selectRoom(prevRoomId)}
+ variant="Success"
+ size="300"
+ fill="Soft"
+ radii="300"
+ >
+ <Text size="B300">Open Old Room</Text>
+ </Button>
+ ) : (
+ <Button
+ onClick={() => joinPrevRoom(prevRoomId)}
+ variant="Secondary"
+ size="300"
+ fill="Soft"
+ radii="300"
+ disabled={prevRoomState.status === AsyncStatus.Loading}
+ after={
+ prevRoomState.status === AsyncStatus.Loading ? (
+ <Spinner size="50" variant="Secondary" fill="Soft" />
+ ) : undefined
+ }
+ >
+ <Text size="B300">Join Old Room</Text>
+ </Button>
+ ))}
+ </Box>
+ </Box>
+ </Box>
+ );
+});
--- /dev/null
+export * from './RoomIntro';
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config } from 'folds';
+
+export const TextViewer = style([
+ DefaultReset,
+ {
+ height: '100%',
+ },
+]);
+
+export const TextViewerHeader = style([
+ DefaultReset,
+ {
+ paddingLeft: config.space.S200,
+ paddingRight: config.space.S200,
+ borderBottomWidth: config.borderWidth.B300,
+ flexShrink: 0,
+ gap: config.space.S200,
+ },
+]);
+
+export const TextViewerContent = style([
+ DefaultReset,
+ {
+ backgroundColor: color.Background.Container,
+ color: color.Background.OnContainer,
+ overflow: 'hidden',
+ },
+]);
+
+export const TextViewerPre = style([
+ DefaultReset,
+ {
+ padding: config.space.S600,
+ whiteSpace: 'pre-wrap',
+ },
+]);
--- /dev/null
+/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
+import React, { Suspense, lazy } from 'react';
+import classNames from 'classnames';
+import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'folds';
+import { ErrorBoundary } from 'react-error-boundary';
+import * as css from './TextViewer.css';
+import { mimeTypeToExt } from '../../utils/mimeTypes';
+import { copyToClipboard } from '../../utils/dom';
+
+const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism'));
+
+export type TextViewerProps = {
+ name: string;
+ text: string;
+ mimeType: string;
+ requestClose: () => void;
+};
+
+export const TextViewer = as<'div', TextViewerProps>(
+ ({ className, name, text, mimeType, requestClose, ...props }, ref) => {
+ const handleCopy = () => {
+ copyToClipboard(text);
+ };
+
+ return (
+ <Box
+ className={classNames(css.TextViewer, className)}
+ direction="Column"
+ {...props}
+ ref={ref}
+ >
+ <Header className={css.TextViewerHeader} size="400">
+ <Box grow="Yes" alignItems="Center" gap="200">
+ <IconButton size="300" radii="300" onClick={requestClose}>
+ <Icon size="50" src={Icons.ArrowLeft} />
+ </IconButton>
+ <Text size="T300" truncate>
+ {name}
+ </Text>
+ </Box>
+ <Box shrink="No" alignItems="Center" gap="200">
+ <Chip variant="Primary" radii="300" onClick={handleCopy}>
+ <Text size="B300">Copy All</Text>
+ </Chip>
+ </Box>
+ </Header>
+ <Box
+ grow="Yes"
+ className={css.TextViewerContent}
+ justifyContent="Center"
+ alignItems="Center"
+ >
+ <Scroll hideTrack variant="Background" visibility="Hover">
+ <Text
+ as="pre"
+ className={classNames(css.TextViewerPre, `language-${mimeTypeToExt(mimeType)}`)}
+ >
+ <ErrorBoundary fallback={<code>{text}</code>}>
+ <Suspense fallback={<code>{text}</code>}>
+ <ReactPrism>{(codeRef) => <code ref={codeRef}>{text}</code>}</ReactPrism>
+ </Suspense>
+ </ErrorBoundary>
+ </Text>
+ </Scroll>
+ </Box>
+ </Box>
+ );
+ }
+);
--- /dev/null
+export * from './TextViewer';
--- /dev/null
+import { keyframes } from '@vanilla-extract/css';
+import { recipe } from '@vanilla-extract/recipes';
+import { DefaultReset, toRem } from 'folds';
+
+const TypingDotAnime = keyframes({
+ to: {
+ opacity: '0.4',
+ transform: 'translateY(-15%)',
+ },
+});
+
+export const TypingDot = recipe({
+ base: [
+ DefaultReset,
+ {
+ display: 'inline-block',
+ backgroundColor: 'currentColor',
+ borderRadius: '50%',
+ transform: 'translateY(15%)',
+ animation: `${TypingDotAnime} 0.6s infinite alternate`,
+ },
+ ],
+ variants: {
+ size: {
+ '300': {
+ width: toRem(4),
+ height: toRem(4),
+ },
+ '400': {
+ width: toRem(8),
+ height: toRem(8),
+ },
+ },
+ index: {
+ '0': {
+ animationDelay: '0s',
+ },
+ '1': {
+ animationDelay: '0.2s',
+ },
+ '2': {
+ animationDelay: '0.4s',
+ },
+ },
+ },
+ defaultVariants: {
+ size: '400',
+ },
+});
--- /dev/null
+import React from 'react';
+import { Box, as, toRem } from 'folds';
+import * as css from './TypingIndicator.css';
+
+export type TypingIndicatorProps = {
+ size?: '300' | '400';
+};
+
+export const TypingIndicator = as<'div', TypingIndicatorProps>(({ size, style, ...props }, ref) => (
+ <Box
+ as="span"
+ alignItems="Center"
+ style={{ gap: toRem(size === '300' ? 1 : 2), ...style }}
+ {...props}
+ ref={ref}
+ >
+ <span className={css.TypingDot({ size, index: '0' })} />
+ <span className={css.TypingDot({ size, index: '1' })} />
+ <span className={css.TypingDot({ size, index: '2' })} />
+ </Box>
+));
--- /dev/null
+export * from './TypingIndicator';
--- /dev/null
+export * from './useMediaPlay';
+export * from './useMediaPlayTimeCallback';
+export * from './useMediaPlaybackRate';
+export * from './useMediaSeek';
+export * from './useMediaVolume';
+export * from './useMediaLoading';
--- /dev/null
+import { useEffect, useState } from 'react';
+
+export type MediaLoadingData = {
+ loading: boolean;
+ error: boolean;
+};
+
+export const useMediaLoading = (
+ getTargetElement: () => HTMLMediaElement | null
+): MediaLoadingData => {
+ const [loadingData, setLoadingData] = useState<MediaLoadingData>({
+ loading: false,
+ error: false,
+ });
+
+ useEffect(() => {
+ const targetEl = getTargetElement();
+ const handleStart = () => {
+ setLoadingData({
+ loading: true,
+ error: false,
+ });
+ };
+ const handleStop = () => {
+ setLoadingData({
+ loading: false,
+ error: false,
+ });
+ };
+ const handleError = () => {
+ setLoadingData({
+ loading: false,
+ error: true,
+ });
+ };
+ targetEl?.addEventListener('loadstart', handleStart);
+ targetEl?.addEventListener('loadeddata', handleStop);
+ targetEl?.addEventListener('stalled', handleStop);
+ targetEl?.addEventListener('suspend', handleStop);
+ targetEl?.addEventListener('error', handleError);
+ return () => {
+ targetEl?.removeEventListener('loadstart', handleStart);
+ targetEl?.removeEventListener('loadeddata', handleStop);
+ targetEl?.removeEventListener('stalled', handleStop);
+ targetEl?.removeEventListener('suspend', handleStop);
+ targetEl?.removeEventListener('error', handleError);
+ };
+ }, [getTargetElement]);
+
+ return loadingData;
+};
--- /dev/null
+import { useCallback, useEffect, useState } from 'react';
+
+export type MediaPlayData = {
+ playing: boolean;
+};
+
+export type MediaPlayControl = {
+ setPlaying: (play: boolean) => void;
+};
+
+export const useMediaPlay = (
+ getTargetElement: () => HTMLMediaElement | null
+): MediaPlayData & MediaPlayControl => {
+ const [playing, setPlay] = useState(false);
+
+ const setPlaying = useCallback(
+ (play: boolean) => {
+ const targetEl = getTargetElement();
+ if (!targetEl) return;
+ if (play) targetEl.play();
+ else targetEl.pause();
+ },
+ [getTargetElement]
+ );
+
+ useEffect(() => {
+ const targetEl = getTargetElement();
+ const handleChange = () => {
+ if (!targetEl) return;
+ setPlay(targetEl.paused === false);
+ };
+ targetEl?.addEventListener('playing', handleChange);
+ targetEl?.addEventListener('play', handleChange);
+ targetEl?.addEventListener('pause', handleChange);
+ return () => {
+ targetEl?.removeEventListener('playing', handleChange);
+ targetEl?.removeEventListener('play', handleChange);
+ targetEl?.removeEventListener('pause', handleChange);
+ };
+ }, [getTargetElement]);
+
+ return {
+ playing,
+ setPlaying,
+ };
+};
--- /dev/null
+import { useEffect } from 'react';
+
+export type PlayTimeCallback = (duration: number, currentTime: number) => void;
+
+export const useMediaPlayTimeCallback = (
+ getTargetElement: () => HTMLMediaElement | null,
+ onPlayTimeCallback: PlayTimeCallback
+): void => {
+ useEffect(() => {
+ const targetEl = getTargetElement();
+ const handleChange = () => {
+ if (!targetEl) return;
+ onPlayTimeCallback(targetEl.duration, targetEl.currentTime);
+ };
+ targetEl?.addEventListener('timeupdate', handleChange);
+ targetEl?.addEventListener('loadedmetadata', handleChange);
+ targetEl?.addEventListener('ended', handleChange);
+ return () => {
+ targetEl?.removeEventListener('timeupdate', handleChange);
+ targetEl?.removeEventListener('loadedmetadata', handleChange);
+ targetEl?.removeEventListener('ended', handleChange);
+ };
+ }, [getTargetElement, onPlayTimeCallback]);
+};
--- /dev/null
+import { useCallback, useEffect, useState } from 'react';
+
+export type MediaPlaybackRateData = {
+ playbackRate: number;
+};
+export type MediaPlaybackRateControl = {
+ setPlaybackRate: (rate: number) => void;
+};
+
+export const useMediaPlaybackRate = (
+ getTargetElement: () => HTMLMediaElement | null
+): MediaPlaybackRateData & MediaPlaybackRateControl => {
+ const [rate, setRate] = useState(1.0);
+
+ const setPlaybackRate = useCallback(
+ (playbackRate: number) => {
+ const targetEl = getTargetElement();
+ if (!targetEl) return;
+ targetEl.playbackRate = playbackRate;
+ },
+ [getTargetElement]
+ );
+
+ useEffect(() => {
+ const targetEl = getTargetElement();
+ const handleChange = () => {
+ if (!targetEl) return;
+ setRate(targetEl.playbackRate);
+ };
+ targetEl?.addEventListener('ratechange', handleChange);
+ return () => {
+ targetEl?.removeEventListener('ratechange', handleChange);
+ };
+ }, [getTargetElement]);
+
+ return {
+ playbackRate: rate,
+ setPlaybackRate,
+ };
+};
--- /dev/null
+import { useCallback, useEffect, useState } from 'react';
+
+export type MediaSeekData = {
+ seeking: boolean;
+ seekable?: TimeRanges;
+};
+export type MediaSeekControl = {
+ seek: (time: number) => void;
+};
+
+export const useMediaSeek = (
+ getTargetElement: () => HTMLMediaElement | null
+): MediaSeekData & MediaSeekControl => {
+ const [seekData, setSeekData] = useState<MediaSeekData>({
+ seeking: false,
+ seekable: undefined,
+ });
+
+ const seek = useCallback(
+ (time: number) => {
+ const targetEl = getTargetElement();
+ if (!targetEl) return;
+ targetEl.currentTime = time;
+ },
+ [getTargetElement]
+ );
+
+ useEffect(() => {
+ const targetEl = getTargetElement();
+ const handleChange = () => {
+ if (!targetEl) return;
+ setSeekData({
+ seeking: targetEl.seeking,
+ seekable: targetEl.seekable,
+ });
+ };
+ targetEl?.addEventListener('loadedmetadata', handleChange);
+ targetEl?.addEventListener('seeked', handleChange);
+ targetEl?.addEventListener('seeking', handleChange);
+ return () => {
+ targetEl?.removeEventListener('loadedmetadata', handleChange);
+ targetEl?.removeEventListener('seeked', handleChange);
+ targetEl?.removeEventListener('seeking', handleChange);
+ };
+ }, [getTargetElement]);
+
+ return {
+ ...seekData,
+ seek,
+ };
+};
--- /dev/null
+import { useCallback, useEffect, useState } from 'react';
+
+export type MediaVolumeData = {
+ volume: number;
+ mute: boolean;
+};
+
+export type MediaVolumeControl = {
+ setMute: (mute: boolean) => void;
+ setVolume: (volume: number) => void;
+};
+
+export const useMediaVolume = (
+ getTargetElement: () => HTMLMediaElement | null
+): MediaVolumeData & MediaVolumeControl => {
+ const [volumeData, setVolumeData] = useState<MediaVolumeData>({
+ volume: 1,
+ mute: false,
+ });
+
+ const setMute = useCallback(
+ (mute: boolean) => {
+ const targetEl = getTargetElement();
+ if (!targetEl) return;
+ targetEl.muted = mute;
+ },
+ [getTargetElement]
+ );
+
+ const setVolume = useCallback(
+ (volume: number) => {
+ const targetEl = getTargetElement();
+ if (!targetEl) return;
+ targetEl.volume = volume;
+ },
+ [getTargetElement]
+ );
+
+ useEffect(() => {
+ const targetEl = getTargetElement();
+ const handleChange = () => {
+ if (!targetEl) return;
+
+ setVolumeData({
+ mute: targetEl.muted,
+ volume: Math.max(0, Math.min(targetEl.volume, 1)),
+ });
+ };
+ targetEl?.addEventListener('volumechange', handleChange);
+ return () => {
+ targetEl?.removeEventListener('volumechange', handleChange);
+ };
+ }, [getTargetElement]);
+
+ return {
+ ...volumeData,
+ setMute,
+ setVolume,
+ };
+};
setIntersectionObserver(new IntersectionObserver(onIntersectionCallback, initOpts));
}, [onIntersectionCallback, opts]);
+ useEffect(() => () => intersectionObserver?.disconnect(), [intersectionObserver]);
+
useEffect(() => {
const element = typeof observeElement === 'function' ? observeElement() : observeElement;
if (element) intersectionObserver?.observe(element);
--- /dev/null
+import { ReactNode } from 'react';
+import { MatrixEvent } from 'matrix-js-sdk';
+import { MessageEvent, StateEvent } from '../../types/matrix/room';
+
+export type EventRenderer<T extends unknown[]> = (
+ eventId: string,
+ mEvent: MatrixEvent,
+ ...args: T
+) => ReactNode;
+
+export type EventRendererOpts<T extends unknown[]> = {
+ renderRoomMessage?: EventRenderer<T>;
+ renderRoomEncrypted?: EventRenderer<T>;
+ renderSticker?: EventRenderer<T>;
+ renderRoomMember?: EventRenderer<T>;
+ renderRoomName?: EventRenderer<T>;
+ renderRoomTopic?: EventRenderer<T>;
+ renderRoomAvatar?: EventRenderer<T>;
+ renderStateEvent?: EventRenderer<T>;
+ renderEvent?: EventRenderer<T>;
+};
+
+export type RenderMatrixEvent<T extends unknown[]> = (
+ eventId: string,
+ mEvent: MatrixEvent,
+ ...args: T
+) => ReactNode;
+
+export const useMatrixEventRenderer =
+ <T extends unknown[]>({
+ renderRoomMessage,
+ renderRoomEncrypted,
+ renderSticker,
+ renderRoomMember,
+ renderRoomName,
+ renderRoomTopic,
+ renderRoomAvatar,
+ renderStateEvent,
+ renderEvent,
+ }: EventRendererOpts<T>): RenderMatrixEvent<T> =>
+ (eventId, mEvent, ...args) => {
+ const eventType = mEvent.getWireType();
+
+ if (eventType === MessageEvent.RoomMessage && renderRoomMessage) {
+ return renderRoomMessage(eventId, mEvent, ...args);
+ }
+
+ if (eventType === MessageEvent.RoomMessageEncrypted && renderRoomEncrypted) {
+ return renderRoomEncrypted(eventId, mEvent, ...args);
+ }
+
+ if (eventType === MessageEvent.Sticker && renderSticker) {
+ return renderSticker(eventId, mEvent, ...args);
+ }
+
+ if (eventType === StateEvent.RoomMember && renderRoomMember) {
+ return renderRoomMember(eventId, mEvent, ...args);
+ }
+
+ if (eventType === StateEvent.RoomName && renderRoomName) {
+ return renderRoomName(eventId, mEvent, ...args);
+ }
+
+ if (eventType === StateEvent.RoomTopic && renderRoomTopic) {
+ return renderRoomTopic(eventId, mEvent, ...args);
+ }
+
+ if (eventType === StateEvent.RoomAvatar && renderRoomAvatar) {
+ return renderRoomAvatar(eventId, mEvent, ...args);
+ }
+
+ if (typeof mEvent.getStateKey() === 'string' && renderStateEvent) {
+ return renderStateEvent(eventId, mEvent, ...args);
+ }
+
+ if (typeof mEvent.getStateKey() !== 'string' && renderEvent) {
+ return renderEvent(eventId, mEvent, ...args);
+ }
+ return null;
+ };
--- /dev/null
+import React, { ReactNode } from 'react';
+import { IconSrc, Icons } from 'folds';
+import { MatrixEvent } from 'matrix-js-sdk';
+import { IMemberContent, Membership } from '../../types/matrix/room';
+import { getMxIdLocalPart } from '../utils/matrix';
+
+export type ParsedResult = {
+ icon: IconSrc;
+ body: ReactNode;
+};
+
+export type MemberEventParser = (mEvent: MatrixEvent) => ParsedResult;
+
+export const useMemberEventParser = (): MemberEventParser => {
+ const parseMemberEvent: MemberEventParser = (mEvent) => {
+ const content = mEvent.getContent<IMemberContent>();
+ const prevContent = mEvent.getPrevContent() as IMemberContent;
+ const senderId = mEvent.getSender();
+ const userId = mEvent.getStateKey();
+
+ if (!senderId || !userId)
+ return {
+ icon: Icons.User,
+ body: 'Broken membership event',
+ };
+
+ const senderName = getMxIdLocalPart(senderId);
+ const userName = content.displayname || getMxIdLocalPart(userId);
+
+ if (content.membership !== prevContent.membership) {
+ if (content.membership === Membership.Invite) {
+ if (prevContent.membership === Membership.Knock) {
+ return {
+ icon: Icons.ArrowGoRightPlus,
+ body: (
+ <>
+ <b>{senderName}</b>
+ {' accepted '}
+ <b>{userName}</b>
+ {`'s join request `}
+ {content.reason}
+ </>
+ ),
+ };
+ }
+
+ return {
+ icon: Icons.ArrowGoRightPlus,
+ body: (
+ <>
+ <b>{senderName}</b>
+ {' invited '}
+ <b>{userName}</b> {content.reason}
+ </>
+ ),
+ };
+ }
+
+ if (content.membership === Membership.Knock) {
+ return {
+ icon: Icons.ArrowGoRightPlus,
+ body: (
+ <>
+ <b>{userName}</b>
+ {' request to join room '}
+ {content.reason}
+ </>
+ ),
+ };
+ }
+
+ if (content.membership === Membership.Join) {
+ return {
+ icon: Icons.ArrowGoRight,
+ body: (
+ <>
+ <b>{userName}</b>
+ {' joined the room'}
+ </>
+ ),
+ };
+ }
+
+ if (content.membership === Membership.Leave) {
+ if (prevContent.membership === Membership.Invite) {
+ return {
+ icon: Icons.ArrowGoRightCross,
+ body:
+ senderId === userId ? (
+ <>
+ <b>{userName}</b>
+ {' reject the invitation '}
+ {content.reason}
+ </>
+ ) : (
+ <>
+ <b>{senderName}</b>
+ {' reject '}
+ <b>{userName}</b>
+ {`'s join request `}
+ {content.reason}
+ </>
+ ),
+ };
+ }
+
+ if (prevContent.membership === Membership.Knock) {
+ return {
+ icon: Icons.ArrowGoRightCross,
+ body:
+ senderId === userId ? (
+ <>
+ <b>{userName}</b>
+ {' revoked joined request '}
+ {content.reason}
+ </>
+ ) : (
+ <>
+ <b>{senderName}</b>
+ {' revoked '}
+ <b>{userName}</b>
+ {`'s invite `}
+ {content.reason}
+ </>
+ ),
+ };
+ }
+
+ if (prevContent.membership === Membership.Ban) {
+ return {
+ icon: Icons.ArrowGoLeft,
+ body: (
+ <>
+ <b>{senderName}</b>
+ {' unbanned '}
+ <b>{userName}</b> {content.reason}
+ </>
+ ),
+ };
+ }
+
+ return {
+ icon: Icons.ArrowGoLeft,
+ body:
+ senderId === userId ? (
+ <>
+ <b>{userName}</b>
+ {' left the room '}
+ {content.reason}
+ </>
+ ) : (
+ <>
+ <b>{senderName}</b>
+ {' kicked '}
+ <b>{userName}</b> {content.reason}
+ </>
+ ),
+ };
+ }
+
+ if (content.membership === Membership.Ban) {
+ return {
+ icon: Icons.ArrowGoLeft,
+ body: (
+ <>
+ <b>{senderName}</b>
+ {' banned '}
+ <b>{userName}</b> {content.reason}
+ </>
+ ),
+ };
+ }
+ }
+
+ if (content.displayname !== prevContent.displayname) {
+ const prevUserName = prevContent.displayname || userId;
+
+ return {
+ icon: Icons.Mention,
+ body: content.displayname ? (
+ <>
+ <b>{prevUserName}</b>
+ {' changed display name to '}
+ <b>{userName}</b>
+ </>
+ ) : (
+ <>
+ <b>{prevUserName}</b>
+ {' removed their display name '}
+ </>
+ ),
+ };
+ }
+ if (content.avatar_url !== prevContent.avatar_url) {
+ return {
+ icon: Icons.User,
+ body: content.displayname ? (
+ <>
+ <b>{userName}</b>
+ {' changed their avatar'}
+ </>
+ ) : (
+ <>
+ <b>{userName}</b>
+ {' removed their avatar '}
+ </>
+ ),
+ };
+ }
+
+ return {
+ icon: Icons.User,
+ body: 'Broken membership event',
+ };
+ };
+
+ return parseMemberEvent;
+};
--- /dev/null
+import { MouseEventHandler, useEffect, useState } from 'react';
+
+export type Pan = {
+ translateX: number;
+ translateY: number;
+};
+
+const INITIAL_PAN = {
+ translateX: 0,
+ translateY: 0,
+};
+
+export const usePan = (active: boolean) => {
+ const [pan, setPan] = useState<Pan>(INITIAL_PAN);
+ const [cursor, setCursor] = useState<'grab' | 'grabbing' | 'initial'>(
+ active ? 'grab' : 'initial'
+ );
+
+ useEffect(() => {
+ setCursor(active ? 'grab' : 'initial');
+ }, [active]);
+
+ const handleMouseMove = (evt: MouseEvent) => {
+ evt.preventDefault();
+ evt.stopPropagation();
+
+ setPan((p) => {
+ const { translateX, translateY } = p;
+ const mX = translateX + evt.movementX;
+ const mY = translateY + evt.movementY;
+
+ return { translateX: mX, translateY: mY };
+ });
+ };
+
+ const handleMouseUp = (evt: MouseEvent) => {
+ evt.preventDefault();
+ setCursor('grab');
+
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+
+ const handleMouseDown: MouseEventHandler<HTMLElement> = (evt) => {
+ if (!active) return;
+ evt.preventDefault();
+ setCursor('grabbing');
+
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ };
+
+ useEffect(() => {
+ if (!active) setPan(INITIAL_PAN);
+ }, [active]);
+
+ return {
+ pan,
+ cursor,
+ onMouseDown: handleMouseDown,
+ };
+};
import { Room } from 'matrix-js-sdk';
-import { useCallback } from 'react';
+import { createContext, useCallback, useContext } from 'react';
import { useStateEvent } from './useStateEvent';
import { StateEvent } from '../../types/matrix/room';
+export type PowerLevelActions = 'invite' | 'redact' | 'kick' | 'ban' | 'historical';
+
enum DefaultPowerLevels {
usersDefault = 0,
stateDefault = 50,
notifications?: Record<string, number>;
}
-export function usePowerLevels(room: Room) {
+export type GetPowerLevel = (userId: string) => number;
+export type CanSend = (eventType: string | undefined, powerLevel: number) => boolean;
+export type CanDoAction = (action: PowerLevelActions, powerLevel: number) => boolean;
+
+export type PowerLevelsAPI = {
+ getPowerLevel: GetPowerLevel;
+ canSendEvent: CanSend;
+ canSendStateEvent: CanSend;
+ canDoAction: CanDoAction;
+};
+
+export function usePowerLevels(room: Room): PowerLevelsAPI {
const powerLevelsEvent = useStateEvent(room, StateEvent.RoomPowerLevels);
const powerLevels: IPowerLevels = powerLevelsEvent?.getContent() ?? DefaultPowerLevels;
- const getPowerLevel = useCallback(
- (userId: string) => {
+ const getPowerLevel: GetPowerLevel = useCallback(
+ (userId) => {
const { users_default: usersDefault, users } = powerLevels;
if (users && typeof users[userId] === 'number') {
return users[userId];
[powerLevels]
);
- const canSendEvent = useCallback(
- (eventType: string | undefined, powerLevel: number) => {
+ const canSendEvent: CanSend = useCallback(
+ (eventType, powerLevel) => {
const { events, events_default: eventsDefault } = powerLevels;
if (events && eventType && typeof events[eventType] === 'number') {
return powerLevel >= events[eventType];
[powerLevels]
);
- const canSendStateEvent = useCallback(
- (eventType: string | undefined, powerLevel: number) => {
+ const canSendStateEvent: CanSend = useCallback(
+ (eventType, powerLevel) => {
const { events, state_default: stateDefault } = powerLevels;
if (events && eventType && typeof events[eventType] === 'number') {
return powerLevel >= events[eventType];
[powerLevels]
);
- const canDoAction = useCallback(
- (action: 'invite' | 'redact' | 'kick' | 'ban' | 'historical', powerLevel: number) => {
+ const canDoAction: CanDoAction = useCallback(
+ (action, powerLevel) => {
const requiredPL = powerLevels[action];
if (typeof requiredPL === 'number') {
return powerLevel >= requiredPL;
canDoAction,
};
}
+
+export const PowerLevelsContext = createContext<PowerLevelsAPI | null>(null);
+
+export const PowerLevelsContextProvider = PowerLevelsContext.Provider;
+
+export const usePowerLevelsAPI = (): PowerLevelsAPI => {
+ const api = useContext(PowerLevelsContext);
+ if (!api) throw new Error('PowerLevelContext is not initialized!');
+ return api;
+};
--- /dev/null
+import { useEffect, useState } from 'react';
+import { RelationsEvent, type Relations } from 'matrix-js-sdk/lib/models/relations';
+
+export const useRelations = <T>(
+ relations: Relations,
+ getRelations: (relations: Relations) => T
+) => {
+ const [data, setData] = useState(() => getRelations(relations));
+
+ useEffect(() => {
+ const handleUpdate = () => {
+ setData(getRelations(relations));
+ };
+ relations.on(RelationsEvent.Add, handleUpdate);
+ relations.on(RelationsEvent.Redaction, handleUpdate);
+ relations.on(RelationsEvent.Remove, handleUpdate);
+ return () => {
+ relations.removeListener(RelationsEvent.Add, handleUpdate);
+ relations.removeListener(RelationsEvent.Redaction, handleUpdate);
+ relations.removeListener(RelationsEvent.Remove, handleUpdate);
+ };
+ }, [relations, getRelations]);
+
+ return data;
+};
): ResizeObserver => {
const resizeObserver = useMemo(() => new ResizeObserver(onResizeCallback), [onResizeCallback]);
+ useEffect(() => () => resizeObserver?.disconnect(), [resizeObserver]);
+
useEffect(() => {
const element = typeof observeElement === 'function' ? observeElement() : observeElement;
if (element) resizeObserver.observe(element);
--- /dev/null
+import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
+import { useEffect, useState } from 'react';
+
+const getEventReaders = (room: Room, evtId?: string) => {
+ if (!evtId) return [];
+ const liveEvents = room.getLiveTimeline().getEvents();
+ const userIds: string[] = [];
+
+ for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
+ userIds.splice(userIds.length, 0, ...room.getUsersReadUpTo(liveEvents[i]));
+ if (liveEvents[i].getId() === evtId) break;
+ }
+
+ return [...new Set(userIds)];
+};
+
+export const useRoomEventReaders = (room: Room, eventId?: string): string[] => {
+ const [readers, setReaders] = useState<string[]>(() => getEventReaders(room, eventId));
+
+ useEffect(() => {
+ setReaders(getEventReaders(room, eventId));
+
+ const handleReceipt: RoomEventHandlerMap[RoomEvent.Receipt] = (event, r) => {
+ if (r.roomId !== room.roomId) return;
+ setReaders(getEventReaders(room, eventId));
+ };
+
+ room.on(RoomEvent.Receipt, handleReceipt);
+ return () => {
+ room.removeListener(RoomEvent.Receipt, handleReceipt);
+ };
+ }, [room, eventId]);
+
+ return readers;
+};
--- /dev/null
+import { MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
+import { useEffect, useState } from 'react';
+
+export const useRoomLatestEvent = (room: Room) => {
+ const [latestEvent, setLatestEvent] = useState<MatrixEvent>();
+
+ useEffect(() => {
+ const getLatestEvent = (): MatrixEvent | undefined => {
+ const liveEvents = room.getLiveTimeline().getEvents();
+ for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
+ const evt = liveEvents[i];
+ if (evt) return evt;
+ }
+ return undefined;
+ };
+
+ const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = () => {
+ setLatestEvent(getLatestEvent());
+ };
+ setLatestEvent(getLatestEvent());
+
+ room.on(RoomEvent.Timeline, handleTimelineEvent);
+ return () => {
+ room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
+ };
+ }, [room]);
+
+ return latestEvent;
+};
--- /dev/null
+import { ReactNode } from 'react';
+import { MatrixEvent, MsgType } from 'matrix-js-sdk';
+
+export type MsgContentRenderer<T extends unknown[]> = (
+ eventId: string,
+ mEvent: MatrixEvent,
+ ...args: T
+) => ReactNode;
+
+export type RoomMsgContentRendererOpts<T extends unknown[]> = {
+ renderText?: MsgContentRenderer<T>;
+ renderEmote?: MsgContentRenderer<T>;
+ renderNotice?: MsgContentRenderer<T>;
+ renderImage?: MsgContentRenderer<T>;
+ renderVideo?: MsgContentRenderer<T>;
+ renderAudio?: MsgContentRenderer<T>;
+ renderFile?: MsgContentRenderer<T>;
+ renderLocation?: MsgContentRenderer<T>;
+ renderBadEncrypted?: MsgContentRenderer<T>;
+ renderUnsupported?: MsgContentRenderer<T>;
+ renderBrokenFallback?: MsgContentRenderer<T>;
+};
+
+export type RenderRoomMsgContent<T extends unknown[]> = (
+ eventId: string,
+ mEvent: MatrixEvent,
+ ...args: T
+) => ReactNode;
+
+export const useRoomMsgContentRenderer =
+ <T extends unknown[]>({
+ renderText,
+ renderEmote,
+ renderNotice,
+ renderImage,
+ renderVideo,
+ renderAudio,
+ renderFile,
+ renderLocation,
+ renderBadEncrypted,
+ renderUnsupported,
+ renderBrokenFallback,
+ }: RoomMsgContentRendererOpts<T>): RenderRoomMsgContent<T> =>
+ (eventId, mEvent, ...args) => {
+ const msgType = mEvent.getContent().msgtype;
+
+ let node: ReactNode = null;
+
+ if (msgType === MsgType.Text && renderText) node = renderText(eventId, mEvent, ...args);
+ else if (msgType === MsgType.Emote && renderEmote) node = renderEmote(eventId, mEvent, ...args);
+ else if (msgType === MsgType.Notice && renderNotice)
+ node = renderNotice(eventId, mEvent, ...args);
+ else if (msgType === MsgType.Image && renderImage) node = renderImage(eventId, mEvent, ...args);
+ else if (msgType === MsgType.Video && renderVideo) node = renderVideo(eventId, mEvent, ...args);
+ else if (msgType === MsgType.Audio && renderAudio) node = renderAudio(eventId, mEvent, ...args);
+ else if (msgType === MsgType.File && renderFile) node = renderFile(eventId, mEvent, ...args);
+ else if (msgType === MsgType.Location && renderLocation)
+ node = renderLocation(eventId, mEvent, ...args);
+ else if (msgType === 'm.bad.encrypted' && renderBadEncrypted)
+ node = renderBadEncrypted(eventId, mEvent, ...args);
+ else if (renderUnsupported) {
+ node = renderUnsupported(eventId, mEvent, ...args);
+ }
+
+ if (!node && renderBrokenFallback) node = renderBrokenFallback(eventId, mEvent, ...args);
+
+ return node;
+ };
--- /dev/null
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
+import { OnIntersectionCallback, useIntersectionObserver } from './useIntersectionObserver';
+import {
+ canFitInScrollView,
+ getScrollInfo,
+ isInScrollView,
+ isIntersectingScrollView,
+} from '../utils/dom';
+
+const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor';
+
+export enum Direction {
+ Backward = 'B',
+ Forward = 'F',
+}
+
+export type ItemRange = {
+ start: number;
+ end: number;
+};
+
+export type ScrollToOptions = {
+ offset?: number;
+ align?: 'start' | 'center' | 'end';
+ behavior?: 'auto' | 'instant' | 'smooth';
+ stopInView?: boolean;
+};
+
+export type ScrollToElement = (element: HTMLElement, opts?: ScrollToOptions) => void;
+export type ScrollToItem = (index: number, opts?: ScrollToOptions) => void;
+
+type HandleObserveAnchor = (element: HTMLElement | null) => void;
+
+type VirtualPaginatorOptions<TScrollElement extends HTMLElement> = {
+ count: number;
+ limit: number;
+ range: ItemRange;
+ onRangeChange: (range: ItemRange) => void;
+ getScrollElement: () => TScrollElement | null;
+ getItemElement: (index: number) => HTMLElement | undefined;
+ onEnd?: (back: boolean) => void;
+};
+
+type VirtualPaginator = {
+ getItems: () => number[];
+ scrollToElement: ScrollToElement;
+ scrollToItem: ScrollToItem;
+ observeBackAnchor: HandleObserveAnchor;
+ observeFrontAnchor: HandleObserveAnchor;
+};
+
+const generateItems = (range: ItemRange) => {
+ const items: number[] = [];
+ for (let i = range.start; i < range.end; i += 1) {
+ items.push(i);
+ }
+
+ return items;
+};
+
+const getDropIndex = (
+ scrollEl: HTMLElement,
+ range: ItemRange,
+ dropDirection: Direction,
+ getItemElement: (index: number) => HTMLElement | undefined,
+ pageThreshold = 1
+): number | undefined => {
+ const fromBackward = dropDirection === Direction.Backward;
+ const items = fromBackward ? generateItems(range) : generateItems(range).reverse();
+
+ const { viewHeight, top, height } = getScrollInfo(scrollEl);
+ const { offsetTop: sOffsetTop } = scrollEl;
+ const bottom = top + viewHeight;
+ const dropEdgePx = fromBackward
+ ? Math.max(top - viewHeight * pageThreshold, 0)
+ : Math.min(bottom + viewHeight * pageThreshold, height);
+ if (dropEdgePx === 0 || dropEdgePx === height) return undefined;
+
+ let dropIndex: number | undefined;
+
+ items.find((item) => {
+ const el = getItemElement(item);
+ if (!el) {
+ dropIndex = item;
+ return false;
+ }
+ const { clientHeight } = el;
+ const offsetTop = el.offsetTop - sOffsetTop;
+ const offsetBottom = offsetTop + clientHeight;
+ const isInView = fromBackward ? offsetBottom > dropEdgePx : offsetTop < dropEdgePx;
+ if (isInView) return true;
+ dropIndex = item;
+ return false;
+ });
+
+ return dropIndex;
+};
+
+type RestoreAnchorData = [number | undefined, HTMLElement | undefined];
+const getRestoreAnchor = (
+ range: ItemRange,
+ getItemElement: (index: number) => HTMLElement | undefined,
+ direction: Direction
+): RestoreAnchorData => {
+ let scrollAnchorEl: HTMLElement | undefined;
+ const scrollAnchorItem = (
+ direction === Direction.Backward ? generateItems(range) : generateItems(range).reverse()
+ ).find((i) => {
+ const el = getItemElement(i);
+ if (el) {
+ scrollAnchorEl = el;
+ return true;
+ }
+ return false;
+ });
+ return [scrollAnchorItem, scrollAnchorEl];
+};
+
+const getRestoreScrollData = (scrollTop: number, restoreAnchorData: RestoreAnchorData) => {
+ const [anchorItem, anchorElement] = restoreAnchorData;
+ if (!anchorItem || !anchorElement) {
+ return undefined;
+ }
+ return {
+ scrollTop,
+ anchorItem,
+ anchorOffsetTop: anchorElement.offsetTop,
+ };
+};
+
+const useObserveAnchorHandle = (
+ intersectionObserver: ReturnType<typeof useIntersectionObserver>,
+ anchorType: Direction
+): HandleObserveAnchor =>
+ useMemo<HandleObserveAnchor>(() => {
+ let anchor: HTMLElement | null = null;
+ return (element) => {
+ if (element === anchor) return;
+ if (anchor) intersectionObserver?.unobserve(anchor);
+ if (!element) return;
+ anchor = element;
+ element.setAttribute(PAGINATOR_ANCHOR_ATTR, anchorType);
+ intersectionObserver?.observe(element);
+ };
+ }, [intersectionObserver, anchorType]);
+
+export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
+ options: VirtualPaginatorOptions<TScrollElement>
+): VirtualPaginator => {
+ const { count, limit, range, onRangeChange, getScrollElement, getItemElement, onEnd } = options;
+
+ const initialRenderRef = useRef(true);
+
+ const restoreScrollRef = useRef<{
+ scrollTop: number;
+ anchorOffsetTop: number;
+ anchorItem: number;
+ }>();
+
+ const scrollToItemRef = useRef<{
+ index: number;
+ opts?: ScrollToOptions;
+ }>();
+
+ const propRef = useRef({
+ range,
+ limit,
+ count,
+ });
+ if (propRef.current.count !== count) {
+ // Clear restoreScrollRef on count change
+ // As restoreScrollRef.current.anchorItem might changes
+ restoreScrollRef.current = undefined;
+ }
+ propRef.current = {
+ range,
+ count,
+ limit,
+ };
+
+ const getItems = useMemo(() => {
+ const items = generateItems(range);
+ return () => items;
+ }, [range]);
+
+ const scrollToElement = useCallback<ScrollToElement>(
+ (element, opts) => {
+ const scrollElement = getScrollElement();
+ if (!scrollElement) return;
+
+ if (opts?.stopInView && isInScrollView(scrollElement, element)) {
+ return;
+ }
+ let scrollTo = element.offsetTop;
+ if (opts?.align === 'center' && canFitInScrollView(scrollElement, element)) {
+ const scrollInfo = getScrollInfo(scrollElement);
+ scrollTo =
+ element.offsetTop -
+ Math.round(scrollInfo.viewHeight / 2) +
+ Math.round(element.clientHeight / 2);
+ } else if (opts?.align === 'end' && canFitInScrollView(scrollElement, element)) {
+ const scrollInfo = getScrollInfo(scrollElement);
+ scrollTo = element.offsetTop - Math.round(scrollInfo.viewHeight) + element.clientHeight;
+ }
+
+ scrollElement.scrollTo({
+ top: scrollTo - (opts?.offset ?? 0),
+ behavior: opts?.behavior,
+ });
+ },
+ [getScrollElement]
+ );
+
+ const scrollToItem = useCallback<ScrollToItem>(
+ (index, opts) => {
+ const { range: currentRange, limit: currentLimit, count: currentCount } = propRef.current;
+
+ if (index < 0 || index >= currentCount) return;
+ // index is not in range change range
+ // and trigger scrollToItem in layoutEffect hook
+ if (index < currentRange.start || index >= currentRange.end) {
+ onRangeChange({
+ start: Math.max(index - currentLimit, 0),
+ end: Math.min(index + currentLimit, currentCount),
+ });
+ scrollToItemRef.current = {
+ index,
+ opts,
+ };
+ return;
+ }
+
+ // find target or it's previous rendered element to scroll to
+ const targetItems = generateItems({ start: currentRange.start, end: index + 1 });
+ const targetItem = targetItems.reverse().find((i) => getItemElement(i) !== undefined);
+ const itemElement = targetItem && getItemElement(targetItem);
+
+ if (!itemElement) {
+ const scrollElement = getScrollElement();
+ scrollElement?.scrollTo({
+ top: opts?.offset ?? 0,
+ behavior: opts?.behavior,
+ });
+ return;
+ }
+ scrollToElement(itemElement, opts);
+ },
+ [getScrollElement, scrollToElement, getItemElement, onRangeChange]
+ );
+
+ const paginate = useCallback(
+ (direction: Direction) => {
+ const scrollEl = getScrollElement();
+ const { range: currentRange, limit: currentLimit, count: currentCount } = propRef.current;
+ let { start, end } = currentRange;
+
+ if (direction === Direction.Backward) {
+ restoreScrollRef.current = undefined;
+ if (start === 0) {
+ onEnd?.(true);
+ return;
+ }
+ if (scrollEl) {
+ restoreScrollRef.current = getRestoreScrollData(
+ scrollEl.scrollTop,
+ getRestoreAnchor({ start, end }, getItemElement, Direction.Backward)
+ );
+ }
+ if (scrollEl) {
+ end = getDropIndex(scrollEl, currentRange, Direction.Forward, getItemElement, 2) ?? end;
+ }
+ start = Math.max(start - currentLimit, 0);
+ }
+
+ if (direction === Direction.Forward) {
+ restoreScrollRef.current = undefined;
+ if (end === currentCount) {
+ onEnd?.(false);
+ return;
+ }
+ if (scrollEl) {
+ restoreScrollRef.current = getRestoreScrollData(
+ scrollEl.scrollTop,
+ getRestoreAnchor({ start, end }, getItemElement, Direction.Forward)
+ );
+ }
+ end = Math.min(end + currentLimit, currentCount);
+ if (scrollEl) {
+ start =
+ getDropIndex(scrollEl, currentRange, Direction.Backward, getItemElement, 2) ?? start;
+ }
+ }
+
+ onRangeChange({
+ start,
+ end,
+ });
+ },
+ [getScrollElement, getItemElement, onEnd, onRangeChange]
+ );
+
+ const handlePaginatorElIntersection: OnIntersectionCallback = useCallback(
+ (entries) => {
+ const anchorB = entries.find(
+ (entry) => entry.target.getAttribute(PAGINATOR_ANCHOR_ATTR) === Direction.Backward
+ );
+ if (anchorB?.isIntersecting) {
+ paginate(Direction.Backward);
+ }
+ const anchorF = entries.find(
+ (entry) => entry.target.getAttribute(PAGINATOR_ANCHOR_ATTR) === Direction.Forward
+ );
+ if (anchorF?.isIntersecting) {
+ paginate(Direction.Forward);
+ }
+ },
+ [paginate]
+ );
+
+ const intersectionObserver = useIntersectionObserver(
+ handlePaginatorElIntersection,
+ useMemo(
+ () => ({
+ root: getScrollElement(),
+ }),
+ [getScrollElement]
+ )
+ );
+
+ const observeBackAnchor = useObserveAnchorHandle(intersectionObserver, Direction.Backward);
+ const observeFrontAnchor = useObserveAnchorHandle(intersectionObserver, Direction.Forward);
+
+ // Restore scroll when local pagination.
+ // restoreScrollRef.current only gets set
+ // when paginate() changes range itself
+ useLayoutEffect(() => {
+ const scrollEl = getScrollElement();
+ if (!restoreScrollRef.current || !scrollEl) return;
+ const {
+ anchorOffsetTop: oldOffsetTop,
+ anchorItem,
+ scrollTop: oldScrollTop,
+ } = restoreScrollRef.current;
+ const anchorEl = getItemElement(anchorItem);
+
+ if (!anchorEl) return;
+ const { offsetTop } = anchorEl;
+ const offsetAddition = offsetTop - oldOffsetTop;
+ const restoreTop = oldScrollTop + offsetAddition;
+
+ scrollEl.scrollTo({
+ top: restoreTop,
+ behavior: 'instant',
+ });
+ restoreScrollRef.current = undefined;
+ }, [range, getScrollElement, getItemElement]);
+
+ // When scrollToItem index was not in range.
+ // Scroll to item after range changes.
+ useLayoutEffect(() => {
+ if (scrollToItemRef.current === undefined) return;
+ const { index, opts } = scrollToItemRef.current;
+ scrollToItem(index, {
+ ...opts,
+ behavior: 'instant',
+ });
+ scrollToItemRef.current = undefined;
+ }, [range, scrollToItem]);
+
+ // Continue pagination to fill view height with scroll items
+ // check if pagination anchor are in visible view height
+ // and trigger pagination
+ useEffect(() => {
+ if (initialRenderRef.current) {
+ // Do not trigger pagination on initial render
+ // anchor intersection observable will trigger pagination on mount
+ initialRenderRef.current = false;
+ return;
+ }
+ const scrollElement = getScrollElement();
+ if (!scrollElement) return;
+ const backAnchor = scrollElement.querySelector(
+ `[${PAGINATOR_ANCHOR_ATTR}="${Direction.Backward}"]`
+ ) as HTMLElement | null;
+ const frontAnchor = scrollElement.querySelector(
+ `[${PAGINATOR_ANCHOR_ATTR}="${Direction.Forward}"]`
+ ) as HTMLElement | null;
+
+ if (backAnchor && isIntersectingScrollView(scrollElement, backAnchor)) {
+ paginate(Direction.Backward);
+ return;
+ }
+ if (frontAnchor && isIntersectingScrollView(scrollElement, frontAnchor)) {
+ paginate(Direction.Forward);
+ }
+ }, [range, getScrollElement, paginate]);
+
+ return {
+ getItems,
+ scrollToItem,
+ scrollToElement,
+ observeBackAnchor,
+ observeFrontAnchor,
+ };
+};
--- /dev/null
+import { useState } from 'react';
+
+export const useZoom = (step: number, min = 0.1, max = 5) => {
+ const [zoom, setZoom] = useState<number>(1);
+
+ const zoomIn = () => {
+ setZoom((z) => {
+ const newZ = z + step;
+ return newZ > max ? z : newZ;
+ });
+ };
+
+ const zoomOut = () => {
+ setZoom((z) => {
+ const newZ = z - step;
+ return newZ < min ? z : newZ;
+ });
+ };
+
+ return {
+ zoom,
+ setZoom,
+ zoomIn,
+ zoomOut,
+ };
+};
Avatar,
AvatarFallback,
AvatarImage,
+ Badge,
Box,
Chip,
ContainerColor,
import FocusTrap from 'focus-trap-react';
import millify from 'millify';
import classNames from 'classnames';
+import { useAtomValue } from 'jotai';
import { openInviteUser, openProfileViewer } from '../../../client/action/navigation';
import * as css from './MembersDrawer.css';
import { useDebounce } from '../../hooks/useDebounce';
import colorMXID from '../../../util/colorMXID';
import { usePowerLevelTags, PowerLevelTag } from '../../hooks/usePowerLevelTags';
+import { roomIdToTypingMembersAtom, selectRoomTypingMembersAtom } from '../../state/typingMembers';
+import { TypingIndicator } from '../../components/typing-indicator';
+import { getMemberDisplayName } from '../../utils/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
export const MembershipFilters = {
filterJoined: (m: RoomMember) => m.membership === Membership.Join,
});
const [onTop, setOnTop] = useState(true);
+ const typingMembers = useAtomValue(
+ useMemo(() => selectRoomTypingMembersAtom(room.roomId, roomIdToTypingMembersAtom), [room])
+ );
+
const filteredMembers = useMemo(
() =>
members
{ wait: 200 }
);
+ const getName = (member: RoomMember) =>
+ getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
+
const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
const btn = evt.currentTarget as HTMLButtonElement;
const userId = btn.getAttribute('data-user-id');
}
const member = tagOrMember;
+ const name = getName(member);
const avatarUrl = member.getAvatarUrl(
mx.baseUrl,
100,
return (
<MenuItem
style={{
- padding: config.space.S200,
+ padding: `0 ${config.space.S200}`,
transform: `translateY(${vItem.start}px)`,
}}
data-index={vItem.index}
color: 'white',
}}
>
- <Text size="T200">{member.name[0]}</Text>
+ <Text size="H6">{name[0]}</Text>
</AvatarFallback>
)}
</Avatar>
}
+ after={
+ typingMembers.find((tm) => tm.userId === member.userId) && (
+ <Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
+ <TypingIndicator size="300" />
+ </Badge>
+ )
+ }
>
- <Text size="T400" truncate>
- {member.name}
- </Text>
+ <Box grow="Yes">
+ <Text size="T400" truncate>
+ {name}
+ </Text>
+ </Box>
</MenuItem>
);
})}
+++ /dev/null
-import React, { useState, useEffect } from 'react';
-import './Room.scss';
-import { Line } from 'folds';
-
-import initMatrix from '../../../client/initMatrix';
-import cons from '../../../client/state/cons';
-import RoomTimeline from '../../../client/state/RoomTimeline';
-import navigation from '../../../client/state/navigation';
-import { openNavigation } from '../../../client/action/navigation';
-
-import Welcome from '../welcome/Welcome';
-import RoomView from './RoomView';
-import RoomSettings from './RoomSettings';
-import { MembersDrawer } from './MembersDrawer';
-import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
-import { useSetting } from '../../state/hooks/settings';
-import { settingsAtom } from '../../state/settings';
-
-function Room() {
- const [roomInfo, setRoomInfo] = useState({
- room: null,
- roomTimeline: null,
- eventId: null,
- });
- const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
- const [screenSize] = useScreenSize();
-
- const mx = initMatrix.matrixClient;
-
- useEffect(() => {
- const handleRoomSelected = (rId, pRoomId, eId) => {
- roomInfo.roomTimeline?.removeInternalListeners();
- 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,
- });
- }
- };
-
- navigation.on(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
- return () => {
- navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
- };
- }, [roomInfo, mx]);
-
- const { room, roomTimeline, eventId } = roomInfo;
- if (roomTimeline === null) {
- setTimeout(() => openNavigation());
- return <Welcome />;
- }
-
- return (
- <div className="room">
- <div className="room__content">
- <RoomSettings roomId={roomTimeline.roomId} />
- <RoomView room={room} roomTimeline={roomTimeline} eventId={eventId} />
- </div>
-
- {screenSize === ScreenSize.Desktop && isDrawer && (
- <>
- <Line variant="Background" direction="Vertical" size="300" />
- <MembersDrawer room={room} />
- </>
- )}
- </div>
- );
-}
-
-export default Room;
--- /dev/null
+import React from 'react';
+import './Room.scss';
+import { Room } from 'matrix-js-sdk';
+import { Line } from 'folds';
+
+import RoomView from './RoomView';
+import RoomSettings from './RoomSettings';
+import { MembersDrawer } from './MembersDrawer';
+import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
+import {
+ roomIdToTypingMembersAtom,
+ useBindRoomIdToTypingMembersAtom,
+} from '../../state/typingMembers';
+
+export type RoomBaseViewProps = {
+ room: Room;
+ eventId?: string;
+};
+export function RoomBaseView({ room, eventId }: RoomBaseViewProps) {
+ useBindRoomIdToTypingMembersAtom(room.client, roomIdToTypingMembersAtom);
+
+ const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
+ const [screenSize] = useScreenSize();
+ const powerLevelAPI = usePowerLevels(room);
+
+ return (
+ <PowerLevelsContextProvider value={powerLevelAPI}>
+ <div className="room">
+ <div className="room__content">
+ <RoomSettings roomId={room.roomId} />
+ <RoomView room={room} eventId={eventId} />
+ </div>
+
+ {screenSize === ScreenSize.Desktop && isDrawer && (
+ <>
+ <Line variant="Background" direction="Vertical" size="300" />
+ <MembersDrawer room={room} />
+ </>
+ )}
+ </div>
+ </PowerLevelsContextProvider>
+ );
+}
import { useMatrixClient } from '../../hooks/useMatrixClient';
import {
CustomEditor,
- EditorChangeHandler,
- useEditor,
Toolbar,
toMatrixCustomHTML,
toPlainText,
import { useScreenSize } from '../../hooks/useScreenSize';
interface RoomInputProps {
+ editor: Editor;
roomViewRef: RefObject<HTMLElement>;
roomId: string;
}
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
- ({ roomViewRef, roomId }, ref) => {
+ ({ editor, roomViewRef, roomId }, ref) => {
const mx = useMatrixClient();
- const editor = useEditor();
const room = mx.getRoom(roomId);
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
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));
+ const [imgError, imgContent] = await to(getImageMsgContent(mx, fileItem, upload.mxc));
if (imgError) console.warn(imgError);
if (imgContent) mx.sendMessage(roomId, imgContent);
return;
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
- const { selection } = editor;
if (isHotkey('enter', evt)) {
evt.preventDefault();
submit();
evt.preventDefault();
setReplyDraft();
}
- if (selection && Range.isCollapsed(selection)) {
+
+ if (editor.selection && Range.isCollapsed(editor.selection)) {
if (isHotkey('arrowleft', evt)) {
evt.preventDefault();
Transforms.move(editor, { unit: 'offset', reverse: true });
[submit, editor, setReplyDraft]
);
- const handleChange: EditorChangeHandler = (value) => {
+ const handleKeyUp: KeyboardEventHandler = useCallback(() => {
+ const firstChildren = editor.children[0];
+ if (firstChildren && Element.isElement(firstChildren)) {
+ const isEmpty = editor.children.length === 1 && Editor.isEmpty(editor, firstChildren);
+ sendTypingStatus(!isEmpty);
+ }
+
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);
- }
- };
+ }, [editor, sendTypingStatus]);
const handleEmoticonSelect = (key: string, shortcode: string) => {
editor.insertNode(createEmoticonElement(key, shortcode));
editor={editor}
placeholder="Send a message..."
onKeyDown={handleKeyDown}
- onChange={handleChange}
+ onKeyUp={handleKeyUp}
onPaste={handlePaste}
top={
replyDraft && (
--- /dev/null
+import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
+import { DefaultReset, config } from 'folds';
+
+export const TimelineFloat = recipe({
+ base: [
+ DefaultReset,
+ {
+ position: 'absolute',
+ left: '50%',
+ transform: 'translateX(-50%)',
+ zIndex: 1,
+ minWidth: 'max-content',
+ },
+ ],
+ variants: {
+ position: {
+ Top: {
+ top: config.space.S400,
+ },
+ Bottom: {
+ bottom: config.space.S400,
+ },
+ },
+ },
+ defaultVariants: {
+ position: 'Top',
+ },
+});
+
+export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
--- /dev/null
+import React, {
+ Dispatch,
+ MouseEventHandler,
+ RefObject,
+ SetStateAction,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import {
+ Direction,
+ EventTimeline,
+ EventTimelineSet,
+ EventTimelineSetHandlerMap,
+ EventType,
+ IEncryptedFile,
+ MatrixClient,
+ MatrixEvent,
+ RelationType,
+ Room,
+ RoomEvent,
+ RoomEventHandlerMap,
+} from 'matrix-js-sdk';
+import parse, { HTMLReactParserOptions } from 'html-react-parser';
+import classNames from 'classnames';
+import { ReactEditor } from 'slate-react';
+import { Editor } from 'slate';
+import to from 'await-to-js';
+import { useSetAtom } from 'jotai';
+import {
+ Badge,
+ Box,
+ Chip,
+ ContainerColor,
+ Icon,
+ Icons,
+ Line,
+ Scroll,
+ Text,
+ as,
+ color,
+ config,
+ toRem,
+} from 'folds';
+import Linkify from 'linkify-react';
+import {
+ decryptFile,
+ eventWithShortcode,
+ factoryEventSentBy,
+ getMxIdLocalPart,
+ isRoomId,
+ isUserId,
+ matrixEventByRecency,
+} from '../../utils/matrix';
+import { sanitizeCustomHtml } from '../../utils/sanitize';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
+import { useAlive } from '../../hooks/useAlive';
+import { scrollToBottom } from '../../utils/dom';
+import {
+ DefaultPlaceholder,
+ CompactPlaceholder,
+ Reply,
+ MessageBase,
+ MessageDeletedContent,
+ MessageBrokenContent,
+ MessageUnsupportedContent,
+ MessageEditedContent,
+ MessageEmptyContent,
+ AttachmentBox,
+ Attachment,
+ AttachmentContent,
+ AttachmentHeader,
+ Time,
+ MessageBadEncryptedContent,
+ MessageNotDecryptedContent,
+} from '../../components/message';
+import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
+import {
+ decryptAllTimelineEvent,
+ getMemberDisplayName,
+ getReactionContent,
+} from '../../utils/room';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+import { openJoinAlias, openProfileViewer, selectRoom } from '../../../client/action/navigation';
+import { useForceUpdate } from '../../hooks/useForceUpdate';
+import { parseGeoUri, scaleYDimension } from '../../utils/common';
+import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
+import { useRoomMsgContentRenderer } from '../../hooks/useRoomMsgContentRenderer';
+import { IAudioContent, IImageContent, IVideoContent } from '../../../types/matrix/common';
+import { getBlobSafeMimeType } from '../../utils/mimeTypes';
+import {
+ ImageContent,
+ VideoContent,
+ FileHeader,
+ fileRenderer,
+ AudioContent,
+ Reactions,
+ EventContent,
+ Message,
+ Event,
+ EncryptedContent,
+ StickerContent,
+} from './message';
+import { useMemberEventParser } from '../../hooks/useMemberEventParser';
+import * as customHtmlCss from '../../styles/CustomHtml.css';
+import { RoomIntro } from '../../components/room-intro';
+import {
+ OnIntersectionCallback,
+ getIntersectionObserverEntry,
+ useIntersectionObserver,
+} from '../../hooks/useIntersectionObserver';
+import { markAsRead } from '../../../client/action/notifications';
+import { useDebounce } from '../../hooks/useDebounce';
+import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
+import * as css from './RoomTimeline.css';
+import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
+import { createMentionElement, moveCursor } from '../../components/editor';
+import { roomIdToReplyDraftAtomFamily } from '../../state/roomInputDrafts';
+import { usePowerLevelsAPI } from '../../hooks/usePowerLevels';
+import { MessageEvent } from '../../../types/matrix/room';
+import initMatrix from '../../../client/initMatrix';
+
+const TimelineFloat = as<'div', css.TimelineFloatVariants>(
+ ({ position, className, ...props }, ref) => (
+ <Box
+ className={classNames(css.TimelineFloat({ position }), className)}
+ justifyContent="Center"
+ alignItems="Center"
+ gap="200"
+ {...props}
+ ref={ref}
+ />
+ )
+);
+
+const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
+ ({ variant, children, ...props }, ref) => (
+ <Box gap="100" justifyContent="Center" alignItems="Center" {...props} ref={ref}>
+ <Line style={{ flexGrow: 1 }} variant={variant} size="300" />
+ {children}
+ <Line style={{ flexGrow: 1 }} variant={variant} size="300" />
+ </Box>
+ )
+);
+
+export const getLiveTimeline = (room: Room): EventTimeline =>
+ room.getUnfilteredTimelineSet().getLiveTimeline();
+
+export const getEventTimeline = (room: Room, eventId: string): EventTimeline | undefined => {
+ const timelineSet = room.getUnfilteredTimelineSet();
+ return timelineSet.getTimelineForEvent(eventId) ?? undefined;
+};
+
+export const getFirstLinkedTimeline = (
+ timeline: EventTimeline,
+ direction: Direction
+): EventTimeline => {
+ const linkedTm = timeline.getNeighbouringTimeline(direction);
+ if (!linkedTm) return timeline;
+ return getFirstLinkedTimeline(linkedTm, direction);
+};
+
+export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
+ const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
+ const timelines = [];
+
+ for (
+ let nextTimeline: EventTimeline | null = firstTimeline;
+ nextTimeline;
+ nextTimeline = nextTimeline.getNeighbouringTimeline(Direction.Forward)
+ ) {
+ timelines.push(nextTimeline);
+ }
+ return timelines;
+};
+
+export const timelineToEventsCount = (t: EventTimeline) => t.getEvents().length;
+export const getTimelinesEventsCount = (timelines: EventTimeline[]): number => {
+ const timelineEventCountReducer = (count: number, tm: EventTimeline) =>
+ count + timelineToEventsCount(tm);
+ return timelines.reduce(timelineEventCountReducer, 0);
+};
+
+export const getTimelineAndBaseIndex = (
+ timelines: EventTimeline[],
+ index: number
+): [EventTimeline | undefined, number] => {
+ let uptoTimelineLen = 0;
+ const timeline = timelines.find((t) => {
+ uptoTimelineLen += t.getEvents().length;
+ if (index < uptoTimelineLen) return true;
+ return false;
+ });
+ if (!timeline) return [undefined, 0];
+ return [timeline, uptoTimelineLen - timeline.getEvents().length];
+};
+
+export const getTimelineRelativeIndex = (absoluteIndex: number, timelineBaseIndex: number) =>
+ absoluteIndex - timelineBaseIndex;
+
+export const getTimelineEvent = (timeline: EventTimeline, index: number): MatrixEvent | undefined =>
+ timeline.getEvents()[index];
+
+export const getEventIdAbsoluteIndex = (
+ timelines: EventTimeline[],
+ eventTimeline: EventTimeline,
+ eventId: string
+): number | undefined => {
+ const timelineIndex = timelines.findIndex((t) => t === eventTimeline);
+ if (timelineIndex === -1) return undefined;
+ const eventIndex = eventTimeline.getEvents().findIndex((evt) => evt.getId() === eventId);
+ if (eventIndex === -1) return undefined;
+ const baseIndex = timelines
+ .slice(0, timelineIndex)
+ .reduce((accValue, timeline) => timeline.getEvents().length + accValue, 0);
+ return baseIndex + eventIndex;
+};
+
+export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) =>
+ timelineSet.relations.getChildEventsForEvent(
+ eventId,
+ RelationType.Annotation,
+ EventType.Reaction
+ );
+
+export const getEventEdits = (timelineSet: EventTimelineSet, eventId: string, eventType: string) =>
+ timelineSet.relations.getChildEventsForEvent(eventId, RelationType.Replace, eventType);
+
+export const getLatestEdit = (
+ targetEvent: MatrixEvent,
+ editEvents: MatrixEvent[]
+): MatrixEvent | undefined => {
+ const eventByTargetSender = (rEvent: MatrixEvent) =>
+ rEvent.getSender() === targetEvent.getSender();
+ return editEvents.sort(matrixEventByRecency).find(eventByTargetSender);
+};
+
+export const getEditedEvent = (
+ mEventId: string,
+ mEvent: MatrixEvent,
+ timelineSet: EventTimelineSet
+): MatrixEvent | undefined => {
+ const edits = getEventEdits(timelineSet, mEventId, mEvent.getType());
+ return edits && getLatestEdit(mEvent, edits.getRelations());
+};
+
+export const factoryGetFileSrcUrl =
+ (httpUrl: string, mimeType: string, encFile?: IEncryptedFile) => async (): Promise<string> => {
+ if (encFile) {
+ if (typeof httpUrl !== 'string') throw new Error('Malformed event');
+ const encRes = await fetch(httpUrl, { method: 'GET' });
+ const encData = await encRes.arrayBuffer();
+ const decryptedBlob = await decryptFile(encData, mimeType, encFile);
+ return URL.createObjectURL(decryptedBlob);
+ }
+ return httpUrl;
+ };
+
+type RoomTimelineProps = {
+ room: Room;
+ eventId?: string;
+ roomInputRef: RefObject<HTMLElement>;
+ editor: Editor;
+};
+
+const PAGINATION_LIMIT = 80;
+
+type Timeline = {
+ linkedTimelines: EventTimeline[];
+ range: ItemRange;
+};
+
+const useEventTimelineLoader = (
+ mx: MatrixClient,
+ room: Room,
+ onLoad: (eventId: string, linkedTimelines: EventTimeline[], evtAbsIndex: number) => void,
+ onError: (err: Error | null) => void
+) => {
+ const loadEventTimeline = useCallback(
+ async (eventId: string) => {
+ const [err, replyEvtTimeline] = await to(
+ mx.getEventTimeline(room.getUnfilteredTimelineSet(), eventId)
+ );
+ if (!replyEvtTimeline) {
+ onError(err ?? null);
+ return;
+ }
+ const linkedTimelines = getLinkedTimelines(replyEvtTimeline);
+ const absIndex = getEventIdAbsoluteIndex(linkedTimelines, replyEvtTimeline, eventId);
+
+ if (absIndex === undefined) {
+ onError(err ?? null);
+ return;
+ }
+
+ onLoad(eventId, linkedTimelines, absIndex);
+ },
+ [mx, room, onLoad, onError]
+ );
+
+ return loadEventTimeline;
+};
+
+const useTimelinePagination = (
+ mx: MatrixClient,
+ timeline: Timeline,
+ setTimeline: Dispatch<SetStateAction<Timeline>>,
+ limit: number
+) => {
+ const timelineRef = useRef(timeline);
+ timelineRef.current = timeline;
+ const alive = useAlive();
+
+ const handleTimelinePagination = useMemo(() => {
+ let fetching = false;
+
+ const recalibratePagination = (
+ linkedTimelines: EventTimeline[],
+ timelinesEventsCount: number[],
+ backwards: boolean
+ ) => {
+ const topTimeline = linkedTimelines[0];
+ const timelineMatch = (mt: EventTimeline) => (t: EventTimeline) => t === mt;
+
+ const newLTimelines = getLinkedTimelines(topTimeline);
+ const topTmIndex = newLTimelines.findIndex(timelineMatch(topTimeline));
+ const topAddedTm = topTmIndex === -1 ? [] : newLTimelines.slice(0, topTmIndex);
+
+ const topTmAddedEvt =
+ timelineToEventsCount(newLTimelines[topTmIndex]) - timelinesEventsCount[0];
+ const offsetRange = getTimelinesEventsCount(topAddedTm) + (backwards ? topTmAddedEvt : 0);
+
+ setTimeline((currentTimeline) => ({
+ linkedTimelines: newLTimelines,
+ range:
+ offsetRange > 0
+ ? {
+ start: currentTimeline.range.start + offsetRange,
+ end: currentTimeline.range.end + offsetRange,
+ }
+ : { ...currentTimeline.range },
+ }));
+ };
+
+ return async (backwards: boolean) => {
+ if (fetching) return;
+ const { linkedTimelines: lTimelines } = timelineRef.current;
+ const timelinesEventsCount = lTimelines.map(timelineToEventsCount);
+
+ const timelineToPaginate = backwards ? lTimelines[0] : lTimelines[lTimelines.length - 1];
+ if (!timelineToPaginate) return;
+
+ const paginationToken = timelineToPaginate.getPaginationToken(
+ backwards ? Direction.Backward : Direction.Forward
+ );
+ if (
+ !paginationToken &&
+ getTimelinesEventsCount(lTimelines) !==
+ getTimelinesEventsCount(getLinkedTimelines(timelineToPaginate))
+ ) {
+ recalibratePagination(lTimelines, timelinesEventsCount, backwards);
+ return;
+ }
+
+ fetching = true;
+ const [err] = await to(
+ mx.paginateEventTimeline(timelineToPaginate, {
+ backwards,
+ limit,
+ })
+ );
+ if (err) {
+ // TODO: handle pagination error.
+ return;
+ }
+ const fetchedTimeline =
+ timelineToPaginate.getNeighbouringTimeline(
+ backwards ? Direction.Backward : Direction.Forward
+ ) ?? timelineToPaginate;
+ // Decrypt all event ahead of render cycle
+ if (mx.isRoomEncrypted(fetchedTimeline.getRoomId() ?? '')) {
+ await to(decryptAllTimelineEvent(mx, fetchedTimeline));
+ }
+
+ fetching = false;
+ if (alive()) {
+ recalibratePagination(lTimelines, timelinesEventsCount, backwards);
+ }
+ };
+ }, [mx, alive, setTimeline, limit]);
+ return handleTimelinePagination;
+};
+
+const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) => {
+ useEffect(() => {
+ const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
+ mEvent,
+ eventRoom,
+ toStartOfTimeline,
+ removed,
+ data
+ ) => {
+ if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
+ onArrive(mEvent);
+ };
+ const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
+ if (eventRoom?.roomId !== room.roomId) return;
+ onArrive(mEvent);
+ };
+
+ room.on(RoomEvent.Timeline, handleTimelineEvent);
+ room.on(RoomEvent.Redaction, handleRedaction);
+ return () => {
+ room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
+ room.removeListener(RoomEvent.Redaction, handleRedaction);
+ };
+ }, [room, onArrive]);
+};
+
+const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => {
+ useEffect(() => {
+ const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => {
+ if (r.roomId !== room.roomId) return;
+ onRefresh();
+ };
+
+ room.on(RoomEvent.TimelineRefresh, handleTimelineRefresh);
+ return () => {
+ room.removeListener(RoomEvent.TimelineRefresh, handleTimelineRefresh);
+ };
+ }, [room, onRefresh]);
+};
+
+const getInitialTimeline = (room: Room) => {
+ const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
+ const evLength = getTimelinesEventsCount(linkedTimelines);
+ return {
+ linkedTimelines,
+ range: {
+ start: Math.max(evLength - PAGINATION_LIMIT, 0),
+ end: evLength,
+ },
+ };
+};
+
+const getEmptyTimeline = () => ({
+ range: { start: 0, end: 0 },
+ linkedTimelines: [],
+});
+
+const getRoomUnreadInfo = (room: Room, scrollTo = false) => {
+ const readUptoEventId = room.getEventReadUpTo(room.client.getUserId() ?? '');
+ if (!readUptoEventId) return undefined;
+ const evtTimeline = getEventTimeline(room, readUptoEventId);
+ const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
+ return {
+ readUptoEventId,
+ inLiveTimeline: latestTimeline === room.getLiveTimeline(),
+ scrollTo,
+ };
+};
+
+export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) {
+ const mx = useMatrixClient();
+ const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
+ const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
+ const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
+ const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
+ const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
+ const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
+ const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(room.roomId));
+ const { canDoAction, canSendEvent, getPowerLevel } = usePowerLevelsAPI();
+ const myPowerLevel = getPowerLevel(mx.getUserId() ?? '');
+ const canRedact = canDoAction('redact', myPowerLevel);
+ const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
+
+ const imagePackRooms: Room[] = useMemo(() => {
+ const allParentSpaces = [
+ room.roomId,
+ ...(initMatrix.roomList?.getAllParentSpaces(room.roomId) ?? []),
+ ];
+ return allParentSpaces.reduce<Room[]>((list, rId) => {
+ const r = mx.getRoom(rId);
+ if (r) list.push(r);
+ return list;
+ }, []);
+ }, [mx, room]);
+
+ const [unreadInfo, setUnreadInfo] = useState(() => getRoomUnreadInfo(room, true));
+ const readUptoEventIdRef = useRef<string>();
+ if (unreadInfo) {
+ readUptoEventIdRef.current = unreadInfo.readUptoEventId;
+ }
+
+ const atBottomAnchorRef = useRef<HTMLElement>(null);
+ const [atBottom, setAtBottom] = useState<boolean>();
+ const atBottomRef = useRef(atBottom);
+ atBottomRef.current = atBottom;
+
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const scrollToBottomRef = useRef({
+ count: 0,
+ smooth: true,
+ });
+
+ const focusItem = useRef<{
+ index: number;
+ scrollTo: boolean;
+ highlight: boolean;
+ }>();
+ const alive = useAlive();
+ const [, forceUpdate] = useForceUpdate();
+
+ const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
+ () =>
+ getReactCustomHtmlParser(mx, room, {
+ handleSpoilerClick: (evt) => {
+ const target = evt.currentTarget;
+ if (target.getAttribute('aria-pressed') === 'true') {
+ evt.stopPropagation();
+ target.setAttribute('aria-pressed', 'false');
+ target.style.cursor = 'initial';
+ }
+ },
+ handleMentionClick: (evt) => {
+ const target = evt.currentTarget;
+ const mentionId = target.getAttribute('data-mention-id');
+ if (typeof mentionId !== 'string') return;
+ if (isUserId(mentionId)) {
+ openProfileViewer(mentionId, room.roomId);
+ return;
+ }
+ if (isRoomId(mentionId) && mx.getRoom(mentionId)) {
+ selectRoom(mentionId);
+ return;
+ }
+ openJoinAlias(mentionId);
+ },
+ }),
+ [mx, room]
+ );
+ const parseMemberEvent = useMemberEventParser();
+
+ const [timeline, setTimeline] = useState<Timeline>(() =>
+ eventId ? getEmptyTimeline() : getInitialTimeline(room)
+ );
+ const eventsLength = getTimelinesEventsCount(timeline.linkedTimelines);
+ const liveTimelineLinked =
+ timeline.linkedTimelines[timeline.linkedTimelines.length - 1] === getLiveTimeline(room);
+ const canPaginateBack =
+ typeof timeline.linkedTimelines[0]?.getPaginationToken(Direction.Backward) === 'string';
+ const rangeAtStart = timeline.range.start === 0;
+ const rangeAtEnd = timeline.range.end === eventsLength;
+
+ const handleTimelinePagination = useTimelinePagination(
+ mx,
+ timeline,
+ setTimeline,
+ PAGINATION_LIMIT
+ );
+
+ const getScrollElement = useCallback(() => scrollRef.current, []);
+
+ const { getItems, scrollToItem, observeBackAnchor, observeFrontAnchor } = useVirtualPaginator({
+ count: eventsLength,
+ limit: PAGINATION_LIMIT,
+ range: timeline.range,
+ onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
+ getScrollElement,
+ getItemElement: useCallback(
+ (index: number) =>
+ (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
+ undefined,
+ []
+ ),
+ onEnd: handleTimelinePagination,
+ });
+
+ const loadEventTimeline = useEventTimelineLoader(
+ mx,
+ room,
+ useCallback(
+ (evtId, lTimelines, evtAbsIndex) => {
+ if (!alive()) return;
+ const evLength = getTimelinesEventsCount(lTimelines);
+
+ focusItem.current = {
+ index: evtAbsIndex,
+ scrollTo: true,
+ highlight: evtId !== unreadInfo?.readUptoEventId,
+ };
+ setTimeline({
+ linkedTimelines: lTimelines,
+ range: {
+ start: Math.max(evtAbsIndex - PAGINATION_LIMIT, 0),
+ end: Math.min(evtAbsIndex + PAGINATION_LIMIT, evLength),
+ },
+ });
+ },
+ [unreadInfo, alive]
+ ),
+ useCallback(() => {
+ if (!alive()) return;
+ setTimeline(getInitialTimeline(room));
+ scrollToBottomRef.current.count += 1;
+ scrollToBottomRef.current.smooth = false;
+ }, [alive, room])
+ );
+
+ useLiveEventArrive(
+ room,
+ useCallback(
+ (mEvt: MatrixEvent) => {
+ if (atBottomRef.current && document.hasFocus()) {
+ if (!unreadInfo && mEvt.getSender() !== mx.getUserId()) {
+ markAsRead(mEvt.getRoomId());
+ }
+
+ scrollToBottomRef.current.count += 1;
+ scrollToBottomRef.current.smooth = true;
+ setTimeline((ct) => ({
+ ...ct,
+ range: {
+ start: ct.range.start + 1,
+ end: ct.range.end + 1,
+ },
+ }));
+ return;
+ }
+ setTimeline((ct) => ({ ...ct }));
+ if (!unreadInfo) {
+ setUnreadInfo(getRoomUnreadInfo(room));
+ }
+ },
+ [mx, room, unreadInfo]
+ )
+ );
+
+ useLiveTimelineRefresh(
+ room,
+ useCallback(() => {
+ if (liveTimelineLinked) {
+ setTimeline(getInitialTimeline(room));
+ }
+ }, [room, liveTimelineLinked])
+ );
+
+ // Stay at bottom when room editor resize
+ useResizeObserver(
+ useCallback(
+ (entries) => {
+ if (!roomInputRef.current) return;
+ const editorBaseEntry = getResizeObserverEntry(roomInputRef.current, entries);
+ const scrollElement = getScrollElement();
+ if (!editorBaseEntry || !scrollElement) return;
+
+ if (atBottomRef.current) {
+ scrollToBottom(scrollElement);
+ }
+ },
+ [getScrollElement, roomInputRef]
+ ),
+ useCallback(() => roomInputRef.current, [roomInputRef])
+ );
+
+ const handleAtBottomIntersection: OnIntersectionCallback = useCallback((entries) => {
+ const target = atBottomAnchorRef.current;
+ if (!target) return;
+ const targetEntry = getIntersectionObserverEntry(target, entries);
+
+ setAtBottom(targetEntry?.isIntersecting === true);
+ }, []);
+ useIntersectionObserver(
+ useDebounce(handleAtBottomIntersection, {
+ wait: 200,
+ }),
+ useMemo(
+ () => ({
+ root: getScrollElement(),
+ rootMargin: '100px',
+ }),
+ [getScrollElement]
+ ),
+ useCallback(() => atBottomAnchorRef.current, [])
+ );
+
+ useEffect(() => {
+ if (eventId) {
+ setTimeline(getEmptyTimeline());
+ loadEventTimeline(eventId);
+ }
+ }, [eventId, loadEventTimeline]);
+
+ // Scroll to bottom on initial timeline load
+ useLayoutEffect(() => {
+ const scrollEl = scrollRef.current;
+ if (scrollEl) scrollToBottom(scrollEl);
+ }, []);
+
+ // Scroll to last read message if it is linked to live timeline
+ useLayoutEffect(() => {
+ const { readUptoEventId, inLiveTimeline, scrollTo } = unreadInfo ?? {};
+ if (readUptoEventId && inLiveTimeline && scrollTo) {
+ const linkedTimelines = getLinkedTimelines(getLiveTimeline(room));
+ const evtTimeline = getEventTimeline(room, readUptoEventId);
+ const absoluteIndex =
+ evtTimeline && getEventIdAbsoluteIndex(linkedTimelines, evtTimeline, readUptoEventId);
+ if (absoluteIndex)
+ scrollToItem(absoluteIndex, {
+ behavior: 'instant',
+ align: 'start',
+ stopInView: true,
+ });
+ }
+ }, [room, unreadInfo, scrollToItem]);
+
+ // scroll to focused message
+ const focusItm = focusItem.current;
+ useLayoutEffect(() => {
+ if (focusItm && focusItm.scrollTo) {
+ scrollToItem(focusItm.index, {
+ behavior: 'instant',
+ align: 'center',
+ stopInView: true,
+ });
+ }
+
+ focusItem.current = undefined;
+ }, [focusItm, scrollToItem]);
+
+ // scroll to bottom of timeline
+ const scrollToBottomCount = scrollToBottomRef.current.count;
+ useLayoutEffect(() => {
+ if (scrollToBottomCount > 0) {
+ const scrollEl = scrollRef.current;
+ if (scrollEl)
+ scrollToBottom(scrollEl, scrollToBottomRef.current.smooth ? 'smooth' : 'instant');
+ }
+ }, [scrollToBottomCount]);
+
+ // send readReceipts when reach bottom
+ useEffect(() => {
+ if (liveTimelineLinked && rangeAtEnd && atBottom && document.hasFocus()) {
+ if (!unreadInfo) {
+ markAsRead(room.roomId);
+ return;
+ }
+ const evtTimeline = getEventTimeline(room, unreadInfo.readUptoEventId);
+ const latestTimeline = evtTimeline && getFirstLinkedTimeline(evtTimeline, Direction.Forward);
+ if (latestTimeline === room.getLiveTimeline()) {
+ markAsRead();
+ setUnreadInfo(undefined);
+ }
+ }
+ }, [room, unreadInfo, liveTimelineLinked, rangeAtEnd, atBottom]);
+
+ const handleJumpToLatest = () => {
+ setTimeline(getInitialTimeline(room));
+ scrollToBottomRef.current.count += 1;
+ scrollToBottomRef.current.smooth = false;
+ };
+
+ const handleJumpToUnread = () => {
+ if (unreadInfo?.readUptoEventId) {
+ setTimeline(getEmptyTimeline());
+ loadEventTimeline(unreadInfo.readUptoEventId);
+ }
+ };
+
+ const handleMarkAsRead = () => {
+ markAsRead(room.roomId);
+ setUnreadInfo(undefined);
+ };
+
+ const handleOpenReply: MouseEventHandler<HTMLButtonElement> = useCallback(
+ async (evt) => {
+ const replyId = evt.currentTarget.getAttribute('data-reply-id');
+ if (typeof replyId !== 'string') return;
+ const replyTimeline = getEventTimeline(room, replyId);
+ const absoluteIndex =
+ replyTimeline && getEventIdAbsoluteIndex(timeline.linkedTimelines, replyTimeline, replyId);
+
+ if (typeof absoluteIndex === 'number') {
+ scrollToItem(absoluteIndex, {
+ behavior: 'smooth',
+ align: 'center',
+ stopInView: true,
+ });
+ focusItem.current = {
+ index: absoluteIndex,
+ scrollTo: false,
+ highlight: true,
+ };
+ forceUpdate();
+ } else {
+ setTimeline(getEmptyTimeline());
+ loadEventTimeline(replyId);
+ }
+ },
+ [room, timeline, scrollToItem, loadEventTimeline, forceUpdate]
+ );
+
+ const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
+ (evt) => {
+ evt.preventDefault();
+ evt.stopPropagation();
+ const userId = evt.currentTarget.getAttribute('data-user-id');
+ if (!userId) {
+ console.warn('Button should have "data-user-id" attribute!');
+ return;
+ }
+ openProfileViewer(userId, room.roomId);
+ },
+ [room]
+ );
+ const handleUsernameClick: MouseEventHandler<HTMLButtonElement> = useCallback(
+ (evt) => {
+ evt.preventDefault();
+ const userId = evt.currentTarget.getAttribute('data-user-id');
+ if (!userId) {
+ console.warn('Button should have "data-user-id" attribute!');
+ return;
+ }
+ const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
+ editor.insertNode(
+ createMentionElement(
+ userId,
+ name.startsWith('@') ? name : `@${name}`,
+ userId === mx.getUserId()
+ )
+ );
+ ReactEditor.focus(editor);
+ moveCursor(editor);
+ },
+ [mx, room, editor]
+ );
+
+ const handleReplyClick: MouseEventHandler<HTMLButtonElement> = useCallback(
+ (evt) => {
+ const replyId = evt.currentTarget.getAttribute('data-event-id');
+ if (!replyId) {
+ console.warn('Button should have "data-event-id" attribute!');
+ return;
+ }
+ const replyEvt = room.findEventById(replyId);
+ if (!replyEvt) return;
+ const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
+ const { body, formatted_body: formattedBody }: Record<string, string> =
+ editedReply?.getContent()['m.new.content'] ?? replyEvt.getContent();
+ const senderId = replyEvt.getSender();
+ if (senderId && typeof body === 'string') {
+ setReplyDraft({
+ userId: senderId,
+ eventId: replyId,
+ body,
+ formattedBody,
+ });
+ setTimeout(() => ReactEditor.focus(editor), 100);
+ }
+ },
+ [room, setReplyDraft, editor]
+ );
+
+ const handleReactionToggle = useCallback(
+ (targetEventId: string, key: string, shortcode?: string) => {
+ const relations = getEventReactions(room.getUnfilteredTimelineSet(), targetEventId);
+ const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
+ const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
+ const reactions = reactionsSet ? Array.from(reactionsSet) : [];
+ const myReaction = reactions.find(factoryEventSentBy(mx.getUserId()!));
+
+ if (myReaction && !!myReaction?.isRelation()) {
+ mx.redactEvent(room.roomId, myReaction.getId()!);
+ return;
+ }
+ const rShortcode =
+ shortcode ||
+ (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
+ mx.sendEvent(
+ room.roomId,
+ MessageEvent.Reaction,
+ getReactionContent(targetEventId, key, rShortcode)
+ );
+ },
+ [mx, room]
+ );
+
+ const renderBody = (body: string, customBody?: string) => {
+ if (body === '') <MessageEmptyContent />;
+ if (customBody) {
+ if (customBody === '') <MessageEmptyContent />;
+ return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
+ }
+ return <Linkify options={LINKIFY_OPTS}>{body}</Linkify>;
+ };
+
+ const renderRoomMsgContent = useRoomMsgContentRenderer<[EventTimelineSet]>({
+ renderText: (mEventId, mEvent, timelineSet) => {
+ const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
+ const { body, formatted_body: customBody }: Record<string, unknown> =
+ editedEvent?.getContent()['m.new.content'] ?? mEvent.getContent();
+
+ if (typeof body !== 'string') return null;
+ return (
+ <Text
+ as="div"
+ style={{
+ whiteSpace: typeof customBody === 'string' ? 'initial' : 'pre-wrap',
+ wordBreak: 'break-word',
+ }}
+ priority="400"
+ >
+ {renderBody(body, typeof customBody === 'string' ? customBody : undefined)}
+ {!!editedEvent && <MessageEditedContent />}
+ </Text>
+ );
+ },
+ renderEmote: (mEventId, mEvent, timelineSet) => {
+ const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
+ const { body, formatted_body: customBody } =
+ editedEvent?.getContent()['m.new.content'] ?? mEvent.getContent();
+ const senderId = mEvent.getSender() ?? '';
+
+ const senderDisplayName =
+ getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
+ return (
+ <Text
+ as="div"
+ style={{
+ color: color.Success.Main,
+ fontStyle: 'italic',
+ whiteSpace: customBody ? 'initial' : 'pre-wrap',
+ wordBreak: 'break-word',
+ }}
+ priority="400"
+ >
+ <b>{`${senderDisplayName} `}</b>
+ {renderBody(body, typeof customBody === 'string' ? customBody : undefined)}
+ {!!editedEvent && <MessageEditedContent />}
+ </Text>
+ );
+ },
+ renderNotice: (mEventId, mEvent, timelineSet) => {
+ const editedEvent = getEditedEvent(mEventId, mEvent, timelineSet);
+ const { body, formatted_body: customBody }: Record<string, unknown> =
+ editedEvent?.getContent()['m.new.content'] ?? mEvent.getContent();
+
+ if (typeof body !== 'string') return null;
+ return (
+ <Text
+ as="div"
+ style={{
+ whiteSpace: typeof customBody === 'string' ? 'initial' : 'pre-wrap',
+ wordBreak: 'break-word',
+ }}
+ priority="300"
+ >
+ {renderBody(body, typeof customBody === 'string' ? customBody : undefined)}
+ {!!editedEvent && <MessageEditedContent />}
+ </Text>
+ );
+ },
+ renderImage: (mEventId, mEvent) => {
+ const content = mEvent.getContent<IImageContent>();
+ const imgInfo = content?.info;
+ const mxcUrl = content.file?.url ?? content.url;
+ if (!imgInfo || typeof imgInfo.mimetype !== 'string' || typeof mxcUrl !== 'string') {
+ if (mxcUrl) {
+ return fileRenderer(mEventId, mEvent);
+ }
+ return null;
+ }
+ const height = scaleYDimension(imgInfo.w || 400, 400, imgInfo.h || 400);
+
+ return (
+ <Attachment>
+ <AttachmentBox
+ style={{
+ height: toRem(height < 48 ? 48 : height),
+ }}
+ >
+ <ImageContent
+ body={content.body || 'Image'}
+ info={imgInfo}
+ mimeType={imgInfo.mimetype}
+ url={mxcUrl}
+ encInfo={content.file}
+ autoPlay={mediaAutoLoad}
+ />
+ </AttachmentBox>
+ </Attachment>
+ );
+ },
+ renderVideo: (mEventId, mEvent) => {
+ const content = mEvent.getContent<IVideoContent>();
+
+ const videoInfo = content?.info;
+ const mxcUrl = content.file?.url ?? content.url;
+ const safeMimeType = getBlobSafeMimeType(videoInfo?.mimetype ?? '');
+
+ if (!videoInfo || !safeMimeType.startsWith('video') || typeof mxcUrl !== 'string') {
+ if (mxcUrl) {
+ return fileRenderer(mEventId, mEvent);
+ }
+ return null;
+ }
+
+ const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
+
+ return (
+ <Attachment>
+ <AttachmentBox
+ style={{
+ height: toRem(height < 48 ? 48 : height),
+ }}
+ >
+ <VideoContent
+ body={content.body || 'Video'}
+ info={videoInfo}
+ mimeType={safeMimeType}
+ url={mxcUrl}
+ encInfo={content.file}
+ loadThumbnail={mediaAutoLoad}
+ />
+ </AttachmentBox>
+ </Attachment>
+ );
+ },
+ renderAudio: (mEventId, mEvent) => {
+ const content = mEvent.getContent<IAudioContent>();
+
+ const audioInfo = content?.info;
+ const mxcUrl = content.file?.url ?? content.url;
+ const safeMimeType = getBlobSafeMimeType(audioInfo?.mimetype ?? '');
+
+ if (!audioInfo || !safeMimeType.startsWith('audio') || typeof mxcUrl !== 'string') {
+ if (mxcUrl) {
+ return fileRenderer(mEventId, mEvent);
+ }
+ return null;
+ }
+
+ return (
+ <Attachment>
+ <AttachmentHeader>
+ <FileHeader body={content.body ?? 'Audio'} mimeType={safeMimeType} />
+ </AttachmentHeader>
+ <AttachmentBox>
+ <AttachmentContent>
+ <AudioContent
+ info={audioInfo}
+ mimeType={safeMimeType}
+ url={mxcUrl}
+ encInfo={content.file}
+ />
+ </AttachmentContent>
+ </AttachmentBox>
+ </Attachment>
+ );
+ },
+ renderLocation: (mEventId, mEvent) => {
+ const content = mEvent.getContent();
+ const geoUri = content.geo_uri;
+ if (typeof geoUri !== 'string') return null;
+ const location = parseGeoUri(geoUri);
+ return (
+ <Box direction="Column" alignItems="Start" gap="100">
+ <Text size="T400">{geoUri}</Text>
+ <Chip
+ as="a"
+ size="400"
+ href={`https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=16/${location.latitude}/${location.longitude}`}
+ target="_blank"
+ rel="noreferrer noopener"
+ variant="Primary"
+ radii="Pill"
+ before={<Icon src={Icons.External} size="50" />}
+ >
+ <Text size="B300">Open Location</Text>
+ </Chip>
+ </Box>
+ );
+ },
+ renderFile: fileRenderer,
+ renderBadEncrypted: () => (
+ <Text>
+ <MessageBadEncryptedContent />
+ </Text>
+ ),
+ renderUnsupported: (mEventId, mEvent) => {
+ if (mEvent.isRedacted()) {
+ const redactedEvt = mEvent.getRedactionEvent();
+ const reason =
+ redactedEvt && 'content' in redactedEvt ? redactedEvt.content.reason : undefined;
+
+ return (
+ <Text>
+ <MessageDeletedContent reason={reason} />
+ </Text>
+ );
+ }
+ return (
+ <Text>
+ <MessageUnsupportedContent />
+ </Text>
+ );
+ },
+ renderBrokenFallback: (mEventId, mEvent) => {
+ if (mEvent.isRedacted()) {
+ const redactedEvt = mEvent.getRedactionEvent();
+ const reason =
+ redactedEvt && 'content' in redactedEvt ? redactedEvt.content.reason : undefined;
+ return (
+ <Text>
+ <MessageDeletedContent reason={reason} />
+ </Text>
+ );
+ }
+ return (
+ <Text>
+ <MessageBrokenContent />
+ </Text>
+ );
+ },
+ });
+
+ const renderMatrixEvent = useMatrixEventRenderer<[number, EventTimelineSet, boolean]>({
+ renderRoomMessage: (mEventId, mEvent, item, timelineSet, collapse) => {
+ const reactionRelations = getEventReactions(timelineSet, mEventId);
+ const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
+ const hasReactions = reactions && reactions.length > 0;
+ const { replyEventId } = mEvent;
+ const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
+
+ return (
+ <Message
+ key={mEvent.getId()}
+ data-message-item={item}
+ room={room}
+ mEvent={mEvent}
+ messageSpacing={messageSpacing}
+ messageLayout={messageLayout}
+ collapse={collapse}
+ highlight={highlighted}
+ canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+ canSendReaction={canSendReaction}
+ imagePackRooms={imagePackRooms}
+ relations={hasReactions ? reactionRelations : undefined}
+ onUserClick={handleUserClick}
+ onUsernameClick={handleUsernameClick}
+ onReplyClick={handleReplyClick}
+ onReactionToggle={handleReactionToggle}
+ reply={
+ replyEventId && (
+ <Reply
+ as="button"
+ mx={mx}
+ room={room}
+ timelineSet={timelineSet}
+ eventId={replyEventId}
+ data-reply-id={replyEventId}
+ onClick={handleOpenReply}
+ />
+ )
+ }
+ reactions={
+ reactionRelations && (
+ <Reactions
+ style={{ marginTop: config.space.S200 }}
+ room={room}
+ relations={reactionRelations}
+ mEventId={mEventId}
+ canSendReaction={canSendReaction}
+ onReactionToggle={handleReactionToggle}
+ />
+ )
+ }
+ >
+ {renderRoomMsgContent(mEventId, mEvent, timelineSet)}
+ </Message>
+ );
+ },
+ renderRoomEncrypted: (mEventId, mEvent, item, timelineSet, collapse) => {
+ const reactionRelations = getEventReactions(timelineSet, mEventId);
+ const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
+ const hasReactions = reactions && reactions.length > 0;
+ const { replyEventId } = mEvent;
+ const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
+
+ return (
+ <Message
+ key={mEvent.getId()}
+ data-message-item={item}
+ room={room}
+ mEvent={mEvent}
+ messageSpacing={messageSpacing}
+ messageLayout={messageLayout}
+ collapse={collapse}
+ highlight={highlighted}
+ canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+ canSendReaction={canSendReaction}
+ imagePackRooms={imagePackRooms}
+ relations={hasReactions ? reactionRelations : undefined}
+ onUserClick={handleUserClick}
+ onUsernameClick={handleUsernameClick}
+ onReplyClick={handleReplyClick}
+ onReactionToggle={handleReactionToggle}
+ reply={
+ replyEventId && (
+ <Reply
+ as="button"
+ mx={mx}
+ room={room}
+ timelineSet={timelineSet}
+ eventId={replyEventId}
+ data-reply-id={replyEventId}
+ onClick={handleOpenReply}
+ />
+ )
+ }
+ reactions={
+ reactionRelations && (
+ <Reactions
+ style={{ marginTop: config.space.S200 }}
+ room={room}
+ relations={reactionRelations}
+ mEventId={mEventId}
+ canSendReaction={canSendReaction}
+ onReactionToggle={handleReactionToggle}
+ />
+ )
+ }
+ >
+ <EncryptedContent mEvent={mEvent}>
+ {() => {
+ if (mEvent.getType() === MessageEvent.Sticker)
+ return <StickerContent mEvent={mEvent} autoPlay={mediaAutoLoad} />;
+ if (mEvent.getType() === MessageEvent.RoomMessage)
+ return renderRoomMsgContent(mEventId, mEvent, timelineSet);
+ if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
+ return (
+ <Text>
+ <MessageNotDecryptedContent />
+ </Text>
+ );
+ return (
+ <Text>
+ <MessageUnsupportedContent />
+ </Text>
+ );
+ }}
+ </EncryptedContent>
+ </Message>
+ );
+ },
+ renderSticker: (mEventId, mEvent, item, timelineSet, collapse) => {
+ const reactionRelations = getEventReactions(timelineSet, mEventId);
+ const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
+ const hasReactions = reactions && reactions.length > 0;
+ const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
+
+ return (
+ <Message
+ key={mEvent.getId()}
+ data-message-item={item}
+ room={room}
+ mEvent={mEvent}
+ messageSpacing={messageSpacing}
+ messageLayout={messageLayout}
+ collapse={collapse}
+ highlight={highlighted}
+ canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+ canSendReaction={canSendReaction}
+ imagePackRooms={imagePackRooms}
+ relations={hasReactions ? reactionRelations : undefined}
+ onUserClick={handleUserClick}
+ onUsernameClick={handleUsernameClick}
+ onReplyClick={handleReplyClick}
+ onReactionToggle={handleReactionToggle}
+ reactions={
+ reactionRelations && (
+ <Reactions
+ style={{ marginTop: config.space.S200 }}
+ room={room}
+ relations={reactionRelations}
+ mEventId={mEventId}
+ canSendReaction={canSendReaction}
+ onReactionToggle={handleReactionToggle}
+ />
+ )
+ }
+ >
+ <StickerContent mEvent={mEvent} autoPlay={mediaAutoLoad} />
+ </Message>
+ );
+ },
+ renderRoomMember: (mEventId, mEvent, item) => {
+ const membershipChanged =
+ mEvent.getContent().membership !== mEvent.getPrevContent().membership;
+ if (membershipChanged && hideMembershipEvents) return null;
+ if (!membershipChanged && hideNickAvatarEvents) return null;
+
+ const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
+ const parsed = parseMemberEvent(mEvent);
+
+ const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+ return (
+ <Event
+ key={mEvent.getId()}
+ data-message-item={item}
+ room={room}
+ mEvent={mEvent}
+ highlight={highlighted}
+ messageSpacing={messageSpacing}
+ canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+ >
+ <EventContent
+ messageLayout={messageLayout}
+ time={timeJSX}
+ iconSrc={parsed.icon}
+ content={
+ <Box grow="Yes" direction="Column">
+ <Text size="T300" priority="300">
+ {parsed.body}
+ </Text>
+ </Box>
+ }
+ />
+ </Event>
+ );
+ },
+ renderRoomName: (mEventId, mEvent, item) => {
+ const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
+ const senderId = mEvent.getSender() ?? '';
+ const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+ const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+ return (
+ <Event
+ key={mEvent.getId()}
+ data-message-item={item}
+ room={room}
+ mEvent={mEvent}
+ highlight={highlighted}
+ messageSpacing={messageSpacing}
+ canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+ >
+ <EventContent
+ messageLayout={messageLayout}
+ time={timeJSX}
+ iconSrc={Icons.Hash}
+ content={
+ <Box grow="Yes" direction="Column">
+ <Text size="T300" priority="300">
+ <b>{senderName}</b>
+ {' changed room name'}
+ </Text>
+ </Box>
+ }
+ />
+ </Event>
+ );
+ },
+ renderRoomTopic: (mEventId, mEvent, item) => {
+ const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
+ const senderId = mEvent.getSender() ?? '';
+ const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+ const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+ return (
+ <Event
+ key={mEvent.getId()}
+ data-message-item={item}
+ room={room}
+ mEvent={mEvent}
+ highlight={highlighted}
+ messageSpacing={messageSpacing}
+ canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+ >
+ <EventContent
+ messageLayout={messageLayout}
+ time={timeJSX}
+ iconSrc={Icons.Hash}
+ content={
+ <Box grow="Yes" direction="Column">
+ <Text size="T300" priority="300">
+ <b>{senderName}</b>
+ {' changed room topic'}
+ </Text>
+ </Box>
+ }
+ />
+ </Event>
+ );
+ },
+ renderRoomAvatar: (mEventId, mEvent, item) => {
+ const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
+ const senderId = mEvent.getSender() ?? '';
+ const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+ const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+ return (
+ <Event
+ key={mEvent.getId()}
+ data-message-item={item}
+ room={room}
+ mEvent={mEvent}
+ highlight={highlighted}
+ messageSpacing={messageSpacing}
+ canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+ >
+ <EventContent
+ messageLayout={messageLayout}
+ time={timeJSX}
+ iconSrc={Icons.Hash}
+ content={
+ <Box grow="Yes" direction="Column">
+ <Text size="T300" priority="300">
+ <b>{senderName}</b>
+ {' changed room avatar'}
+ </Text>
+ </Box>
+ }
+ />
+ </Event>
+ );
+ },
+ renderStateEvent: (mEventId, mEvent, item) => {
+ if (!showHiddenEvents) return null;
+ const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
+ const senderId = mEvent.getSender() ?? '';
+ const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+ const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+ return (
+ <Event
+ key={mEvent.getId()}
+ data-message-item={item}
+ room={room}
+ mEvent={mEvent}
+ highlight={highlighted}
+ messageSpacing={messageSpacing}
+ canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+ >
+ <EventContent
+ messageLayout={messageLayout}
+ time={timeJSX}
+ iconSrc={Icons.Code}
+ content={
+ <Box grow="Yes" direction="Column">
+ <Text size="T300" priority="300">
+ <b>{senderName}</b>
+ {' sent '}
+ <code className={customHtmlCss.Code}>{mEvent.getType()}</code>
+ {' state event'}
+ </Text>
+ </Box>
+ }
+ />
+ </Event>
+ );
+ },
+ renderEvent: (mEventId, mEvent, item) => {
+ if (!showHiddenEvents) return null;
+ if (Object.keys(mEvent.getContent()).length === 0) return null;
+ if (mEvent.getRelation()) return null;
+ if (mEvent.isRedaction()) return null;
+
+ const highlighted = focusItem.current?.index === item && focusItem.current.highlight;
+ const senderId = mEvent.getSender() ?? '';
+ const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+
+ const timeJSX = <Time ts={mEvent.getTs()} compact={messageLayout === 1} />;
+
+ return (
+ <Event
+ key={mEvent.getId()}
+ data-message-item={item}
+ room={room}
+ mEvent={mEvent}
+ highlight={highlighted}
+ messageSpacing={messageSpacing}
+ canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
+ >
+ <EventContent
+ messageLayout={messageLayout}
+ time={timeJSX}
+ iconSrc={Icons.Code}
+ content={
+ <Box grow="Yes" direction="Column">
+ <Text size="T300" priority="300">
+ <b>{senderName}</b>
+ {' sent '}
+ <code className={customHtmlCss.Code}>{mEvent.getType()}</code>
+ {' event'}
+ </Text>
+ </Box>
+ }
+ />
+ </Event>
+ );
+ },
+ });
+
+ let prevEvent: MatrixEvent | undefined;
+ let isPrevRendered = false;
+ let newDivider = false;
+ let dayDivider = false;
+ const eventRenderer = (item: number) => {
+ const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
+ if (!eventTimeline) return null;
+ const timelineSet = eventTimeline?.getTimelineSet();
+ const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
+ const mEventId = mEvent?.getId();
+
+ if (!mEvent || !mEventId) return null;
+
+ if (!newDivider && readUptoEventIdRef.current) {
+ newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
+ }
+ if (!dayDivider) {
+ dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false;
+ }
+
+ const collapsed =
+ isPrevRendered &&
+ !dayDivider &&
+ (!newDivider || mEvent.getSender() === mx.getUserId()) &&
+ prevEvent !== undefined &&
+ prevEvent.getSender() === mEvent.getSender() &&
+ prevEvent.getType() === mEvent.getType() &&
+ minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
+
+ const eventJSX = mEvent.isRelation()
+ ? null
+ : renderMatrixEvent(mEventId, mEvent, item, timelineSet, collapsed);
+ prevEvent = mEvent;
+ isPrevRendered = !!eventJSX;
+
+ const newDividerJSX =
+ newDivider && eventJSX && mEvent.getSender() !== mx.getUserId() ? (
+ <MessageBase space={messageSpacing}>
+ <TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
+ <Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
+ <Text size="L400">New Messages</Text>
+ </Badge>
+ </TimelineDivider>
+ </MessageBase>
+ ) : null;
+
+ const dayDividerJSX =
+ dayDivider && eventJSX ? (
+ <MessageBase space={messageSpacing}>
+ <TimelineDivider variant="Surface">
+ <Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
+ <Text size="L400">
+ {(() => {
+ if (today(mEvent.getTs())) return 'Today';
+ if (yesterday(mEvent.getTs())) return 'Yesterday';
+ return timeDayMonthYear(mEvent.getTs());
+ })()}
+ </Text>
+ </Badge>
+ </TimelineDivider>
+ </MessageBase>
+ ) : null;
+
+ if (eventJSX && (newDividerJSX || dayDividerJSX)) {
+ if (newDividerJSX) newDivider = false;
+ if (dayDividerJSX) dayDivider = false;
+
+ return (
+ <React.Fragment key={mEventId}>
+ {newDividerJSX}
+ {dayDividerJSX}
+ {eventJSX}
+ </React.Fragment>
+ );
+ }
+
+ return eventJSX;
+ };
+
+ return (
+ <Box style={{ height: '100%', color: color.Surface.OnContainer }} grow="Yes">
+ {unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
+ <TimelineFloat position="Top">
+ <Chip
+ variant="Primary"
+ radii="Pill"
+ outlined
+ before={<Icon size="50" src={Icons.MessageUnread} />}
+ onClick={handleJumpToUnread}
+ >
+ <Text size="L400">Jump to Unread</Text>
+ </Chip>
+
+ <Chip
+ variant="SurfaceVariant"
+ radii="Pill"
+ outlined
+ before={<Icon size="50" src={Icons.CheckTwice} />}
+ onClick={handleMarkAsRead}
+ >
+ <Text size="L400">Mark as Read</Text>
+ </Chip>
+ </TimelineFloat>
+ )}
+ <Scroll ref={scrollRef} visibility="Hover">
+ <Box
+ direction="Column"
+ justifyContent="End"
+ style={{ minHeight: '100%', padding: `${config.space.S600} 0` }}
+ >
+ {!canPaginateBack && rangeAtStart && getItems().length > 0 && (
+ <div
+ style={{
+ padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
+ messageLayout === 1 ? config.space.S400 : toRem(64)
+ }`,
+ }}
+ >
+ <RoomIntro room={room} />
+ </div>
+ )}
+ {(canPaginateBack || !rangeAtStart) &&
+ (messageLayout === 1 ? (
+ <>
+ <CompactPlaceholder />
+ <CompactPlaceholder />
+ <CompactPlaceholder />
+ <CompactPlaceholder />
+ <CompactPlaceholder ref={observeBackAnchor} />
+ </>
+ ) : (
+ <>
+ <DefaultPlaceholder />
+ <DefaultPlaceholder />
+ <DefaultPlaceholder ref={observeBackAnchor} />
+ </>
+ ))}
+
+ {getItems().map(eventRenderer)}
+
+ {(!liveTimelineLinked || !rangeAtEnd) &&
+ (messageLayout === 1 ? (
+ <>
+ <CompactPlaceholder ref={observeFrontAnchor} />
+ <CompactPlaceholder />
+ <CompactPlaceholder />
+ <CompactPlaceholder />
+ <CompactPlaceholder />
+ </>
+ ) : (
+ <>
+ <DefaultPlaceholder ref={observeFrontAnchor} />
+ <DefaultPlaceholder />
+ <DefaultPlaceholder />
+ </>
+ ))}
+ <span ref={atBottomAnchorRef} />
+ </Box>
+ </Scroll>
+ {(atBottom === false || !liveTimelineLinked || !rangeAtEnd) && (
+ <TimelineFloat position="Bottom">
+ <Chip
+ variant="SurfaceVariant"
+ radii="Pill"
+ outlined
+ before={<Icon size="50" src={Icons.ArrowBottom} />}
+ onClick={handleJumpToLatest}
+ >
+ <Text size="L400">Jump to Latest</Text>
+ </Chip>
+ </TimelineFloat>
+ )}
+ </Box>
+ );
+}
import { Text, config } from 'folds';
import { EventType } from 'matrix-js-sdk';
-import EventEmitter from 'events';
-
import cons from '../../../client/state/cons';
import navigation from '../../../client/state/navigation';
import RoomViewHeader from './RoomViewHeader';
-import RoomViewContent from './RoomViewContent';
-import RoomViewFloating from './RoomViewFloating';
-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 { usePowerLevelsAPI } from '../../hooks/usePowerLevels';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
+import { RoomTimeline } from './RoomTimeline';
+import { RoomViewTyping } from './RoomViewTyping';
+import { RoomViewFollowing } from './RoomViewFollowing';
+import { useEditor } from '../../components/editor';
-const viewEvent = new EventEmitter();
-
-function RoomView({ room, roomTimeline, eventId }) {
+function RoomView({ room, eventId }) {
const roomInputRef = useRef(null);
const roomViewRef = useRef(null);
+
// eslint-disable-next-line react/prop-types
- const { roomId } = roomTimeline;
+ const { roomId } = room;
+ const editor = useEditor();
const mx = useMatrixClient();
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
- const { getPowerLevel, canSendEvent } = usePowerLevels(room);
+ const { getPowerLevel, canSendEvent } = usePowerLevelsAPI();
const myUserId = mx.getUserId();
const canMessage = myUserId
? canSendEvent(EventType.RoomMessage, getPowerLevel(myUserId))
<RoomViewHeader roomId={roomId} />
<div className="room-view__content-wrapper">
<div className="room-view__scrollable">
- <RoomViewContent
+ <RoomTimeline
+ key={roomId}
+ room={room}
eventId={eventId}
- roomTimeline={roomTimeline}
roomInputRef={roomInputRef}
+ editor={editor}
/>
- <RoomViewFloating roomId={roomId} roomTimeline={roomTimeline} />
+ <RoomViewTyping room={room} />
</div>
<div className="room-view__sticky">
<div className="room-view__editor">
) : (
<>
{canMessage && (
- <RoomInput roomId={roomId} roomViewRef={roomViewRef} ref={roomInputRef} />
+ <RoomInput
+ editor={editor}
+ roomId={roomId}
+ roomViewRef={roomViewRef}
+ ref={roomInputRef}
+ />
)}
{!canMessage && (
<RoomInputPlaceholder
</>
)}
</div>
- <RoomViewCmdBar roomId={roomId} roomTimeline={roomTimeline} viewEvent={viewEvent} />
+ <RoomViewFollowing room={room} />
</div>
</div>
</div>
};
RoomView.propTypes = {
room: PropTypes.shape({}).isRequired,
- roomTimeline: PropTypes.shape({}).isRequired,
eventId: PropTypes.string,
};
--- /dev/null
+import { recipe } from '@vanilla-extract/recipes';
+import { DefaultReset, color, config, toRem } from 'folds';
+
+export const RoomViewFollowing = recipe({
+ base: [
+ DefaultReset,
+ {
+ minHeight: toRem(28),
+ padding: `0 ${config.space.S400}`,
+ width: '100%',
+ backgroundColor: color.Surface.Container,
+ color: color.Surface.OnContainer,
+ outline: 'none',
+ },
+ ],
+ variants: {
+ clickable: {
+ true: {
+ cursor: 'pointer',
+ selectors: {
+ '&:hover, &:focus-visible': {
+ color: color.Primary.Main,
+ },
+ '&:active': {
+ color: color.Primary.Main,
+ },
+ },
+ },
+ },
+ },
+});
--- /dev/null
+import React, { useState } from 'react';
+import {
+ Box,
+ Icon,
+ Icons,
+ Modal,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Text,
+ as,
+ config,
+} from 'folds';
+import { Room, RoomMember } from 'matrix-js-sdk';
+import classNames from 'classnames';
+import FocusTrap from 'focus-trap-react';
+
+import { getMemberDisplayName } from '../../utils/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import * as css from './RoomViewFollowing.css';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useRoomLatestEvent } from '../../hooks/useRoomLatestEvent';
+import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
+import { EventReaders } from '../../components/event-readers';
+
+export type RoomViewFollowingProps = {
+ room: Room;
+};
+export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
+ ({ className, room, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const [open, setOpen] = useState(false);
+ const latestEvent = useRoomLatestEvent(room);
+ const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId());
+ const followingMembers = latestEventReaders
+ .map((readerId) => room.getMember(readerId))
+ .filter((member) => member && member.userId !== mx.getUserId()) as RoomMember[];
+
+ const names = followingMembers.map(
+ (member) => getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId)
+ );
+
+ const eventId = latestEvent?.getId();
+
+ return (
+ <>
+ {eventId && (
+ <Overlay open={open} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setOpen(false),
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Modal variant="Surface" size="300">
+ <EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
+ </Modal>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ )}
+ <Box
+ as={names.length > 0 ? 'button' : 'div'}
+ onClick={names.length > 0 ? () => setOpen(true) : undefined}
+ className={classNames(css.RoomViewFollowing({ clickable: names.length > 0 }), className)}
+ alignItems="Center"
+ justifyContent="End"
+ gap="200"
+ {...props}
+ ref={ref}
+ >
+ {names.length > 0 && (
+ <>
+ <Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.CheckTwice} />
+ <Text size="T300" truncate>
+ {names.length === 1 && (
+ <>
+ <b>{names[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' is following the conversation.'}
+ </Text>
+ </>
+ )}
+ {names.length === 2 && (
+ <>
+ <b>{names[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' and '}
+ </Text>
+ <b>{names[1]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' are following the conversation.'}
+ </Text>
+ </>
+ )}
+ {names.length === 3 && (
+ <>
+ <b>{names[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {', '}
+ </Text>
+ <b>{names[1]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' and '}
+ </Text>
+ <b>{names[2]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' are following the conversation.'}
+ </Text>
+ </>
+ )}
+ {names.length > 3 && (
+ <>
+ <b>{names[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {', '}
+ </Text>
+ <b>{names[1]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {', '}
+ </Text>
+ <b>{names[2]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' and '}
+ </Text>
+ <b>{names.length - 3} others</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' are following the conversation.'}
+ </Text>
+ </>
+ )}
+ </Text>
+ </>
+ )}
+ </Box>
+ </>
+ );
+ }
+);
--- /dev/null
+import { keyframes, style } from '@vanilla-extract/css';
+import { DefaultReset, color, config } from 'folds';
+
+const SlideUpAnime = keyframes({
+ from: {
+ transform: 'translateY(100%)',
+ },
+ to: {
+ transform: 'translateY(0)',
+ },
+});
+
+export const RoomViewTyping = style([
+ DefaultReset,
+ {
+ padding: `${config.space.S100} ${config.space.S500}`,
+ width: '100%',
+ backgroundColor: color.Surface.Container,
+ color: color.Surface.OnContainer,
+ position: 'absolute',
+ bottom: 0,
+ animation: `${SlideUpAnime} 100ms ease-in-out`,
+ },
+]);
--- /dev/null
+import React, { useMemo } from 'react';
+import { Box, Text, as } from 'folds';
+import { Room } from 'matrix-js-sdk';
+import classNames from 'classnames';
+import { useAtomValue } from 'jotai';
+import { roomIdToTypingMembersAtom, selectRoomTypingMembersAtom } from '../../state/typingMembers';
+import { TypingIndicator } from '../../components/typing-indicator';
+import { getMemberDisplayName } from '../../utils/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
+import * as css from './RoomViewTyping.css';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+
+export type RoomViewTypingProps = {
+ room: Room;
+};
+export const RoomViewTyping = as<'div', RoomViewTypingProps>(
+ ({ className, room, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const typingMembers = useAtomValue(
+ useMemo(() => selectRoomTypingMembersAtom(room.roomId, roomIdToTypingMembersAtom), [room])
+ );
+
+ const typingNames = typingMembers
+ .filter((member) => member.userId !== mx.getUserId())
+ .map((member) => getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId))
+ .reverse();
+
+ if (typingNames.length === 0) {
+ return null;
+ }
+
+ return (
+ <Box
+ className={classNames(css.RoomViewTyping, className)}
+ alignItems="Center"
+ gap="400"
+ {...props}
+ ref={ref}
+ >
+ <TypingIndicator />
+ <Text size="T300" truncate>
+ {typingNames.length === 1 && (
+ <>
+ <b>{typingNames[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' is typing...'}
+ </Text>
+ </>
+ )}
+ {typingNames.length === 2 && (
+ <>
+ <b>{typingNames[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' and '}
+ </Text>
+ <b>{typingNames[1]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' are typing...'}
+ </Text>
+ </>
+ )}
+ {typingNames.length === 3 && (
+ <>
+ <b>{typingNames[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {', '}
+ </Text>
+ <b>{typingNames[1]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' and '}
+ </Text>
+ <b>{typingNames[2]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' are typing...'}
+ </Text>
+ </>
+ )}
+ {typingNames.length > 3 && (
+ <>
+ <b>{typingNames[0]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {', '}
+ </Text>
+ <b>{typingNames[1]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {', '}
+ </Text>
+ <b>{typingNames[2]}</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' and '}
+ </Text>
+ <b>{typingNames.length - 3} others</b>
+ <Text as="span" size="Inherit" priority="300">
+ {' are typing...'}
+ </Text>
+ </>
+ )}
+ </Text>
+ </Box>
+ );
+ }
+);
--- /dev/null
+/* eslint-disable jsx-a11y/media-has-caption */
+import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, as, toRem } from 'folds';
+import React, { useCallback, useRef, useState } from 'react';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import { Range } from 'react-range';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { getFileSrcUrl } from './util';
+import { IAudioInfo } from '../../../../types/matrix/common';
+import { MediaControl } from '../../../components/media';
+import {
+ PlayTimeCallback,
+ useMediaLoading,
+ useMediaPlay,
+ useMediaPlayTimeCallback,
+ useMediaSeek,
+ useMediaVolume,
+} from '../../../hooks/media';
+import { useThrottle } from '../../../hooks/useThrottle';
+import { secondsToMinutesAndSeconds } from '../../../utils/common';
+
+const PLAY_TIME_THROTTLE_OPS = {
+ wait: 500,
+ immediate: true,
+};
+
+export type AudioContentProps = {
+ mimeType: string;
+ url: string;
+ info: IAudioInfo;
+ encInfo?: EncryptedAttachmentInfo;
+};
+export const AudioContent = as<'div', AudioContentProps>(
+ ({ mimeType, url, info, encInfo, ...props }, ref) => {
+ const mx = useMatrixClient();
+
+ const [srcState, loadSrc] = useAsyncCallback(
+ useCallback(
+ () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
+ [mx, url, mimeType, encInfo]
+ )
+ );
+
+ const audioRef = useRef<HTMLAudioElement | null>(null);
+
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(info.duration ?? 0);
+
+ const getAudioRef = useCallback(() => audioRef.current, []);
+ const { loading } = useMediaLoading(getAudioRef);
+ const { playing, setPlaying } = useMediaPlay(getAudioRef);
+ const { seek } = useMediaSeek(getAudioRef);
+ const { volume, mute, setMute, setVolume } = useMediaVolume(getAudioRef);
+ const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, ct) => {
+ setDuration(d);
+ setCurrentTime(ct);
+ }, []);
+ useMediaPlayTimeCallback(
+ getAudioRef,
+ useThrottle(handlePlayTimeCallback, PLAY_TIME_THROTTLE_OPS)
+ );
+
+ const handlePlay = () => {
+ if (srcState.status === AsyncStatus.Success) {
+ setPlaying(!playing);
+ } else if (srcState.status !== AsyncStatus.Loading) {
+ loadSrc();
+ }
+ };
+
+ return (
+ <MediaControl
+ after={
+ <Range
+ step={1}
+ min={0}
+ max={duration || 1}
+ values={[currentTime]}
+ onChange={(values) => seek(values[0])}
+ renderTrack={(params) => (
+ <div {...params.props}>
+ {params.children}
+ <ProgressBar
+ as="div"
+ variant="Secondary"
+ size="300"
+ min={0}
+ max={duration}
+ value={currentTime}
+ radii="300"
+ />
+ </div>
+ )}
+ renderThumb={(params) => (
+ <Badge
+ size="300"
+ variant="Secondary"
+ fill="Solid"
+ radii="Pill"
+ outlined
+ {...params.props}
+ style={{
+ ...params.props.style,
+ zIndex: 0,
+ }}
+ />
+ )}
+ />
+ }
+ leftControl={
+ <>
+ <Chip
+ onClick={handlePlay}
+ variant="Secondary"
+ radii="300"
+ disabled={srcState.status === AsyncStatus.Loading}
+ before={
+ srcState.status === AsyncStatus.Loading || loading ? (
+ <Spinner variant="Secondary" size="50" />
+ ) : (
+ <Icon src={playing ? Icons.Pause : Icons.Play} size="50" filled={playing} />
+ )
+ }
+ >
+ <Text size="B300">{playing ? 'Pause' : 'Play'}</Text>
+ </Chip>
+
+ <Text size="T200">{`${secondsToMinutesAndSeconds(
+ currentTime
+ )} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
+ </>
+ }
+ rightControl={
+ <>
+ <IconButton
+ variant="SurfaceVariant"
+ size="300"
+ radii="Pill"
+ onClick={() => setMute(!mute)}
+ aria-pressed={mute}
+ >
+ <Icon src={mute ? Icons.VolumeMute : Icons.VolumeHigh} size="50" />
+ </IconButton>
+ <Range
+ step={0.1}
+ min={0}
+ max={1}
+ values={[volume]}
+ onChange={(values) => setVolume(values[0])}
+ renderTrack={(params) => (
+ <div {...params.props}>
+ {params.children}
+ <ProgressBar
+ style={{ width: toRem(48) }}
+ variant="Secondary"
+ size="300"
+ min={0}
+ max={1}
+ value={volume}
+ radii="300"
+ />
+ </div>
+ )}
+ renderThumb={(params) => (
+ <Badge
+ size="300"
+ variant="Secondary"
+ fill="Solid"
+ radii="Pill"
+ outlined
+ {...params.props}
+ style={{
+ ...params.props.style,
+ zIndex: 0,
+ }}
+ />
+ )}
+ />
+ </>
+ }
+ {...props}
+ ref={ref}
+ >
+ <audio controls={false} autoPlay ref={audioRef}>
+ {srcState.status === AsyncStatus.Success && (
+ <source src={srcState.data} type={mimeType} />
+ )}
+ </audio>
+ </MediaControl>
+ );
+ }
+);
--- /dev/null
+import { MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from 'matrix-js-sdk';
+import React, { ReactNode, useEffect, useState } from 'react';
+
+type EncryptedContentProps = {
+ mEvent: MatrixEvent;
+ children: () => ReactNode;
+};
+
+export function EncryptedContent({ mEvent, children }: EncryptedContentProps) {
+ const [, setDecrypted] = useState(mEvent.isBeingDecrypted());
+
+ useEffect(() => {
+ const handleDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = () =>
+ setDecrypted(true);
+ mEvent.on(MatrixEventEvent.Decrypted, handleDecrypted);
+ return () => {
+ mEvent.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
+ };
+ }, [mEvent]);
+
+ return <>{children()}</>;
+}
--- /dev/null
+import { Box, Icon, IconSrc } from 'folds';
+import React, { ReactNode } from 'react';
+import { CompactLayout, ModernLayout } from '../../../components/message';
+
+export type EventContentProps = {
+ messageLayout: number;
+ time: ReactNode;
+ iconSrc: IconSrc;
+ content: ReactNode;
+};
+export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) {
+ const beforeJSX = (
+ <Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
+ {messageLayout === 1 && time}
+ <Box
+ grow={messageLayout === 1 ? undefined : 'Yes'}
+ alignItems="Center"
+ justifyContent="Center"
+ >
+ <Icon style={{ opacity: 0.6 }} size="50" src={iconSrc} />
+ </Box>
+ </Box>
+ );
+
+ const msgContentJSX = (
+ <Box justifyContent="SpaceBetween" alignItems="Baseline" gap="200">
+ {content}
+ {messageLayout !== 1 && time}
+ </Box>
+ );
+
+ return messageLayout === 1 ? (
+ <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>
+ ) : (
+ <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>
+ );
+}
--- /dev/null
+import React, { useCallback, useState } from 'react';
+import {
+ Box,
+ Button,
+ Icon,
+ Icons,
+ Modal,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Spinner,
+ Text,
+ Tooltip,
+ TooltipProvider,
+ as,
+} from 'folds';
+import FileSaver from 'file-saver';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import FocusTrap from 'focus-trap-react';
+import { IFileInfo } from '../../../../types/matrix/common';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getFileSrcUrl, getSrcFile } from './util';
+import { bytesToSize } from '../../../utils/common';
+import { TextViewer } from '../../../components/text-viewer';
+import { READABLE_TEXT_MIME_TYPES } from '../../../utils/mimeTypes';
+import { PdfViewer } from '../../../components/Pdf-viewer';
+
+export type FileContentProps = {
+ body: string;
+ mimeType: string;
+ url: string;
+ info: IFileInfo;
+ encInfo?: EncryptedAttachmentInfo;
+};
+
+const renderErrorButton = (retry: () => void, text: string) => (
+ <TooltipProvider
+ tooltip={
+ <Tooltip variant="Critical">
+ <Text>Failed to load file!</Text>
+ </Tooltip>
+ }
+ position="Top"
+ align="Center"
+ >
+ {(triggerRef) => (
+ <Button
+ ref={triggerRef}
+ size="400"
+ variant="Critical"
+ fill="Soft"
+ outlined
+ radii="300"
+ onClick={retry}
+ before={<Icon size="100" src={Icons.Warning} filled />}
+ >
+ <Text size="B400" truncate>
+ {text}
+ </Text>
+ </Button>
+ )}
+ </TooltipProvider>
+);
+
+function ReadTextFile({ body, mimeType, url, encInfo }: Omit<FileContentProps, 'info'>) {
+ const mx = useMatrixClient();
+ const [textViewer, setTextViewer] = useState(false);
+
+ const loadSrc = useCallback(
+ () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
+ [mx, url, mimeType, encInfo]
+ );
+
+ const [textState, loadText] = useAsyncCallback(
+ useCallback(async () => {
+ const src = await loadSrc();
+ const blob = await getSrcFile(src);
+ const text = blob.text();
+ setTextViewer(true);
+ return text;
+ }, [loadSrc])
+ );
+
+ return (
+ <>
+ {textState.status === AsyncStatus.Success && (
+ <Overlay open={textViewer} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setTextViewer(false),
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Modal size="500">
+ <TextViewer
+ name={body}
+ text={textState.data}
+ mimeType={mimeType}
+ requestClose={() => setTextViewer(false)}
+ />
+ </Modal>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ )}
+ {textState.status === AsyncStatus.Error ? (
+ renderErrorButton(loadText, 'Open File')
+ ) : (
+ <Button
+ variant="Secondary"
+ fill="Solid"
+ radii="300"
+ size="400"
+ onClick={() =>
+ textState.status === AsyncStatus.Success ? setTextViewer(true) : loadText()
+ }
+ disabled={textState.status === AsyncStatus.Loading}
+ before={
+ textState.status === AsyncStatus.Loading ? (
+ <Spinner fill="Solid" size="100" variant="Secondary" />
+ ) : (
+ <Icon size="100" src={Icons.ArrowRight} filled />
+ )
+ }
+ >
+ <Text size="B400" truncate>
+ Open File
+ </Text>
+ </Button>
+ )}
+ </>
+ );
+}
+
+function ReadPdfFile({ body, mimeType, url, encInfo }: Omit<FileContentProps, 'info'>) {
+ const mx = useMatrixClient();
+ const [pdfViewer, setPdfViewer] = useState(false);
+
+ const [pdfState, loadPdf] = useAsyncCallback(
+ useCallback(async () => {
+ const httpUrl = await getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo);
+ setPdfViewer(true);
+ return httpUrl;
+ }, [mx, url, mimeType, encInfo])
+ );
+
+ return (
+ <>
+ {pdfState.status === AsyncStatus.Success && (
+ <Overlay open={pdfViewer} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setPdfViewer(false),
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Modal size="500">
+ <PdfViewer
+ name={body}
+ src={pdfState.data}
+ requestClose={() => setPdfViewer(false)}
+ />
+ </Modal>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ )}
+ {pdfState.status === AsyncStatus.Error ? (
+ renderErrorButton(loadPdf, 'Open PDF')
+ ) : (
+ <Button
+ variant="Secondary"
+ fill="Solid"
+ radii="300"
+ size="400"
+ onClick={() => (pdfState.status === AsyncStatus.Success ? setPdfViewer(true) : loadPdf())}
+ disabled={pdfState.status === AsyncStatus.Loading}
+ before={
+ pdfState.status === AsyncStatus.Loading ? (
+ <Spinner fill="Solid" size="100" variant="Secondary" />
+ ) : (
+ <Icon size="100" src={Icons.ArrowRight} filled />
+ )
+ }
+ >
+ <Text size="B400" truncate>
+ Open PDF
+ </Text>
+ </Button>
+ )}
+ </>
+ );
+}
+
+function DownloadFile({ body, mimeType, url, info, encInfo }: FileContentProps) {
+ const mx = useMatrixClient();
+
+ const [downloadState, download] = useAsyncCallback(
+ useCallback(async () => {
+ const httpUrl = await getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo);
+ FileSaver.saveAs(httpUrl, body);
+ return httpUrl;
+ }, [mx, url, mimeType, encInfo, body])
+ );
+
+ return downloadState.status === AsyncStatus.Error ? (
+ renderErrorButton(download, `Retry Download (${bytesToSize(info.size ?? 0)})`)
+ ) : (
+ <Button
+ variant="Secondary"
+ fill="Soft"
+ radii="300"
+ size="400"
+ onClick={() =>
+ downloadState.status === AsyncStatus.Success
+ ? FileSaver.saveAs(downloadState.data, body)
+ : download()
+ }
+ disabled={downloadState.status === AsyncStatus.Loading}
+ before={
+ downloadState.status === AsyncStatus.Loading ? (
+ <Spinner fill="Soft" size="100" variant="Secondary" />
+ ) : (
+ <Icon size="100" src={Icons.Download} filled />
+ )
+ }
+ >
+ <Text size="B400" truncate>{`Download (${bytesToSize(info.size ?? 0)})`}</Text>
+ </Button>
+ );
+}
+
+export const FileContent = as<'div', FileContentProps>(
+ ({ body, mimeType, url, info, encInfo, ...props }, ref) => (
+ <Box direction="Column" gap="300" {...props} ref={ref}>
+ {READABLE_TEXT_MIME_TYPES.includes(mimeType) && (
+ <ReadTextFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} />
+ )}
+ {mimeType === 'application/pdf' && (
+ <ReadPdfFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} />
+ )}
+ <DownloadFile body={body} mimeType={mimeType} url={url} info={info} encInfo={encInfo} />
+ </Box>
+ )
+);
--- /dev/null
+import { Badge, Box, Text, as, toRem } from 'folds';
+import React from 'react';
+import { mimeTypeToExt } from '../../../utils/mimeTypes';
+
+const badgeStyles = { maxWidth: toRem(100) };
+
+export type FileHeaderProps = {
+ body: string;
+ mimeType: string;
+};
+export const FileHeader = as<'div', FileHeaderProps>(({ body, mimeType, ...props }, ref) => (
+ <Box alignItems="Center" gap="200" grow="Yes" {...props} ref={ref}>
+ <Badge style={badgeStyles} variant="Secondary" radii="Pill">
+ <Text size="O400" truncate>
+ {mimeTypeToExt(mimeType)}
+ </Text>
+ </Badge>
+ <Text size="T300" truncate>
+ {body}
+ </Text>
+ </Box>
+));
--- /dev/null
+import React, { useCallback, useEffect, useState } from 'react';
+import {
+ Badge,
+ Box,
+ Button,
+ Icon,
+ Icons,
+ Modal,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Spinner,
+ Text,
+ Tooltip,
+ TooltipProvider,
+ as,
+} from 'folds';
+import classNames from 'classnames';
+import { BlurhashCanvas } from 'react-blurhash';
+import FocusTrap from 'focus-trap-react';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getFileSrcUrl } from './util';
+import { Image } from '../../../components/media';
+import * as css from './styles.css';
+import { bytesToSize } from '../../../utils/common';
+import { ImageViewer } from '../../../components/image-viewer';
+
+export type ImageContentProps = {
+ body: string;
+ mimeType: string;
+ url: string;
+ info: IImageInfo;
+ encInfo?: EncryptedAttachmentInfo;
+ autoPlay?: boolean;
+};
+export const ImageContent = as<'div', ImageContentProps>(
+ ({ className, body, mimeType, url, info, encInfo, autoPlay, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const blurHash = info[MATRIX_BLUR_HASH_PROPERTY_NAME];
+
+ const [load, setLoad] = useState(false);
+ const [error, setError] = useState(false);
+ const [viewer, setViewer] = useState(false);
+
+ const [srcState, loadSrc] = useAsyncCallback(
+ useCallback(
+ () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
+ [mx, url, mimeType, encInfo]
+ )
+ );
+
+ const handleLoad = () => {
+ setLoad(true);
+ };
+ const handleError = () => {
+ setLoad(false);
+ setError(true);
+ };
+
+ const handleRetry = () => {
+ setError(false);
+ loadSrc();
+ };
+
+ useEffect(() => {
+ if (autoPlay) loadSrc();
+ }, [autoPlay, loadSrc]);
+
+ return (
+ <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
+ {srcState.status === AsyncStatus.Success && (
+ <Overlay open={viewer} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setViewer(false),
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Modal size="500">
+ <ImageViewer
+ src={srcState.data}
+ alt={body}
+ requestClose={() => setViewer(false)}
+ />
+ </Modal>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ )}
+ {typeof blurHash === 'string' && !load && (
+ <BlurhashCanvas style={{ width: '100%', height: '100%' }} hash={blurHash} punch={1} />
+ )}
+ {!autoPlay && srcState.status === AsyncStatus.Idle && (
+ <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+ <Button
+ variant="Secondary"
+ fill="Solid"
+ radii="300"
+ size="300"
+ onClick={loadSrc}
+ before={<Icon size="Inherit" src={Icons.Photo} filled />}
+ >
+ <Text size="B300">View</Text>
+ </Button>
+ </Box>
+ )}
+ {srcState.status === AsyncStatus.Success && (
+ <Box className={css.AbsoluteContainer}>
+ <Image
+ alt={body}
+ title={body}
+ src={srcState.data}
+ loading="lazy"
+ onLoad={handleLoad}
+ onError={handleError}
+ onClick={() => setViewer(true)}
+ tabIndex={0}
+ />
+ </Box>
+ )}
+ {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
+ !load && (
+ <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+ <Spinner variant="Secondary" />
+ </Box>
+ )}
+ {(error || srcState.status === AsyncStatus.Error) && (
+ <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+ <TooltipProvider
+ tooltip={
+ <Tooltip variant="Critical">
+ <Text>Failed to load image!</Text>
+ </Tooltip>
+ }
+ position="Top"
+ align="Center"
+ >
+ {(triggerRef) => (
+ <Button
+ ref={triggerRef}
+ size="300"
+ variant="Critical"
+ fill="Soft"
+ outlined
+ radii="300"
+ onClick={handleRetry}
+ before={<Icon size="Inherit" src={Icons.Warning} filled />}
+ >
+ <Text size="B300">Retry</Text>
+ </Button>
+ )}
+ </TooltipProvider>
+ </Box>
+ )}
+ {!load && typeof info.size === 'number' && (
+ <Box className={css.AbsoluteFooter} justifyContent="End" alignContent="Center" gap="200">
+ <Badge variant="Secondary" fill="Soft">
+ <Text size="L400">{bytesToSize(info.size)}</Text>
+ </Badge>
+ </Box>
+ )}
+ </Box>
+ );
+ }
+);
--- /dev/null
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+ Box,
+ Button,
+ Dialog,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Input,
+ Line,
+ Menu,
+ MenuItem,
+ Modal,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ PopOut,
+ Spinner,
+ Text,
+ as,
+ color,
+ config,
+} from 'folds';
+import React, {
+ FormEventHandler,
+ MouseEventHandler,
+ ReactNode,
+ useCallback,
+ useState,
+} from 'react';
+import FocusTrap from 'focus-trap-react';
+import { MatrixEvent, Room } from 'matrix-js-sdk';
+import { Relations } from 'matrix-js-sdk/lib/models/relations';
+import classNames from 'classnames';
+import {
+ AvatarBase,
+ BubbleLayout,
+ CompactLayout,
+ MessageBase,
+ ModernLayout,
+ Time,
+ Username,
+} from '../../../components/message';
+import colorMXID from '../../../../util/colorMXID';
+import { getMemberAvatarMxc, getMemberDisplayName } from '../../../utils/room';
+import { getMxIdLocalPart } from '../../../utils/matrix';
+import { MessageLayout, MessageSpacing } from '../../../state/settings';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
+import * as css from './styles.css';
+import { EventReaders } from '../../../components/event-readers';
+import { TextViewer } from '../../../components/text-viewer';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { EmojiBoard } from '../../../components/emoji-board';
+import { ReactionViewer } from '../reaction-viewer';
+
+export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
+
+type MessageQuickReactionsProps = {
+ onReaction: ReactionHandler;
+};
+export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
+ ({ onReaction, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const recentEmojis = useRecentEmoji(mx, 4);
+
+ if (recentEmojis.length === 0) return <span />;
+ return (
+ <>
+ <Box
+ style={{ padding: config.space.S200 }}
+ alignItems="Center"
+ justifyContent="Center"
+ gap="200"
+ {...props}
+ ref={ref}
+ >
+ {recentEmojis.map((emoji) => (
+ <IconButton
+ key={emoji.unicode}
+ className={css.MessageQuickReaction}
+ size="300"
+ variant="SurfaceVariant"
+ radii="Pill"
+ title={emoji.shortcode}
+ aria-label={emoji.shortcode}
+ onClick={() => onReaction(emoji.unicode, emoji.shortcode)}
+ >
+ <Text size="T500">{emoji.unicode}</Text>
+ </IconButton>
+ ))}
+ </Box>
+ <Line size="300" />
+ </>
+ );
+ }
+);
+
+export const MessageAllReactionItem = as<
+ 'button',
+ {
+ room: Room;
+ relations: Relations;
+ onClose?: () => void;
+ }
+>(({ room, relations, onClose, ...props }, ref) => {
+ const [open, setOpen] = useState(false);
+
+ const handleClose = () => {
+ setOpen(false);
+ onClose?.();
+ };
+
+ return (
+ <>
+ <Overlay
+ onContextMenu={(evt: any) => {
+ evt.stopPropagation();
+ }}
+ open={open}
+ backdrop={<OverlayBackdrop />}
+ >
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ returnFocusOnDeactivate: false,
+ onDeactivate: () => handleClose(),
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Modal variant="Surface" size="300">
+ <ReactionViewer
+ room={room}
+ relations={relations}
+ requestClose={() => setOpen(false)}
+ />
+ </Modal>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ <MenuItem
+ size="300"
+ after={<Icon size="100" src={Icons.Smile} />}
+ radii="300"
+ onClick={() => setOpen(true)}
+ {...props}
+ ref={ref}
+ aria-pressed={open}
+ >
+ <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+ View Reactions
+ </Text>
+ </MenuItem>
+ </>
+ );
+});
+
+export const MessageReadReceiptItem = as<
+ 'button',
+ {
+ room: Room;
+ eventId: string;
+ onClose?: () => void;
+ }
+>(({ room, eventId, onClose, ...props }, ref) => {
+ const [open, setOpen] = useState(false);
+
+ const handleClose = () => {
+ setOpen(false);
+ onClose?.();
+ };
+
+ return (
+ <>
+ <Overlay open={open} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: handleClose,
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Modal variant="Surface" size="300">
+ <EventReaders room={room} eventId={eventId} requestClose={handleClose} />
+ </Modal>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ <MenuItem
+ size="300"
+ after={<Icon size="100" src={Icons.CheckTwice} />}
+ radii="300"
+ onClick={() => setOpen(true)}
+ {...props}
+ ref={ref}
+ aria-pressed={open}
+ >
+ <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+ Read Receipts
+ </Text>
+ </MenuItem>
+ </>
+ );
+});
+
+export const MessageSourceCodeItem = as<
+ 'button',
+ {
+ mEvent: MatrixEvent;
+ onClose?: () => void;
+ }
+>(({ mEvent, onClose, ...props }, ref) => {
+ const [open, setOpen] = useState(false);
+ const text = JSON.stringify(
+ mEvent.isEncrypted()
+ ? {
+ [`<== DECRYPTED_EVENT ==>`]: mEvent.getEffectiveEvent(),
+ [`<== ORIGINAL_EVENT ==>`]: mEvent.event,
+ }
+ : mEvent.event,
+ null,
+ 2
+ );
+
+ const handleClose = () => {
+ setOpen(false);
+ onClose?.();
+ };
+
+ return (
+ <>
+ <Overlay open={open} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: handleClose,
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Modal variant="Surface" size="500">
+ <TextViewer
+ name="Source Code"
+ mimeType="application/json"
+ text={text}
+ requestClose={handleClose}
+ />
+ </Modal>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ <MenuItem
+ size="300"
+ after={<Icon size="100" src={Icons.BlockCode} />}
+ radii="300"
+ onClick={() => setOpen(true)}
+ {...props}
+ ref={ref}
+ aria-pressed={open}
+ >
+ <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+ View Source
+ </Text>
+ </MenuItem>
+ </>
+ );
+});
+
+export const MessageDeleteItem = as<
+ 'button',
+ {
+ room: Room;
+ mEvent: MatrixEvent;
+ onClose?: () => void;
+ }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const [open, setOpen] = useState(false);
+
+ const [deleteState, deleteMessage] = useAsyncCallback(
+ useCallback(
+ (eventId: string, reason?: string) =>
+ mx.redactEvent(room.roomId, eventId, undefined, reason ? { reason } : undefined),
+ [mx, room]
+ )
+ );
+
+ const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+ const eventId = mEvent.getId();
+ if (
+ !eventId ||
+ deleteState.status === AsyncStatus.Loading ||
+ deleteState.status === AsyncStatus.Success
+ )
+ return;
+ const target = evt.target as HTMLFormElement | undefined;
+ const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
+ const reason = reasonInput && reasonInput.value.trim();
+ deleteMessage(eventId, reason);
+ };
+
+ const handleClose = () => {
+ setOpen(false);
+ onClose?.();
+ };
+
+ return (
+ <>
+ <Overlay open={open} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: handleClose,
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Dialog variant="Surface">
+ <Header
+ style={{
+ padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+ borderBottomWidth: config.borderWidth.B300,
+ }}
+ variant="Surface"
+ size="500"
+ >
+ <Box grow="Yes">
+ <Text size="H4">Delete Message</Text>
+ </Box>
+ <IconButton size="300" onClick={handleClose} radii="300">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Header>
+ <Box
+ as="form"
+ onSubmit={handleSubmit}
+ style={{ padding: config.space.S400 }}
+ direction="Column"
+ gap="400"
+ >
+ <Text priority="400">
+ This action is irreversible! Are you sure that you want to delete this message?
+ </Text>
+ <Box direction="Column" gap="100">
+ <Text size="L400">
+ Reason{' '}
+ <Text as="span" size="T200">
+ (optional)
+ </Text>
+ </Text>
+ <Input name="reasonInput" variant="Background" />
+ {deleteState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} size="T300">
+ Failed to delete message! Please try again.
+ </Text>
+ )}
+ </Box>
+ <Button
+ type="submit"
+ variant="Critical"
+ before={
+ deleteState.status === AsyncStatus.Loading ? (
+ <Spinner fill="Soft" variant="Critical" size="200" />
+ ) : undefined
+ }
+ aria-disabled={deleteState.status === AsyncStatus.Loading}
+ >
+ <Text size="B400">
+ {deleteState.status === AsyncStatus.Loading ? 'Deleting...' : 'Delete'}
+ </Text>
+ </Button>
+ </Box>
+ </Dialog>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ <Button
+ variant="Critical"
+ fill="None"
+ size="300"
+ after={<Icon size="100" src={Icons.Delete} />}
+ radii="300"
+ onClick={() => setOpen(true)}
+ aria-pressed={open}
+ {...props}
+ ref={ref}
+ >
+ <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+ Delete
+ </Text>
+ </Button>
+ </>
+ );
+});
+
+export const MessageReportItem = as<
+ 'button',
+ {
+ room: Room;
+ mEvent: MatrixEvent;
+ onClose?: () => void;
+ }
+>(({ room, mEvent, onClose, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const [open, setOpen] = useState(false);
+
+ const [reportState, reportMessage] = useAsyncCallback(
+ useCallback(
+ (eventId: string, score: number, reason: string) =>
+ mx.reportEvent(room.roomId, eventId, score, reason),
+ [mx, room]
+ )
+ );
+
+ const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+ evt.preventDefault();
+ const eventId = mEvent.getId();
+ if (
+ !eventId ||
+ reportState.status === AsyncStatus.Loading ||
+ reportState.status === AsyncStatus.Success
+ )
+ return;
+ const target = evt.target as HTMLFormElement | undefined;
+ const reasonInput = target?.reasonInput as HTMLInputElement | undefined;
+ const reason = reasonInput && reasonInput.value.trim();
+ if (reasonInput) reasonInput.value = '';
+ reportMessage(eventId, reason ? -100 : -50, reason || 'No reason provided');
+ };
+
+ const handleClose = () => {
+ setOpen(false);
+ onClose?.();
+ };
+
+ return (
+ <>
+ <Overlay open={open} backdrop={<OverlayBackdrop />}>
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: handleClose,
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Dialog variant="Surface">
+ <Header
+ style={{
+ padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
+ borderBottomWidth: config.borderWidth.B300,
+ }}
+ variant="Surface"
+ size="500"
+ >
+ <Box grow="Yes">
+ <Text size="H4">Report Message</Text>
+ </Box>
+ <IconButton size="300" onClick={handleClose} radii="300">
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Header>
+ <Box
+ as="form"
+ onSubmit={handleSubmit}
+ style={{ padding: config.space.S400 }}
+ direction="Column"
+ gap="400"
+ >
+ <Text priority="400">
+ Report this message to server, which may then notify the appropriate people to
+ take action.
+ </Text>
+ <Box direction="Column" gap="100">
+ <Text size="L400">Reason</Text>
+ <Input name="reasonInput" variant="Background" required />
+ {reportState.status === AsyncStatus.Error && (
+ <Text style={{ color: color.Critical.Main }} size="T300">
+ Failed to report message! Please try again.
+ </Text>
+ )}
+ {reportState.status === AsyncStatus.Success && (
+ <Text style={{ color: color.Success.Main }} size="T300">
+ Message has been reported to server.
+ </Text>
+ )}
+ </Box>
+ <Button
+ type="submit"
+ variant="Critical"
+ before={
+ reportState.status === AsyncStatus.Loading ? (
+ <Spinner fill="Soft" variant="Critical" size="200" />
+ ) : undefined
+ }
+ aria-disabled={
+ reportState.status === AsyncStatus.Loading ||
+ reportState.status === AsyncStatus.Success
+ }
+ >
+ <Text size="B400">
+ {reportState.status === AsyncStatus.Loading ? 'Reporting...' : 'Report'}
+ </Text>
+ </Button>
+ </Box>
+ </Dialog>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ <Button
+ variant="Critical"
+ fill="None"
+ size="300"
+ after={<Icon size="100" src={Icons.Warning} />}
+ radii="300"
+ onClick={() => setOpen(true)}
+ aria-pressed={open}
+ {...props}
+ ref={ref}
+ >
+ <Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
+ Report
+ </Text>
+ </Button>
+ </>
+ );
+});
+
+export type MessageProps = {
+ room: Room;
+ mEvent: MatrixEvent;
+ collapse: boolean;
+ highlight: boolean;
+ canDelete?: boolean;
+ canSendReaction?: boolean;
+ imagePackRooms?: Room[];
+ relations?: Relations;
+ messageLayout: MessageLayout;
+ messageSpacing: MessageSpacing;
+ onUserClick: MouseEventHandler<HTMLButtonElement>;
+ onUsernameClick: MouseEventHandler<HTMLButtonElement>;
+ onReplyClick: MouseEventHandler<HTMLButtonElement>;
+ onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
+ reply?: ReactNode;
+ reactions?: ReactNode;
+};
+export const Message = as<'div', MessageProps>(
+ (
+ {
+ className,
+ room,
+ mEvent,
+ collapse,
+ highlight,
+ canDelete,
+ canSendReaction,
+ imagePackRooms,
+ relations,
+ messageLayout,
+ messageSpacing,
+ onUserClick,
+ onUsernameClick,
+ onReplyClick,
+ onReactionToggle,
+ reply,
+ reactions,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ const mx = useMatrixClient();
+ const senderId = mEvent.getSender() ?? '';
+ const [hover, setHover] = useState(false);
+ const [menu, setMenu] = useState(false);
+ const [emojiBoard, setEmojiBoard] = useState(false);
+
+ const senderDisplayName =
+ getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
+ const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
+
+ const headerJSX = !collapse && (
+ <Box
+ gap="300"
+ direction={messageLayout === 1 ? 'RowReverse' : 'Row'}
+ justifyContent="SpaceBetween"
+ alignItems="Baseline"
+ grow="Yes"
+ >
+ <Username
+ as="button"
+ style={{ color: colorMXID(senderId) }}
+ data-user-id={senderId}
+ onContextMenu={onUserClick}
+ onClick={onUsernameClick}
+ >
+ <Text as="span" size={messageLayout === 2 ? 'T300' : 'T400'} truncate>
+ <b>{senderDisplayName}</b>
+ </Text>
+ </Username>
+ <Box shrink="No" gap="100">
+ {messageLayout !== 1 && hover && (
+ <>
+ <Text as="span" size="T200" priority="300">
+ {senderId}
+ </Text>
+ <Text as="span" size="T200" priority="300">
+ |
+ </Text>
+ </>
+ )}
+ <Time ts={mEvent.getTs()} compact={messageLayout === 1} />
+ </Box>
+ </Box>
+ );
+
+ const avatarJSX = !collapse && messageLayout !== 1 && (
+ <AvatarBase>
+ <Avatar as="button" size="300" data-user-id={senderId} onClick={onUserClick}>
+ {senderAvatarMxc ? (
+ <AvatarImage
+ src={mx.mxcUrlToHttp(senderAvatarMxc, 48, 48, 'crop') ?? senderAvatarMxc}
+ />
+ ) : (
+ <AvatarFallback
+ style={{
+ background: colorMXID(senderId),
+ color: 'white',
+ }}
+ >
+ <Text size="H4">{senderDisplayName[0]}</Text>
+ </AvatarFallback>
+ )}
+ </Avatar>
+ </AvatarBase>
+ );
+
+ const msgContentJSX = (
+ <Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
+ {reply}
+ {children}
+ {reactions}
+ </Box>
+ );
+
+ const showOptions = () => setHover(true);
+ const hideOptions = () => setHover(false);
+
+ const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
+ if (evt.altKey) return;
+ const tag = (evt.target as any).tagName;
+ if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
+ evt.preventDefault();
+ setMenu(true);
+ };
+
+ const closeMenu = () => {
+ setMenu(false);
+ };
+
+ return (
+ <MessageBase
+ className={classNames(css.MessageBase, className)}
+ tabIndex={0}
+ space={messageSpacing}
+ collapse={collapse}
+ highlight={highlight}
+ selected={menu || emojiBoard}
+ {...props}
+ onMouseEnter={showOptions}
+ onMouseLeave={hideOptions}
+ ref={ref}
+ >
+ {(hover || menu || emojiBoard) && (
+ <div className={css.MessageOptionsBase}>
+ <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
+ <Box gap="100">
+ {canSendReaction && (
+ <PopOut
+ alignOffset={-65}
+ position="Bottom"
+ align="End"
+ open={emojiBoard}
+ content={
+ <EmojiBoard
+ imagePackRooms={imagePackRooms ?? []}
+ returnFocusOnDeactivate={false}
+ onEmojiSelect={(key) => {
+ onReactionToggle(mEvent.getId()!, key);
+ setEmojiBoard(false);
+ }}
+ onCustomEmojiSelect={(mxc, shortcode) => {
+ onReactionToggle(mEvent.getId()!, mxc, shortcode);
+ setEmojiBoard(false);
+ }}
+ requestClose={() => {
+ setEmojiBoard(false);
+ }}
+ />
+ }
+ >
+ {(anchorRef) => (
+ <IconButton
+ ref={anchorRef}
+ onClick={() => setEmojiBoard(true)}
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ aria-pressed={emojiBoard}
+ >
+ <Icon src={Icons.SmilePlus} size="100" />
+ </IconButton>
+ )}
+ </PopOut>
+ )}
+ <IconButton
+ onClick={onReplyClick}
+ data-event-id={mEvent.getId()}
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ >
+ <Icon src={Icons.ReplyArrow} size="100" />
+ </IconButton>
+ <PopOut
+ open={menu}
+ alignOffset={-5}
+ position="Bottom"
+ align="End"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setMenu(false),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ }}
+ >
+ <Menu {...props} ref={ref}>
+ {canSendReaction && (
+ <MessageQuickReactions
+ onReaction={(key, shortcode) => {
+ onReactionToggle(mEvent.getId()!, key, shortcode);
+ closeMenu();
+ }}
+ />
+ )}
+ <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
+ {canSendReaction && (
+ <MenuItem
+ size="300"
+ after={<Icon size="100" src={Icons.SmilePlus} />}
+ radii="300"
+ onClick={() => {
+ closeMenu();
+ // open it with timeout because closeMenu
+ // FocusTrap will return focus from emojiBoard
+ setTimeout(() => setEmojiBoard(true), 100);
+ }}
+ >
+ <Text
+ className={css.MessageMenuItemText}
+ as="span"
+ size="T300"
+ truncate
+ >
+ Add Reaction
+ </Text>
+ </MenuItem>
+ )}
+ {relations && (
+ <MessageAllReactionItem
+ room={room}
+ relations={relations}
+ onClose={closeMenu}
+ />
+ )}
+ <MenuItem
+ size="300"
+ after={<Icon size="100" src={Icons.ReplyArrow} />}
+ radii="300"
+ data-event-id={mEvent.getId()}
+ onClick={(evt: any) => {
+ onReplyClick(evt);
+ closeMenu();
+ }}
+ >
+ <Text
+ className={css.MessageMenuItemText}
+ as="span"
+ size="T300"
+ truncate
+ >
+ Reply
+ </Text>
+ </MenuItem>
+ <MessageReadReceiptItem
+ room={room}
+ eventId={mEvent.getId() ?? ''}
+ onClose={closeMenu}
+ />
+ <MessageSourceCodeItem mEvent={mEvent} onClose={closeMenu} />
+ </Box>
+ {((!mEvent.isRedacted() && canDelete) ||
+ mEvent.getSender() !== mx.getUserId()) && (
+ <>
+ <Line size="300" />
+ <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
+ {!mEvent.isRedacted() && canDelete && (
+ <MessageDeleteItem
+ room={room}
+ mEvent={mEvent}
+ onClose={closeMenu}
+ />
+ )}
+ {mEvent.getSender() !== mx.getUserId() && (
+ <MessageReportItem
+ room={room}
+ mEvent={mEvent}
+ onClose={closeMenu}
+ />
+ )}
+ </Box>
+ </>
+ )}
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ {(targetRef) => (
+ <IconButton
+ ref={targetRef}
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ onClick={() => setMenu((v) => !v)}
+ aria-pressed={menu}
+ >
+ <Icon src={Icons.VerticalDots} size="100" />
+ </IconButton>
+ )}
+ </PopOut>
+ </Box>
+ </Menu>
+ </div>
+ )}
+ {messageLayout === 1 && (
+ <CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
+ {msgContentJSX}
+ </CompactLayout>
+ )}
+ {messageLayout === 2 && (
+ <BubbleLayout before={avatarJSX} onContextMenu={handleContextMenu}>
+ {headerJSX}
+ {msgContentJSX}
+ </BubbleLayout>
+ )}
+ {messageLayout !== 1 && messageLayout !== 2 && (
+ <ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
+ {headerJSX}
+ {msgContentJSX}
+ </ModernLayout>
+ )}
+ </MessageBase>
+ );
+ }
+);
+
+export type EventProps = {
+ room: Room;
+ mEvent: MatrixEvent;
+ highlight: boolean;
+ canDelete?: boolean;
+ messageSpacing: MessageSpacing;
+};
+export const Event = as<'div', EventProps>(
+ ({ className, room, mEvent, highlight, canDelete, messageSpacing, children, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const [hover, setHover] = useState(false);
+ const [menu, setMenu] = useState(false);
+ const stateEvent = typeof mEvent.getStateKey() === 'string';
+
+ const showOptions = () => setHover(true);
+ const hideOptions = () => setHover(false);
+
+ const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
+ if (evt.altKey) return;
+ const tag = (evt.target as any).tagName;
+ if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
+ evt.preventDefault();
+ setMenu(true);
+ };
+
+ const closeMenu = () => {
+ setMenu(false);
+ };
+
+ return (
+ <MessageBase
+ className={classNames(css.MessageBase, className)}
+ tabIndex={0}
+ space={messageSpacing}
+ autoCollapse
+ highlight={highlight}
+ selected={menu}
+ {...props}
+ onMouseEnter={showOptions}
+ onMouseLeave={hideOptions}
+ ref={ref}
+ >
+ {(hover || menu) && (
+ <div className={css.MessageOptionsBase}>
+ <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
+ <Box gap="100">
+ <PopOut
+ open={menu}
+ alignOffset={-5}
+ position="Bottom"
+ align="End"
+ content={
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ onDeactivate: () => setMenu(false),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
+ isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
+ }}
+ >
+ <Menu {...props} ref={ref}>
+ <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
+ <MessageReadReceiptItem
+ room={room}
+ eventId={mEvent.getId() ?? ''}
+ onClose={closeMenu}
+ />
+ <MessageSourceCodeItem mEvent={mEvent} onClose={closeMenu} />
+ </Box>
+ {((!mEvent.isRedacted() && canDelete && !stateEvent) ||
+ (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
+ <>
+ <Line size="300" />
+ <Box direction="Column" gap="100" className={css.MessageMenuGroup}>
+ {!mEvent.isRedacted() && canDelete && (
+ <MessageDeleteItem
+ room={room}
+ mEvent={mEvent}
+ onClose={closeMenu}
+ />
+ )}
+ {mEvent.getSender() !== mx.getUserId() && (
+ <MessageReportItem
+ room={room}
+ mEvent={mEvent}
+ onClose={closeMenu}
+ />
+ )}
+ </Box>
+ </>
+ )}
+ </Menu>
+ </FocusTrap>
+ }
+ >
+ {(targetRef) => (
+ <IconButton
+ ref={targetRef}
+ variant="SurfaceVariant"
+ size="300"
+ radii="300"
+ onClick={() => setMenu((v) => !v)}
+ aria-pressed={menu}
+ >
+ <Icon src={Icons.VerticalDots} size="100" />
+ </IconButton>
+ )}
+ </PopOut>
+ </Box>
+ </Menu>
+ </div>
+ )}
+ <div onContextMenu={handleContextMenu}>{children}</div>
+ </MessageBase>
+ );
+ }
+);
--- /dev/null
+import React, { MouseEventHandler, useCallback, useState } from 'react';
+import {
+ Box,
+ Modal,
+ Overlay,
+ OverlayBackdrop,
+ OverlayCenter,
+ Text,
+ Tooltip,
+ TooltipProvider,
+ as,
+ toRem,
+} from 'folds';
+import classNames from 'classnames';
+import { EventTimelineSet, EventType, RelationType, Room } from 'matrix-js-sdk';
+import { type Relations } from 'matrix-js-sdk/lib/models/relations';
+import FocusTrap from 'focus-trap-react';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { factoryEventSentBy } from '../../../utils/matrix';
+import { Reaction, ReactionTooltipMsg } from '../../../components/message';
+import { useRelations } from '../../../hooks/useRelations';
+import * as css from './styles.css';
+import { ReactionViewer } from '../reaction-viewer';
+
+export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) =>
+ timelineSet.relations.getChildEventsForEvent(
+ eventId,
+ RelationType.Annotation,
+ EventType.Reaction
+ );
+
+export type ReactionsProps = {
+ room: Room;
+ mEventId: string;
+ canSendReaction?: boolean;
+ relations: Relations;
+ onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
+};
+export const Reactions = as<'div', ReactionsProps>(
+ ({ className, room, relations, mEventId, canSendReaction, onReactionToggle, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const [viewer, setViewer] = useState<boolean | string>(false);
+ const myUserId = mx.getUserId();
+ const reactions = useRelations(
+ relations,
+ useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
+ );
+
+ const handleViewReaction: MouseEventHandler<HTMLButtonElement> = (evt) => {
+ evt.stopPropagation();
+ evt.preventDefault();
+ const key = evt.currentTarget.getAttribute('data-reaction-key');
+ console.log(key);
+ if (!key) setViewer(true);
+ else setViewer(key);
+ };
+
+ return (
+ <Box
+ className={classNames(css.ReactionsContainer, className)}
+ gap="200"
+ wrap="Wrap"
+ {...props}
+ ref={ref}
+ >
+ {reactions.map(([key, events]) => {
+ const rEvents = Array.from(events);
+ if (rEvents.length === 0) return null;
+ const myREvent = myUserId ? rEvents.find(factoryEventSentBy(myUserId)) : undefined;
+ const isPressed = !!myREvent?.getRelation();
+
+ return (
+ <TooltipProvider
+ key={key}
+ position="Top"
+ tooltip={
+ <Tooltip style={{ maxWidth: toRem(200) }}>
+ <Text size="T300">
+ <ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
+ </Text>
+ </Tooltip>
+ }
+ >
+ {(targetRef) => (
+ <Reaction
+ ref={targetRef}
+ data-reaction-key={key}
+ aria-pressed={isPressed}
+ key={key}
+ mx={mx}
+ reaction={key}
+ count={events.size}
+ onClick={canSendReaction ? () => onReactionToggle(mEventId, key) : undefined}
+ onContextMenu={handleViewReaction}
+ aria-disabled={!canSendReaction}
+ />
+ )}
+ </TooltipProvider>
+ );
+ })}
+ {reactions.length > 0 && (
+ <Overlay
+ onContextMenu={(evt: any) => {
+ evt.stopPropagation();
+ }}
+ open={!!viewer}
+ backdrop={<OverlayBackdrop />}
+ >
+ <OverlayCenter>
+ <FocusTrap
+ focusTrapOptions={{
+ initialFocus: false,
+ returnFocusOnDeactivate: false,
+ onDeactivate: () => setViewer(false),
+ clickOutsideDeactivates: true,
+ }}
+ >
+ <Modal variant="Surface" size="300">
+ <ReactionViewer
+ room={room}
+ initialKey={typeof viewer === 'string' ? viewer : undefined}
+ relations={relations}
+ requestClose={() => setViewer(false)}
+ />
+ </Modal>
+ </FocusTrap>
+ </OverlayCenter>
+ </Overlay>
+ )}
+ </Box>
+ );
+ }
+);
--- /dev/null
+import React from 'react';
+import { as, toRem } from 'folds';
+import { MatrixEvent } from 'matrix-js-sdk';
+import { AttachmentBox, MessageBrokenContent } from '../../../components/message';
+import { ImageContent } from './ImageContent';
+import { scaleYDimension } from '../../../utils/common';
+import { IImageContent } from '../../../../types/matrix/common';
+
+type StickerContentProps = {
+ mEvent: MatrixEvent;
+ autoPlay: boolean;
+};
+export const StickerContent = as<'div', StickerContentProps>(({ mEvent, autoPlay, ...props }, ref) => {
+ const content = mEvent.getContent<IImageContent>();
+ const imgInfo = content?.info;
+ const mxcUrl = content.file?.url ?? content.url;
+ if (!imgInfo || typeof imgInfo.mimetype !== 'string' || typeof mxcUrl !== 'string') {
+ return <MessageBrokenContent />;
+ }
+ const height = scaleYDimension(imgInfo.w || 152, 152, imgInfo.h || 152);
+
+ return (
+ <AttachmentBox
+ style={{
+ height: toRem(height < 48 ? 48 : height),
+ width: toRem(152),
+ }}
+ {...props}
+ ref={ref}
+ >
+ <ImageContent
+ autoPlay={autoPlay}
+ body={content.body || 'Image'}
+ info={imgInfo}
+ mimeType={imgInfo.mimetype}
+ url={mxcUrl}
+ encInfo={content.file}
+ />
+ </AttachmentBox>
+ );
+});
--- /dev/null
+import React, { useCallback, useEffect, useState } from 'react';
+import {
+ Badge,
+ Box,
+ Button,
+ Icon,
+ Icons,
+ Spinner,
+ Text,
+ Tooltip,
+ TooltipProvider,
+ as,
+} from 'folds';
+import classNames from 'classnames';
+import { BlurhashCanvas } from 'react-blurhash';
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import {
+ IThumbnailContent,
+ IVideoInfo,
+ MATRIX_BLUR_HASH_PROPERTY_NAME,
+} from '../../../../types/matrix/common';
+import * as css from './styles.css';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { getFileSrcUrl } from './util';
+import { Image, Video } from '../../../components/media';
+import { bytesToSize } from '../../../../util/common';
+import { millisecondsToMinutesAndSeconds } from '../../../utils/common';
+
+export type VideoContentProps = {
+ body: string;
+ mimeType: string;
+ url: string;
+ info: IVideoInfo & IThumbnailContent;
+ encInfo?: EncryptedAttachmentInfo;
+ autoPlay?: boolean;
+ loadThumbnail?: boolean;
+};
+export const VideoContent = as<'div', VideoContentProps>(
+ ({ className, body, mimeType, url, info, encInfo, autoPlay, loadThumbnail, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const blurHash = info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME];
+
+ const [load, setLoad] = useState(false);
+ const [error, setError] = useState(false);
+
+ const [srcState, loadSrc] = useAsyncCallback(
+ useCallback(
+ () => getFileSrcUrl(mx.mxcUrlToHttp(url) ?? '', mimeType, encInfo),
+ [mx, url, mimeType, encInfo]
+ )
+ );
+ const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
+ useCallback(() => {
+ const thumbInfo = info.thumbnail_info;
+ const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
+ if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') {
+ throw new Error('Failed to load thumbnail');
+ }
+ return getFileSrcUrl(
+ mx.mxcUrlToHttp(thumbMxcUrl) ?? '',
+ thumbInfo.mimetype,
+ info.thumbnail_file
+ );
+ }, [mx, info])
+ );
+
+ const handleLoad = () => {
+ setLoad(true);
+ };
+ const handleError = () => {
+ setLoad(false);
+ setError(true);
+ };
+
+ const handleRetry = () => {
+ setError(false);
+ loadSrc();
+ };
+
+ useEffect(() => {
+ if (autoPlay) loadSrc();
+ }, [autoPlay, loadSrc]);
+ useEffect(() => {
+ if (loadThumbnail) loadThumbSrc();
+ }, [loadThumbnail, loadThumbSrc]);
+
+ return (
+ <Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
+ {typeof blurHash === 'string' && !load && (
+ <BlurhashCanvas style={{ width: '100%', height: '100%' }} hash={blurHash} punch={1} />
+ )}
+ {thumbSrcState.status === AsyncStatus.Success && !load && (
+ <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+ <Image alt={body} title={body} src={thumbSrcState.data} loading="lazy" />
+ </Box>
+ )}
+ {!autoPlay && srcState.status === AsyncStatus.Idle && (
+ <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+ <Button
+ variant="Secondary"
+ fill="Solid"
+ radii="300"
+ size="300"
+ onClick={loadSrc}
+ before={<Icon size="Inherit" src={Icons.Play} filled />}
+ >
+ <Text size="B300">Watch</Text>
+ </Button>
+ </Box>
+ )}
+ {srcState.status === AsyncStatus.Success && (
+ <Box className={css.AbsoluteContainer}>
+ <Video
+ title={body}
+ src={srcState.data}
+ onLoadedMetadata={handleLoad}
+ onError={handleError}
+ autoPlay
+ controls
+ />
+ </Box>
+ )}
+ {(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Success) &&
+ !load && (
+ <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+ <Spinner variant="Secondary" />
+ </Box>
+ )}
+ {(error || srcState.status === AsyncStatus.Error) && (
+ <Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
+ <TooltipProvider
+ tooltip={
+ <Tooltip variant="Critical">
+ <Text>Failed to load video!</Text>
+ </Tooltip>
+ }
+ position="Top"
+ align="Center"
+ >
+ {(triggerRef) => (
+ <Button
+ ref={triggerRef}
+ size="300"
+ variant="Critical"
+ fill="Soft"
+ outlined
+ radii="300"
+ onClick={handleRetry}
+ before={<Icon size="Inherit" src={Icons.Warning} filled />}
+ >
+ <Text size="B300">Retry</Text>
+ </Button>
+ )}
+ </TooltipProvider>
+ </Box>
+ )}
+ {!load && typeof info.size === 'number' && (
+ <Box
+ className={css.AbsoluteFooter}
+ justifyContent="SpaceBetween"
+ alignContent="Center"
+ gap="200"
+ >
+ <Badge variant="Secondary" fill="Soft">
+ <Text size="L400">{millisecondsToMinutesAndSeconds(info.duration ?? 0)}</Text>
+ </Badge>
+ <Badge variant="Secondary" fill="Soft">
+ <Text size="L400">{bytesToSize(info.size)}</Text>
+ </Badge>
+ </Box>
+ )}
+ </Box>
+ );
+ }
+);
--- /dev/null
+import React from 'react';
+import { MatrixEvent } from 'matrix-js-sdk';
+import { IFileContent } from '../../../../types/matrix/common';
+import {
+ Attachment,
+ AttachmentBox,
+ AttachmentContent,
+ AttachmentHeader,
+} from '../../../components/message';
+import { FileHeader } from './FileHeader';
+import { FileContent } from './FileContent';
+import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
+
+export const fileRenderer = (mEventId: string, mEvent: MatrixEvent) => {
+ const content = mEvent.getContent<IFileContent>();
+
+ const fileInfo = content?.info;
+ const mxcUrl = content.file?.url ?? content.url;
+
+ if (typeof mxcUrl !== 'string') {
+ return null;
+ }
+
+ return (
+ <Attachment>
+ <AttachmentHeader>
+ <FileHeader
+ body={content.body ?? 'Unnamed File'}
+ mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
+ />
+ </AttachmentHeader>
+ <AttachmentBox>
+ <AttachmentContent>
+ <FileContent
+ body={content.body ?? 'File'}
+ info={fileInfo ?? {}}
+ mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
+ url={mxcUrl}
+ encInfo={content.file}
+ />
+ </AttachmentContent>
+ </AttachmentBox>
+ </Attachment>
+ );
+};
--- /dev/null
+export * from './ImageContent';
+export * from './VideoContent';
+export * from './FileHeader';
+export * from './fileRenderer';
+export * from './AudioContent';
+export * from './Reactions';
+export * from './EventContent';
+export * from './Message';
+export * from './EncryptedContent';
+export * from './StickerContent';
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, config, toRem } from 'folds';
+
+export const RelativeBase = style([
+ DefaultReset,
+ {
+ position: 'relative',
+ width: '100%',
+ height: '100%',
+ },
+]);
+
+export const AbsoluteContainer = style([
+ DefaultReset,
+ {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: '100%',
+ height: '100%',
+ },
+]);
+
+export const AbsoluteFooter = style([
+ DefaultReset,
+ {
+ position: 'absolute',
+ bottom: config.space.S100,
+ left: config.space.S100,
+ right: config.space.S100,
+ },
+]);
+
+export const MessageBase = style({
+ position: 'relative',
+});
+
+export const MessageOptionsBase = style([
+ DefaultReset,
+ {
+ position: 'absolute',
+ top: toRem(-30),
+ right: 0,
+ zIndex: 1,
+ },
+]);
+export const MessageOptionsBar = style([
+ DefaultReset,
+ {
+ padding: config.space.S100,
+ },
+]);
+
+export const MessageQuickReaction = style({
+ minWidth: toRem(32),
+});
+
+export const MessageMenuGroup = style({
+ padding: config.space.S100,
+});
+
+export const MessageMenuItemText = style({
+ flexGrow: 1,
+});
+
+export const ReactionsContainer = style({
+ selectors: {
+ '&:empty': {
+ display: 'none',
+ },
+ },
+});
--- /dev/null
+import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import { decryptFile } from '../../../utils/matrix';
+
+export const getFileSrcUrl = async (
+ httpUrl: string,
+ mimeType: string,
+ encInfo?: EncryptedAttachmentInfo
+): Promise<string> => {
+ if (encInfo) {
+ if (typeof httpUrl !== 'string') throw new Error('Malformed event');
+ const encRes = await fetch(httpUrl, { method: 'GET' });
+ const encData = await encRes.arrayBuffer();
+ const decryptedBlob = await decryptFile(encData, mimeType, encInfo);
+ return URL.createObjectURL(decryptedBlob);
+ }
+ return httpUrl;
+};
+
+export const getSrcFile = async (src: string): Promise<Blob> => {
+ const res = await fetch(src, { method: 'GET' });
+ const blob = await res.blob();
+ return blob;
+};
import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
import to from 'await-to-js';
-import { IThumbnailContent } from '../../../types/matrix/common';
+import { IThumbnailContent, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../types/matrix/common';
import {
getImageFileUrl,
getThumbnail,
} 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';
+import { encodeBlurHash } from '../../utils/blurHash';
const generateThumbnailContent = async (
mx: MatrixClient,
return thumbnailContent;
};
-export const getImageMsgContent = async (item: TUploadItem, mxc: string): Promise<IContent> => {
+export const getImageMsgContent = async (
+ mx: MatrixClient,
+ item: TUploadItem,
+ mxc: string
+): Promise<IContent> => {
const { file, originalFile, encInfo } = item;
const [imgError, imgEl] = await to(loadImageElement(getImageFileUrl(originalFile)));
if (imgError) console.warn(imgError);
body: file.name,
};
if (imgEl) {
+ const blurHash = encodeBlurHash(imgEl);
+ const [thumbError, thumbContent] = await to(
+ generateThumbnailContent(
+ mx,
+ imgEl,
+ getThumbnailDimensions(imgEl.width, imgEl.height),
+ !!encInfo
+ )
+ );
+
+ if (thumbContent && thumbContent.thumbnail_info) {
+ thumbContent.thumbnail_info[MATRIX_BLUR_HASH_PROPERTY_NAME] = blurHash;
+ }
+ if (thumbError) console.warn(thumbError);
content.info = {
...getImageInfo(imgEl, file),
- [MATRIX_BLUR_HASH_PROPERTY_NAME]: encodeBlurHash(imgEl),
+ [MATRIX_BLUR_HASH_PROPERTY_NAME]: blurHash,
+ ...thumbContent,
};
}
if (encInfo) {
!!encInfo
)
);
+ if (thumbContent && thumbContent.thumbnail_info) {
+ thumbContent.thumbnail_info[MATRIX_BLUR_HASH_PROPERTY_NAME] = encodeBlurHash(videoEl);
+ }
if (thumbError) console.warn(thumbError);
content.info = {
...getVideoInfo(videoEl, file),
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { DefaultReset, color, config } from 'folds';
+
+export const ReactionViewer = style([
+ DefaultReset,
+ {
+ height: '100%',
+ },
+]);
+
+export const Sidebar = style({
+ backgroundColor: color.Background.Container,
+ color: color.Background.OnContainer,
+});
+export const SidebarContent = style({
+ padding: config.space.S200,
+ paddingRight: 0,
+});
+
+export const Header = style({
+ paddingLeft: config.space.S400,
+ paddingRight: config.space.S300,
+
+ flexShrink: 0,
+ gap: config.space.S200,
+});
+
+export const Content = style({
+ paddingLeft: config.space.S200,
+ paddingBottom: config.space.S400,
+});
--- /dev/null
+import React, { useCallback, useState } from 'react';
+import classNames from 'classnames';
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+ Box,
+ Header,
+ Icon,
+ IconButton,
+ Icons,
+ Line,
+ MenuItem,
+ Scroll,
+ Text,
+ as,
+ config,
+} from 'folds';
+import { MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
+import { Relations } from 'matrix-js-sdk/lib/models/relations';
+import { getMemberDisplayName } from '../../../utils/room';
+import { eventWithShortcode, getMxIdLocalPart } from '../../../utils/matrix';
+import * as css from './ReactionViewer.css';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import colorMXID from '../../../../util/colorMXID';
+import { openProfileViewer } from '../../../../client/action/navigation';
+import { useRelations } from '../../../hooks/useRelations';
+import { Reaction } from '../../../components/message';
+import { getHexcodeForEmoji, getShortcodeFor } from '../../../plugins/emoji';
+
+export type ReactionViewerProps = {
+ room: Room;
+ initialKey?: string;
+ relations: Relations;
+ requestClose: () => void;
+};
+export const ReactionViewer = as<'div', ReactionViewerProps>(
+ ({ className, room, initialKey, relations, requestClose, ...props }, ref) => {
+ const mx = useMatrixClient();
+ const reactions = useRelations(
+ relations,
+ useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
+ );
+ const [selectedKey, setSelectedKey] = useState<string>(initialKey ?? reactions[0][0]);
+
+ const getName = (member: RoomMember) =>
+ getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
+
+ const getReactionsForKey = (key: string): MatrixEvent[] => {
+ const reactSet = reactions.find(([k]) => k === key)?.[1];
+ if (!reactSet) return [];
+ return Array.from(reactSet);
+ };
+
+ const selectedReactions = getReactionsForKey(selectedKey);
+ const selectedShortcode =
+ selectedReactions.find(eventWithShortcode)?.getContent().shortcode ??
+ getShortcodeFor(getHexcodeForEmoji(selectedKey)) ??
+ selectedKey;
+
+ return (
+ <Box
+ className={classNames(css.ReactionViewer, className)}
+ direction="Row"
+ {...props}
+ ref={ref}
+ >
+ <Box shrink="No" className={css.Sidebar}>
+ <Scroll visibility="Hover" hideTrack size="300">
+ <Box className={css.SidebarContent} direction="Column" gap="200">
+ {reactions.map(([key, evts]) => (
+ <Reaction
+ key={key}
+ mx={mx}
+ reaction={key}
+ count={evts.size}
+ aria-selected={key === selectedKey}
+ onClick={() => setSelectedKey(key)}
+ />
+ ))}
+ </Box>
+ </Scroll>
+ </Box>
+ <Line variant="Surface" direction="Vertical" size="300" />
+ <Box grow="Yes" direction="Column">
+ <Header className={css.Header} variant="Surface" size="600">
+ <Box grow="Yes">
+ <Text size="H3" truncate>{`Reacted with :${selectedShortcode}:`}</Text>
+ </Box>
+ <IconButton size="300" onClick={requestClose}>
+ <Icon src={Icons.Cross} />
+ </IconButton>
+ </Header>
+
+ <Box grow="Yes">
+ <Scroll visibility="Hover" hideTrack size="300">
+ <Box className={css.Content} direction="Column">
+ {selectedReactions.map((mEvent) => {
+ const senderId = mEvent.getSender();
+ if (!senderId) return null;
+ const member = room.getMember(senderId);
+ if (!member) return null;
+ const name = getName(member);
+
+ const avatarUrl = member.getAvatarUrl(
+ mx.baseUrl,
+ 100,
+ 100,
+ 'crop',
+ undefined,
+ false
+ );
+
+ return (
+ <MenuItem
+ key={member.userId}
+ style={{ padding: `0 ${config.space.S200}` }}
+ radii="400"
+ onClick={() => {
+ requestClose();
+ openProfileViewer(member.userId, room.roomId);
+ }}
+ before={
+ <Avatar size="200">
+ {avatarUrl ? (
+ <AvatarImage src={avatarUrl} />
+ ) : (
+ <AvatarFallback
+ style={{
+ background: colorMXID(member.userId),
+ color: 'white',
+ }}
+ >
+ <Text size="H6">{name[0]}</Text>
+ </AvatarFallback>
+ )}
+ </Avatar>
+ }
+ >
+ <Box grow="Yes">
+ <Text size="T400" truncate>
+ {name}
+ </Text>
+ </Box>
+ </MenuItem>
+ );
+ })}
+ </Box>
+ </Scroll>
+ </Box>
+ </Box>
+ </Box>
+ );
+ }
+);
--- /dev/null
+export * from './ReactionViewer';
import settings from '../../../client/state/settings';
import navigation from '../../../client/state/navigation';
import {
- toggleSystemTheme, toggleMarkdown, toggleMembershipEvents, toggleNickAvatarEvents,
+ toggleSystemTheme, toggleMarkdown,
toggleNotifications, toggleNotificationSounds,
} from '../../../client/action/settings';
import { usePermission } from '../../hooks/usePermission';
import CinnySVG from '../../../../public/res/svg/cinny.svg';
import { confirmDialog } from '../../molecules/confirm-dialog/ConfirmDialog';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
function AppearanceSection() {
const [, updateState] = useState({});
+ const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
+ const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
+ const [useSystemEmoji, setUseSystemEmoji] = useSetting(settingsAtom, 'useSystemEmoji');
+ const [hideMembershipEvents, setHideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
+ const [hideNickAvatarEvents, setHideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
+ const [mediaAutoLoad, setMediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
+ const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
+ const spacings = ['0', '100', '200', '300', '400', '500']
+
return (
<div className="settings-appearance">
<div className="settings-appearance__card">
/>
)}
/>
+ <SettingTile
+ title="Use System Emoji"
+ options={(
+ <Toggle
+ isActive={useSystemEmoji}
+ onToggle={() => setUseSystemEmoji(!useSystemEmoji)}
+ />
+ )}
+ content={<Text variant="b3">Use system emoji instead of Twitter emojis.</Text>}
+ />
</div>
<div className="settings-appearance__card">
<MenuHeader>Room messages</MenuHeader>
+ <SettingTile
+ title="Message Layout"
+ content={
+ <SegmentedControls
+ selected={messageLayout}
+ segments={[
+ { text: 'Modern' },
+ { text: 'Compact' },
+ { text: 'Bubble' },
+ ]}
+ onSelect={(index) => setMessageLayout(index)}
+ />
+ }
+ />
+ <SettingTile
+ title="Message Spacing"
+ content={
+ <SegmentedControls
+ selected={spacings.findIndex((s) => s === messageSpacing)}
+ segments={[
+ { text: 'No' },
+ { text: 'XXS' },
+ { text: 'XS' },
+ { text: 'S' },
+ { text: 'M' },
+ { text: 'L' },
+ ]}
+ onSelect={(index) => {
+ setMessageSpacing(spacings[index])
+ }}
+ />
+ }
+ />
<SettingTile
title="Markdown formatting"
options={(
title="Hide membership events"
options={(
<Toggle
- isActive={settings.hideMembershipEvents}
- onToggle={() => { toggleMembershipEvents(); updateState({}); }}
+ isActive={hideMembershipEvents}
+ onToggle={() => setHideMembershipEvents(!hideMembershipEvents)}
/>
)}
content={<Text variant="b3">Hide membership change messages from room timeline. (Join, Leave, Invite, Kick and Ban)</Text>}
title="Hide nick/avatar events"
options={(
<Toggle
- isActive={settings.hideNickAvatarEvents}
- onToggle={() => { toggleNickAvatarEvents(); updateState({}); }}
+ isActive={hideNickAvatarEvents}
+ onToggle={() => setHideNickAvatarEvents(!hideNickAvatarEvents)}
/>
)}
content={<Text variant="b3">Hide nick and avatar change messages from room timeline.</Text>}
/>
+ <SettingTile
+ title="Disable media auto load"
+ options={(
+ <Toggle
+ isActive={!mediaAutoLoad}
+ onToggle={() => setMediaAutoLoad(!mediaAutoLoad)}
+ />
+ )}
+ content={<Text variant="b3">Prevent images and videos from auto loading to save bandwidth.</Text>}
+ />
+ <SettingTile
+ title="Show hidden events"
+ options={(
+ <Toggle
+ isActive={showHiddenEvents}
+ onToggle={() => setShowHiddenEvents(!showHiddenEvents)}
+ />
+ )}
+ content={<Text variant="b3">Show hidden state and message events.</Text>}
+ />
</div>
</div>
);
-import { CompactEmoji } from 'emojibase';
+import { CompactEmoji, fromUnicodeToHexcode } 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';
emojis: IEmoji[];
};
+export const getShortcodesFor = (hexcode: string): string[] | string | undefined =>
+ joypixels[hexcode] || emojibase[hexcode];
+
+export const getShortcodeFor = (hexcode: string): string | undefined => {
+ const shortcode = joypixels[hexcode] || emojibase[hexcode];
+ return Array.isArray(shortcode) ? shortcode[0] : shortcode;
+};
+
+export const getHexcodeForEmoji = fromUnicodeToHexcode;
+
export const emojiGroups: IEmojiGroup[] = [
{
id: EmojiGroupId.People,
}
emojisData.forEach((emoji) => {
- const myShortCodes = joypixels[emoji.hexcode] || emojibase[emoji.hexcode];
+ const myShortCodes = getShortcodesFor(emoji.hexcode);
if (!myShortCodes) return;
if (Array.isArray(myShortCodes) && myShortCodes.length === 0) return;
--- /dev/null
+import { useCallback } from 'react';
+import type * as PdfJsDist from 'pdfjs-dist';
+import type { GetViewportParameters } from 'pdfjs-dist/types/src/display/api';
+import { useAsyncCallback } from '../hooks/useAsyncCallback';
+
+export const usePdfJSLoader = () =>
+ useAsyncCallback(
+ useCallback(async () => {
+ const pdf = await import('pdfjs-dist');
+ pdf.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js';
+ return pdf;
+ }, [])
+ );
+
+export const usePdfDocumentLoader = (pdfJS: typeof PdfJsDist | undefined, src: string) =>
+ useAsyncCallback(
+ useCallback(async () => {
+ if (!pdfJS) {
+ throw new Error('PdfJS is not loaded');
+ }
+ const doc = await pdfJS.getDocument(src).promise;
+ return doc;
+ }, [pdfJS, src])
+ );
+
+export const createPage = async (
+ doc: PdfJsDist.PDFDocumentProxy,
+ pNo: number,
+ opts: GetViewportParameters
+): Promise<HTMLCanvasElement> => {
+ const page = await doc.getPage(pNo);
+ const pageViewport = page.getViewport(opts);
+ const canvas = document.createElement('canvas');
+ const context = canvas.getContext('2d');
+
+ if (!context) throw new Error('failed to render page.');
+
+ canvas.width = pageViewport.width;
+ canvas.height = pageViewport.height;
+
+ page.render({
+ canvasContext: context,
+ viewport: pageViewport,
+ });
+
+ return canvas;
+};
--- /dev/null
+/* eslint-disable jsx-a11y/alt-text */
+import React, { ReactEventHandler, Suspense, lazy } from 'react';
+import {
+ Element,
+ Text as DOMText,
+ HTMLReactParserOptions,
+ attributesToProps,
+ domToReact,
+} from 'html-react-parser';
+import { MatrixClient, Room } from 'matrix-js-sdk';
+import classNames from 'classnames';
+import { Scroll, Text } from 'folds';
+import { Opts as LinkifyOpts } from 'linkifyjs';
+import Linkify from 'linkify-react';
+import { ErrorBoundary } from 'react-error-boundary';
+import * as css from '../styles/CustomHtml.css';
+import { getMxIdLocalPart, getRoomWithCanonicalAlias } from '../utils/matrix';
+import { getMemberDisplayName } from '../utils/room';
+
+const ReactPrism = lazy(() => import('./react-prism/ReactPrism'));
+
+export const LINKIFY_OPTS: LinkifyOpts = {
+ attributes: {
+ target: '_blank',
+ rel: 'noreferrer noopener',
+ },
+ validate: {
+ url: (value) => /^(https|http|ftp|mailto|magnet)?:/.test(value),
+ },
+};
+
+export const getReactCustomHtmlParser = (
+ mx: MatrixClient,
+ room: Room,
+ params: {
+ handleSpoilerClick?: ReactEventHandler<HTMLElement>;
+ handleMentionClick?: ReactEventHandler<HTMLElement>;
+ }
+): HTMLReactParserOptions => {
+ const opts: HTMLReactParserOptions = {
+ replace: (domNode) => {
+ if (domNode instanceof Element && 'name' in domNode) {
+ const { name, attribs, children, parent } = domNode;
+ const props = attributesToProps(attribs);
+
+ if (name === 'h1') {
+ return (
+ <Text className={css.Heading} size="H2" {...props}>
+ {domToReact(children, opts)}
+ </Text>
+ );
+ }
+
+ if (name === 'h2') {
+ return (
+ <Text className={css.Heading} size="H3" {...props}>
+ {domToReact(children, opts)}
+ </Text>
+ );
+ }
+
+ if (name === 'h3') {
+ return (
+ <Text className={css.Heading} size="H4" {...props}>
+ {domToReact(children, opts)}
+ </Text>
+ );
+ }
+
+ if (name === 'h4') {
+ return (
+ <Text className={css.Heading} size="H4" {...props}>
+ {domToReact(children, opts)}
+ </Text>
+ );
+ }
+
+ if (name === 'h5') {
+ return (
+ <Text className={css.Heading} size="H5" {...props}>
+ {domToReact(children, opts)}
+ </Text>
+ );
+ }
+
+ if (name === 'h6') {
+ return (
+ <Text className={css.Heading} size="H6" {...props}>
+ {domToReact(children, opts)}
+ </Text>
+ );
+ }
+
+ if (name === 'p') {
+ return (
+ <Text className={classNames(css.Paragraph, css.MarginSpaced)} size="Inherit" {...props}>
+ {domToReact(children, opts)}
+ </Text>
+ );
+ }
+
+ if (name === 'pre') {
+ return (
+ <Text as="pre" className={css.CodeBlock} {...props}>
+ <Scroll
+ direction="Horizontal"
+ variant="Secondary"
+ size="300"
+ visibility="Hover"
+ hideTrack
+ >
+ <div className={css.CodeBlockInternal}>{domToReact(children, opts)}</div>
+ </Scroll>
+ </Text>
+ );
+ }
+
+ if (name === 'blockquote') {
+ return (
+ <Text size="Inherit" as="blockquote" className={css.BlockQuote} {...props}>
+ {domToReact(children, opts)}
+ </Text>
+ );
+ }
+
+ if (name === 'ul') {
+ return (
+ <ul className={css.List} {...props}>
+ {domToReact(children, opts)}
+ </ul>
+ );
+ }
+ if (name === 'ol') {
+ return (
+ <ol className={css.List} {...props}>
+ {domToReact(children, opts)}
+ </ol>
+ );
+ }
+
+ if (name === 'code') {
+ if (parent && 'name' in parent && parent.name === 'pre') {
+ const codeReact = domToReact(children, opts);
+ if (typeof codeReact === 'string') {
+ let lang = props.className;
+ if (lang === 'language-rs') lang = 'language-rust';
+ return (
+ <ErrorBoundary fallback={<code {...props}>{codeReact}</code>}>
+ <Suspense fallback={<code {...props}>{codeReact}</code>}>
+ <ReactPrism>
+ {(ref) => (
+ <code ref={ref} {...props} className={lang}>
+ {codeReact}
+ </code>
+ )}
+ </ReactPrism>
+ </Suspense>
+ </ErrorBoundary>
+ );
+ }
+ } else {
+ return (
+ <code className={css.Code} {...props}>
+ {domToReact(children, opts)}
+ </code>
+ );
+ }
+ }
+
+ if (name === 'a') {
+ const mention = decodeURIComponent(props.href).match(
+ /^https?:\/\/matrix.to\/#\/((@|#|!).+:[^?/]+)/
+ );
+ if (mention) {
+ // convert mention link to pill
+ const mentionId = mention[1];
+ const mentionPrefix = mention[2];
+ if (mentionPrefix === '#' || mentionPrefix === '!') {
+ const mentionRoom =
+ mentionPrefix === '#'
+ ? getRoomWithCanonicalAlias(mx, mentionId)
+ : mx.getRoom(mentionId);
+ const mentionName = mentionRoom?.name;
+
+ const mentionDisplayName =
+ mentionName && (mentionName.startsWith('#') ? mentionName : `#${mentionName}`);
+ return (
+ <span
+ {...props}
+ className={css.Mention({
+ highlight: room.roomId === (mentionRoom?.roomId ?? mentionId),
+ })}
+ data-mention-id={mentionRoom?.roomId ?? mentionId}
+ data-mention-href={props.href}
+ role="button"
+ tabIndex={params.handleMentionClick ? 0 : -1}
+ onKeyDown={params.handleMentionClick}
+ onClick={params.handleMentionClick}
+ style={{ cursor: 'pointer' }}
+ >
+ {mentionDisplayName ?? mentionId}
+ </span>
+ );
+ }
+ if (mentionPrefix === '@')
+ return (
+ <span
+ {...props}
+ className={css.Mention({ highlight: mx.getUserId() === mentionId })}
+ data-mention-id={mentionId}
+ data-mention-href={props.href}
+ role="button"
+ tabIndex={params.handleMentionClick ? 0 : -1}
+ onKeyDown={params.handleMentionClick}
+ onClick={params.handleMentionClick}
+ style={{ cursor: 'pointer' }}
+ >
+ {`@${getMemberDisplayName(room, mentionId) ?? getMxIdLocalPart(mentionId)}`}
+ </span>
+ );
+ }
+ }
+
+ if (name === 'span' && 'data-mx-spoiler' in props) {
+ return (
+ <span
+ {...props}
+ role="button"
+ tabIndex={params.handleSpoilerClick ? 0 : -1}
+ onKeyDown={params.handleSpoilerClick}
+ onClick={params.handleSpoilerClick}
+ className={css.Spoiler()}
+ aria-pressed
+ style={{ cursor: 'pointer' }}
+ >
+ {domToReact(children, opts)}
+ </span>
+ );
+ }
+
+ if (name === 'img') {
+ const htmlSrc = mx.mxcUrlToHttp(props.src);
+ if (htmlSrc && props.src.startsWith('mxc://') === false) {
+ return (
+ <a href={htmlSrc} target="_blank" rel="noreferrer noopener">
+ {props.alt && htmlSrc}
+ </a>
+ );
+ }
+ if (htmlSrc && 'data-mx-emoticon' in props) {
+ return (
+ <span className={css.EmoticonBase}>
+ <span className={css.Emoticon()} contentEditable={false}>
+ <img className={css.EmoticonImg} src={htmlSrc} data-mx-emoticon />
+ </span>
+ </span>
+ );
+ }
+ if (htmlSrc) return <img className={css.Img} {...props} src={htmlSrc} />;
+ }
+ }
+
+ if (
+ domNode instanceof DOMText &&
+ !(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'code') &&
+ !(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'a')
+ ) {
+ return <Linkify options={LINKIFY_OPTS}>{domNode.data}</Linkify>;
+ }
+ return undefined;
+ },
+ };
+ return opts;
+};
--- /dev/null
+.prism-light {
+ --prism-comment: #0f4777;
+ --prism-punctuation: #6d5050;
+ --prism-property: #9b1144;
+ --prism-boolean: #4816a3;
+ --prism-selector: #659604;
+ --prism-operator: #2a2a2a;
+ --prism-atrule: #7e6d00;
+ --prism-keyword: #00829f;
+ --prism-regex: #9b6426;
+}
+
+.prism-dark {
+ --prism-comment: #8292a2;
+ --prism-punctuation: #f8f8f2;
+ --prism-property: #f92672;
+ --prism-boolean: #ae81ff;
+ --prism-selector: #a6e22e;
+ --prism-operator: #f8f8f2;
+ --prism-atrule: #e6db74;
+ --prism-keyword: #66d9ef;
+ --prism-regex: #fd971f;
+}
+
+code .token.comment,
+code .token.prolog,
+code .token.doctype,
+code .token.cdata {
+ color: var(--prism-comment);
+}
+
+code .token.punctuation {
+ color: var(--prism-punctuation);
+}
+
+code .token.namespace {
+ opacity: 0.7;
+}
+
+code .token.property,
+code .token.tag,
+code .token.constant,
+code .token.symbol,
+code .token.deleted {
+ color: var(--prism-property);
+}
+
+code .token.boolean,
+code .token.number {
+ color: var(--prism-boolean);
+}
+
+code .token.selector,
+code .token.attr-name,
+code .token.string,
+code .token.char,
+code .token.builtin,
+code .token.inserted {
+ color: var(--prism-selector);
+}
+
+code .token.operator,
+code .token.entity,
+code .token.url,
+.language-css code .token.string,
+.style code .token.string,
+code .token.variable {
+ color: var(--prism-operator);
+}
+
+code .token.atrule,
+code .token.attr-value,
+code .token.function,
+code .token.class-name {
+ color: var(--prism-atrule);
+}
+
+code .token.keyword {
+ color: var(--prism-keyword);
+}
+
+code .token.regex,
+code .token.important {
+ color: var(--prism-regex);
+}
+
+code .token.important,
+code .token.bold {
+ font-weight: bold;
+}
+code .token.italic {
+ font-style: italic;
+}
+
+code .token.entity {
+ cursor: help;
+}
--- /dev/null
+import React, { MutableRefObject, ReactNode, useEffect, useRef } from 'react';
+
+import Prism from 'prismjs';
+
+import 'prismjs/components/prism-json';
+import 'prismjs/components/prism-javascript';
+import 'prismjs/components/prism-typescript';
+import 'prismjs/components/prism-css';
+import 'prismjs/components/prism-sass';
+import 'prismjs/components/prism-swift';
+import 'prismjs/components/prism-rust';
+import 'prismjs/components/prism-go';
+import 'prismjs/components/prism-c';
+import 'prismjs/components/prism-cpp';
+import 'prismjs/components/prism-java';
+import 'prismjs/components/prism-python';
+
+import './ReactPrism.css';
+// we apply theme in client/state/settings.js
+// using classNames .prism-dark .prism-light from ReactPrism.css
+
+export default function ReactPrism({
+ children,
+}: {
+ children: (ref: MutableRefObject<null>) => ReactNode;
+}) {
+ const codeRef = useRef<HTMLElement>(null);
+
+ useEffect(() => {
+ const el = codeRef.current;
+ if (el) Prism.highlightElement(el);
+ }, []);
+
+ return <>{children(codeRef as MutableRefObject<null>)}</>;
+}
import { atom } from 'jotai';
const STORAGE_KEY = 'settings';
+export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
+export type MessageLayout = 0 | 1 | 2;
export interface Settings {
themeIndex: number;
useSystemTheme: boolean;
isMarkdown: boolean;
editorToolbar: boolean;
isPeopleDrawer: boolean;
+ useSystemEmoji: boolean;
+ messageLayout: MessageLayout;
+ messageSpacing: MessageSpacing;
hideMembershipEvents: boolean;
hideNickAvatarEvents: boolean;
+ mediaAutoLoad: boolean;
+ showHiddenEvents: boolean;
showNotifications: boolean;
isNotificationSounds: boolean;
isMarkdown: true,
editorToolbar: false,
isPeopleDrawer: true,
+ useSystemEmoji: false,
+ messageLayout: 0,
+ messageSpacing: '400',
hideMembershipEvents: false,
hideNickAvatarEvents: true,
+ mediaAutoLoad: true,
+ showHiddenEvents: false,
showNotifications: true,
isNotificationSounds: true,
export const getSettings = () => {
const settings = localStorage.getItem(STORAGE_KEY);
if (settings === null) return defaultSettings;
- return JSON.parse(settings) as Settings;
+ return {
+ ...defaultSettings,
+ ...(JSON.parse(settings) as Settings),
+ };
};
export const setSettings = (settings: Settings) => {
--- /dev/null
+import { atom, useSetAtom } from 'jotai';
+import { selectAtom } from 'jotai/utils';
+import {
+ MatrixClient,
+ RoomMember,
+ RoomMemberEvent,
+ RoomMemberEventHandlerMap,
+} from 'matrix-js-sdk';
+import { useEffect } from 'react';
+
+export type IRoomIdToTypingMembers = Map<string, RoomMember[]>;
+
+export type IRoomIdToTypingMembersAction =
+ | {
+ type: 'PUT';
+ roomId: string;
+ member: RoomMember;
+ }
+ | {
+ type: 'DELETE';
+ roomId: string;
+ member: RoomMember;
+ };
+
+const baseRoomIdToTypingMembersAtom = atom<IRoomIdToTypingMembers>(new Map());
+export const roomIdToTypingMembersAtom = atom<IRoomIdToTypingMembers, IRoomIdToTypingMembersAction>(
+ (get) => get(baseRoomIdToTypingMembersAtom),
+ (get, set, action) => {
+ const roomIdToTypingMembers = get(baseRoomIdToTypingMembersAtom);
+ let typingMembers = roomIdToTypingMembers.get(action.roomId) ?? [];
+
+ typingMembers = typingMembers.filter((member) => member.userId !== action.member.userId);
+
+ if (action.type === 'PUT') {
+ typingMembers = [...typingMembers, action.member];
+ }
+ roomIdToTypingMembers.set(action.roomId, typingMembers);
+ set(baseRoomIdToTypingMembersAtom, new Map([...roomIdToTypingMembers]));
+ }
+);
+
+export const useBindRoomIdToTypingMembersAtom = (
+ mx: MatrixClient,
+ typingMembersAtom: typeof roomIdToTypingMembersAtom
+) => {
+ const setTypingMembers = useSetAtom(typingMembersAtom);
+
+ useEffect(() => {
+ const handleTypingEvent: RoomMemberEventHandlerMap[RoomMemberEvent.Typing] = (
+ event,
+ member
+ ) => {
+ setTypingMembers({
+ type: member.typing ? 'PUT' : 'DELETE',
+ roomId: member.roomId,
+ member,
+ });
+ };
+
+ mx.on(RoomMemberEvent.Typing, handleTypingEvent);
+ return () => {
+ mx.removeListener(RoomMemberEvent.Typing, handleTypingEvent);
+ };
+ }, [mx, setTypingMembers]);
+};
+
+export const selectRoomTypingMembersAtom = (
+ roomId: string,
+ typingMembersAtom: typeof roomIdToTypingMembersAtom
+) => selectAtom(typingMembersAtom, (atoms) => atoms.get(roomId) ?? []);
--- /dev/null
+import { style } from '@vanilla-extract/css';
+import { recipe } from '@vanilla-extract/recipes';
+import { color, config, DefaultReset, toRem } from 'folds';
+
+export const MarginSpaced = style({
+ marginBottom: config.space.S200,
+ marginTop: config.space.S200,
+ selectors: {
+ '&:first-child': {
+ marginTop: 0,
+ },
+ '&:last-child': {
+ marginBottom: 0,
+ },
+ },
+});
+
+export const Paragraph = style([DefaultReset]);
+
+export const Heading = style([
+ DefaultReset,
+ MarginSpaced,
+ {
+ marginTop: config.space.S400,
+ selectors: {
+ '&:first-child': {
+ marginTop: 0,
+ },
+ },
+ },
+]);
+
+export const BlockQuote = style([
+ DefaultReset,
+ MarginSpaced,
+ {
+ paddingLeft: config.space.S200,
+ borderLeft: `${config.borderWidth.B700} solid ${color.SurfaceVariant.ContainerLine}`,
+ fontStyle: 'italic',
+ },
+]);
+
+const BaseCode = style({
+ fontFamily: 'monospace',
+ color: color.Secondary.OnContainer,
+ background: color.Secondary.Container,
+ border: `${config.borderWidth.B300} solid ${color.Secondary.ContainerLine}`,
+ borderRadius: config.radii.R300,
+});
+
+export const Code = style([
+ DefaultReset,
+ BaseCode,
+ {
+ padding: `0 ${config.space.S100}`,
+ },
+]);
+
+export const Spoiler = recipe({
+ base: [
+ DefaultReset,
+ {
+ padding: `0 ${config.space.S100}`,
+ backgroundColor: color.SurfaceVariant.ContainerActive,
+ borderRadius: config.radii.R300,
+ selectors: {
+ '&[aria-pressed=true]': {
+ color: 'transparent',
+ },
+ },
+ },
+ ],
+ variants: {
+ active: {
+ true: {
+ color: 'transparent',
+ },
+ },
+ },
+});
+
+export const CodeBlock = style([
+ DefaultReset,
+ BaseCode,
+ MarginSpaced,
+ {
+ fontStyle: 'normal',
+ },
+]);
+export const CodeBlockInternal = style({
+ padding: `${config.space.S200} ${config.space.S200} 0`,
+});
+
+export const List = style([
+ DefaultReset,
+ MarginSpaced,
+ {
+ padding: `0 ${config.space.S100}`,
+ paddingLeft: config.space.S600,
+ },
+]);
+
+export const Img = style([
+ DefaultReset,
+ MarginSpaced,
+ {
+ maxWidth: toRem(296),
+ borderRadius: config.radii.R300,
+ },
+]);
+
+export const InlineChromiumBugfix = style({
+ fontSize: 0,
+ lineHeight: 0,
+});
+
+export const Mention = recipe({
+ base: [
+ DefaultReset,
+ {
+ backgroundColor: color.SurfaceVariant.Container,
+ color: color.SurfaceVariant.OnContainer,
+ boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`,
+ padding: `0 ${toRem(2)}`,
+ borderRadius: config.radii.R300,
+ fontWeight: config.fontWeight.W500,
+ },
+ ],
+ variants: {
+ highlight: {
+ true: {
+ backgroundColor: color.Success.Container,
+ color: color.Success.OnContainer,
+ boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.Success.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',
+ },
+]);
import ContextMenu, { MenuItem } from '../../atoms/context-menu/ContextMenu';
import IconButton from '../../atoms/button/IconButton';
import ReusableContextMenu from '../../atoms/context-menu/ReusableContextMenu';
-import Room from '../../organisms/room/Room';
import Windows from '../../organisms/pw/Windows';
import Dialogs from '../../organisms/pw/Dialogs';
-import EmojiBoardOpener from '../../organisms/emoji-board/EmojiBoardOpener';
import initMatrix from '../../../client/initMatrix';
import navigation from '../../../client/state/navigation';
import VerticalMenuIC from '../../../../public/res/ic/outlined/vertical-menu.svg';
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
+import { ClientContent } from './ClientContent';
+import { useSetting } from '../../state/hooks/settings';
+import { settingsAtom } from '../../state/settings';
+
+function SystemEmojiFeature() {
+ const [systemEmoji] = useSetting(settingsAtom, 'useSystemEmoji');
+
+ if (systemEmoji) {
+ document.documentElement.style.setProperty('--font-emoji', 'Twemoji_DISABLED');
+ } else {
+ document.documentElement.style.setProperty('--font-emoji', 'Twemoji');
+ }
+
+ return null;
+}
function Client() {
const [isLoading, changeLoading] = useState(true);
<Navigation />
</div>
<div className={`room__wrapper ${classNameHidden}`} ref={roomWrapperRef}>
- <Room />
+ <ClientContent />
</div>
<Windows />
<Dialogs />
- <EmojiBoardOpener />
<ReusableContextMenu />
+ <SystemEmojiFeature />
</div>
</MatrixClientProvider>
);
--- /dev/null
+import React, { useState, useEffect } from 'react';
+
+import initMatrix from '../../../client/initMatrix';
+import cons from '../../../client/state/cons';
+import navigation from '../../../client/state/navigation';
+import { openNavigation } from '../../../client/action/navigation';
+
+import Welcome from '../../organisms/welcome/Welcome';
+import { RoomBaseView } from '../../organisms/room/Room';
+
+export function ClientContent() {
+ const [roomInfo, setRoomInfo] = useState({
+ room: null,
+ eventId: null,
+ });
+
+ const mx = initMatrix.matrixClient;
+
+ useEffect(() => {
+ const handleRoomSelected = (rId, pRoomId, eId) => {
+ roomInfo.roomTimeline?.removeInternalListeners();
+ const r = mx.getRoom(rId);
+ if (r) {
+ setRoomInfo({
+ room: r,
+ eventId: eId ?? null,
+ });
+ } else {
+ setRoomInfo({
+ room: null,
+ eventId: null,
+ });
+ }
+ };
+
+ navigation.on(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
+ return () => {
+ navigation.removeListener(cons.events.navigation.ROOM_SELECTED, handleRoomSelected);
+ };
+ }, [roomInfo, mx]);
+
+ const { room, eventId } = roomInfo;
+ if (!room) {
+ setTimeout(() => openNavigation());
+ return <Welcome />;
+ }
+
+ return <RoomBaseView room={room} eventId={eventId} />;
+}
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 imgWidth = img instanceof HTMLVideoElement ? img.videoWidth : img.width;
+ const imgHeight = img instanceof HTMLVideoElement ? img.videoHeight : img.height;
const canvas = document.createElement('canvas');
- canvas.width = width || img.width;
- canvas.height = height || img.height;
+ canvas.width = width || imgWidth;
+ canvas.height = height || imgHeight;
const context = canvas.getContext('2d');
if (!context) return undefined;
return `${(bytes / 1000 ** sizeIndex).toFixed(1)} ${sizes[sizeIndex]}`;
};
+export const millisecondsToMinutesAndSeconds = (milliseconds: number): string => {
+ const seconds = Math.floor(milliseconds / 1000);
+ const mm = Math.floor(seconds / 60);
+ const ss = Math.round(seconds % 60);
+ return `${mm}:${ss < 10 ? '0' : ''}${ss}`;
+};
+
+export const secondsToMinutesAndSeconds = (seconds: number): string => {
+ const mm = Math.floor(seconds / 60);
+ const ss = Math.round(seconds % 60);
+ return `${mm}:${ss < 10 ? '0' : ''}${ss}`;
+};
+
export const getFileTypeIcon = (icons: Record<IconName, IconSrc>, fileType: string): IconSrc => {
const type = fileType.toLowerCase();
if (type.startsWith('audio')) {
if (pr.status === 'fulfilled') values.push(pr.value);
return values;
}, []);
+
+export const binarySearch = <T>(items: T[], match: (item: T) => -1 | 0 | 1): T | undefined => {
+ const search = (start: number, end: number): T | undefined => {
+ if (start > end) return undefined;
+
+ const mid = Math.floor((start + end) / 2);
+
+ const result = match(items[mid]);
+ if (result === 0) return items[mid];
+
+ if (result === 1) return search(start, mid - 1);
+ return search(mid + 1, end);
+ };
+
+ return search(0, items.length - 1);
+};
+
+export const randomNumberBetween = (min: number, max: number) =>
+ Math.floor(Math.random() * (max - min + 1)) + min;
+
+export const scaleYDimension = (x: number, scaledX: number, y: number): number => {
+ const scaleFactor = scaledX / x;
+ return scaleFactor * y;
+};
+
+export const parseGeoUri = (location: string) => {
+ const [, data] = location.split(':');
+ const [cords] = data.split(';');
+ const [latitude, longitude] = cords.split(',');
+ return {
+ latitude,
+ longitude,
+ };
+};
!!document.activeElement &&
/^(input)|(textarea)$/.test(document.activeElement.nodeName.toLowerCase());
-export const inVisibleScrollArea = (
+export const isIntersectingScrollView = (
scrollElement: HTMLElement,
childElement: HTMLElement
): boolean => {
const childBottom = childTop + childElement.clientHeight;
if (childTop >= scrollTop && childTop < scrollBottom) return true;
- if (childTop < scrollTop && childBottom > scrollTop) return true;
+ if (childBottom > scrollTop && childBottom <= scrollBottom) return true;
+ if (childTop < scrollTop && childBottom > scrollBottom) return true;
return false;
};
+export const isInScrollView = (scrollElement: HTMLElement, childElement: HTMLElement): boolean => {
+ const scrollTop = scrollElement.offsetTop + scrollElement.scrollTop;
+ const scrollBottom = scrollTop + scrollElement.offsetHeight;
+ return (
+ childElement.offsetTop >= scrollTop &&
+ childElement.offsetTop + childElement.offsetHeight <= scrollBottom
+ );
+};
+
+export const canFitInScrollView = (
+ scrollElement: HTMLElement,
+ childElement: HTMLElement
+): boolean => childElement.offsetHeight < scrollElement.offsetHeight;
+
export type FilesOrFile<T extends boolean | undefined = undefined> = T extends true ? File[] : File;
export const selectFile = <M extends boolean | undefined = undefined>(
resolve(thumbnail ?? undefined);
}, thumbnailMimeType ?? 'image/jpeg');
});
+
+export type ScrollInfo = {
+ offsetTop: number;
+ top: number;
+ height: number;
+ viewHeight: number;
+ scrollable: boolean;
+};
+export const getScrollInfo = (target: HTMLElement): ScrollInfo => ({
+ offsetTop: Math.round(target.offsetTop),
+ top: Math.round(target.scrollTop),
+ height: Math.round(target.scrollHeight),
+ viewHeight: Math.round(target.offsetHeight),
+ scrollable: target.scrollHeight > target.offsetHeight,
+});
+
+export const scrollToBottom = (scrollEl: HTMLElement, behavior?: 'auto' | 'instant' | 'smooth') => {
+ scrollEl.scrollTo({
+ top: Math.round(scrollEl.scrollHeight - scrollEl.offsetHeight),
+ behavior,
+ });
+};
+
+export const copyToClipboard = (text: string) => {
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(text);
+ } else {
+ const host = document.body;
+ const copyInput = document.createElement('input');
+ copyInput.style.position = 'fixed';
+ copyInput.style.opacity = '0';
+ copyInput.value = text;
+ host.append(copyInput);
+
+ copyInput.select();
+ copyInput.setSelectionRange(0, 99999);
+ document.execCommand('Copy');
+ copyInput.remove();
+ }
+};
-import { EncryptedAttachmentInfo, encryptAttachment } from 'browser-encrypt-attachment';
-import { MatrixClient, MatrixError, UploadProgress, UploadResponse } from 'matrix-js-sdk';
+import {
+ EncryptedAttachmentInfo,
+ decryptAttachment,
+ encryptAttachment,
+} from 'browser-encrypt-attachment';
+import {
+ MatrixClient,
+ MatrixError,
+ MatrixEvent,
+ Room,
+ UploadProgress,
+ UploadResponse,
+} from 'matrix-js-sdk';
import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common';
export const matchMxId = (id: string): RegExpMatchArray | null =>
export const isUserId = (id: string): boolean => validMxId(id) && id.startsWith('@');
+export const isRoomId = (id: string): boolean => validMxId(id) && id.startsWith('!');
+
+export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#');
+
+export const getRoomWithCanonicalAlias = (mx: MatrixClient, alias: string): Room | undefined =>
+ mx.getRooms()?.find((room) => room.getCanonicalAlias() === alias);
+
export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => {
const info: IImageInfo = {};
info.w = img.width;
export const getVideoInfo = (video: HTMLVideoElement, fileOrBlob: File | Blob): IVideoInfo => {
const info: IVideoInfo = {};
- info.duration = Number.isNaN(video.duration) ? undefined : video.duration;
+ info.duration = Number.isNaN(video.duration) ? undefined : Math.floor(video.duration * 1000);
info.w = video.videoWidth;
info.h = video.videoHeight;
info.mimetype = fileOrBlob.type;
};
};
+export const decryptFile = async (
+ dataBuffer: ArrayBuffer,
+ type: string,
+ encInfo: EncryptedAttachmentInfo
+): Promise<Blob> => {
+ const dataArray = await decryptAttachment(dataBuffer, encInfo);
+ const blob = new Blob([dataArray], { type });
+ return blob;
+};
+
export type TUploadContent = File | Blob;
export type ContentUploadOptions = {
onError(new MatrixError({ error, errcode }));
}
};
+
+export const matrixEventByRecency = (m1: MatrixEvent, m2: MatrixEvent) => m2.getTs() - m1.getTs();
+
+export const factoryEventSentBy = (senderId: string) => (ev: MatrixEvent) =>
+ ev.getSender() === senderId;
+
+export const eventWithShortcode = (ev: MatrixEvent) =>
+ typeof ev.getContent().shortcode === 'string';
+
+export const trimReplyFromBody = (body: string): string => {
+ if (body.match(/^> <.+>/) === null) return body;
+
+ const trimmedBody = body.slice(body.indexOf('\n\n') + 2);
+
+ return trimmedBody || body;
+};
-// https://github.com/matrix-org/matrix-react-sdk/blob/cd15e08fc285da42134817cce50de8011809cd53/src/utils/blobs.ts
-export const ALLOWED_BLOB_MIMETYPES = [
+export const IMAGE_MIME_TYPES = [
'image/jpeg',
'image/gif',
'image/png',
'image/apng',
'image/webp',
'image/avif',
+];
- 'video/mp4',
- 'video/webm',
- 'video/ogg',
- 'video/quicktime',
+export const VIDEO_MIME_TYPES = ['video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'];
+export const AUDIO_MIME_TYPES = [
'audio/mp4',
'audio/webm',
'audio/aac',
'audio/x-flac',
];
+export const APPLICATION_MIME_TYPES = [
+ 'application/pdf',
+ 'application/json',
+ 'application/x-sh',
+ 'application/ecmascript',
+ 'application/javascript',
+ 'application/xhtml+xml',
+ 'application/xml',
+];
+
+export const TEXT_MIME_TYPE = [
+ 'text/plain',
+ 'text/html',
+ 'text/css',
+ 'text/javascript',
+ 'text/x-c',
+ 'text/csv',
+ 'text/tab-separated-values',
+ 'text/yaml',
+ 'text/x-java-source,java',
+ 'text/markdown',
+];
+
+export const READABLE_TEXT_MIME_TYPES = [
+ 'application/json',
+ 'application/x-sh',
+ 'application/ecmascript',
+ 'application/javascript',
+ 'application/xhtml+xml',
+ 'application/xml',
+
+ ...TEXT_MIME_TYPE,
+];
+
+export const ALLOWED_BLOB_MIME_TYPES = [
+ ...IMAGE_MIME_TYPES,
+ ...VIDEO_MIME_TYPES,
+ ...AUDIO_MIME_TYPES,
+ ...APPLICATION_MIME_TYPES,
+ ...TEXT_MIME_TYPE,
+];
+
+export const FALLBACK_MIMETYPE = 'application/octet-stream';
+
export const getBlobSafeMimeType = (mimeType: string) => {
- if (typeof mimeType !== 'string') return 'application/octet-stream';
+ if (typeof mimeType !== 'string') return FALLBACK_MIMETYPE;
const [type] = mimeType.split(';');
- if (!ALLOWED_BLOB_MIMETYPES.includes(type)) {
- return 'application/octet-stream';
+ if (!ALLOWED_BLOB_MIME_TYPES.includes(type)) {
+ return FALLBACK_MIMETYPE;
}
// Required for Chromium browsers
if (type === 'video/quicktime') {
}
return f;
};
+
+export const mimeTypeToExt = (mimeType: string): string => {
+ const extStart = mimeType.lastIndexOf('/') + 1;
+ return mimeType.slice(extStart);
+};
import { IconName, IconSrc } from 'folds';
import {
+ EventTimeline,
IPushRule,
IPushRules,
JoinRule,
NotificationCountType,
Room,
} from 'matrix-js-sdk';
+import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import { AccountDataEvent } from '../../types/matrix/accountData';
import {
NotificationType,
return `<mx-reply><blockquote>${replyToLink}${userLink}<br />${formattedBody}</blockquote></mx-reply>`;
};
+
+export const getMemberDisplayName = (room: Room, userId: string): string | undefined => {
+ const member = room.getMember(userId);
+ const name = member?.rawDisplayName;
+ if (name === userId) return undefined;
+ return name;
+};
+
+export const getMemberAvatarMxc = (room: Room, userId: string): string | undefined => {
+ const member = room.getMember(userId);
+ return member?.getMxcAvatarUrl();
+};
+
+export const decryptAllTimelineEvent = async (mx: MatrixClient, timeline: EventTimeline) => {
+ const crypto = mx.getCrypto();
+ if (!crypto) return;
+ const decryptionPromises = timeline
+ .getEvents()
+ .filter((event) => event.isEncrypted())
+ .reverse()
+ .map((event) => event.attemptDecryption(crypto as CryptoBackend, { isRetry: true }));
+ await Promise.allSettled(decryptionPromises);
+};
+
+export const getReactionContent = (eventId: string, key: string, shortcode?: string) => ({
+ 'm.relates_to': {
+ event_id: eventId,
+ key,
+ rel_type: 'm.annotation',
+ },
+ shortcode,
+});
+import sanitizeHtml, { Transformer } from 'sanitize-html';
+
+const MAX_TAG_NESTING = 100;
+
+const permittedHtmlTags = [
+ 'font',
+ 'del',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'blockquote',
+ 'p',
+ 'a',
+ 'ul',
+ 'ol',
+ 'sup',
+ 'sub',
+ 'li',
+ 'b',
+ 'i',
+ 'u',
+ 'strong',
+ 'em',
+ 'strike',
+ 's',
+ 'code',
+ 'hr',
+ 'br',
+ 'div',
+ 'table',
+ 'thead',
+ 'tbody',
+ 'tr',
+ 'th',
+ 'td',
+ 'caption',
+ 'pre',
+ 'span',
+ 'img',
+ 'details',
+ 'summary',
+];
+
+const urlSchemes = ['https', 'http', 'ftp', 'mailto', 'magnet'];
+
+const permittedTagToAttributes = {
+ font: ['style', 'data-mx-bg-color', 'data-mx-color', 'color'],
+ span: [
+ 'style',
+ 'data-mx-bg-color',
+ 'data-mx-color',
+ 'data-mx-spoiler',
+ 'data-mx-maths',
+ 'data-mx-pill',
+ 'data-mx-ping',
+ ],
+ div: ['data-mx-maths'],
+ a: ['name', 'target', 'href', 'rel'],
+ img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'],
+ ol: ['start'],
+ code: ['class'],
+};
+
+const transformFontTag: Transformer = (tagName, attribs) => ({
+ tagName,
+ attribs: {
+ ...attribs,
+ style: `background-color: ${attribs['data-mx-bg-color']}; color: ${attribs['data-mx-color']}`,
+ },
+});
+
+const transformSpanTag: Transformer = (tagName, attribs) => ({
+ tagName,
+ attribs: {
+ ...attribs,
+ style: `background-color: ${attribs['data-mx-bg-color']}; color: ${attribs['data-mx-color']}`,
+ },
+});
+
+const transformATag: Transformer = (tagName, attribs) => ({
+ tagName,
+ attribs: {
+ ...attribs,
+ rel: 'noopener',
+ target: '_blank',
+ },
+});
+
+const transformImgTag: Transformer = (tagName, attribs) => {
+ const { src } = attribs;
+ if (src.startsWith('mxc://') === false) {
+ return {
+ tagName: 'a',
+ attribs: {
+ href: src,
+ rel: 'noopener',
+ target: '_blank',
+ },
+ text: attribs.alt || src,
+ };
+ }
+ return {
+ tagName,
+ attribs: {
+ ...attribs,
+ },
+ };
+};
+
+export const sanitizeCustomHtml = (customHtml: string): string =>
+ sanitizeHtml(customHtml, {
+ allowedTags: permittedHtmlTags,
+ allowedAttributes: permittedTagToAttributes,
+ disallowedTagsMode: 'discard',
+ allowedSchemes: urlSchemes,
+ allowedSchemesByTag: {
+ a: urlSchemes,
+ },
+ allowedSchemesAppliedToAttributes: ['href'],
+ allowProtocolRelative: false,
+ allowedClasses: {
+ code: ['language-*'],
+ },
+ allowedStyles: {
+ '*': {
+ color: [/^#(?:[0-9a-fA-F]{3}){1,2}$/],
+ 'background-color': [/^#(?:[0-9a-fA-F]{3}){1,2}$/],
+ },
+ },
+ transformTags: {
+ font: transformFontTag,
+ span: transformSpanTag,
+ a: transformATag,
+ img: transformImgTag,
+ },
+ nonTextTags: ['style', 'script', 'textarea', 'option', 'noscript', 'mx-reply'],
+ nestingLimit: MAX_TAG_NESTING,
+ });
+
export const sanitizeText = (body: string) => {
const tagsToReplace: Record<string, string> = {
'&': '&',
--- /dev/null
+import dayjs from 'dayjs';
+import isToday from 'dayjs/plugin/isToday';
+import isYesterday from 'dayjs/plugin/isYesterday';
+
+dayjs.extend(isToday);
+dayjs.extend(isYesterday);
+
+export const today = (ts: number): boolean => dayjs(ts).isToday();
+
+export const yesterday = (ts: number): boolean => dayjs(ts).isYesterday();
+
+export const timeHourMinute = (ts: number): string => dayjs(ts).format('hh:mm A');
+
+export const timeDayMonYear = (ts: number): string => dayjs(ts).format('D MMM YYYY');
+
+export const timeDayMonthYear = (ts: number): string => dayjs(ts).format('D MMMM YYYY');
+
+export const inSameDay = (ts1: number, ts2: number): boolean => {
+ const dt1 = new Date(ts1);
+ const dt2 = new Date(ts2);
+ return (
+ dt2.getFullYear() === dt1.getFullYear() &&
+ dt2.getMonth() === dt1.getMonth() &&
+ dt2.getDate() === dt1.getDate()
+ );
+};
+
+export const minuteDifference = (ts1: number, ts2: number): number => {
+ const dt1 = new Date(ts1);
+ const dt2 = new Date(ts2);
+
+ let diff = (dt2.getTime() - dt1.getTime()) / 1000;
+ diff /= 60;
+ return Math.abs(Math.round(diff));
+};
this.themes.forEach((themeName, index) => {
if (themeName !== '') document.body.classList.remove(themeName);
document.body.classList.remove(this.themeClasses[index]);
+ document.body.classList.remove('prism-light')
+ document.body.classList.remove('prism-dark')
});
}
if (this.themes[themeIndex] === undefined) return
if (this.themes[themeIndex]) document.body.classList.add(this.themes[themeIndex]);
document.body.classList.add(this.themeClasses[themeIndex]);
+ document.body.classList.add(themeIndex < 2 ? 'prism-light' : 'prism-dark');
}
setTheme(themeIndex) {
}
export function encryptAttachment(dataBuffer: ArrayBuffer): Promise<EncryptedAttachment>;
+
+ export function decryptAttachment(
+ dataBuffer: ArrayBuffer,
+ info: EncryptedAttachmentInfo
+ ): Promise<ArrayBuffer>;
}
.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-surface-low: hsl(208, 8%, 16%);
- --bg-surface-low-transparent: hsla(208, 8%, 16%, 0);
- --bg-surface-extra-low: hsl(208, 8%, 14%);
- --bg-surface-extra-low-transparent: hsla(208, 8%, 14%, 0);
- --bg-surface-hover: rgba(255, 255, 255, 3%);
- --bg-surface-active: rgba(255, 255, 255, 5%);
+ --bg-surface: #1f2326;
+ --bg-surface-transparent: #1f232600;
+ --bg-surface-low: #15171a;
+ --bg-surface-low-transparent: #15171a00;
+ --bg-surface-extra-low: #15171a;
+ --bg-surface-extra-low-transparent: #15171a00;
+ --bg-surface-hover: #1f2326;
+ --bg-surface-active: #2a2e33;
--bg-surface-border: rgba(0, 0, 0, 20%);
--bg-primary: rgb(42, 98, 166);
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
+import { MsgType } from 'matrix-js-sdk';
+
+export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash';
export type IImageInfo = {
w?: number;
h?: number;
mimetype?: string;
size?: number;
+ [MATRIX_BLUR_HASH_PROPERTY_NAME]?: string;
+};
+
+export type IVideoInfo = {
+ w?: number;
+ h?: number;
+ mimetype?: string;
+ size?: number;
+ duration?: number;
};
-export type IVideoInfo = IImageInfo & {
+export type IAudioInfo = {
+ mimetype?: string;
+ size?: number;
duration?: number;
};
+export type IFileInfo = {
+ mimetype?: string;
+ size?: number;
+};
+
export type IEncryptedFile = EncryptedAttachmentInfo & {
url: string;
};
thumbnail_file?: IEncryptedFile;
thumbnail_url?: string;
};
+
+export type IImageContent = {
+ msgtype: MsgType.Image;
+ body?: string;
+ url?: string;
+ info?: IImageInfo & IThumbnailContent;
+ file?: IEncryptedFile;
+};
+
+export type IVideoContent = {
+ msgtype: MsgType.Video;
+ body?: string;
+ url?: string;
+ info?: IVideoInfo & IThumbnailContent;
+ file?: IEncryptedFile;
+};
+
+export type IAudioContent = {
+ msgtype: MsgType.Audio;
+ body?: string;
+ url?: string;
+ info?: IAudioInfo;
+ file?: IEncryptedFile;
+};
+
+export type IFileContent = {
+ msgtype: MsgType.File;
+ body?: string;
+ url?: string;
+ info?: IFileInfo & IThumbnailContent;
+ file?: IEncryptedFile;
+};
+
+export type ILocationContent = {
+ msgtype: MsgType.Location;
+ body?: string;
+ geo_uri?: string;
+ info?: IThumbnailContent;
+};
Ban = 'ban',
}
+export type IMemberContent = {
+ avatar_url?: string;
+ displayname?: string;
+ membership?: Membership;
+ reason?: string;
+ is_direct?: boolean;
+};
+
export enum StateEvent {
RoomCanonicalAlias = 'm.room.canonical_alias',
RoomCreate = 'm.room.create',
PoniesRoomEmotes = 'im.ponies.room_emotes',
}
+export enum MessageEvent {
+ RoomMessage = 'm.room.message',
+ RoomMessageEncrypted = 'm.room.encrypted',
+ Sticker = 'm.sticker',
+ RoomRedaction = 'm.room.redaction',
+ Reaction = 'm.reaction',
+}
+
export enum RoomType {
Space = 'm.space',
}
Mute = 'mute',
}
+export type IRoomCreateContent = {
+ creator?: string;
+ ['m.federate']?: boolean;
+ room_version: string;
+ type?: string;
+ predecessor?: {
+ event_id: string;
+ room_id: string;
+ };
+};
+
export type RoomToParents = Map<string, Set<string>>;
export type RoomToUnread = Map<
string,
"sourceMap": true,
"jsx": "react",
"target": "ES2016",
+ "module": "ES2020",
"allowJs": true,
"strict": true,
"esModuleInterop": true,
src: 'node_modules/@matrix-org/olm/olm.wasm',
dest: '',
},
+ {
+ src: 'node_modules/pdfjs-dist/build/pdf.worker.min.js',
+ dest: '',
+ },
{
src: '_redirects',
dest: '',