diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 9d5a27d9..dca01a3d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -31,12 +31,15 @@ module.exports = { 'linebreak-style': 0, 'no-underscore-dangle': 0, "no-shadow": "off", + "max-classes-per-file": "off", + "no-useless-constructor": "off", + "no-empty-function": "off", "import/prefer-default-export": "off", "import/extensions": "off", "import/no-unresolved": "off", "import/no-extraneous-dependencies": [ - "error", + "off", { devDependencies: true, }, @@ -58,8 +61,10 @@ module.exports = { "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "error", + "@typescript-eslint/no-useless-constructor": "error", + "@typescript-eslint/no-empty-function": "error", "@typescript-eslint/no-unused-vars": "error", - "@typescript-eslint/no-shadow": "error" + "@typescript-eslint/no-shadow": "error", }, overrides: [ { diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 00000000..83b47ade --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,7 @@ +{ + "format_on_save": "on", + "prettier": { + "parser": "", + "allowed": true + } +} diff --git a/call.config.json b/call.config.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/call.config.json @@ -0,0 +1 @@ +{} diff --git a/package-lock.json b/package-lock.json index 67908928..0e089645 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,20 +32,21 @@ "emojibase-data": "15.3.2", "file-saver": "2.0.5", "focus-trap-react": "10.0.2", - "folds": "2.7.2", + "folds": "2.7.3", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", "i18next-browser-languagedetector": "8.0.0", "i18next-http-backend": "4.0.3", + "idb": "8.0.3", "immer": "9.0.16", "is-hotkey": "0.2.0", "jotai": "2.6.0", "linkify-react": "4.3.2", "linkifyjs": "4.3.2", - "matrix-js-sdk": "41.7.0", - "matrix-widget-api": "1.16.1", + "matrix-widget-api": "1.20.0", "millify": "6.1.0", + "neurone": "0.0.9", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", "react": "18.2.0", @@ -66,7 +67,7 @@ "ua-parser-js": "1.0.35" }, "devDependencies": { - "@element-hq/element-call-embedded": "0.20.1", + "@element-hq/element-call-embedded": "0.28.0", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@rollup/plugin-inject": "5.0.3", "@rollup/plugin-wasm": "6.1.1", @@ -95,7 +96,7 @@ "husky": "9.1.7", "lint-staged": "16.3.2", "prettier": "2.8.1", - "typescript": "4.9.4", + "typescript": "6.0.3", "vite": "5.4.19", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", @@ -1774,9 +1775,9 @@ } }, "node_modules/@element-hq/element-call-embedded": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.20.1.tgz", - "integrity": "sha512-ODg2r7UmR8UjRpapLKbn6v1PS8fu/r58zdbvXMYaAlUEAC2f6L/9Moc9S4noG1+ARgWxY+m2vLmNDK9G9uFZYQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.28.0.tgz", + "integrity": "sha512-wM5tLXrSCLbveGlvQg1Pj6dNAp75mhJe2Zr3Vb8dtK6z9tuuFMcsn1q1Gjy1KYWQdzBzKK2wLc1O8H7DzYZRHQ==", "dev": true }, "node_modules/@emotion/hash": { @@ -6215,19 +6216,6 @@ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "optional": true }, - "node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/conventional-commit-types": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/conventional-commit-types/-/conventional-commit-types-3.0.0.tgz", @@ -7906,9 +7894,9 @@ } }, "node_modules/folds": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/folds/-/folds-2.7.2.tgz", - "integrity": "sha512-T/PQuN+ku/55hMVabamwsGp1uPSKkFw4xPEf7uZjAZNoejUL1s0leecA00sudYM2HvZaIUzUrosp+f0+8rWC6w==", + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/folds/-/folds-2.7.3.tgz", + "integrity": "sha512-oMdMOBmU5RCUI4nmQKiEKpJNh+ZmCijvR4+aalk/btZPp1jIRGha7MXBb2NFOy1y9od+RynNFp+bw6E7GMATBA==", "license": "Apache-2.0", "peerDependencies": { "@vanilla-extract/css": "1.9.2", @@ -8593,10 +8581,10 @@ } }, "node_modules/idb": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", - "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", - "dev": true + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" }, "node_modules/ieee754": { "version": "1.2.1", @@ -9026,18 +9014,6 @@ "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", "dev": true }, - "node_modules/is-network-error": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", - "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -9460,15 +9436,6 @@ "node": ">=4.0" } }, - "node_modules/jwt-decode": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", - "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -10010,19 +9977,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/loglevel": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", - "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - }, - "funding": { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/loglevel" - } - }, "node_modules/longest": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-2.0.1.tgz", @@ -10097,40 +10051,10 @@ "node": ">= 0.4" } }, - "node_modules/matrix-events-sdk": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz", - "integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==", - "license": "Apache-2.0" - }, - "node_modules/matrix-js-sdk": { - "version": "41.7.0", - "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.7.0.tgz", - "integrity": "sha512-MP0xNv/VVRbshq00TE6EVo77IIXsQk0KjiVtgKV0t9j/V77a6Klt00QrrO0XykkTUsNC0+mQeBMxnx75rZO86Q==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1", - "another-json": "^0.2.0", - "bs58": "^6.0.0", - "content-type": "^2.0.0", - "jwt-decode": "^4.0.0", - "loglevel": "^1.9.2", - "matrix-events-sdk": "0.0.1", - "matrix-widget-api": "^1.16.1", - "oidc-client-ts": "^3.0.1", - "p-retry": "8", - "sdp-transform": "^3.0.0", - "unhomoglyph": "^1.0.6" - }, - "engines": { - "node": ">=22.0.0" - } - }, "node_modules/matrix-widget-api": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.16.1.tgz", - "integrity": "sha512-oCfTV4xNPo02qIgveqdkIyKQjOPpsjhF3bmJBotHrhr8TsrhVa7kx8PtuiUPnQTjz0tdBle7falR2Fw8VKsedw==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.20.0.tgz", + "integrity": "sha512-qwFP37rgns7mPesM5/vvZgknzb+kvyRB5BISv5sCuBxsGMceMr+Kogf7OkjRxBXdMCPBFuf6gIuYRe5S+lpzQw==", "license": "Apache-2.0", "dependencies": { "@types/events": "^3.0.0", @@ -10382,6 +10306,24 @@ "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", "dev": true }, + "node_modules/neurone": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/neurone/-/neurone-0.0.9.tgz", + "integrity": "sha512-A//2eeyxJLwuDGCZh7N6jNy0tloQ1pgY/0pNna5SrOsj5UPKb2W7U3wE9+2q7sN2kT+0SZr2xijfi06EGkrlyQ==", + "license": "Apache-2.0", + "dependencies": { + "@matrix-org/matrix-sdk-crypto-wasm": "18.3.1", + "another-json": "0.2.0", + "bs58": "6.0.0", + "idb": "8.0.0" + } + }, + "node_modules/neurone/node_modules/idb": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.0.tgz", + "integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==", + "license": "ISC" + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -10574,18 +10516,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/oidc-client-ts": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.2.1.tgz", - "integrity": "sha512-hS5AJ5s/x4bXhHvNJT1v+GGvzHUwdRWqNQQbSrp10L1IRmzfRGKQ3VWN3dstJb+oF3WtAyKezwD2+dTEIyBiAA==", - "license": "Apache-2.0", - "dependencies": { - "jwt-decode": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -10725,21 +10655,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-retry": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-8.0.0.tgz", - "integrity": "sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A==", - "license": "MIT", - "dependencies": { - "is-network-error": "^1.3.0" - }, - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -11810,15 +11725,6 @@ "compute-scroll-into-view": "^3.0.2" } }, - "node_modules/sdp-transform": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz", - "integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==", - "license": "MIT", - "bin": { - "sdp-verify": "checker.js" - } - }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -12724,9 +12630,9 @@ } }, "node_modules/typescript": { - "version": "4.9.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", - "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -12734,7 +12640,7 @@ "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/ua-parser-js": { @@ -12778,12 +12684,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/unhomoglyph": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz", - "integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==", - "license": "MIT" - }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -13626,6 +13526,13 @@ "workbox-core": "7.3.0" } }, + "node_modules/workbox-background-sync/node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, "node_modules/workbox-broadcast-update": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.3.0.tgz", @@ -13772,6 +13679,13 @@ "workbox-core": "7.3.0" } }, + "node_modules/workbox-expiration/node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, "node_modules/workbox-google-analytics": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.3.0.tgz", diff --git a/package.json b/package.json index d434af0a..054bb1cb 100644 --- a/package.json +++ b/package.json @@ -56,20 +56,21 @@ "emojibase-data": "15.3.2", "file-saver": "2.0.5", "focus-trap-react": "10.0.2", - "folds": "2.7.2", + "folds": "2.7.3", "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "i18next": "23.12.2", "i18next-browser-languagedetector": "8.0.0", "i18next-http-backend": "4.0.3", + "idb": "8.0.3", "immer": "9.0.16", "is-hotkey": "0.2.0", "jotai": "2.6.0", "linkify-react": "4.3.2", "linkifyjs": "4.3.2", - "matrix-js-sdk": "41.7.0", - "matrix-widget-api": "1.16.1", + "matrix-widget-api": "1.20.0", "millify": "6.1.0", + "neurone": "0.0.9", "pdfjs-dist": "4.2.67", "prismjs": "1.30.0", "react": "18.2.0", @@ -90,7 +91,7 @@ "ua-parser-js": "1.0.35" }, "devDependencies": { - "@element-hq/element-call-embedded": "0.20.1", + "@element-hq/element-call-embedded": "0.28.0", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@rollup/plugin-inject": "5.0.3", "@rollup/plugin-wasm": "6.1.1", @@ -119,7 +120,7 @@ "husky": "9.1.7", "lint-staged": "16.3.2", "prettier": "2.8.1", - "typescript": "4.9.4", + "typescript": "6.0.3", "vite": "5.4.19", "vite-plugin-pwa": "0.20.5", "vite-plugin-static-copy": "1.0.4", diff --git a/src/app/components/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx index 2dbaf1f1..d328a849 100644 --- a/src/app/components/AccountDataEditor.tsx +++ b/src/app/components/AccountDataEditor.tsx @@ -14,7 +14,7 @@ import { Scroll, config, } from 'folds'; -import { MatrixError } from 'matrix-js-sdk'; +import { AccountDataEvent } from 'neurone/lib/api/types'; import { Cursor } from '../plugins/text-area'; import { syntaxErrorPosition } from '../utils/dom'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; @@ -28,17 +28,12 @@ const EDITOR_INTENT_SPACE_COUNT = 2; export type AccountDataSubmitCallback = (type: string, content: object) => Promise; -type AccountDataInfo = { - type: string; - content: object; -}; - type AccountDataEditProps = { type: string; defaultContent: string; submitChange: AccountDataSubmitCallback; onCancel: () => void; - onSave: (info: AccountDataInfo) => void; + onSave: (info: AccountDataEvent) => void; }; function AccountDataEdit({ type, @@ -57,7 +52,7 @@ function AccountDataEdit({ EDITOR_INTENT_SPACE_COUNT ); - const [submitState, submit] = useAsyncCallback(submitChange); + const [submitState, submit] = useAsyncCallback(submitChange); const submitting = submitState.status === AsyncStatus.Loading; const handleSubmit: FormEventHandler = (evt) => { @@ -159,7 +154,9 @@ function AccountDataEdit({ {submitState.status === AsyncStatus.Error && ( - {submitState.error.message} + + {submitState.error instanceof Error ? submitState.error.message : 'Unknown Error'} + )} @@ -242,34 +239,30 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps) } export type AccountDataEditorProps = { - type?: string; - content?: object; + event?: AccountDataEvent; submitChange: AccountDataSubmitCallback; requestClose: () => void; }; -export function AccountDataEditor({ - type, - content, - submitChange, - requestClose, -}: AccountDataEditorProps) { - const [data, setData] = useState({ - type: type ?? '', - content: content ?? {}, - }); +export function AccountDataEditor({ event, submitChange, requestClose }: AccountDataEditorProps) { + const [data, setData] = useState( + event ?? { + type: '', + content: {}, + } + ); - const [edit, setEdit] = useState(!type); + const [edit, setEdit] = useState(!data.type); const closeEdit = useCallback(() => { - if (!type) { + if (!event) { requestClose(); return; } setEdit(false); - }, [type, requestClose]); + }, [event, requestClose]); - const handleSave = useCallback((info: AccountDataInfo) => { + const handleSave = useCallback((info: AccountDataEvent) => { setData(info); setEdit(false); }, []); diff --git a/src/app/components/ActionUIA.tsx b/src/app/components/ActionUIA.tsx index b9cd122f..63bd5a69 100644 --- a/src/app/components/ActionUIA.tsx +++ b/src/app/components/ActionUIA.tsx @@ -1,33 +1,48 @@ -import React, { ReactNode } from 'react'; -import { AuthDict, AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk'; -import { getUIAFlowForStages } from '../utils/matrix-uia'; +import React, { ReactNode, useState } from 'react'; +import { + config, + Dialog, + Header, + Text, + IconButton, + Box, + Icon, + Icons, + Button, + OverlayBackdrop, + Overlay, + OverlayCenter, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { AuthResponse, AuthType, getUIAFlowForStages, UIAFlow } from '../utils/matrix-uia'; import { useSupportedUIAFlows, useUIACompleted, useUIAFlow } from '../hooks/useUIAFlows'; import { UIAFlowOverlay } from './UIAFlowOverlay'; -import { PasswordStage, SSOStage } from './uia-stages'; -import { useMatrixClient } from '../hooks/useMatrixClient'; +import { OAuthStage, PasswordStage, SSOStage } from './uia-stages'; +import { useClient } from '../hooks/useClient'; -export const SUPPORTED_IN_APP_UIA_STAGES = [AuthType.Password, AuthType.Sso]; - -export const pickUIAFlow = (uiaFlows: UIAFlow[]): UIAFlow | undefined => { - const passwordFlow = getUIAFlowForStages(uiaFlows, [AuthType.Password]); - if (passwordFlow) return passwordFlow; - return getUIAFlowForStages(uiaFlows, [AuthType.Sso]); -}; +export const SUPPORTED_IN_APP_UIA_STAGES = [AuthType.Password, AuthType.Sso, AuthType.OAuth]; type ActionUIAProps = { - authData: IAuthData; + authData: AuthResponse; ongoingFlow: UIAFlow; - action: (authDict: AuthDict) => void; + action: (authDict: object) => void; onCancel: () => void; }; export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIAProps) { - const mx = useMatrixClient(); + const ct = useClient(); const completed = useUIACompleted(authData); const { getStageToComplete } = useUIAFlow(authData, ongoingFlow); const stageToComplete = getStageToComplete(); if (!stageToComplete) return null; + + const ssoRedirectURL = new URL(`/auth/${AuthType.Sso}/fallback/web`, ct.session.baseUrl); + + if (stageToComplete.session) { + ssoRedirectURL.searchParams.set('session', stageToComplete.session); + } + return ( {stageToComplete.type === AuthType.Password && ( )} + {stageToComplete.type === AuthType.OAuth && ( + + )} ); } +function UIAFlowPicker({ + flows, + onCancel, + onSelect, +}: { + flows: UIAFlow[]; + onCancel: () => void; + onSelect: (flow: UIAFlow) => void; +}) { + const passwordFlow = getUIAFlowForStages(flows, [AuthType.Password]); + const ssoFlow = getUIAFlowForStages(flows, [AuthType.Sso]); + + return ( + }> + + + +
+ + Authentication + + + + +
+ + + Choose an authentication method to perform this action. + + + {passwordFlow && ( + + )} + {ssoFlow && ( + + )} + + +
+
+
+
+ ); +} + type ActionUIAFlowsLoaderProps = { - authData: IAuthData; + authData: AuthResponse; + onCancel: () => void; unsupported: () => ReactNode; children: (ongoingFlow: UIAFlow) => ReactNode; }; export function ActionUIAFlowsLoader({ authData, + onCancel, unsupported, children, }: ActionUIAFlowsLoaderProps) { const supportedFlows = useSupportedUIAFlows(authData.flows ?? [], SUPPORTED_IN_APP_UIA_STAGES); - const ongoingFlow = supportedFlows.length > 0 ? supportedFlows[0] : undefined; + const [ongoingFlow, setOngoingFlow] = useState( + supportedFlows.length === 1 ? supportedFlows[0] : undefined + ); - if (!ongoingFlow) return unsupported(); + if (supportedFlows.length === 0) return unsupported(); + + if (!ongoingFlow) { + return ; + } return children(ongoingFlow); } diff --git a/src/app/components/AuthFlowsLoader.tsx b/src/app/components/AuthFlowsLoader.tsx index f21bad04..14ba56b2 100644 --- a/src/app/components/AuthFlowsLoader.tsx +++ b/src/app/components/AuthFlowsLoader.tsx @@ -1,14 +1,18 @@ import { ReactNode, useCallback, useEffect, useMemo } from 'react'; -import { MatrixError, createClient } from 'matrix-js-sdk'; +import { getLogin, GetLoginOK } from 'neurone/lib/api/matrix/client/v3/login'; +import { postRegister } from 'neurone/lib/api/matrix/client/v3/register'; +import { MatrixErrorData, parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo'; -import { promiseFulfilledResult, promiseRejectedResult } from '../utils/common'; +import { promiseFulfilledResult } from '../utils/common'; import { AuthFlows, RegisterFlowStatus, RegisterFlowsResponse, parseRegisterErrResp, } from '../hooks/useAuthFlows'; +import { getJSONRequest } from '../../client/request'; +import { AuthResponse } from '../utils/matrix-uia'; type AuthFlowsLoaderProps = { fallback?: () => ReactNode; @@ -19,24 +23,33 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr const autoDiscoveryInfo = useAutoDiscoveryInfo(); const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url; - const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]); + const requestJSON = useMemo(() => getJSONRequest(baseUrl), [baseUrl]); const [state, load] = useAsyncCallback( useCallback(async () => { - const result = await Promise.allSettled([mx.loginFlows(), mx.registerRequest({})]); - const loginFlows = promiseFulfilledResult(result[0]); - const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined; - let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest }; + const loginEndpoint = getLogin({}); + const registerEndpoint = postRegister({}); - if (typeof registerResp === 'object' && registerResp.httpStatus) { - registerFlows = parseRegisterErrResp(registerResp); + const [loginResponse, registerResponse] = ( + await Promise.allSettled([requestJSON(loginEndpoint), requestJSON(registerEndpoint)]) + ).map(promiseFulfilledResult); + + let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest }; + const [registerError] = + typeof registerResponse === 'object' + ? await parseMatrixResp(registerResponse) + : []; + + if (registerError) { + registerFlows = parseRegisterErrResp(registerError); } - if (!loginFlows) { + if (!loginResponse) { throw new Error('Missing auth flow!'); } - if ('errcode' in loginFlows) { - throw new Error('Failed to load auth flow!'); + const [loginError, loginFlows] = await parseMatrixResp(loginResponse); + if (loginError) { + throw new Error(`Failed to load auth flow! ${printMatrixError(loginError)}`); } const authFlows: AuthFlows = { @@ -45,7 +58,7 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr }; return authFlows; - }, [mx]) + }, [requestJSON]) ); useEffect(() => { diff --git a/src/app/components/BackupRestore.tsx b/src/app/components/BackupRestore.tsx index 068f437b..bd6289a3 100644 --- a/src/app/components/BackupRestore.tsx +++ b/src/app/components/BackupRestore.tsx @@ -1,6 +1,4 @@ import React, { MouseEventHandler, useCallback, useState } from 'react'; -import { useAtom } from 'jotai'; -import { CryptoApi, KeyBackupInfo } from 'matrix-js-sdk/lib/crypto-api'; import { Badge, Box, @@ -19,130 +17,113 @@ import { Text, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { BackupProgressStatus, backupRestoreProgressAtom } from '../state/backupRestore'; import { InfoCard } from './info-card'; -import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; -import { - useKeyBackupInfo, - useKeyBackupStatus, - useKeyBackupSync, - useKeyBackupTrust, -} from '../hooks/useKeyBackup'; import { stopPropagation } from '../utils/keyboard'; -import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification'; +import { bytesToSize } from '../utils/common'; +import { useObservableValue } from '../hooks/useObservableValue'; +import { useClient } from '../hooks/useClient'; +import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback'; type BackupStatusProps = { - enabled: boolean; + active: boolean; }; -function BackupStatus({ enabled }: BackupStatusProps) { +function BackupStatus({ active }: BackupStatusProps) { return ( - + - {enabled ? 'Connected' : 'Disconnected'} + {active ? 'Connected' : 'Disconnected'} ); } -type BackupSyncingProps = { - count: number; -}; -function BackupSyncing({ count }: BackupSyncingProps) { + +function BackupSyncing() { + const ct = useClient(); + const [countState] = useAsyncCallbackValue(useCallback(() => ct.machine.roomKeys.count(), [ct])); + + const count = countState.status === AsyncStatus.Success ? countState.data : undefined; + + const remaining = count ? count.total - count.backedUp : undefined; + return ( - Syncing ({count}) + Syncing ({remaining ?? '...'}) ); } -function BackupProgressFetching() { +function BackupProgressFetching({ + receivedBytes, + totalBytes, +}: { + receivedBytes: number; + totalBytes?: number; +}) { + const receviedStr = bytesToSize(receivedBytes); + const totalStr = totalBytes ? bytesToSize(totalBytes) : undefined; + return ( - Restoring: 0% + Downloading - + - + + {totalStr ? `${receivedBytes} / ${totalStr}` : receviedStr} + ); } type BackupProgressProps = { - total: number; - downloaded: number; + total?: number; + imported: number; }; -function BackupProgress({ total, downloaded }: BackupProgressProps) { +function BackupProgress({ total, imported }: BackupProgressProps) { return ( - Restoring: {`${Math.round(percent(0, total, downloaded))}%`} + + Restoring: {total ? `${Math.round(percent(0, total, imported))}%` : '0%'} + - + - - - {downloaded} / {total} - - - - ); -} - -type BackupTrustInfoProps = { - crypto: CryptoApi; - backupInfo: KeyBackupInfo; -}; -function BackupTrustInfo({ crypto, backupInfo }: BackupTrustInfoProps) { - const trust = useKeyBackupTrust(crypto, backupInfo); - - if (!trust) return null; - - return ( - - {trust.matchesDecryptionKey ? ( - - Backup has trusted decryption key. - - ) : ( - - Backup does not have trusted decryption key! - - )} - {trust.trusted ? ( - - Backup has trusted by signature. - - ) : ( - - Backup does not have trusted signature! - + {total && ( + + + {imported} / {total} + + )} ); } -type BackupRestoreTileProps = { - crypto: CryptoApi; -}; -export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) { - const [restoreProgress, setRestoreProgress] = useAtom(backupRestoreProgressAtom); - const restoring = - restoreProgress.status === BackupProgressStatus.Fetching || - restoreProgress.status === BackupProgressStatus.Loading; - - const backupEnabled = useKeyBackupStatus(crypto); - const backupInfo = useKeyBackupInfo(crypto); - const [remainingSession, syncFailure] = useKeyBackupSync(); +export function BackupRestoreTile() { + const ct = useClient(); + const backupInfo = useObservableValue(ct.backupInfo); + const keyBackup = useObservableValue(ct.megolmBackup.keyBackup); + const restoreState = useObservableValue(ct.megolmBackup.restoreState); + const backupInProgress = useObservableValue(ct.megolmBackup.backupInProgress); + const restoring = restoreState.type === 'download' || restoreState.type === 'import'; const [menuCords, setMenuCords] = useState(); @@ -150,19 +131,11 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) { setMenuCords(evt.currentTarget.getBoundingClientRect()); }; - const [restoreState, restoreBackup] = useAsyncCallback( - useCallback(async () => { - await crypto.restoreKeyBackup({ - progressCallback(progress) { - setRestoreProgress(progress); - }, - }); - }, [crypto, setRestoreProgress]) - ); - const handleRestore = () => { setMenuCords(undefined); - restoreBackup(); + if (keyBackup) { + ct.megolmBackup.restore(keyBackup); + } }; return ( @@ -171,111 +144,99 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) { title="Encryption Backup" after={ - {remainingSession === 0 ? ( - - ) : ( - - )} - - - - setMenuCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => - evt.key === 'ArrowDown' || evt.key === 'ArrowRight', - isKeyBackward: (evt: KeyboardEvent) => - evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', - escapeDeactivates: stopPropagation, - }} + {backupInfo && ( + <> + {backupInProgress ? : } + - - - - - Version: {backupInfo?.version ?? 'NIL'} -
- Keys: {backupInfo?.count ?? 'NIL'} - - } - /> -
- -
-
- - } - /> + +
+ setMenuCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + + + Version: {backupInfo.version} +
+ Keys: {backupInfo.count} + + } + /> +
+ +
+
+ + } + /> + + )}
} > - {syncFailure && ( + {!backupInfo && ( - {syncFailure} + No key backup has been configured. )} - {!backupEnabled && backupInfo === null && ( + + {backupInfo && !keyBackup && ( - No backup present on server! + Cannot start key backup because the backup could not be verified. )} - {!syncFailure && !backupEnabled && backupInfo && ( - - )} - {restoreState.status === AsyncStatus.Loading && !restoring && } - {restoreProgress.status === BackupProgressStatus.Fetching && } - {restoreProgress.status === BackupProgressStatus.Loading && ( - )} - {restoreState.status === AsyncStatus.Error && ( + {restoreState.type === 'import' && ( + + )} + {restoreState.type === 'error' && ( - {restoreState.error.message} + {restoreState.error.data.error ?? restoreState.error.data.errcode} )} ); } - -export function AutoRestoreBackupOnVerification() { - useRestoreBackupOnVerification(); - - return null; -} diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index a6f6dec5..6c346b58 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -17,15 +17,10 @@ import { Text, toRem, } from 'folds'; -import { - EventTimelineSetHandlerMap, - EventType, - RelationType, - Room, - RoomEvent, -} from 'matrix-js-sdk'; -import { IRTCNotificationContent, RTCNotificationType } from 'matrix-js-sdk/lib/matrixrtc/types'; -import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; +import { putSend } from 'neurone/lib/api/matrix/client/v3/rooms'; +import { roomTimeline } from 'neurone/lib/utils/sync/v3/join'; +import { ClientEventWithoutRoomId } from 'neurone/lib/api/types'; +import { ObjectReader } from 'neurone/lib/utils'; import { CallEmbedContextProvider, CallEmbedRefContextProvider, @@ -39,57 +34,73 @@ import { callChatAtom, callEmbedAtom } from '../state/callEmbed'; import { CallEmbed } from '../plugins/call'; import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; -import { useMatrixClient } from '../hooks/useMatrixClient'; import CallSound from '../../../public/sound/call.ogg'; import { useCallMembersChange, useCallSession } from '../hooks/useCall'; -import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta'; import { mDirectAtom } from '../state/mDirectList'; import { useMediaAuthentication } from '../hooks/useMediaAuthentication'; -import { mxcUrlToHttp } from '../utils/matrix'; import { RoomAvatar, RoomIcon } from './room-avatar'; import { useRoomNavigate } from '../hooks/useRoomNavigate'; -import { getStateEvent } from '../utils/room'; -import { StateEvent } from '../../types/matrix/room'; -import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels'; -import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators'; -import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions'; +import { CallEventType, MessageEvent, StateEvent, StateEventType } from '../../types/matrix/room'; import { useLivekitSupport } from '../hooks/useLivekitSupport'; import { CallAvatarAnimation } from '../styles/Animations.css'; import { webRTCSupported } from '../utils/rtc'; +import { useClient } from '../hooks/useClient'; +import { useGetRoomName } from '../hooks/storage/useGetRoomName'; +import { useRoomHeroes } from '../hooks/storage/useRoomHeros'; +import { useGetRoomAvatar } from '../hooks/storage/useGetRoomAvatar'; +import { mxcToHttp } from '../../client/mxcToHttp'; +import { RelationType } from '../../types/matrix/common'; +import { ensureEncryptionSession } from '../hooks/useEnsureRoomEncryptionSession'; +import { SyncValue } from '../../client/sync'; +import { isCallRoom } from '../utils/room'; +import { reference } from '../../client/room/relations'; +import { rContent, rMentions, rRelatesTo } from '../../client/reader'; +import { getJoinRoomStateId } from '../../client/storage/collections'; +import { getPowerLevels } from '../hooks/usePowerLevels'; +import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions'; type IncomingCallInfo = { - room: Room; + roomId: string; sender: string; senderTs: number; lifetime: number; intent?: string; - notificationType: RTCNotificationType; + silent?: boolean; refEventId: string; }; type IncomingCallProps = { dm: boolean; info: IncomingCallInfo; onIgnore: () => void; - onAnswer: (room: Room, video: boolean) => void; - onReject: (room: Room, eventId: string) => void; + onAnswer: (roomId: string, video: boolean) => void; + onReject: (roomId: string, eventId: string) => void; }; function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallProps) { - const mx = useMatrixClient(); + const ct = useClient(); const useAuthentication = useMediaAuthentication(); const livekitSupported = useLivekitSupport(); const rtcSupported = webRTCSupported(); const canAnswer = livekitSupported && rtcSupported; - const { room } = info; + const { roomId } = info; const audioRef = useRef(null); - const roomName = useRoomName(room); - const roomAvatar = useRoomAvatar(room, dm); + const getRoomHeroes = useRoomHeroes(); + const getRoomName = useGetRoomName(getRoomHeroes); + const getRoomAvatar = useGetRoomAvatar(getRoomHeroes); + + const roomName = getRoomName(roomId); + const roomAvatar = getRoomAvatar(roomId); + const avatarUrl = roomAvatar - ? mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined + ? mxcToHttp(ct.session.baseUrl, roomAvatar, useAuthentication).thumbnail({ + width: 96, + height: 96, + method: 'crop', + }) : undefined; - const session = useCallSession(room); + const session = useCallSession(roomId); useCallMembersChange( session, useCallback( @@ -108,10 +119,10 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr }, []); useEffect(() => { - if (info.notificationType === 'ring') { + if (!info.silent) { playSound(); } - }, [playSound, info.notificationType]); + }, [playSound, info.silent]); return ( <> @@ -134,14 +145,14 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr ( )} @@ -181,7 +192,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr variant="Success" size="400" radii="400" - onClick={() => onAnswer(room, info.intent === 'video')} + onClick={() => onAnswer(roomId, info.intent === 'video')} before={ (dm ? onReject(room, info.refEventId) : onIgnore())} + onClick={() => (dm ? onReject(roomId, info.refEventId) : onIgnore())} before={} > @@ -226,117 +237,150 @@ type IncomingCallListenerProps = { joined?: boolean; }; function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) { - const mx = useMatrixClient(); + const ct = useClient(); const directs = useAtomValue(mDirectAtom); const { navigateRoom } = useRoomNavigate(); const [callInfo, setCallInfo] = useState(); - const dm = callInfo ? directs.has(callInfo.room.roomId) : false; - const startCall = useCallStart(dm); + const dm = callInfo ? directs.has(callInfo.roomId) : false; + const startCall = useCallStart(); - const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback( - async (event, room, toStartOfTimeline, removed, data) => { - // only process rtc notification reference events. - // we do not want to wait to decrypt all events. - if (event.getRelation()?.rel_type !== RelationType.Reference) return; - if (room?.isCallRoom()) return; + const handleReferenceEvent = useCallback( + async (roomId: string, event: ClientEventWithoutRoomId) => { + let targetEvent = event; - if (event.isEncrypted()) { - if (!event.isBeingDecrypted()) { - await event.attemptDecryption(mx.getCrypto() as CryptoBackend); - } - await event.getDecryptionPromise(); + if (ct.encryptedRooms.read.has(roomId) && event.type === MessageEvent.RoomMessageEncrypted) { + targetEvent = await ct.decryption.tryDecryptEvent(roomId, event); } if ( - !room || - event.getType() !== EventType.RTCNotification || - event.getSender() === mx.getSafeUserId() || - !data.liveEvent + targetEvent.type !== CallEventType.RTCInvite || + targetEvent.sender === ct.session.userId ) { return; } - const sender = event.getSender(); - const content = event.getContent(); - const senderTs = - content.sender_ts - event.getTs() > 20000 ? event.getTs() : content.sender_ts; - const lifetime = Math.min(content.lifetime, 120000); - const notificationType = content.notification_type; - const relation = - event.getRelation()?.rel_type === RelationType.Reference ? event.getRelation() : undefined; - const refEventId = relation?.event_id; + const { sender, content } = targetEvent; + + const senderTs = ObjectReader.number(content, 'sender_ts') ?? targetEvent.origin_server_ts; + const lifetime = Math.min(ObjectReader.number(content, 'lifetime') ?? 120000, 120000); + + const refEventId = rRelatesTo.eventId(rContent.relatesTo(content)); const mention = - content['m.mentions']?.room || - content['m.mentions']?.user_ids?.includes(mx.getSafeUserId()); - if (!sender || !refEventId || !mention || Date.now() >= senderTs + lifetime) { + rMentions.room(rContent.mentions(content)) || + rMentions.userIds(rContent.mentions(content))?.includes(ct.session.userId); + + if (!refEventId || !mention || Date.now() >= senderTs + lifetime) { return; } - const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels); - const powerLevels = getPowersLevelFromMatrixEvent(powerLevelsEvent); - const creators = getRoomCreatorsForRoomId(mx, room.roomId); + const powerLevelsObj = await ct.storage.joinRoomState.read.get( + getJoinRoomStateId(roomId, StateEventType.RoomPowerLevels, '') + ); + if (!powerLevelsObj) return; + const powerLevels = getPowerLevels(powerLevelsObj); + const creators = ct.roomToCreators.read.get(roomId); const permissions = getRoomPermissionsAPI(creators, powerLevels); const hasCallPermission = permissions.stateEvent( StateEvent.GroupCallMemberPrefix, - mx.getSafeUserId() + ct.session.userId ); if (!hasCallPermission) return; const info: IncomingCallInfo = { - room, + roomId, sender, senderTs, lifetime, - intent: - 'm.call.intent' in content && typeof content['m.call.intent'] === 'string' - ? content['m.call.intent'] - : undefined, - notificationType, + intent: ObjectReader.string(content, 'm.call.intent'), + silent: !ct.directRooms.read.has(roomId), refEventId, }; - setCallInfo(info); + setCallInfo((currentInfo) => { + if (currentInfo) return currentInfo; + return info; + }); }, - [mx] + [ct] ); useEffect(() => { - mx.on(RoomEvent.Timeline, handleTimelineEvent); + const unsub = ct.sync.observable.subscribe(async (syncValue: SyncValue) => { + const [, data] = syncValue ?? []; + if (!data) return; + + Object.keys(data.rooms?.join ?? {}).forEach(async (roomId) => { + if (isCallRoom(ct.roomToType.read.get(roomId))) return; + + const joinedRoom = data.rooms?.join?.[roomId]; + if (!joinedRoom) return; + + const timelineEvents = roomTimeline(joinedRoom); + Array.from(timelineEvents) + .reverse() + .forEach((event) => { + if (reference(event)) handleReferenceEvent(roomId, event); + }); + }); + }); return () => { - mx.removeListener(RoomEvent.Timeline, handleTimelineEvent); + unsub(); }; - }, [mx, handleTimelineEvent]); + }, [ct, handleReferenceEvent]); const handleIgnore = useCallback(() => { setCallInfo(undefined); }, []); const handleReject = useCallback( - (room: Room, eventId: string) => { - mx.sendEvent(room.roomId, EventType.RTCDecline, { + async (roomId: string, eventId: string) => { + setCallInfo(undefined); + const content = { 'm.relates_to': { rel_type: RelationType.Reference, event_id: eventId, }, - }); - setCallInfo(undefined); + }; + + let encContent: object | undefined; + if (ct.encryptedRooms.read.has(roomId)) { + await ensureEncryptionSession(ct, roomId); + + const encStr = await ct.machine.roomEncryption.encryptMessageEvent( + roomId, + CallEventType.RTCDecline, + content + ); + encContent = JSON.parse(encStr); + } + + ct.requestAuthJSON( + putSend({ + pathParams: { + roomId, + eventType: encContent ? MessageEvent.RoomMessageEncrypted : CallEventType.RTCDecline, + txnId: ct.getTxnId(), + }, + body: encContent ?? content, + }) + ); }, - [mx] + [ct] ); const handleAnswer = useCallback( - (room: Room, video: boolean) => { - startCall(room, { microphone: true, video, sound: true }); + (roomId: string, video: boolean) => { + startCall(roomId, { microphone: true, video, sound: true }); setCallInfo(undefined); - navigateRoom(room.roomId); + navigateRoom(roomId); }, [startCall, navigateRoom] ); - if (callInfo && callEmbed?.roomId === callInfo.room.roomId) { + if (callInfo && callEmbed?.roomId === callInfo.roomId) { return null; } return !joined && callInfo ? ( diff --git a/src/app/components/CapabilitiesLoader.tsx b/src/app/components/CapabilitiesLoader.tsx index 667191e9..bfdae4e5 100644 --- a/src/app/components/CapabilitiesLoader.tsx +++ b/src/app/components/CapabilitiesLoader.tsx @@ -1,15 +1,23 @@ import { ReactNode, useCallback, useEffect } from 'react'; -import { Capabilities } from 'matrix-js-sdk'; +import { getCapabilities, GetCapabilitiesOK } from 'neurone/lib/api/matrix/client/v3/capabilities'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; -import { useMatrixClient } from '../hooks/useMatrixClient'; +import { useClient } from '../hooks/useClient'; +import { Capabilities } from '../hooks/useCapabilities'; type CapabilitiesLoaderProps = { children: (capabilities: Capabilities | undefined) => ReactNode; }; export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) { - const mx = useMatrixClient(); + const ct = useClient(); - const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(), [mx])); + const [state, load] = useAsyncCallback( + useCallback(async () => { + const resp = await ct.requestAuthJSON(getCapabilities({})); + const [, result] = await parseMatrixResp(resp); + return result?.capabilities; + }, [ct]) + ); useEffect(() => { load(); diff --git a/src/app/components/DeviceVerification.tsx b/src/app/components/DeviceVerification.tsx index d2502ccf..ced0ae25 100644 --- a/src/app/components/DeviceVerification.tsx +++ b/src/app/components/DeviceVerification.tsx @@ -1,11 +1,4 @@ -import { - ShowSasCallbacks, - VerificationPhase, - VerificationRequest, - Verifier, -} from 'matrix-js-sdk/lib/crypto-api'; import React, { CSSProperties, useCallback, useEffect, useState } from 'react'; -import { VerificationMethod } from 'matrix-js-sdk/lib/types'; import { Box, Button, @@ -23,13 +16,24 @@ import { } from 'folds'; import FocusTrap from 'focus-trap-react'; import { - useVerificationRequestPhase, - useVerificationRequestReceived, - useVerifierCancel, - useVerifierShowSas, -} from '../hooks/useVerificationRequest'; + CancelableVerificationStage, + CancelVerificationStage, + DoneVerificationStage, + InboundRequestStage, + InboundVerification, + OutboundRequestStage, + OutboundVerification, + ReadyVerificationStage, + SasAcceptStage, + SasCancelState, + SasMethod, + SasStartStage, + StartVerificationStage, +} from 'neurone/lib/crypto/machine/verification'; +import { useVerificationRequestReceived } from '../hooks/useVerificationRequest'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { ContainerColor } from '../styles/ContainerColor.css'; +import { useObservableValue } from '../hooks/useObservableValue'; const DialogHeaderStyles: CSSProperties = { padding: `0 ${config.space.S200} 0 ${config.space.S400}`, @@ -70,10 +74,12 @@ function VerificationWaitAccept() { } type VerificationAcceptProps = { - onAccept: () => Promise; + stage: InboundRequestStage; }; -function VerificationAccept({ onAccept }: VerificationAcceptProps) { - const [acceptState, accept] = useAsyncCallback(onAccept); +function VerificationAccept({ stage }: VerificationAcceptProps) { + const [acceptState, accept] = useAsyncCallback( + useCallback(() => stage.ready(['m.sas.v1']), [stage]) + ); const accepting = acceptState.status === AsyncStatus.Loading; return ( @@ -102,12 +108,12 @@ function VerificationWaitStart() { } type VerificationStartProps = { - onStart: () => Promise; + stage: ReadyVerificationStage; }; -function AutoVerificationStart({ onStart }: VerificationStartProps) { +function AutoVerificationStart({ stage }: VerificationStartProps) { useEffect(() => { - onStart(); - }, [onStart]); + stage.startSas(); + }, [stage]); return ( @@ -116,8 +122,8 @@ function AutoVerificationStart({ onStart }: VerificationStartProps) { ); } -function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) { - const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData])); +function CompareEmoji({ stage }: { stage: SasAcceptStage }) { + const [confirmState, confirm] = useAsyncCallback(useCallback(() => stage.match(), [stage])); const confirming = confirmState.status === AsyncStatus.Loading || confirmState.status === AsyncStatus.Success; @@ -135,17 +141,17 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) { wrap="Wrap" justifyContent="Center" > - {sasData.sas.emoji?.map(([emoji, name], index) => ( + {stage.emoji?.map((emoji, index) => ( - {emoji} - {name} + {emoji.emoji} + {emoji.description} ))} @@ -162,7 +168,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) { @@ -228,23 +237,18 @@ function VerificationCanceled({ onClose }: VerificationCanceledProps) { } type DeviceVerificationProps = { - request: VerificationRequest; + request: InboundVerification | OutboundVerification; onExit: () => void; }; export function DeviceVerification({ request, onExit }: DeviceVerificationProps) { - const phase = useVerificationRequestPhase(request); + const stage = useObservableValue(request.stage); const handleCancel = useCallback(() => { - if (request.phase !== VerificationPhase.Done && request.phase !== VerificationPhase.Cancelled) { - request.cancel(); + if (stage instanceof CancelableVerificationStage) { + stage.cancel(); } onExit(); - }, [request, onExit]); - - const handleAccept = useCallback(() => request.accept(), [request]); - const handleStart = useCallback(async () => { - await request.startVerification(VerificationMethod.Sas); - }, [request]); + }, [stage, onExit]); return ( }> @@ -266,30 +270,27 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps) - {phase === VerificationPhase.Requested && - (request.initiatedByMe ? ( - - ) : ( - - ))} - {phase === VerificationPhase.Ready && - (request.initiatedByMe ? ( - + {stage instanceof InboundRequestStage && } + {stage instanceof OutboundRequestStage && } + {stage instanceof ReadyVerificationStage && + (request instanceof OutboundVerification ? ( + ) : ( ))} - {phase === VerificationPhase.Started && - (request.verifier ? ( - + {stage instanceof StartVerificationStage && + (stage.method ? ( + ) : ( ))} - {phase === VerificationPhase.Done && } - {phase === VerificationPhase.Cancelled && ( - + + {stage instanceof DoneVerificationStage && } + {stage instanceof CancelVerificationStage && ( + )} @@ -300,7 +301,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps) } export function ReceiveSelfDeviceVerification() { - const [request, setRequest] = useState(); + const [request, setRequest] = useState(); useVerificationRequestReceived(setRequest); @@ -310,7 +311,7 @@ export function ReceiveSelfDeviceVerification() { if (!request) return null; - if (!request.isSelfVerification) { + if (!request.selfVerification) { return null; } diff --git a/src/app/components/DeviceVerificationSetup.tsx b/src/app/components/DeviceVerificationSetup.tsx index 433fa6a1..f482e569 100644 --- a/src/app/components/DeviceVerificationSetup.tsx +++ b/src/app/components/DeviceVerificationSetup.tsx @@ -1,4 +1,11 @@ -import React, { FormEventHandler, forwardRef, useCallback, useState } from 'react'; +import React, { + FormEventHandler, + forwardRef, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { Dialog, Header, @@ -12,154 +19,236 @@ import { Chip, color, Spinner, + ProgressBar, } from 'folds'; import FileSaver from 'file-saver'; -import to from 'await-to-js'; -import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk'; +import { BackupManager } from 'neurone/lib/crypto/machine/backup'; +import { putAccountData } from 'neurone/lib/api/matrix/client/v3/user'; +import { + AesHmacSha2, + AesHmacSha2KeyDescription, + DefaultKey, + KeyStorage, + Passphrase, + RecoveryKey, + Secret, + SECRET_STORAGE_ALGORITHM_V1_AES_HMAC_SHA2, +} from 'neurone/lib/crypto/ssss'; +import { + MatrixRequestRetrier, + MatrixUIAData, + MatrixUIAHandler, + randomString, + sleep, +} from 'neurone/lib/utils'; +import { postVersion, PostVersionBody } from 'neurone/lib/api/matrix/client/v3/roomKeys'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; +import { postDeviceSigningUpload } from 'neurone/lib/api/matrix/client/v3/keys'; import { PasswordInput } from './password-input'; import { ContainerColor } from '../styles/ContainerColor.css'; import { copyToClipboard } from '../utils/dom'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; -import { clearSecretStorageKeys } from '../../client/secretStorageKeys'; import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA'; -import { useMatrixClient } from '../hooks/useMatrixClient'; -import { useAlive } from '../hooks/useAlive'; import { UseStateProvider } from './UseStateProvider'; +import { useClient } from '../hooks/useClient'; +import { getMxIdLocalPart, getMxIdServer } from '../utils/matrix'; +import { AccountDataType } from '../../types/matrix/accountData'; +import { useAlive } from '../hooks/useAlive'; -type UIACallback = ( - authDict: AuthDict | null -) => Promise<[IAuthData, undefined] | [undefined, T]>; - -type PerformAction = (authDict: AuthDict | null) => Promise; - -type UIAAction = { - authData: IAuthData; - callback: UIACallback; - cancelCallback: () => void; +type SetupProgressChecklist = { + bootstrapCrossSinging?: boolean; + putMasterAccountData?: boolean; + putSelfAccountData?: boolean; + putUserAcountData?: boolean; + verfiyOwnDevice?: boolean; + postBackupVersion?: boolean; + putBackupAccountData?: boolean; + putRecoveryKeyAccountData?: boolean; + putDefaultKeyAccountData?: boolean; }; -function makeUIAAction( - authData: IAuthData, - performAction: PerformAction, - resolve: (data: T) => void, - reject: (error?: any) => void -): UIAAction { - const action: UIAAction = { - authData, - callback: async (authDict) => { - const [error, data] = await to(performAction(authDict)); - - if (error instanceof MatrixError && error.httpStatus === 401) { - return [error.data as IAuthData, undefined]; - } - - if (error) { - reject(error); - throw error; - } - - resolve(data); - return [undefined, data]; - }, - cancelCallback: reject, - }; - - return action; -} - type SetupVerificationProps = { onComplete: (recoveryKey: string) => void; }; function SetupVerification({ onComplete }: SetupVerificationProps) { - const mx = useMatrixClient(); + const ct = useClient(); + const uia = useMemo(() => new MatrixUIAHandler(), []); + const [uiaData, setUIAData] = useState(); + const [setupProgress, setSetupProgress] = useState(); const alive = useAlive(); - const [uiaAction, setUIAAction] = useState>(); - const [nextAuthData, setNextAuthData] = useState(); // null means no next action. + useEffect(() => uia.onUIA.subscribe(setUIAData), [uia]); const handleAction = useCallback( - async (authDict: AuthDict) => { - if (!uiaAction) { - throw new Error('Unexpected Error! UIA action is perform without data.'); - } - if (alive()) { - setNextAuthData(null); - } - const [authData] = await uiaAction.callback(authDict); - - if (alive() && authData) { - setNextAuthData(authData); - } + (authDict: object) => { + setUIAData(undefined); + uia.retry(authDict); }, - [uiaAction, alive] + [uia] ); - const resetUIA = useCallback(() => { - if (!alive()) return; - setUIAAction(undefined); - setNextAuthData(undefined); - }, [alive]); - - const authUploadDeviceSigningKeys: UIAuthCallback = useCallback( - (makeRequest) => - new Promise((resolve, reject) => { - makeRequest(null) - .then(() => { - resolve(); - resetUIA(); - }) - .catch((error) => { - if (error instanceof MatrixError && error.httpStatus === 401) { - const authData = error.data as IAuthData; - const action = makeUIAAction( - authData, - makeRequest as PerformAction, - resolve, - (err) => { - resetUIA(); - reject(err); - } - ); - if (alive()) { - setUIAAction(action); - } else { - reject(new Error('Authentication failed! Failed to setup device verification.')); - } - return; - } - reject(error); - }); - }), - [alive, resetUIA] - ); + const handleCancel = useCallback(() => { + setUIAData(undefined); + uia.clear(); + }, [uia]); const [setupState, setup] = useAsyncCallback( useCallback( async (passphrase) => { - const crypto = mx.getCrypto(); - if (!crypto) throw new Error('Unexpected Error! Crypto module not found!'); + setSetupProgress({}); + const retrier = new MatrixRequestRetrier(sleep); - const recoveryKeyData = await crypto.createRecoveryKeyFromPassphrase(passphrase); - if (!recoveryKeyData.encodedPrivateKey) { - throw new Error('Unexpected Error! Failed to create recovery key.'); + const defaultKeyId = randomString(16); + + const passData = passphrase ? await Passphrase.create(passphrase) : undefined; + const recoveryKey = passData?.key ?? AesHmacSha2.generateRawKey(); + + const uploadDeviceSigningKeys = async (bodyStr: string) => { + const body = JSON.parse(bodyStr); + const resp = await retrier.execute(() => + uia.execute((authData) => + ct.requestAuthJSON(postDeviceSigningUpload({ body: { ...body, auth: authData } })) + ) + ); + const [err] = await parseMatrixResp(resp); + if (err) throw new Error(err.data.error ?? err.data.errcode); + }; + + await ct.machine.crossSigning.bootstrapCrossSigning(uploadDeviceSigningKeys); + const crossSigning = await ct.machine.crossSigning.exportKeys(); + if (!crossSigning || !crossSigning.master || !crossSigning.self || !crossSigning.user) { + throw new Error('Failed to reset cross signing. No cross signing keys available.'); } - clearSecretStorageKeys(); + if (alive()) setSetupProgress((v) => ({ ...v, bootstrapCrossSinging: true })); - await crypto.bootstrapSecretStorage({ - createSecretStorageKey: async () => recoveryKeyData, - setupNewSecretStorage: true, - }); + const masterEnc = await AesHmacSha2.encrypt( + recoveryKey, + AccountDataType.CrossSigningMaster, + crossSigning.master + ); + const selfEnc = await AesHmacSha2.encrypt( + recoveryKey, + AccountDataType.CrossSigningSelf, + crossSigning.self + ); + const userEnc = await AesHmacSha2.encrypt( + recoveryKey, + AccountDataType.CrossSigningUser, + crossSigning.user + ); - await crypto.bootstrapCrossSigning({ - authUploadDeviceSigningKeys, - setupNewCrossSigning: true, - }); + const { userId } = ct.session; + const masterSecret: Secret = { encrypted: { [defaultKeyId]: masterEnc } }; + const selfSecret: Secret = { encrypted: { [defaultKeyId]: selfEnc } }; + const userSecret: Secret = { encrypted: { [defaultKeyId]: userEnc } }; + await retrier.execute(() => + ct.requestAuthJSON( + putAccountData({ + pathParams: { userId, type: AccountDataType.CrossSigningMaster }, + body: masterSecret, + }) + ) + ); + if (alive()) setSetupProgress((v) => ({ ...v, putMasterAccountData: true })); + await retrier.execute(() => + ct.requestAuthJSON( + putAccountData({ + pathParams: { userId, type: AccountDataType.CrossSigningSelf }, + body: selfSecret, + }) + ) + ); + if (alive()) setSetupProgress((v) => ({ ...v, putSelfAccountData: true })); + await retrier.execute(() => + ct.requestAuthJSON( + putAccountData({ + pathParams: { userId, type: AccountDataType.CrossSigningUser }, + body: userSecret, + }) + ) + ); + if (alive()) setSetupProgress((v) => ({ ...v, putUserAcountData: true })); - await crypto.resetKeyBackup(); + const device = await ct.machine.userTracker.getDevice( + ct.session.userId, + ct.session.deviceId + ); + if (device && !device.crossSigningTrusted) { + const resp = await device.verify(); + const [err] = await parseMatrixResp(resp); + if (err) { + throw new Error(err.data.error ?? 'Failed to verify. Signature upload request failed.'); + } + await ct.machine.outgoingRequests.queryKeys([ct.session.userId]); + } + if (alive()) setSetupProgress((v) => ({ ...v, verfiyOwnDevice: true })); - onComplete(recoveryKeyData.encodedPrivateKey); + const backupKey = BackupManager.createBackupKey(); + await ct.megolmBackup.cacheDecryptionKey(backupKey.privateKey); + + const backupEnc = await AesHmacSha2.encrypt( + recoveryKey, + AccountDataType.MegolmBackupV1, + backupKey.privateKey + ); + + const backupAuthData = { + public_key: backupKey.publicKey, + }; + await ct.machine.sign(backupAuthData); + + const backupVersion: PostVersionBody = { + algorithm: backupKey.algorithm, + auth_data: backupAuthData, + }; + await retrier.execute(() => ct.requestAuthJSON(postVersion({ body: backupVersion }))); + if (alive()) setSetupProgress((v) => ({ ...v, postBackupVersion: true })); + + const backupSecret: Secret = { encrypted: { [defaultKeyId]: backupEnc } }; + + await retrier.execute(() => + ct.requestAuthJSON( + putAccountData({ + pathParams: { userId, type: AccountDataType.MegolmBackupV1 }, + body: backupSecret, + }) + ) + ); + if (alive()) setSetupProgress((v) => ({ ...v, putBackupAccountData: true })); + + const { iv, mac } = await AesHmacSha2.driveKeyMetaData(recoveryKey); + + const keyDescription: AesHmacSha2KeyDescription = { + algorithm: SECRET_STORAGE_ALGORITHM_V1_AES_HMAC_SHA2, + passphrase: passData?.passphrase, + iv, + mac, + }; + await retrier.execute(() => + ct.requestAuthJSON( + putAccountData({ + pathParams: { userId, type: KeyStorage.keyIdentifier(defaultKeyId) }, + body: keyDescription, + }) + ) + ); + if (alive()) setSetupProgress((v) => ({ ...v, putRecoveryKeyAccountData: true })); + const defaultKey: DefaultKey = { + key: defaultKeyId, + }; + await retrier.execute(() => + ct.requestAuthJSON( + putAccountData({ + pathParams: { userId, type: KeyStorage.defaultKeyIdentifier() }, + body: defaultKey, + }) + ) + ); + if (alive()) setSetupProgress((v) => ({ ...v, putDefaultKeyAccountData: true })); + + onComplete(RecoveryKey.encode(recoveryKey)); }, - [mx, onComplete, authUploadDeviceSigningKeys] + [ct, uia, onComplete, alive] ) ); @@ -179,43 +268,80 @@ function SetupVerification({ onComplete }: SetupVerificationProps) { setup(passphrase); }; + const setupSteps: { + key: keyof SetupProgressChecklist; + label: string; + }[] = useMemo( + () => [ + { key: 'bootstrapCrossSinging', label: 'Setting up cross-signing' }, + { key: 'putMasterAccountData', label: 'Securing your master signing key' }, + { key: 'putSelfAccountData', label: 'Securing your self signing key' }, + { key: 'putUserAcountData', label: 'Securing your user signing key' }, + { key: 'verfiyOwnDevice', label: 'Verifying this device' }, + { key: 'postBackupVersion', label: 'Creating your secure backup' }, + { key: 'putBackupAccountData', label: 'Securing your backup key' }, + { key: 'putRecoveryKeyAccountData', label: 'Saving your recovery key information' }, + { key: 'putDefaultKeyAccountData', label: 'Setting up your recovery key' }, + ], + [] + ); + + const completedSteps = setupSteps.filter(({ key }) => setupProgress?.[key] === true).length; + return ( - - Generate a Recovery Key for verifying identity if you do not have access to other - devices. Additionally, setup a passphrase as a memorable alternative. - - - Passphrase (Optional) - - - + {setupProgress ? ( + + Setup progress + + {setupSteps.map(({ key, label }) => ( + + {setupProgress[key] ? '✅' : '⏳'} {label} + + ))} + + + + ) : ( + <> + + Generate a Recovery Key for verifying identity if you do not have access to other + devices. Additionally, setup a passphrase as a memorable alternative. + + + Passphrase (Optional) + + + + + )} {setupState.status === AsyncStatus.Error && ( {setupState.error ? setupState.error.message : 'Unexpected Error!'} )} - {nextAuthData !== null && uiaAction && ( + {uiaData && ( ( - + Authentication steps to perform this action are not supported by client. )} > {(ongoingFlow) => ( )} @@ -228,6 +354,7 @@ type RecoveryKeyDisplayProps = { recoveryKey: string; }; function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) { + const ct = useClient(); const [show, setShow] = useState(false); const handleCopy = () => { @@ -235,10 +362,13 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) { }; const handleDownload = () => { + const username = getMxIdLocalPart(ct.session.userId); + const servername = getMxIdServer(ct.session.userId); + const blob = new Blob([recoveryKey], { type: 'text/plain;charset=us-ascii', }); - FileSaver.saveAs(blob, 'recovery-key.txt'); + FileSaver.saveAs(blob, `Recovery Key - ${username} (${servername}).txt`); }; const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*'); @@ -270,12 +400,12 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) { - - + ); diff --git a/src/app/components/DeviceVerificationStatus.ts b/src/app/components/DeviceVerificationStatus.ts index e8a6b0c1..3f168313 100644 --- a/src/app/components/DeviceVerificationStatus.ts +++ b/src/app/components/DeviceVerificationStatus.ts @@ -1,24 +1,21 @@ import { ReactNode } from 'react'; -import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api'; import { useDeviceVerificationStatus, VerificationStatus, } from '../hooks/useDeviceVerificationStatus'; type DeviceVerificationStatusProps = { - crypto?: CryptoApi; userId: string; deviceId: string; children: (verificationStatus: VerificationStatus) => ReactNode; }; export function DeviceVerificationStatus({ - crypto, userId, deviceId, children, }: DeviceVerificationStatusProps) { - const status = useDeviceVerificationStatus(crypto, userId, deviceId); + const status = useDeviceVerificationStatus(userId, deviceId); return children(status); } diff --git a/src/app/components/JoinRulesSwitcher.tsx b/src/app/components/JoinRulesSwitcher.tsx index bbc0a65d..58cf039b 100644 --- a/src/app/components/JoinRulesSwitcher.tsx +++ b/src/app/components/JoinRulesSwitcher.tsx @@ -13,44 +13,43 @@ import { Button, Spinner, } from 'folds'; -import { JoinRule } from 'matrix-js-sdk'; import FocusTrap from 'focus-trap-react'; import { stopPropagation } from '../utils/keyboard'; import { getRoomIconSrc } from '../utils/room'; +import { JoinRule } from '../../types/matrix/room'; export type ExtraJoinRules = 'knock_restricted'; -export type ExtendedJoinRules = JoinRule | ExtraJoinRules; -type JoinRuleIcons = Record; +type JoinRuleIcons = Record; export const useJoinRuleIcons = (roomType?: string): JoinRuleIcons => useMemo( () => ({ - [JoinRule.Invite]: getRoomIconSrc(Icons, roomType, JoinRule.Invite), - [JoinRule.Knock]: getRoomIconSrc(Icons, roomType, JoinRule.Knock), - knock_restricted: getRoomIconSrc(Icons, roomType, JoinRule.Restricted), - [JoinRule.Restricted]: getRoomIconSrc(Icons, roomType, JoinRule.Restricted), - [JoinRule.Public]: getRoomIconSrc(Icons, roomType, JoinRule.Public), - [JoinRule.Private]: getRoomIconSrc(Icons, roomType, JoinRule.Private), + invite: getRoomIconSrc(Icons, roomType, 'invite'), + knock: getRoomIconSrc(Icons, roomType, 'knock'), + restricted: getRoomIconSrc(Icons, roomType, 'restricted'), + knock_restricted: getRoomIconSrc(Icons, roomType, 'knock_restricted'), + public: getRoomIconSrc(Icons, roomType, 'public'), + private: getRoomIconSrc(Icons, roomType, 'private'), }), [roomType] ); -type JoinRuleLabels = Record; +type JoinRuleLabels = Record; export const useRoomJoinRuleLabel = (): JoinRuleLabels => useMemo( () => ({ - [JoinRule.Invite]: 'Invite Only', - [JoinRule.Knock]: 'Knock & Invite', + invite: 'Invite Only', + knock: 'Knock & Invite', knock_restricted: 'Space Members or Knock', - [JoinRule.Restricted]: 'Space Members', - [JoinRule.Public]: 'Public', - [JoinRule.Private]: 'Invite Only', + restricted: 'Space Members', + public: 'Public', + private: 'Invite Only', }), [] ); -type JoinRulesSwitcherProps = { +type JoinRulesSwitcherProps = { icons: JoinRuleIcons; labels: JoinRuleLabels; rules: T; @@ -59,7 +58,7 @@ type JoinRulesSwitcherProps = { disabled?: boolean; changing?: boolean; }; -export function JoinRulesSwitcher({ +export function JoinRulesSwitcher({ icons, labels, rules, @@ -75,7 +74,7 @@ export function JoinRulesSwitcher({ }; const handleChange = useCallback( - (selectedRule: ExtendedJoinRules) => { + (selectedRule: JoinRule) => { setCords(undefined); onChange(selectedRule); }, @@ -127,7 +126,7 @@ export function JoinRulesSwitcher({ fill="Soft" radii="300" outlined - before={} + before={} after={ changing ? ( diff --git a/src/app/components/LogoutDialog.tsx b/src/app/components/LogoutDialog.tsx index cc9bf067..42206ead 100644 --- a/src/app/components/LogoutDialog.tsx +++ b/src/app/components/LogoutDialog.tsx @@ -1,10 +1,9 @@ import React, { forwardRef, useCallback } from 'react'; import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; -import { logoutClient } from '../../client/initMatrix'; -import { useMatrixClient } from '../hooks/useMatrixClient'; -import { useCrossSigningActive } from '../hooks/useCrossSigning'; +import { useClient } from '../hooks/useClient'; import { InfoCard } from './info-card'; +import { useCrossSigning } from '../hooks/useCrossSigning'; import { useDeviceVerificationStatus, VerificationStatus, @@ -15,19 +14,16 @@ type LogoutDialogProps = { }; export const LogoutDialog = forwardRef( ({ handleClose }, ref) => { - const mx = useMatrixClient(); - const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent()); - const crossSigningActive = useCrossSigningActive(); - const verificationStatus = useDeviceVerificationStatus( - mx.getCrypto(), - mx.getSafeUserId(), - mx.getDeviceId() ?? undefined - ); + const ct = useClient(); + const hasEncryptedRoom = ct.encryptedRooms.read.size > 0; + const crossSigning = useCrossSigning(); + + const verificationStatus = useDeviceVerificationStatus(ct.session.userId, ct.session.deviceId); const [logoutState, logout] = useAsyncCallback( useCallback(async () => { - await logoutClient(mx); - }, [mx]) + await ct.logout(); + }, [ct]) ); const ongoingLogout = logoutState.status === AsyncStatus.Loading; @@ -48,7 +44,7 @@ export const LogoutDialog = forwardRef( {hasEncryptedRoom && - (crossSigningActive ? ( + (crossSigning ? ( verificationStatus === VerificationStatus.Unverified && ( { - const crypto = mx.getCrypto(); - if (!crypto) { - throw new Error('Unexpected Error! Crypto object not found.'); + async (recoveryKey: Uint8Array) => { + const status = await ct.machine.crossSigning.getStatus(); + const machineHasKeys = status.master && status.self && status.user; + + if (!machineHasKeys && csSecretData) { + const crossSigningKeys = await getCrossSigningPrivateKeys(recoveryKey, csSecretData); + + const s = await ct.machine.crossSigning.importKeys(crossSigningKeys); + if (!s.master || !s.self || !s.user) { + throw new Error('Failed to import to CrossSigningSecrets keys!'); + } } - storePrivateKey(secretStorageKeyId, recoveryKey); + const { userId, deviceId } = ct.session; + const device = await ct.machine.userTracker.getDevice(userId, deviceId); + if (device && !device.crossSigningTrusted) { + const resp = await device.verify(); + const [err] = await parseMatrixResp(resp); + if (err) { + throw new Error(err.data.error ?? 'Failed to verify. Signature upload request failed.'); + } - await crypto.bootstrapCrossSigning({}); - await crypto.bootstrapSecretStorage({}); + // device.verify uploads signatures to mark device as verifed + // query keys to receive signs now instead of update via /sync + // if we don't do that device remains unverified until next sync device_list change + await ct.machine.outgoingRequests.queryKeys([ct.session.userId]); + } - await crypto.loadSessionBackupPrivateKeyFromSecretStorage(); + const backupKeyEncryptedData = ct.secrets.megolmBackup.read?.get(secretStorageKeyId); + if (!backupKeyEncryptedData) { + return; + } + const backupKey = await AesHmacSha2.decrypt( + recoveryKey, + AccountDataType.MegolmBackupV1, + backupKeyEncryptedData + ); + await ct.megolmBackup.saveDecryptionKey(backupKey); }, - [mx, secretStorageKeyId] + [ct, secretStorageKeyId, csSecretData] ); - const [verifyState, handleDecodedRecoveryKey] = useAsyncCallback( - verifyAndRestoreBackup - ); + const [verifyState, handleDecodedRecoveryKey] = useAsyncCallback< + void, + Error, + [Uint8Array] + >(verifyAndRestoreBackup); const verifying = verifyState.status === AsyncStatus.Loading; return ( @@ -174,7 +205,7 @@ export function ManualVerificationTile({ {method === ManualVerificationMethod.RecoveryKey && ( )} @@ -182,8 +213,8 @@ export function ManualVerificationTile({ secretStorageKeyContent.passphrase && ( )} diff --git a/src/app/components/MediaConfigLoader.tsx b/src/app/components/MediaConfigLoader.tsx index 9fd99330..634973dd 100644 --- a/src/app/components/MediaConfigLoader.tsx +++ b/src/app/components/MediaConfigLoader.tsx @@ -1,15 +1,25 @@ import { ReactNode, useCallback, useEffect } from 'react'; +import { getConfig, GetConfigOK } from 'neurone/lib/api/matrix/client/v1/media'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; -import { useMatrixClient } from '../hooks/useMatrixClient'; import { MediaConfig } from '../hooks/useMediaConfig'; +import { useClient } from '../hooks/useClient'; type MediaConfigLoaderProps = { children: (mediaConfig: MediaConfig | undefined) => ReactNode; }; export function MediaConfigLoader({ children }: MediaConfigLoaderProps) { - const mx = useMatrixClient(); + const ct = useClient(); - const [state, load] = useAsyncCallback(useCallback(() => mx.getMediaConfig(), [mx])); + const [state, load] = useAsyncCallback( + useCallback(async () => { + const resp = await ct.requestAuthJSON(getConfig({})); + + const [, config] = await parseMatrixResp(resp); + + return config; + }, [ct]) + ); useEffect(() => { load(); diff --git a/src/app/components/MessageContent.tsx b/src/app/components/MessageContent.tsx new file mode 100644 index 00000000..336a9bbd --- /dev/null +++ b/src/app/components/MessageContent.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { MessageEventWithoutRoomId } from 'neurone/lib/api/types'; +import { DecryptionManager } from '../../client/decryption'; +import { MessageEvent } from '../../types/matrix/room'; +import { EncryptedMessageEvent } from './encrypted-event'; + +type MessageContentProps = { + event: MessageEventWithoutRoomId; + editedEvent?: MessageEventWithoutRoomId; + roomId: string; + decryptionManager: DecryptionManager; + reply?: boolean; + children: (event: MessageEventWithoutRoomId) => React.ReactNode; +}; + +export function MessageContent({ + event, + editedEvent, + roomId, + decryptionManager, + reply, + children, +}: MessageContentProps) { + const effectiveEvent = editedEvent ?? event; + + if (effectiveEvent.type === MessageEvent.RoomMessageEncrypted) { + return ( + + {(decryptedEvent) => children(decryptedEvent)} + + ); + } + + return children(effectiveEvent); +} diff --git a/src/app/pages/auth/OrDivider.tsx b/src/app/components/OrDivider.tsx similarity index 100% rename from src/app/pages/auth/OrDivider.tsx rename to src/app/components/OrDivider.tsx diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 4cfcb7dc..a73906f2 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -1,43 +1,30 @@ import React from 'react'; -import { MsgType } from 'matrix-js-sdk'; import { HTMLReactParserOptions } from 'html-react-parser'; import { Opts } from 'linkifyjs'; import { config } from 'folds'; import { - AudioContent, - DownloadFile, - FileContent, - ImageContent, + EmptyContent, MAudio, - MBadEncrypted, MEmote, MFile, MImage, MLocation, MNotice, + MSticker, MText, MVideo, - ReadPdfFile, - ReadTextFile, - RenderBody, - ThumbnailContent, UnsupportedContent, - VideoContent, } from './message'; -import { UrlPreviewCard, UrlPreviewHolder } from './url-preview'; -import { Image, MediaControl, Video } from './media'; -import { ImageViewer } from './image-viewer'; -import { PdfViewer } from './Pdf-viewer'; -import { TextViewer } from './text-viewer'; -import { testMatrixTo } from '../plugins/matrix-to'; -import { IImageContent } from '../../types/matrix/common'; +import { MsgType } from '../../types/matrix/common'; +import { rContent } from '../../client/reader'; +import { MessageEvent } from '../../types/matrix/room'; type RenderMessageContentProps = { displayName: string; - msgType: string; + eventType: string; ts: number; edited?: boolean; - getContent: () => T; + content: object; mediaAutoLoad?: boolean; urlPreview?: boolean; highlightRegex?: RegExp; @@ -47,10 +34,10 @@ type RenderMessageContentProps = { }; export function RenderMessageContent({ displayName, - msgType, + eventType, ts, edited, - getContent, + content, mediaAutoLoad, urlPreview, highlightRegex, @@ -58,90 +45,53 @@ export function RenderMessageContent({ linkifyOpts, outlineAttachment, }: RenderMessageContentProps) { - const renderUrlsPreview = (urls: string[]) => { - const filteredUrls = urls.filter((url) => !testMatrixTo(url)); - if (filteredUrls.length === 0) return undefined; + if (eventType === MessageEvent.Sticker) { return ( - - {filteredUrls.map((url) => ( - - ))} - + ); - }; + } + + if (eventType !== MessageEvent.RoomMessage) { + return ; + } + const renderCaption = () => { - const content: IImageContent = getContent(); - if (content.filename && content.filename !== content.body) { + const filename = rContent.filename(content); + const body = rContent.body(content); + + if (filename && filename !== body && body) { return ( ( - - )} - renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + urlPreview={urlPreview} + ts={ts} + highlightRegex={highlightRegex} + htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={linkifyOpts} /> ); } return null; }; - const renderFile = () => ( - <> - ( - ( - } - /> - )} - renderAsTextFile={() => ( - } - /> - )} - > - - - )} - outlined={outlineAttachment} - /> - {renderCaption()} - - ); + const msgType = rContent.msgtype(content); if (msgType === MsgType.Text) { return ( ( - - )} - renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + content={content} + urlPreview={urlPreview} + ts={ts} + highlightRegex={highlightRegex} + htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={linkifyOpts} /> ); } @@ -151,16 +101,12 @@ export function RenderMessageContent({ ( - - )} - renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + content={content} + urlPreview={urlPreview} + ts={ts} + highlightRegex={highlightRegex} + htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={linkifyOpts} /> ); } @@ -169,16 +115,12 @@ export function RenderMessageContent({ return ( ( - - )} - renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined} + content={content} + urlPreview={urlPreview} + ts={ts} + highlightRegex={highlightRegex} + htmlReactParserOptions={htmlReactParserOptions} + linkifyOpts={linkifyOpts} /> ); } @@ -186,18 +128,7 @@ export function RenderMessageContent({ if (msgType === MsgType.Image) { return ( <> - ( - } - renderViewer={(p) => } - /> - )} - outlined={outlineAttachment} - /> + {renderCaption()} ); @@ -206,31 +137,7 @@ export function RenderMessageContent({ if (msgType === MsgType.Video) { return ( <> - ( - ( - ( - {body} - )} - /> - ) - : undefined - } - renderVideo={(p) =>