diff --git a/package-lock.json b/package-lock.json index 64120f5..b430fd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,8 +72,10 @@ "react-error-boundary": "6.1.2", "react-google-recaptcha": "3.1.0", "react-i18next": "17.0.8", + "react-markdown": "10.1.0", "react-range": "1.10.0", "react-router-dom": "7.18.1", + "remark-gfm": "4.0.1", "sanitize-html": "2.17.5", "slate": "0.124.1", "slate-dom": "0.124.1", @@ -4129,6 +4131,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -4140,9 +4151,17 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/events": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz", @@ -4156,6 +4175,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/is-hotkey": { "version": "0.1.10", "resolved": "https://registry.npmjs.org/@types/is-hotkey/-/is-hotkey-0.1.10.tgz", @@ -4176,6 +4204,21 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", @@ -4259,6 +4302,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.63.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", @@ -4531,6 +4580,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, "node_modules/@vanilla-extract/babel-plugin-debug-ids": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@vanilla-extract/babel-plugin-debug-ids/-/babel-plugin-debug-ids-1.2.2.tgz", @@ -5042,6 +5097,16 @@ "integrity": "sha512-FxfZUp7B9yCnesNtFQS9v6PvZdxTYa14Q60JR6vhjdQdWI4naTjJIyx22JzoER8ooeT8SAAKoHLjKfCV7XgYUQ==", "license": "MIT" }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -5288,6 +5353,56 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -5374,6 +5489,16 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -5590,6 +5715,19 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dedent": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", @@ -5656,6 +5794,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-europe-js": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/detect-europe-js/-/detect-europe-js-0.1.2.tgz", @@ -5685,6 +5832,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/direction": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz", @@ -6657,6 +6817,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -6706,6 +6876,12 @@ "node": ">=0.8.x" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7269,6 +7445,64 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime/node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/hast-util-to-jsx-runtime/node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -7419,6 +7653,16 @@ } } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -7587,6 +7831,30 @@ "node": ">= 0.4" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -7725,6 +7993,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -7787,6 +8065,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-hotkey": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz", @@ -7872,6 +8160,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-plain-object": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", @@ -8653,6 +8953,16 @@ "url": "https://tidelift.com/funding/github/npm/loglevel" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -8697,6 +9007,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -8761,6 +9081,288 @@ "events": "^3.2.0" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/media-query-parser": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/media-query-parser/-/media-query-parser-2.0.2.tgz", @@ -8769,6 +9371,569 @@ "@babel/runtime": "^7.12.5" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/millify": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/millify/-/millify-6.1.0.tgz", @@ -9136,6 +10301,31 @@ ], "license": "(MIT AND Zlib)" }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-srcset": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", @@ -9330,6 +10520,16 @@ "react-is": "^16.13.1" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9473,6 +10673,33 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-property": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.2.tgz", @@ -9668,6 +10895,72 @@ "regjsparser": "bin/parser" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -10191,6 +11484,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", @@ -10339,6 +11642,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stringify-object": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", @@ -10512,6 +11829,26 @@ "punycode": "^2.1.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -10807,6 +12144,25 @@ "node": ">=4" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", @@ -10820,6 +12176,74 @@ "node": ">=8" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -10903,6 +12327,34 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", @@ -11553,6 +13005,16 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 45bc99e..d314a2b 100644 --- a/package.json +++ b/package.json @@ -84,8 +84,10 @@ "react-error-boundary": "6.1.2", "react-google-recaptcha": "3.1.0", "react-i18next": "17.0.8", + "react-markdown": "10.1.0", "react-range": "1.10.0", "react-router-dom": "7.18.1", + "remark-gfm": "4.0.1", "sanitize-html": "2.17.5", "slate": "0.124.1", "slate-dom": "0.124.1", diff --git a/public/update/4.11.175.md b/public/update/4.11.175.md new file mode 100644 index 0000000..df24e91 --- /dev/null +++ b/public/update/4.11.175.md @@ -0,0 +1,16 @@ +--- +version: 4.11.175 +title: Stability & polish +date: 2026-08-10 +summary: Desktop update flow improvements and UI refinements. +--- + +## Desktop updates + +- Smoother download and install flow in the title bar updater. +- Better handling when an update is already downloading. + +## UI polish + +- Refined spacing and contrast in settings panels. +- Improved mobile layout for room headers and notifications. diff --git a/public/update/currentupdate.md b/public/update/currentupdate.md new file mode 100644 index 0000000..8e7166d --- /dev/null +++ b/public/update/currentupdate.md @@ -0,0 +1,22 @@ +--- +version: 4.11.177 +title: Profile colors & banners +date: 2026-08-23 +summary: Username colors now follow MSC4522 with separate dark and light theme pickers. +--- + +## Profile colors (MSC4522) + +Username colors are now stored on your Matrix profile instead of inside avatar images. In turn **will need to be updated in your Account settings**, this is now cross compatible with Element + Cinny + +- Set **separate colors for dark and light themes** in Settings → Account → Profile. +- Per-room username overrides are read from member profile data when available. + +![Paarrot](./images/usernamecolors.png) + +## Profile banners (MSC4133) + +Profile banners now live on your Matrix profile via `m.banner_url` (MSC4133), not embedded in avatar metadata. + +- Upload or remove a banner independently from your avatar. +- Other clients that support MSC4133 can read your banner directly. diff --git a/public/update/images/paarrot.svg b/public/update/images/paarrot.svg new file mode 100644 index 0000000..7122ce0 --- /dev/null +++ b/public/update/images/paarrot.svg @@ -0,0 +1,5634 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/update/images/usernamecolors.png b/public/update/images/usernamecolors.png new file mode 100644 index 0000000..123a82a Binary files /dev/null and b/public/update/images/usernamecolors.png differ diff --git a/public/update/manifest.json b/public/update/manifest.json new file mode 100644 index 0000000..ff083b5 --- /dev/null +++ b/public/update/manifest.json @@ -0,0 +1,11 @@ +{ + "current": "currentupdate.md", + "older": [ + { + "file": "4.11.175.md", + "version": "4.11.175", + "title": "Stability & polish", + "date": "2026-08-10" + } + ] +} diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index a14da2b..a149b1e 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -117,7 +117,7 @@ export const Reply = as<'div', ReplyProps>( const { body } = replyEvent?.getContent() ?? {}; const sender = replyEvent?.getSender(); const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined; - const customUserColor = useOtherUserColor(sender ?? '', senderAvatarMxc); + const customUserColor = useOtherUserColor(sender ?? '', room); const powerTag = sender ? getMemberPowerTag?.(sender) : undefined; const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined; diff --git a/src/app/components/updates-dialog/UpdateMarkdownViewer.tsx b/src/app/components/updates-dialog/UpdateMarkdownViewer.tsx new file mode 100644 index 0000000..7b56bea --- /dev/null +++ b/src/app/components/updates-dialog/UpdateMarkdownViewer.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { resolveUpdateAssetUrl } from '../../data/updateNotes'; +import * as css from './UpdatesDialog.css'; + +type UpdateMarkdownViewerProps = { + markdown: string; +}; + +export function UpdateMarkdownViewer({ markdown }: UpdateMarkdownViewerProps) { + if (!markdown.trim()) { + return null; + } + + return ( +
+ ( + + {children} + + ), + img: ({ src, alt }) => ( + {alt + ), + }} + > + {markdown} + +
+ ); +} diff --git a/src/app/components/updates-dialog/UpdatesDialog.css.ts b/src/app/components/updates-dialog/UpdatesDialog.css.ts new file mode 100644 index 0000000..cc86776 --- /dev/null +++ b/src/app/components/updates-dialog/UpdatesDialog.css.ts @@ -0,0 +1,296 @@ +import { globalStyle, style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +const MOBILE_BREAKPOINT = '480px'; + +/** Dialog height cap — 85% of the viewport, minus safe areas. */ +const DIALOG_MAX_HEIGHT = + 'calc(85vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))'; + +export const OverlayFrame = style({ + boxSizing: 'border-box', + maxHeight: '100vh', + maxWidth: '100vw', + padding: + 'env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px)', +}); + +export const DialogShell = style({ + position: 'relative', + display: 'flex', + flexDirection: 'column', + width: '100%', + maxWidth: `min(${toRem(560)}, calc(100vw - ${config.space.S800}))`, + maxHeight: DIALOG_MAX_HEIGHT, + overflow: 'hidden', + backgroundColor: color.Surface.Container, + color: color.Surface.OnContainer, + '@media': { + [`(max-width: ${MOBILE_BREAKPOINT})`]: { + maxWidth: `calc(100vw - ${config.space.S400})`, + }, + }, +}); + +export const Hero = style({ + display: 'flex', + flexDirection: 'column', + gap: config.space.S200, + padding: config.space.S500, + paddingBottom: config.space.S400, + backgroundColor: color.Primary.Container, + color: color.Primary.OnContainer, + borderBottom: `${config.borderWidth.B300} solid ${color.Primary.ContainerLine}`, + '@media': { + [`(max-width: ${MOBILE_BREAKPOINT})`]: { + padding: config.space.S400, + paddingBottom: config.space.S300, + }, + }, +}); + +export const HeroRow = style({ + display: 'flex', + alignItems: 'center', + gap: config.space.S300, + minWidth: 0, +}); + +export const HeroLogo = style({ + width: toRem(48), + height: toRem(48), + flexShrink: 0, + '@media': { + [`(max-width: ${MOBILE_BREAKPOINT})`]: { + width: toRem(40), + height: toRem(40), + }, + }, +}); + +export const HeroTitleWrap = style({ + display: 'flex', + flexDirection: 'column', + gap: config.space.S100, + minWidth: 0, +}); + +export const BodyScroll = style({ + flex: '1 1 auto', + minHeight: 0, + backgroundColor: color.Surface.Container, + color: color.Surface.OnContainer, +}); + +export const BodyContent = style({ + display: 'flex', + flexDirection: 'column', + padding: config.space.S500, + paddingTop: config.space.S400, + '@media': { + [`(max-width: ${MOBILE_BREAKPOINT})`]: { + padding: config.space.S400, + paddingTop: config.space.S300, + }, + }, +}); + +export const BodyLoading = style({ + padding: config.space.S800, +}); + +export const Markdown = style({ + width: '100%', + color: color.Surface.OnContainer, + fontSize: toRem(14), + lineHeight: 1.55, +}); + +globalStyle(`${Markdown} h1, ${Markdown} h2, ${Markdown} h3, ${Markdown} h4`, { + margin: 0, + marginTop: config.space.S400, + marginBottom: config.space.S200, + lineHeight: 1.3, + color: color.Surface.OnContainer, +}); + +globalStyle(`${Markdown} h1:first-child, ${Markdown} h2:first-child, ${Markdown} h3:first-child, ${Markdown} h4:first-child`, { + marginTop: 0, +}); + +globalStyle(`${Markdown} h2`, { fontSize: toRem(18) }); +globalStyle(`${Markdown} h3`, { fontSize: toRem(16) }); + +globalStyle(`${Markdown} p`, { + margin: 0, + marginBottom: config.space.S300, +}); + +globalStyle(`${Markdown} p:last-child`, { marginBottom: 0 }); + +globalStyle(`${Markdown} ul, ${Markdown} ol`, { + margin: 0, + marginBottom: config.space.S300, + paddingLeft: config.space.S500, +}); + +globalStyle(`${Markdown} li`, { marginBottom: config.space.S100 }); +globalStyle(`${Markdown} li:last-child`, { marginBottom: 0 }); + +globalStyle(`${Markdown} a`, { + color: color.Primary.Main, + textDecoration: 'underline', + textUnderlineOffset: toRem(2), +}); + +globalStyle(`${Markdown} a:hover`, { color: color.Primary.MainHover }); + +globalStyle(`${Markdown} img`, { + display: 'block', + maxWidth: '100%', + height: 'auto', + marginTop: config.space.S200, + marginBottom: config.space.S300, + borderRadius: config.radii.R300, + border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, +}); + +globalStyle(`${Markdown} code`, { + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontSize: toRem(13), + padding: `0 ${config.space.S100}`, + borderRadius: config.radii.R200, + backgroundColor: color.SurfaceVariant.Container, + color: color.SurfaceVariant.OnContainer, +}); + +globalStyle(`${Markdown} pre`, { + margin: 0, + marginBottom: config.space.S300, + padding: config.space.S300, + borderRadius: config.radii.R300, + backgroundColor: color.SurfaceVariant.Container, + overflow: 'auto', +}); + +globalStyle(`${Markdown} pre code`, { + padding: 0, + backgroundColor: 'transparent', +}); + +globalStyle(`${Markdown} blockquote`, { + margin: 0, + marginBottom: config.space.S300, + paddingLeft: config.space.S300, + borderLeft: `${toRem(3)} solid ${color.Primary.Main}`, + color: color.Surface.OnContainer, + opacity: 0.9, +}); + +globalStyle(`${Markdown} hr`, { + border: 'none', + borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + margin: `${config.space.S400} 0`, +}); + +globalStyle(`${Markdown} table`, { + width: '100%', + borderCollapse: 'collapse', + marginBottom: config.space.S300, + fontSize: toRem(13), +}); + +globalStyle(`${Markdown} th, ${Markdown} td`, { + padding: config.space.S200, + border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + textAlign: 'left', +}); + +globalStyle(`${Markdown} th`, { + backgroundColor: color.SurfaceVariant.Container, +}); + +export const OlderSection = style({ + width: '100%', + marginTop: config.space.S500, +}); + +export const OlderToggle = style({ + width: '100%', +}); + +export const OlderList = style({ + listStyle: 'none', + margin: 0, + padding: 0, + display: 'flex', + flexDirection: 'column', + gap: config.space.S100, + width: '100%', +}); + +export const OlderListItem = style({ + width: '100%', +}); + +export const OlderItem = style({ + all: 'unset', + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: config.space.S100, + width: '100%', + minHeight: toRem(48), + padding: config.space.S300, + borderRadius: config.radii.R300, + border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + backgroundColor: color.SurfaceVariant.Container, + color: color.SurfaceVariant.OnContainer, + cursor: 'pointer', + textAlign: 'left', + selectors: { + '&:hover': { + backgroundColor: color.SurfaceVariant.ContainerHover, + }, + '&[data-active="true"]': { + borderColor: color.Primary.Main, + backgroundColor: color.Primary.Container, + color: color.Primary.OnContainer, + }, + }, +}); + +export const Footer = style({ + display: 'flex', + flexDirection: 'column', + gap: config.space.S200, + padding: config.space.S400, + paddingTop: config.space.S300, + paddingBottom: `calc(${config.space.S400} + env(safe-area-inset-bottom, 0px))`, + borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + backgroundColor: color.Surface.Container, + '@media': { + [`(max-width: ${MOBILE_BREAKPOINT})`]: { + padding: config.space.S300, + paddingTop: config.space.S300, + paddingBottom: `calc(${config.space.S300} + env(safe-area-inset-bottom, 0px))`, + }, + }, +}); + +export const FooterButton = style({ + width: '100%', +}); + +export const CloseButton = style({ + position: 'absolute', + top: config.space.S300, + right: config.space.S300, + '@media': { + [`(max-width: ${MOBILE_BREAKPOINT})`]: { + top: config.space.S200, + right: config.space.S200, + }, + }, +}); diff --git a/src/app/components/updates-dialog/UpdatesDialog.tsx b/src/app/components/updates-dialog/UpdatesDialog.tsx new file mode 100644 index 0000000..202fda9 --- /dev/null +++ b/src/app/components/updates-dialog/UpdatesDialog.tsx @@ -0,0 +1,181 @@ +import React, { useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + Dialog, + IconButton, + Overlay, + OverlayBackdrop, + OverlayCenter, + Scroll, + Spinner, + Text, +} from 'folds'; +import { Icon, Icons } from '../icons'; +import { stopPropagation } from '../../utils/keyboard'; +import type { ParsedUpdateDoc, UpdateManifest } from '../../data/updateNotes'; +import PaarrotSVG from '../../../../public/res/svg/paarrot.svg'; +import { UpdateMarkdownViewer } from './UpdateMarkdownViewer'; +import * as css from './UpdatesDialog.css'; + +type UpdatesDialogProps = { + manifest: UpdateManifest | null; + activeDoc: ParsedUpdateDoc | null; + loading: boolean; + onSelectFile: (file: string) => void; + onClose: () => void; +}; + +export function UpdatesDialog({ + manifest, + activeDoc, + loading, + onSelectFile, + onClose, +}: UpdatesDialogProps) { + const [showOlderList, setShowOlderList] = useState(false); + + const displayVersion = activeDoc?.version; + const displayTitle = activeDoc?.title ?? (displayVersion ? `Version ${displayVersion}` : 'Release notes'); + const isCurrent = manifest && activeDoc?.file === manifest.current; + const olderEntries = manifest?.older ?? []; + + const handleSelectOlder = (file: string) => { + onSelectFile(file); + setShowOlderList(false); + }; + + return ( + }> + + + + + + + + What's new + + {displayVersion ? `Paarrot ${displayVersion}` : 'Paarrot updates'} + + + + {displayTitle} + {activeDoc?.date && ( + + {activeDoc.date} + + )} + {!loading && !isCurrent && manifest?.current && ( + + )} + + + + + {loading && ( + + + + )} + + {!loading && activeDoc?.markdown && ( + + )} + + {!loading && !activeDoc?.markdown && ( + + No update notes are available right now. + + )} + + {!loading && olderEntries.length > 0 && ( + + + + {showOlderList && ( +
    + {olderEntries.map((entry) => { + const isActive = activeDoc?.file === entry.file; + const label = entry.version ?? entry.file.replace(/\.md$/i, ''); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ )} +
+
+ + + + + + + + + + +
+
+
+
+ ); +} diff --git a/src/app/components/updates-dialog/UpdatesDialogHost.tsx b/src/app/components/updates-dialog/UpdatesDialogHost.tsx new file mode 100644 index 0000000..cdc51c8 --- /dev/null +++ b/src/app/components/updates-dialog/UpdatesDialogHost.tsx @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useAtom, useSetAtom } from 'jotai'; +import { + loadCurrentUpdateDocument, + loadUpdateDocument, + loadUpdateManifest, + type ParsedUpdateDoc, + type UpdateManifest, +} from '../../data/updateNotes'; +import { + clearPendingReleaseNotes, + getLastSeenAppVersion, + releaseNotesDialogAtom, + setLastSeenAppVersion, + storePendingReleaseNotes, +} from '../../state/releaseNotes'; +import { getAppVersion } from '../../utils/appVersion'; +import { UpdatesDialog } from './UpdatesDialog'; + +type UpdaterInfo = { + version: string; + releaseNotes?: unknown; +}; + +/** Open the What's New dialog from Settings → About (or anywhere else). */ +export function useOpenReleaseNotesDialog(): () => void { + const setDialogState = useSetAtom(releaseNotesDialogAtom); + return useCallback(() => { + setDialogState({ open: true, manual: true }); + }, [setDialogState]); +} + +export function UpdatesDialogHost() { + const [dialogState, setDialogState] = useAtom(releaseNotesDialogAtom); + const [manifest, setManifest] = useState(null); + const [activeDoc, setActiveDoc] = useState(null); + const [loading, setLoading] = useState(false); + + const closeDialog = useCallback(() => { + setDialogState({ open: false, manual: false }); + setManifest(null); + setActiveDoc(null); + setLoading(false); + }, [setDialogState]); + + const selectFile = useCallback(async (file: string) => { + setLoading(true); + const doc = await loadUpdateDocument(file); + setActiveDoc(doc); + setLoading(false); + }, []); + + // Persist updater metadata when a download finishes (version tracking after restart). + useEffect(() => { + const electron = window.electron; + if (!electron?.updater?.onUpdateDownloaded) return undefined; + + const onDownloaded = (info: UpdaterInfo) => { + if (!info?.version) return; + storePendingReleaseNotes(info.version, info.releaseNotes); + }; + + electron.updater.onUpdateDownloaded(onDownloaded); + return undefined; + }, []); + + // Show What's New after the app version changes (post-update restart). + useEffect(() => { + let cancelled = false; + + const checkVersion = async () => { + const currentVersion = await getAppVersion(); + if (cancelled || currentVersion === 'unknown') return; + + const lastSeen = getLastSeenAppVersion(); + const shouldAutoShow = Boolean(lastSeen && lastSeen !== currentVersion); + + if (shouldAutoShow) { + const loadedManifest = await loadUpdateManifest(); + if (cancelled || !loadedManifest) return; + + const doc = await loadUpdateDocument(loadedManifest.current); + if (cancelled || !doc) return; + + setManifest(loadedManifest); + setActiveDoc(doc); + setDialogState({ open: true, manual: false }); + clearPendingReleaseNotes(); + } + + if (!lastSeen || lastSeen !== currentVersion) { + setLastSeenAppVersion(currentVersion); + } + }; + + const timer = window.setTimeout(checkVersion, 1500); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [setDialogState]); + + // Load markdown when the dialog opens (manual open from About, etc.). + useEffect(() => { + if (!dialogState.open) return undefined; + + let cancelled = false; + + const load = async () => { + setLoading(true); + const loadedManifest = await loadUpdateManifest(); + if (cancelled) return; + + if (!loadedManifest) { + setManifest(null); + setActiveDoc(null); + setLoading(false); + return; + } + + const doc = await loadUpdateDocument(loadedManifest.current); + if (cancelled) return; + + setManifest(loadedManifest); + setActiveDoc(doc); + setLoading(false); + }; + + load(); + + return () => { + cancelled = true; + }; + }, [dialogState.open]); + + if (!dialogState.open) { + return null; + } + + return ( + + ); +} diff --git a/src/app/components/updates-dialog/index.ts b/src/app/components/updates-dialog/index.ts new file mode 100644 index 0000000..5836ceb --- /dev/null +++ b/src/app/components/updates-dialog/index.ts @@ -0,0 +1,3 @@ +export * from './UpdatesDialog'; +export * from './UpdatesDialogHost'; +export * from './UpdateMarkdownViewer'; diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx index 5cd496b..eefb9c0 100644 --- a/src/app/components/user-profile/UserHero.tsx +++ b/src/app/components/user-profile/UserHero.tsx @@ -14,7 +14,6 @@ import { ImageViewer } from '../image-viewer'; import { stopPropagation } from '../../utils/keyboard'; import { useOtherUserColor } from '../../hooks/useUserColor'; import { useOtherUserBanner } from '../../hooks/useUserBanner'; -import { useOtherUserProfileStyle } from '../../hooks/useUserProfileStyle'; type UserHeroProps = { userId: string; @@ -24,15 +23,7 @@ type UserHeroProps = { }; export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroProps) { const [viewAvatar, setViewAvatar] = useState(); - const bannerUrl = useOtherUserBanner(userId, avatarMxc); // Now returns blob URL directly - const profileStyle = useOtherUserProfileStyle(userId, avatarMxc); - - console.log('[UserHero]', { - userId, - avatarMxc, - bannerUrl, - profileStyle, - }); + const bannerUrl = useOtherUserBanner(userId); return ( } @@ -71,10 +60,6 @@ export function UserHero({ userId, avatarUrl, avatarMxc, presence }: UserHeroPro style={{ width: toRem(72), height: toRem(72), - ...(profileStyle.avatarBorderColor ? { - outline: `${toRem(4)} solid ${profileStyle.avatarBorderColor}`, - outlineOffset: toRem(-1), - } : {}), }} > diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index 78af091..8279aa5 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -10,7 +10,6 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { usePowerLevels } from '../../hooks/usePowerLevels'; import { useRoom } from '../../hooks/useRoom'; import { useUserPresence } from '../../hooks/useUserPresence'; -import { useOtherUserProfileStyle } from '../../hooks/useUserProfileStyle'; import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips'; import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { PowerChip } from './PowerChip'; @@ -24,7 +23,7 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare'; import { CreatorChip } from './CreatorChip'; import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils'; import { DirectCreateSearchParams } from '../../pages/paths'; -import { getContrastingTextColor, stripAlphaFromColor, getTextShadowColor } from '../../utils/common'; +import { getTextShadowColor } from '../../utils/common'; import classNames from 'classnames'; import { BreakWord, LineClamp3 } from '../../styles/Text.css'; import colorMXID, { getColorMXIDValue } from '../../../util/colorMXID'; @@ -65,13 +64,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined; const presence = useUserPresence(userId); - const profileStyle = useOtherUserProfileStyle(userId, avatarMxc); - const userColor = useOtherUserColor(userId, avatarMxc); - - // Build gradient CSS if configured - const gradientStyle = profileStyle.gradient - ? `linear-gradient(${profileStyle.gradient.direction}, ${profileStyle.gradient.startColor}, ${profileStyle.gradient.stopColor})` - : undefined; + const userColor = useOtherUserColor(userId); const handleMessage = () => { closeUserRoomProfile(); @@ -93,22 +86,6 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { // Use userColor for text, or fall back to colorMXID const profileColor = userColor ? userColor : getColorMXIDValue(userId); const profileTextShadow = `0 1px 4px ${getTextShadowColor(profileColor)}`; - - // Use avatarBorderColor for pill background only if it has visible alpha (not fully transparent) - const hasVisibleBorder = profileStyle.avatarBorderColor && !profileStyle.avatarBorderColor.endsWith('00'); - const pillBgColor = hasVisibleBorder ? stripAlphaFromColor(profileStyle.avatarBorderColor!) : undefined; - - console.log('[UserRoomProfile]', { - userId, - avatarMxc, - userColor, - profileStyle, - profileColor, - profileTextShadow, - hasVisibleBorder, - pillBgColor, - fallbackColorMXID: getColorMXIDValue(userId), - }); return ( @@ -122,12 +99,11 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { direction="Column" gap="200" alignItems="Center" - style={{ - padding: config.space.S400, + style={{ + padding: config.space.S400, paddingTop: `calc(${config.space.S200} + ${toRem(36)})`, marginTop: toRem(-36), - background: gradientStyle, - textAlign: 'center' + textAlign: 'center', }} > {/* Display Name */} @@ -152,20 +128,14 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { {presence?.status && ( - + {presence.status} @@ -173,9 +143,9 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { {/* Chips Row */} - {server && } - - {creator ? : } + {server && } + + {creator ? : } {/* Message Button */} @@ -195,8 +165,8 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) { {/* Additional Chips if needed */} {userId !== myUserId && ( - - + + )} diff --git a/src/app/data/updateNotes.ts b/src/app/data/updateNotes.ts new file mode 100644 index 0000000..9add859 --- /dev/null +++ b/src/app/data/updateNotes.ts @@ -0,0 +1,125 @@ +import { trimTrailingSlash } from '../utils/common'; + +const UPDATE_BASE_PATH = `${trimTrailingSlash(import.meta.env.BASE_URL)}/update`; + +export type UpdateManifestEntry = { + file: string; + version?: string; + title?: string; + date?: string; +}; + +export type UpdateManifest = { + current: string; + older: UpdateManifestEntry[]; +}; + +export type ParsedUpdateDoc = { + file: string; + version?: string; + title?: string; + date?: string; + summary?: string; + markdown: string; +}; + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; + +function parseFrontmatter(source: string): { frontmatter: Record; body: string } { + const match = FRONTMATTER_RE.exec(source.trimStart()); + if (!match) { + return { frontmatter: {}, body: source }; + } + + const frontmatter: Record = {}; + match[1].split('\n').forEach((line) => { + const colon = line.indexOf(':'); + if (colon <= 0) return; + const key = line.slice(0, colon).trim(); + const raw = line.slice(colon + 1).trim(); + frontmatter[key] = raw.replace(/^['"]|['"]$/g, ''); + }); + + return { frontmatter, body: match[2] }; +} + +function parseUpdateMarkdown(file: string, source: string): ParsedUpdateDoc { + const { frontmatter, body } = parseFrontmatter(source); + return { + file, + version: frontmatter.version, + title: frontmatter.title, + date: frontmatter.date, + summary: frontmatter.summary, + markdown: body.trim(), + }; +} + +export function resolveUpdateAssetUrl(src: string | undefined): string | undefined { + if (!src) return undefined; + if (/^(https?:|data:|blob:)/i.test(src)) return src; + if (src.startsWith('/')) return src; + + const normalized = src.replace(/^\.\//, ''); + return `${UPDATE_BASE_PATH}/${normalized}`; +} + +export async function loadUpdateManifest(): Promise { + try { + const response = await fetch(`${UPDATE_BASE_PATH}/manifest.json`, { cache: 'no-cache' }); + if (!response.ok) return null; + + const data = (await response.json()) as UpdateManifest; + if (!data?.current || !Array.isArray(data.older)) return null; + + return { + current: data.current, + older: data.older.filter((entry) => entry?.file), + }; + } catch { + return null; + } +} + +export async function loadUpdateDocument(file: string): Promise { + const safeFile = file.replace(/^\/+/, ''); + if (!safeFile || safeFile.includes('..')) return null; + + try { + const response = await fetch(`${UPDATE_BASE_PATH}/${safeFile}`, { cache: 'no-cache' }); + if (!response.ok) return null; + + const source = await response.text(); + return parseUpdateMarkdown(safeFile, source); + } catch { + return null; + } +} + +export async function loadCurrentUpdateDocument(): Promise { + const manifest = await loadUpdateManifest(); + if (!manifest) return null; + return loadUpdateDocument(manifest.current); +} + +export async function getCurrentUpdatePreview(): Promise<{ + title: string; + description: string; +} | null> { + const doc = await loadCurrentUpdateDocument(); + if (!doc) return null; + + const title = doc.title ?? (doc.version ? `Paarrot ${doc.version}` : 'Release notes'); + const description = + doc.summary ?? + doc.markdown + .replace(/^#+\s+/gm, '') + .replace(/!\[[^\]]*\]\([^)]+\)/g, '') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .split('\n') + .map((line) => line.replace(/^[-*•]\s*/, '').trim()) + .find(Boolean) ?? + 'See what changed in this version.'; + + return { title, description }; +} diff --git a/src/app/features/message-search/SearchResultGroup.tsx b/src/app/features/message-search/SearchResultGroup.tsx index 8d003ac..d4951aa 100644 --- a/src/app/features/message-search/SearchResultGroup.tsx +++ b/src/app/features/message-search/SearchResultGroup.tsx @@ -105,7 +105,7 @@ function SearchResultItem({ relation?.rel_type === RelationType.Thread ? relation.event_id : undefined; // Get custom user color from avatar metadata - const customUserColor = useOtherUserColor(event.sender, senderAvatarMxc); + const customUserColor = useOtherUserColor(event.sender, room); const memberPowerTag = getMemberPowerTag(event.sender); const tagColor = memberPowerTag?.color diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 3208c2b..5f382d7 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -183,7 +183,7 @@ export const RoomInput = forwardRef( ); // Get custom user color from avatar metadata - const replyCustomColor = useOtherUserColor(replyUserID ?? '', replyAvatarMxc); + const replyCustomColor = useOtherUserColor(replyUserID ?? '', room); const replyPowerTag = replyUserID ? getMemberPowerTag(replyUserID) : undefined; const replyPowerColor = replyPowerTag?.color diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 55f746d..7399701 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -489,7 +489,7 @@ export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) { const dmAvatarMxc = (dmUserId ? room.getMember(dmUserId)?.getMxcAvatarUrl() : undefined) || (isDirect ? avatarMxc : undefined); - const dmCustomColor = useOtherUserColor(dmUserId ?? '', dmAvatarMxc); + const dmCustomColor = useOtherUserColor(dmUserId ?? ''); const dmFolderTabColor = stationery && isDirect ? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))` diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index ace65e8..013e3b6 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -785,7 +785,7 @@ export const Message = as<'div', MessageProps>( const senderAvatarMxc = getMemberAvatarMxc(room, senderId); // Get custom user color from avatar metadata (takes priority) - const customUserColor = useOtherUserColor(senderId, senderAvatarMxc); + const customUserColor = useOtherUserColor(senderId, room); const tagColor = memberPowerTag?.color ? accessibleTagColors?.get(memberPowerTag.color) diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx index 584906c..84999ad 100644 --- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx @@ -116,7 +116,7 @@ function PinnedMessage({ const sender = pinnedEvent?.getSender(); const senderAvatarMxc = sender ? getMemberAvatarMxc(room, sender) : undefined; - const customUserColor = useOtherUserColor(sender ?? '', senderAvatarMxc); + const customUserColor = useOtherUserColor(sender ?? '', room); const handleOpenClick: MouseEventHandler = (evt) => { evt.stopPropagation(); diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index bbb70fd..59d98c4 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -1,5 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { getVersion } from '@tauri-apps/api/app'; +import { getAppVersion } from '../../../utils/appVersion'; +import { useOpenReleaseNotesDialog } from '../../../components/updates-dialog/UpdatesDialogHost'; +import { getCurrentUpdatePreview } from '../../../data/updateNotes'; import { Box, Text, IconButton, Scroll, Button, config, toRem } from 'folds'; import { Icon, Icons } from '../../../components/icons'; import { Page, PageContent, PageHeader } from '../../../components/page'; @@ -15,6 +17,7 @@ type AboutProps = { }; export function About({ requestClose }: AboutProps) { const mx = useMatrixClient(); + const openReleaseNotes = useOpenReleaseNotesDialog(); const [version, setVersion] = useState(''); const [protocolStatus, setProtocolStatus] = useState('Checking desktop protocol integration...'); const [protocolBusy, setProtocolBusy] = useState(false); @@ -93,10 +96,13 @@ export function About({ requestClose }: AboutProps) { }; useEffect(() => { - getVersion().then(setVersion).catch(() => setVersion('unknown')); + getAppVersion().then(setVersion).catch(() => setVersion('unknown')); refreshProtocolStatus(); + getCurrentUpdatePreview().then(setUpdatePreview).catch(() => setUpdatePreview(null)); }, [refreshProtocolStatus]); + const [updatePreview, setUpdatePreview] = useState<{ title: string; description: string } | null>(null); + return ( @@ -151,6 +157,36 @@ export function About({ requestClose }: AboutProps) { + + What's new + + + View updates + + } + /> + + Options ; nameRef: React.RefObject }) { const mx = useMatrixClient(); @@ -98,26 +90,25 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject(); useEffect(() => { - if (userColor) { - setLocalColor(userColor); - } - }, [userColor]); + if (colorPreference?.on_dark) setLocalOnDark(colorPreference.on_dark); + if (colorPreference?.on_light) setLocalOnLight(colorPreference.on_light); + }, [colorPreference]); const handleColorSave = async () => { setSavingColor(true); setColorError(undefined); try { - await setUserColor(localColor); - // Sync user object to propagate changes throughout app - await syncUserAvatar(); + await setColorPreference({ on_dark: localOnDark, on_light: localOnLight }); } catch (e) { - setColorError('Failed to save color'); + setColorError('Failed to save colors. Your server may not support profile fields (MSC4133).'); } setSavingColor(false); }; @@ -126,141 +117,15 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject({ r: 59, g: 130, b: 246, a: 0 }); - const [localGradientStart, setLocalGradientStart] = useState({ r: 0, g: 0, b: 0, a: 0 }); - const [localGradientStop, setLocalGradientStop] = useState({ r: 0, g: 0, b: 0, a: 0 }); - const [localGradientDirection, setLocalGradientDirection] = useState(180); // degrees (180 = top to bottom) - const [savingStyle, setSavingStyle] = useState(false); - const [styleError, setStyleError] = useState(); - // Track if user has started editing (to show local values in preview) - const [editingBorder, setEditingBorder] = useState(false); - const [editingGradient, setEditingGradient] = useState(false); - - // Helper to convert RGBA to hex with alpha (#RRGGBBAA) - const rgbaToHex = (rgba: RgbaColor): string => { - const r = rgba.r.toString(16).padStart(2, '0'); - const g = rgba.g.toString(16).padStart(2, '0'); - const b = rgba.b.toString(16).padStart(2, '0'); - const a = Math.round(rgba.a * 255).toString(16).padStart(2, '0'); - return `#${r}${g}${b}${a}`; - }; - - // Helper to convert hex with alpha to RGBA - const hexToRgba = (hex: string): RgbaColor => { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(hex); - if (result) { - return { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16), - a: result[4] ? parseInt(result[4], 16) / 255 : 1, - }; - } - return { r: 59, g: 130, b: 246, a: 1 }; - }; - - // Helper to parse degrees from direction string (e.g., "180deg" -> 180) - const parseDirectionDegrees = (direction: string): number => { - const degMatch = direction.match(/^(\d+)deg$/i); - if (degMatch) return parseInt(degMatch[1], 10); - // Fallback for legacy "to X" format - const keywordMap: Record = { - 'to top': 0, - 'to top right': 45, - 'to right': 90, - 'to bottom right': 135, - 'to bottom': 180, - 'to bottom left': 225, - 'to left': 270, - 'to top left': 315, - }; - return keywordMap[direction.toLowerCase()] ?? 180; - }; - - // Sync local style state with loaded profile style - useEffect(() => { - if (profileStyle.avatarBorderColor) { - setLocalBorderColor(hexToRgba(profileStyle.avatarBorderColor)); - } - if (profileStyle.gradient) { - setLocalGradientStart(hexToRgba(profileStyle.gradient.startColor)); - setLocalGradientStop(hexToRgba(profileStyle.gradient.stopColor)); - setLocalGradientDirection(parseDirectionDegrees(profileStyle.gradient.direction)); - } - }, [profileStyle]); - - const handleBorderColorSave = async () => { - setSavingStyle(true); - setStyleError(undefined); - try { - await setProfileStyle({ avatarBorderColor: rgbaToHex(localBorderColor) }); - await syncUserAvatar(); - setEditingBorder(false); - } catch (e) { - setStyleError('Failed to save border color'); - } - setSavingStyle(false); - }; - - const handleBorderColorRemove = async () => { - setSavingStyle(true); - setStyleError(undefined); - try { - await setProfileStyle({ avatarBorderColor: undefined }); - await syncUserAvatar(); - setEditingBorder(false); - setLocalBorderColor({ r: 59, g: 130, b: 246, a: 0 }); - } catch (e) { - setStyleError('Failed to remove border color'); - } - setSavingStyle(false); - }; - - const handleGradientSave = async () => { - setSavingStyle(true); - setStyleError(undefined); - try { - await setProfileStyle({ - gradient: { - direction: `${localGradientDirection}deg`, - startColor: rgbaToHex(localGradientStart), - stopColor: rgbaToHex(localGradientStop), - }, - }); - await syncUserAvatar(); - setEditingGradient(false); - } catch (e) { - setStyleError('Failed to save gradient'); - } - setSavingStyle(false); - }; - - const handleGradientRemove = async () => { - setSavingStyle(true); - setStyleError(undefined); - try { - await setProfileStyle({ gradient: undefined }); - await syncUserAvatar(); - setEditingGradient(false); - setLocalGradientStart({ r: 0, g: 0, b: 0, a: 0 }); - setLocalGradientStop({ r: 0, g: 0, b: 0, a: 0 }); - setLocalGradientDirection(180); - } catch (e) { - setStyleError('Failed to remove gradient'); - } - setSavingStyle(false); - }; - const [isEditingName, setIsEditingName] = useState(false); const [editedName, setEditedName] = useState(profile.displayName || ''); const [savingName, setSavingName] = useState(false); @@ -475,82 +340,14 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject { @@ -574,24 +371,11 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject @@ -627,7 +411,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject @@ -760,9 +543,7 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject - {/* Profile Info Section - matches UserRoomProfile gradient section */} + {/* Profile Info Section */} - {/* Display Name with Color Picker */} - - - - - { - setLocalColor(e.target.value); - setColorError(undefined); - }} - /> - - - {colorError && ( - - {colorError} - - )} - - } - onRemove={userColor ? handleColorRemove : undefined} - > - {(onOpen) => ( - - )} - + {/* Display Name */} + {isEditingName ? ( setHoveredArea('name')} onMouseLeave={() => setHoveredArea(null)} style={{ + width: '100%', border: 'none', background: 'none', cursor: 'pointer', padding: `${toRem(4)} ${toRem(8)}`, - margin: `${toRem(-4)} ${toRem(-8)}`, position: 'relative', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + lineHeight: 1.2, }} > {profile.displayName || getMxIdLocalPart(userId) || userId} @@ -946,8 +684,9 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject setHoveredArea('status')} onMouseLeave={() => setHoveredArea(null)} style={{ - backgroundColor: previewPillBgColor || color.Surface.Container, - border: previewPillBgColor ? 'none' : `${toRem(1)} solid ${color.Surface.ContainerLine}`, + backgroundColor: color.Surface.Container, + border: `${toRem(1)} solid ${color.Surface.ContainerLine}`, padding: `${toRem(6)} ${toRem(10)}`, borderRadius: toRem(16), maxWidth: toRem(250), @@ -1032,14 +771,13 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject {presence?.status || 'Click to set a custom status...'} {presence?.status && hoveredArea === 'status' && ( <> - + { @@ -1070,63 +808,42 @@ function ProfileBanner({ avatarRef, nameRef }: { avatarRef: React.RefObject - - ruv.wtf - + ruv.wtf - - Share - + Share - - Admin - + Admin - {/* Profile Style Settings */} + {/* MSC4522 username colors */} - Profile Style - - {/* Avatar Border Color */} - - Avatar Border Color - - { setLocalBorderColor(c); setEditingBorder(true); }} /> - - - Preview: {rgbaToHex(localBorderColor)} - - - - - {profileStyle.avatarBorderColor && ( - - )} - - + Username colors + + Set how your name appears on dark and light themes (MSC4522). Other clients that support this + spec will see your chosen colors. + + + + + On dark themes + Bright colors work best + { setLocalOnDark(c); setColorError(undefined); }} /> + { + setLocalOnDark(e.target.value); + setColorError(undefined); + }} + /> + + + + + On light themes + Darker colors work best + { setLocalOnLight(c); setColorError(undefined); }} /> + { + setLocalOnLight(e.target.value); + setColorError(undefined); + }} + /> + - {/* Profile Gradient */} - - Profile Card Gradient - - - Start Color - { setLocalGradientStart(c); setEditingGradient(true); }} /> - - - End Color - { setLocalGradientStop(c); setEditingGradient(true); }} /> - - - Direction - { setLocalGradientDirection(deg); setEditingGradient(true); }} - /> - - - - {profileStyle.gradient && ( - - )} - - - + + + {hasSavedColors && ( + + )} - {styleError && ( - {styleError} + {colorError && ( + {colorError} )} diff --git a/src/app/hooks/useUserBanner.ts b/src/app/hooks/useUserBanner.ts index ddf5662..143676c 100644 --- a/src/app/hooks/useUserBanner.ts +++ b/src/app/hooks/useUserBanner.ts @@ -1,56 +1,17 @@ import { useCallback, useEffect, useState } from 'react'; -import { MatrixClient, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; import { useMediaAuthentication } from './useMediaAuthentication'; import { mxcUrlToHttp } from '../utils/matrix'; import { getCurrentAccessToken } from '../utils/auth'; import { - fetchAndExtractMetadata, - extractMetadataFromImage, - embedMetadataInImage, - detectImageFormat, - getMimeType, - getExtension, - ImageMetadata, - convertImageDataToPng, - uint8ArrayToBlob, -} from '../utils/imageMetadata'; + deleteBannerUrl, + loadBannerUrl, + registerBannerCacheClear, + saveBannerUrl, +} from '../utils/profileFields'; /** - * Fetches the user's current avatar as raw image data - */ -async function fetchAvatarData( - mx: MatrixClient, - avatarMxc: string, - useAuthentication: boolean -): Promise { - const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication); - if (!url) return null; - - try { - // Always use current session's token to avoid stale tokens during account switches - const accessToken = getCurrentAccessToken(); - let response = await fetch(url, { - method: 'GET', - headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined, - }); - - // If we got a 401 and we tried with auth, fallback to unauthenticated request - if (!response.ok && response.status === 401 && accessToken && useAuthentication) { - response = await fetch(url, { method: 'GET' }); - } - - if (!response.ok) return null; - return await response.arrayBuffer(); - } catch { - return null; - } -} - -/** - * Hook to manage the user's chosen profile banner, stored in avatar image metadata - * The banner URL is embedded in avatar metadata and syncs via the avatar - * @returns The current banner URL, a setter function, and loading state + * Hook to manage the user's profile banner (MSC4427 via MSC4133). */ export function useUserBanner(): [ string | undefined, @@ -58,280 +19,124 @@ export function useUserBanner(): [ boolean ] { const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); const [banner, setBanner] = useState(); const [loading, setLoading] = useState(true); - // Extract banner from current avatar on mount and when profile changes useEffect(() => { - const loadBanner = async () => { - setLoading(true); - try { - const userId = mx.getUserId(); - if (!userId) { - setLoading(false); - return; - } - - const profile = await mx.getProfileInfo(userId); - const avatarUrl = profile.avatar_url; - console.log('[useUserBanner.loadBanner] Loading banner from avatar:', avatarUrl); - - if (!avatarUrl) { - setBanner(undefined); - setLoading(false); - return; - } - - const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication); - if (!httpUrl) { - setBanner(undefined); - setLoading(false); - return; - } - - // Always use current session's token to avoid stale tokens during account switches - const accessToken = getCurrentAccessToken(); - const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null); - console.log('[useUserBanner.loadBanner] Extracted banner:', metadata.banner); - setBanner(metadata.banner); - } catch { - setBanner(undefined); - } - setLoading(false); - }; - - loadBanner(); - - // Listen for avatar changes and reload banner - const userId = mx.getUserId(); - if (!userId) return undefined; - - const user = mx.getUser(userId); - const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = (newAvatarUrl) => { - console.log('[useUserBanner] Avatar changed event fired, new URL:', newAvatarUrl); - loadBanner(); - }; - - user?.on(UserEvent.AvatarUrl, onAvatarChange); - - return () => { - user?.removeListener(UserEvent.AvatarUrl, onAvatarChange); - }; - }, [mx, useAuthentication]); - - const updateBanner = useCallback(async (newBanner: string | undefined) => { const userId = mx.getUserId(); if (!userId) { - throw new Error('No user ID'); + setLoading(false); + return undefined; } - const profile = await mx.getProfileInfo(userId); - const avatarUrl = profile.avatar_url; + let cancelled = false; - if (!avatarUrl) { - throw new Error('No avatar set. Please upload an avatar first.'); - } - - // Fetch current avatar - const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication); - if (!avatarData) { - throw new Error('Failed to fetch current avatar'); - } - - // Detect image format — banner lives in PNG tEXt, so convert other formats first - let workingData: ArrayBuffer | Uint8Array = avatarData; - let format = detectImageFormat(workingData); - - if (format === 'unknown') { - throw new Error('Unsupported avatar image format'); - } - - if (format !== 'png') { - console.log('[updateBanner] Converting', format, 'avatar to PNG for banner metadata'); - const pngData = await convertImageDataToPng(workingData); - if (!pngData) { - throw new Error('Failed to convert avatar to PNG for banner metadata'); + const load = async () => { + setLoading(true); + try { + const bannerMxc = await loadBannerUrl(mx, userId); + if (!cancelled) setBanner(bannerMxc); + } catch { + if (!cancelled) setBanner(undefined); } - workingData = pngData; - format = 'png'; - } - - // Get existing metadata to preserve - const existingMetadata = extractMetadataFromImage(workingData); - console.log('[updateBanner] Existing metadata:', existingMetadata); - - // Modify image metadata with new banner, preserving color. - // Empty string clears banner (PNG embed drops falsy fields after stripping old chunks). - const newMetadata: ImageMetadata = { - color: existingMetadata.color, - banner: newBanner ?? '', - avatarBorderColor: existingMetadata.avatarBorderColor, - gradient: existingMetadata.gradient, + if (!cancelled) setLoading(false); }; - console.log('[updateBanner] New metadata to embed:', newMetadata); - const newAvatarData = embedMetadataInImage(workingData, newMetadata); + load(); + return () => { + cancelled = true; + }; + }, [mx]); - if (!newAvatarData) { - throw new Error('Failed to embed banner in avatar metadata'); - } - - // Verify the banner was embedded correctly before uploading - const verifyMetadata = extractMetadataFromImage(newAvatarData); - console.log('[updateBanner] Verification - extracted metadata from new avatar:', verifyMetadata); - - if ((newBanner || undefined) !== verifyMetadata.banner) { - console.error('[updateBanner] Banner verification FAILED!', 'Expected:', newBanner, 'Got:', verifyMetadata.banner); - throw new Error('Banner verification failed'); - } - console.log('[updateBanner] Banner verification passed'); - - // Upload modified avatar with correct MIME type - const mimeType = getMimeType(format); - const extension = getExtension(format); - const blob = uint8ArrayToBlob(newAvatarData, mimeType); - - const uploadResponse = await mx.uploadContent(blob, { - name: `avatar.${extension}`, - type: mimeType, - }); - console.log('[updateBanner] Avatar uploaded successfully, MXC:', uploadResponse.content_uri); - - // Update profile with new avatar - try { - console.log('[updateBanner] Setting avatar URL to:', uploadResponse.content_uri); - await Promise.race([ - mx.setAvatarUrl(uploadResponse.content_uri), - new Promise((_, reject) => { - setTimeout(() => reject(new Error('setAvatarUrl timeout')), 30000); - }) - ]); - console.log('[updateBanner] setAvatarUrl completed'); - - // Manually sync user object to ensure event listeners are triggered - const user = mx.getUser(userId); - if (user && user.avatarUrl !== uploadResponse.content_uri) { - console.log('[updateBanner] Manually syncing user avatar URL'); - user.setAvatarUrl(uploadResponse.content_uri); + const updateBanner = useCallback( + async (newBanner: string | undefined) => { + if (!newBanner) { + await deleteBannerUrl(mx); + setBanner(undefined); + return; } - console.log('[updateBanner] Banner update complete'); - } catch (error) { - throw new Error(`Failed to update avatar URL: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - // Note: Don't set banner here - let the avatar change event handle it - // setBanner(newBanner); - }, [mx, useAuthentication]); + await saveBannerUrl(mx, newBanner); + setBanner(newBanner); + }, + [mx] + ); return [banner, updateBanner, loading]; } -// Cache for user banners to avoid refetching for each message const userBannerCache = new Map(); -const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const CACHE_TTL_MS = 5 * 60 * 1000; + +registerBannerCacheClear((userId: string) => { + userBannerCache.delete(userId); +}); + +async function fetchBannerBlobUrl( + mx: ReturnType, + bannerMxc: string, + useAuthentication: boolean +): Promise { + const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication); + if (!bannerHttpUrl) return undefined; + + const accessToken = getCurrentAccessToken(); + const headers: HeadersInit = {}; + if (useAuthentication && accessToken) { + headers.Authorization = `Bearer ${accessToken}`; + } + + const response = await fetch(bannerHttpUrl, { headers }); + if (!response.ok) return undefined; + + const blob = await response.blob(); + return URL.createObjectURL(blob); +} /** - * Hook to get another user's chosen profile banner from their avatar metadata - * @param userId - The user ID to get banner for - * @param avatarMxc - The user's avatar MXC URL - * @returns The user's chosen banner as a blob URL or undefined + * Hook to get another user's profile banner as a blob URL. */ -export function useOtherUserBanner(userId: string, avatarMxc: string | undefined): string | undefined { +export function useOtherUserBanner(userId: string): string | undefined { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [bannerBlobUrl, setBannerBlobUrl] = useState(); useEffect(() => { - let blobUrl: string | undefined; - - if (!avatarMxc) { - console.log('[useOtherUserBanner] No avatarMxc provided for', userId); + if (!userId) { setBannerBlobUrl(undefined); return undefined; } - const loadBanner = async () => { - console.log('[useOtherUserBanner] Loading banner for', userId, 'avatarMxc:', avatarMxc); - - // Check cache first - const cacheKey = `${userId}:${avatarMxc}`; - const cached = userBannerCache.get(cacheKey); - + let blobUrl: string | undefined; + let cancelled = false; + + const load = async () => { + const cached = userBannerCache.get(userId); let bannerMxc: string | undefined; - + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { - // Use cached banner MXC - console.log('[useOtherUserBanner] Using cached bannerMxc:', cached.bannerMxc); bannerMxc = cached.bannerMxc; } else { - // Fetch banner MXC from avatar metadata - const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication); - console.log('[useOtherUserBanner] Fetching metadata from:', httpUrl); - - if (!httpUrl) { - console.log('[useOtherUserBanner] Failed to get HTTP URL'); - setBannerBlobUrl(undefined); - return; - } - - // Always use current session's token to avoid stale tokens during account switches - const accessToken = getCurrentAccessToken(); - const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null); - - console.log('[useOtherUserBanner] Extracted metadata:', metadata); - - bannerMxc = metadata.banner; - - // Cache the banner MXC (not the blob URL) - userBannerCache.set(cacheKey, { bannerMxc, timestamp: Date.now() }); + bannerMxc = await loadBannerUrl(mx, userId); + userBannerCache.set(userId, { bannerMxc, timestamp: Date.now() }); } - + if (!bannerMxc) { - console.log('[useOtherUserBanner] No banner MXC found'); - setBannerBlobUrl(undefined); + if (!cancelled) setBannerBlobUrl(undefined); return; } - console.log('[useOtherUserBanner] Fetching banner image from MXC:', bannerMxc); - - // Fetch the banner image data with authentication - const bannerHttpUrl = mxcUrlToHttp(mx, bannerMxc, useAuthentication); - if (!bannerHttpUrl) { - console.log('[useOtherUserBanner] Failed to convert banner MXC to HTTP URL'); - setBannerBlobUrl(undefined); - return; - } - - console.log('[useOtherUserBanner] Banner HTTP URL:', bannerHttpUrl); - - // Always use current session's token to avoid stale tokens during account switches - const accessToken = getCurrentAccessToken(); - const headers: HeadersInit = {}; - if (useAuthentication && accessToken) { - headers.Authorization = `Bearer ${accessToken}`; - } - - const response = await fetch(bannerHttpUrl, { headers }); - if (!response.ok) { - setBannerBlobUrl(undefined); - return; - } - - const blob = await response.blob(); - blobUrl = URL.createObjectURL(blob); - setBannerBlobUrl(blobUrl); + blobUrl = await fetchBannerBlobUrl(mx, bannerMxc, useAuthentication); + if (!cancelled) setBannerBlobUrl(blobUrl); }; - loadBanner(); + load(); - // Cleanup blob URL when component unmounts or deps change return () => { - if (blobUrl) { - URL.revokeObjectURL(blobUrl); - } + cancelled = true; + if (blobUrl) URL.revokeObjectURL(blobUrl); }; - }, [mx, useAuthentication, avatarMxc, userId]); + }, [mx, useAuthentication, userId]); return bannerBlobUrl; } diff --git a/src/app/hooks/useUserColor.ts b/src/app/hooks/useUserColor.ts index 48c53dc..96ba5f5 100644 --- a/src/app/hooks/useUserColor.ts +++ b/src/app/hooks/useUserColor.ts @@ -1,227 +1,82 @@ import { useCallback, useEffect, useState } from 'react'; -import { MatrixClient, UserEvent, UserEventHandlerMap } from 'matrix-js-sdk'; +import { Room } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; -import { useMediaAuthentication } from './useMediaAuthentication'; -import { mxcUrlToHttp } from '../utils/matrix'; -import { getCurrentAccessToken } from '../utils/auth'; +import { ThemeKind, useTheme } from './useTheme'; import { - embedColorInImage, - extractColorFromImage, - removeColorFromImage, - fetchAndExtractColor, - detectImageFormat, - getMimeType, - getExtension, - uint8ArrayToBlob, -} from '../utils/imageMetadata'; + ColorPreference, + deleteColorPreference, + extractMemberColorPreference, + hasColorPreference, + loadColorPreference, + registerPreferenceCacheClear, + resolveColorForTheme, + saveColorPreference, +} from '../utils/profileFields'; /** - * Fetches the user's current avatar as raw image data + * Hook to manage the user's MSC4522 color preference on their global profile. */ -async function fetchAvatarData( - mx: MatrixClient, - avatarMxc: string, - useAuthentication: boolean -): Promise { - const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication); - if (!url) return null; - - try { - // Always use current session's token to avoid stale tokens during account switches - const accessToken = getCurrentAccessToken(); - let response = await fetch(url, { - method: 'GET', - headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined, - }); - - // If we got a 401 and we tried with auth, fallback to unauthenticated request - if (!response.ok && response.status === 401 && accessToken && useAuthentication) { - console.warn('[fetchAvatarData] Auth failed (401), attempting unauthenticated fallback'); - response = await fetch(url, { method: 'GET' }); - } - - if (!response.ok) return null; - return await response.arrayBuffer(); - } catch { - return null; - } -} - -/** - * Hook to manage the user's chosen profile color, stored in avatar image metadata - * The color is embedded in PNG tEXt chunk or WebP XMP chunk and syncs via the avatar - * @returns The current color, a setter function, and loading state - */ -export function useUserColor(): [ - string | undefined, - (color: string | undefined) => Promise, +export function useUserColorPreference(): [ + ColorPreference | undefined, + (preference: ColorPreference | undefined) => Promise, boolean ] { const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const [color, setColor] = useState(); + const [preference, setPreference] = useState(); const [loading, setLoading] = useState(true); - // Extract color from current avatar on mount and when profile changes useEffect(() => { - const loadColor = async () => { + const userId = mx.getUserId(); + if (!userId) { + setLoading(false); + return undefined; + } + + let cancelled = false; + + const load = async () => { setLoading(true); try { - const userId = mx.getUserId(); - if (!userId) { - setLoading(false); - return; - } - - const profile = await mx.getProfileInfo(userId); - const avatarUrl = profile.avatar_url; - - if (!avatarUrl) { - setColor(undefined); - setLoading(false); - return; - } - - const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication); - if (!httpUrl) { - setColor(undefined); - setLoading(false); - return; - } - - // Always use current session's token to avoid stale tokens during account switches - const accessToken = getCurrentAccessToken(); - const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null); - setColor(extractedColor); + const loaded = await loadColorPreference(mx, userId); + if (!cancelled) setPreference(loaded); } catch (e) { - console.error('Failed to load user color from avatar:', e); - setColor(undefined); + console.error('Failed to load color preference:', e); + if (!cancelled) setPreference(undefined); } - setLoading(false); + if (!cancelled) setLoading(false); }; - loadColor(); - - // Listen for avatar changes and reload color - const userId = mx.getUserId(); - if (!userId) return undefined; - - const user = mx.getUser(userId); - const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = () => { - loadColor(); - }; - - user?.on(UserEvent.AvatarUrl, onAvatarChange); - + load(); return () => { - user?.removeListener(UserEvent.AvatarUrl, onAvatarChange); + cancelled = true; }; - }, [mx, useAuthentication]); + }, [mx]); - const updateColor = useCallback(async (newColor: string | undefined) => { - const userId = mx.getUserId(); - if (!userId) return; - - try { - const profile = await mx.getProfileInfo(userId); - const avatarUrl = profile.avatar_url; - - if (!avatarUrl) { - // No avatar to embed color in - console.warn('Cannot set color: no avatar image set'); - throw new Error('No avatar set'); + const updatePreference = useCallback( + async (newPreference: ColorPreference | undefined) => { + if (!hasColorPreference(newPreference)) { + await deleteColorPreference(mx); + setPreference(undefined); + return; } - // Fetch current avatar - const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication); - if (!avatarData) { - console.error('Failed to fetch current avatar'); - throw new Error('Failed to fetch avatar'); - } + await saveColorPreference(mx, newPreference!); + setPreference(newPreference); + }, + [mx] + ); - console.log('Original avatar size:', avatarData.byteLength, 'bytes'); - - // Detect image format - const format = detectImageFormat(avatarData); - console.log('Detected image format:', format); - - if (format === 'unknown') { - console.error('Unsupported avatar image format - must be PNG, WebP, JPEG, or GIF'); - throw new Error('Unsupported image format'); - } - - // Modify image metadata - let newAvatarData: Uint8Array | null; - if (newColor) { - newAvatarData = embedColorInImage(avatarData, newColor); - } else { - newAvatarData = removeColorFromImage(avatarData); - } - - if (!newAvatarData) { - console.error('Failed to modify avatar image metadata'); - throw new Error('Failed to modify image metadata'); - } - - console.log('Modified avatar size:', newAvatarData.byteLength, 'bytes'); - - // Verify the color was embedded correctly before uploading - const verifyColor = extractColorFromImage(newAvatarData); - console.log('Verification - embedded color:', verifyColor); - - if (newColor && verifyColor !== newColor) { - console.error('Color verification failed! Expected:', newColor, 'Got:', verifyColor); - throw new Error('Color embedding verification failed'); - } - - // Upload modified avatar with correct MIME type - const mimeType = getMimeType(format); - const extension = getExtension(format); - const blob = uint8ArrayToBlob(newAvatarData, mimeType); - const uploadResponse = await mx.uploadContent(blob, { - name: `avatar.${extension}`, - type: mimeType, - }); - - console.log('Uploaded new avatar:', uploadResponse.content_uri); - - // Update profile with new avatar - await mx.setAvatarUrl(uploadResponse.content_uri); - - // Manually sync user object to ensure event listeners are triggered - const user = mx.getUser(userId); - if (user && user.avatarUrl !== uploadResponse.content_uri) { - user.setAvatarUrl(uploadResponse.content_uri); - } - - setColor(newColor); - console.log('User color updated in avatar:', newColor); - } catch (e) { - console.error('Failed to update user color in avatar:', e); - throw e; - } - }, [mx, useAuthentication]); - - return [color, updateColor, loading]; + return [preference, updatePreference, loading]; } -/** - * LRU Cache implementation with max size limit - */ class LRUCache { - private cache: Map; + private cache = new Map(); - private maxSize: number; - - constructor(maxSize: number) { - this.cache = new Map(); - this.maxSize = maxSize; - } + constructor(private maxSize: number) {} get(key: K): V | undefined { const value = this.cache.get(key); if (value !== undefined) { - // Move to end (most recently used) this.cache.delete(key); this.cache.set(key, value); } @@ -229,88 +84,106 @@ class LRUCache { } set(key: K, value: V): void { - // If key exists, delete it first to re-add at end if (this.cache.has(key)) { this.cache.delete(key); - } - // If at max size, remove oldest (first) entry - else if (this.cache.size >= this.maxSize) { + } else if (this.cache.size >= this.maxSize) { const firstKey = this.cache.keys().next().value; - if (firstKey !== undefined) { - this.cache.delete(firstKey); - } + if (firstKey !== undefined) this.cache.delete(firstKey); } this.cache.set(key, value); } - clear(): void { - this.cache.clear(); + delete(key: K): void { + this.cache.delete(key); } } -// LRU cache for user colors to avoid refetching for each message -const userColorCache = new LRUCache(100); -const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes +const preferenceCache = new LRUCache(200); +const CACHE_TTL_MS = 30 * 60 * 1000; + +registerPreferenceCacheClear((userId: string) => { + preferenceCache.delete(userId); +}); /** - * Hook to get another user's chosen profile color from their avatar metadata - * @param userId - The user ID to get color for - * @param avatarMxc - The user's avatar MXC URL - * @returns The user's chosen color or undefined + * Hook to get another user's MSC4522 color preference from their global profile. */ -export function useOtherUserColor(userId: string, avatarMxc: string | undefined): string | undefined { +export function useOtherUserColorPreference(userId: string): ColorPreference | undefined { const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - - // Check cache synchronously BEFORE initializing state to avoid flash - const cacheKey = avatarMxc ? `${userId}:${avatarMxc}` : ''; - const cachedEntry = cacheKey ? userColorCache.get(cacheKey) : undefined; - const cachedColor = cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS - ? cachedEntry.color - : undefined; - - const [color, setColor] = useState(cachedColor); + const cachedEntry = preferenceCache.get(userId); + const cachedPreference = + cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_TTL_MS ? cachedEntry.preference : undefined; + + const [preference, setPreference] = useState(cachedPreference); useEffect(() => { - if (!avatarMxc) { - console.log('[useOtherUserColor] No avatarMxc provided for', userId); - setColor(undefined); + if (!userId) { + setPreference(undefined); return; } - // Check cache first - const cacheKey = `${userId}:${avatarMxc}`; - const cached = userColorCache.get(cacheKey); + const cached = preferenceCache.get(userId); if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { - console.log('[useOtherUserColor] Using cached color for', userId, ':', cached.color); - setColor(cached.color); + setPreference(cached.preference); return; } - const loadColor = async () => { - const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication); - console.log('[useOtherUserColor] Loading color for', userId, 'from', httpUrl); - if (!httpUrl) { - console.log('[useOtherUserColor] Failed to get HTTP URL for', avatarMxc); - setColor(undefined); - return; - } + let cancelled = false; - // Always use current session's token to avoid stale tokens during account switches - const accessToken = getCurrentAccessToken(); - const extractedColor = await fetchAndExtractColor(httpUrl, useAuthentication ? accessToken : null); - - console.log('[useOtherUserColor] Extracted color for', userId, ':', extractedColor); - - // Cache the result - userColorCache.set(cacheKey, { color: extractedColor, timestamp: Date.now() }); - - setColor(extractedColor); + const load = async () => { + try { + const loaded = await loadColorPreference(mx, userId); + preferenceCache.set(userId, { preference: loaded, timestamp: Date.now() }); + if (!cancelled) setPreference(loaded); + } catch { + if (!cancelled) setPreference(undefined); + } }; - loadColor(); - }, [mx, useAuthentication, avatarMxc, userId]); + load(); + return () => { + cancelled = true; + }; + }, [mx, userId]); - return color; + return preference; } +/** + * MSC4522 per-room member color preference (overrides global profile when set). + */ +export function useMemberColorPreference(userId: string, room?: Room): ColorPreference | undefined { + const [preference, setPreference] = useState(() => + extractMemberColorPreference(room, userId) + ); + + useEffect(() => { + setPreference(extractMemberColorPreference(room, userId)); + }, [room, userId]); + + return preference; +} + +/** + * Resolve a user's username color for the active theme (MSC4522). + * Per-room member colors take precedence over global profile colors. + */ +export function useOtherUserColor(userId: string, room?: Room): string | undefined { + const theme = useTheme(); + const profilePreference = useOtherUserColorPreference(userId); + const memberPreference = useMemberColorPreference(userId, room); + const preference = memberPreference ?? profilePreference; + return resolveColorForTheme(preference, theme.kind); +} + +/** + * Resolve the current user's username color for the active theme. + */ +export function useUsernameColor(userId: string, room?: Room, themeKind?: ThemeKind): string | undefined { + const theme = useTheme(); + const kind = themeKind ?? theme.kind; + const profilePreference = useOtherUserColorPreference(userId); + const memberPreference = useMemberColorPreference(userId, room); + const preference = memberPreference ?? profilePreference; + return resolveColorForTheme(preference, kind); +} diff --git a/src/app/hooks/useUserProfileStyle.ts b/src/app/hooks/useUserProfileStyle.ts deleted file mode 100644 index 727c21c..0000000 --- a/src/app/hooks/useUserProfileStyle.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { UserEvent, UserEventHandlerMap } from 'matrix-js-sdk'; -import { useMatrixClient } from './useMatrixClient'; -import { useMediaAuthentication } from './useMediaAuthentication'; -import { mxcUrlToHttp } from '../utils/matrix';import { getCurrentAccessToken } from '../utils/auth';import { - fetchAndExtractMetadata, - extractMetadataFromImage, - embedMetadataInImage, - detectImageFormat, - getMimeType, - getExtension, - ImageMetadata, - ProfileGradient, - convertImageDataToPng, - uint8ArrayToBlob, -} from '../utils/imageMetadata'; - -/** - * Fetches the user's current avatar as raw image data - */ -async function fetchAvatarData( - mx: ReturnType, - avatarMxc: string, - useAuthentication: boolean -): Promise { - const url = mxcUrlToHttp(mx, avatarMxc, useAuthentication); - if (!url) return null; - - try { - // Always use current session's token to avoid stale tokens during account switches - const accessToken = getCurrentAccessToken(); - let response = await fetch(url, { - method: 'GET', - headers: accessToken && useAuthentication ? { Authorization: `Bearer ${accessToken}` } : undefined, - }); - - // If we got a 401 and we tried with auth, fallback to unauthenticated request - if (!response.ok && response.status === 401 && accessToken && useAuthentication) { - response = await fetch(url, { method: 'GET' }); - } - - if (!response.ok) return null; - return await response.arrayBuffer(); - } catch { - return null; - } -} - -export type ProfileStyleData = { - avatarBorderColor?: string; - gradient?: ProfileGradient; -}; - -/** - * Hook to manage the user's profile style (avatar border color and gradient) - * Stored in avatar image metadata - * @returns Current style data, a setter function, and loading state - */ -export function useUserProfileStyle(): [ - ProfileStyleData, - (style: Partial) => Promise, - boolean -] { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const [style, setStyle] = useState({}); - const [loading, setLoading] = useState(true); - - // Extract style from current avatar on mount and when profile changes - useEffect(() => { - const loadStyle = async () => { - setLoading(true); - try { - const userId = mx.getUserId(); - if (!userId) { - setLoading(false); - return; - } - - const profile = await mx.getProfileInfo(userId); - const avatarUrl = profile.avatar_url; - - if (!avatarUrl) { - setStyle({}); - setLoading(false); - return; - } - - const httpUrl = mxcUrlToHttp(mx, avatarUrl, useAuthentication); - if (!httpUrl) { - setStyle({}); - setLoading(false); - return; - } - - // Always use current session's token to avoid stale tokens during account switches - const accessToken = getCurrentAccessToken(); - const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null); - setStyle({ - avatarBorderColor: metadata.avatarBorderColor, - gradient: metadata.gradient, - }); - } catch { - setStyle({}); - } - setLoading(false); - }; - - loadStyle(); - - // Listen for avatar changes and reload style - const userId = mx.getUserId(); - if (!userId) return undefined; - - const user = mx.getUser(userId); - const onAvatarChange: UserEventHandlerMap[UserEvent.AvatarUrl] = () => { - loadStyle(); - }; - - user?.on(UserEvent.AvatarUrl, onAvatarChange); - - return () => { - user?.removeListener(UserEvent.AvatarUrl, onAvatarChange); - }; - }, [mx, useAuthentication]); - - const updateStyle = useCallback( - async (newStyle: Partial) => { - const userId = mx.getUserId(); - if (!userId) { - throw new Error('No user ID'); - } - - const profile = await mx.getProfileInfo(userId); - const avatarUrl = profile.avatar_url; - - if (!avatarUrl) { - throw new Error('No avatar set. Please upload an avatar first.'); - } - - // Fetch current avatar - const avatarData = await fetchAvatarData(mx, avatarUrl, useAuthentication); - if (!avatarData) { - throw new Error('Failed to fetch current avatar'); - } - - // Border/gradient live in PNG tEXt — convert other formats first - let workingData: ArrayBuffer | Uint8Array = avatarData; - let format = detectImageFormat(workingData); - if (format === 'unknown') { - throw new Error('Unsupported avatar image format'); - } - - if (format !== 'png') { - const pngData = await convertImageDataToPng(workingData); - if (!pngData) { - throw new Error('Failed to convert avatar to PNG for style metadata'); - } - workingData = pngData; - format = 'png'; - } - - // Get existing metadata to preserve - const existingMetadata = extractMetadataFromImage(workingData); - - // Merge with new style - const newMetadata: ImageMetadata = { - ...existingMetadata, - avatarBorderColor: newStyle.avatarBorderColor !== undefined ? newStyle.avatarBorderColor : existingMetadata.avatarBorderColor, - gradient: newStyle.gradient !== undefined ? newStyle.gradient : existingMetadata.gradient, - }; - - // Embed updated metadata - const newAvatarData = embedMetadataInImage(workingData, newMetadata); - if (!newAvatarData) { - throw new Error('Failed to embed style in avatar metadata'); - } - - // Upload modified avatar with correct MIME type - const mimeType = getMimeType(format); - const extension = getExtension(format); - const blob = uint8ArrayToBlob(newAvatarData, mimeType); - - const uploadResponse = await mx.uploadContent(blob, { - name: `avatar.${extension}`, - type: mimeType, - }); - - // Update profile with new avatar - await mx.setAvatarUrl(uploadResponse.content_uri); - - // Manually sync user object - const user = mx.getUser(userId); - if (user && user.avatarUrl !== uploadResponse.content_uri) { - user.setAvatarUrl(uploadResponse.content_uri); - } - - // Update local state - setStyle((prev) => ({ - ...prev, - ...newStyle, - })); - }, - [mx, useAuthentication] - ); - - return [style, updateStyle, loading]; -} - -// Cache for other users' profile styles -const userStyleCache = new Map(); -const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes - -/** - * Hook to get another user's profile style from their avatar metadata - * @param userId - The user ID to get style for - * @param avatarMxc - The user's avatar MXC URL - * @returns The user's profile style data - */ -export function useOtherUserProfileStyle(userId: string, avatarMxc: string | undefined): ProfileStyleData { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const [style, setStyle] = useState({}); - - useEffect(() => { - if (!avatarMxc) { - console.log('[useOtherUserProfileStyle] No avatarMxc provided for', userId); - setStyle({}); - return; - } - - const loadStyle = async () => { - console.log('[useOtherUserProfileStyle] Loading style for', userId, 'avatarMxc:', avatarMxc); - - // Check cache first - const cacheKey = `${userId}:${avatarMxc}`; - const cached = userStyleCache.get(cacheKey); - - if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { - console.log('[useOtherUserProfileStyle] Using cached style:', cached.style); - setStyle(cached.style); - return; - } - - const httpUrl = mxcUrlToHttp(mx, avatarMxc, useAuthentication); - console.log('[useOtherUserProfileStyle] Fetching metadata from:', httpUrl); - - if (!httpUrl) { - console.log('[useOtherUserProfileStyle] Failed to get HTTP URL'); - setStyle({}); - return; - } - - // Always use current session's token to avoid stale tokens during account switches - const accessToken = getCurrentAccessToken(); - const metadata = await fetchAndExtractMetadata(httpUrl, useAuthentication ? accessToken : null); - - console.log('[useOtherUserProfileStyle] Extracted metadata:', metadata); - - const styleData: ProfileStyleData = { - avatarBorderColor: metadata.avatarBorderColor, - gradient: metadata.gradient, - }; - - console.log('[useOtherUserProfileStyle] Final styleData:', styleData); - - // Cache the result - userStyleCache.set(cacheKey, { style: styleData, timestamp: Date.now() }); - setStyle(styleData); - }; - - loadStyle(); - }, [mx, useAuthentication, avatarMxc, userId]); - - return style; -} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index d8ef800..05a7a0b 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -58,6 +58,7 @@ import { listenForAndroidShares, materializeSharedFile, } from '../../utils/androidShare'; +import { UpdatesDialogHost } from '../../components/updates-dialog'; /** * Applies the selected emoji style font to the document. @@ -698,6 +699,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + {children} diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index ac4aa03..be3217d 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -237,7 +237,7 @@ function NotificationItem({ relation?.rel_type === RelationType.Thread ? relation.event_id : undefined; // Get custom user color from avatar metadata - const customUserColor = useOtherUserColor(event.sender, senderAvatarMxc); + const customUserColor = useOtherUserColor(event.sender, room); const memberPowerTag = getMemberPowerTag(event.sender); const tagColor = memberPowerTag?.color diff --git a/src/app/state/releaseNotes.ts b/src/app/state/releaseNotes.ts new file mode 100644 index 0000000..2c071ee --- /dev/null +++ b/src/app/state/releaseNotes.ts @@ -0,0 +1,55 @@ +import { atom } from 'jotai'; + +const LAST_SEEN_VERSION_KEY = 'paarrot:lastSeenVersion'; +const PENDING_RELEASE_NOTES_KEY = 'paarrot:pendingReleaseNotes'; + +export type PendingReleaseNotes = { + version: string; + releaseNotes: unknown; +}; + +export type ReleaseNotesDialogState = { + open: boolean; + /** Opened from About instead of an automatic post-update prompt. */ + manual: boolean; +}; + +export const releaseNotesDialogAtom = atom({ + open: false, + manual: false, +}); + +export function getLastSeenAppVersion(): string | null { + return localStorage.getItem(LAST_SEEN_VERSION_KEY); +} + +export function setLastSeenAppVersion(version: string): void { + localStorage.setItem(LAST_SEEN_VERSION_KEY, version); +} + +export function storePendingReleaseNotes(version: string, releaseNotes: unknown): void { + try { + localStorage.setItem( + PENDING_RELEASE_NOTES_KEY, + JSON.stringify({ version, releaseNotes } satisfies PendingReleaseNotes) + ); + } catch { + // ignore quota errors + } +} + +export function getPendingReleaseNotes(): PendingReleaseNotes | null { + try { + const raw = localStorage.getItem(PENDING_RELEASE_NOTES_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as PendingReleaseNotes; + if (typeof parsed?.version !== 'string') return null; + return parsed; + } catch { + return null; + } +} + +export function clearPendingReleaseNotes(): void { + localStorage.removeItem(PENDING_RELEASE_NOTES_KEY); +} diff --git a/src/app/utils/appVersion.ts b/src/app/utils/appVersion.ts new file mode 100644 index 0000000..b551aa0 --- /dev/null +++ b/src/app/utils/appVersion.ts @@ -0,0 +1,10 @@ +import { getVersion } from '@tauri-apps/api/app'; + +/** Returns the running app version (Electron shim or Tauri). */ +export async function getAppVersion(): Promise { + try { + return await getVersion(); + } catch { + return 'unknown'; + } +} diff --git a/src/app/utils/profileFields.ts b/src/app/utils/profileFields.ts new file mode 100644 index 0000000..40001de --- /dev/null +++ b/src/app/utils/profileFields.ts @@ -0,0 +1,192 @@ +import { MatrixClient, Room } from 'matrix-js-sdk'; +import { ThemeKind } from '../hooks/useTheme'; + +/** MSC4522 stable profile field for username colors */ +export const PROFILE_KEY_COLOR_PREFERENCE_STABLE = 'm.color_preference'; + +/** MSC4522 unstable profile field for username colors */ +export const PROFILE_KEY_COLOR_PREFERENCE_UNSTABLE = 'eu.she-a.color'; + +/** MSC4427 unstable profile field for banner */ +export const PROFILE_KEY_BANNER_URL_UNSTABLE = 'chat.commet.profile_banner'; + +/** MSC4427 stable profile field for banner */ +export const PROFILE_KEY_BANNER_URL_STABLE = 'm.banner_url'; + +export type ColorPreference = { + on_dark?: string; + on_light?: string; +}; + +const HEX_COLOR_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; + +export function isValidHexColor(color: string): boolean { + return HEX_COLOR_RE.test(color); +} + +export function parseColorPreference(value: unknown): ColorPreference | undefined { + if (!value || typeof value !== 'object') return undefined; + + const obj = value as Record; + const on_dark = typeof obj.on_dark === 'string' && isValidHexColor(obj.on_dark) ? obj.on_dark : undefined; + const on_light = typeof obj.on_light === 'string' && isValidHexColor(obj.on_light) ? obj.on_light : undefined; + + if (!on_dark && !on_light) return undefined; + return { on_dark, on_light }; +} + +export function extractColorPreferenceFromProfile(profile: Record): ColorPreference | undefined { + const stable = parseColorPreference(profile[PROFILE_KEY_COLOR_PREFERENCE_STABLE]); + if (stable) return stable; + return parseColorPreference(profile[PROFILE_KEY_COLOR_PREFERENCE_UNSTABLE]); +} + +export function resolveColorForTheme( + preference: ColorPreference | undefined, + themeKind: ThemeKind +): string | undefined { + if (!preference) return undefined; + if (themeKind === ThemeKind.Dark) { + return preference.on_dark ?? preference.on_light; + } + return preference.on_light ?? preference.on_dark; +} + +export function hasColorPreference(preference: ColorPreference | undefined): boolean { + return Boolean(preference?.on_dark || preference?.on_light); +} + +export function extractBannerUrlFromProfile(profile: Record): string | undefined { + const stable = profile[PROFILE_KEY_BANNER_URL_STABLE]; + if (typeof stable === 'string' && stable.startsWith('mxc://')) return stable; + + const unstable = profile[PROFILE_KEY_BANNER_URL_UNSTABLE]; + if (typeof unstable === 'string' && unstable.startsWith('mxc://')) return unstable; + + return undefined; +} + +export async function getBannerUrlProfileKey(mx: MatrixClient): Promise { + if (await mx.isVersionSupported('v1.16')) { + return PROFILE_KEY_BANNER_URL_STABLE; + } + return PROFILE_KEY_BANNER_URL_UNSTABLE; +} + +export async function loadBannerUrl(mx: MatrixClient, userId: string): Promise { + if (await mx.doesServerSupportExtendedProfiles()) { + try { + const profile = await mx.getExtendedProfile(userId); + return extractBannerUrlFromProfile(profile); + } catch { + // fall through + } + } + + try { + const profile = (await mx.getProfileInfo(userId)) as Record; + return extractBannerUrlFromProfile(profile); + } catch { + return undefined; + } +} + +export async function saveBannerUrl(mx: MatrixClient, bannerMxc: string): Promise { + if (!(await mx.doesServerSupportExtendedProfiles())) { + throw new Error('Server does not support extended profile fields (MSC4133)'); + } + + const key = await getBannerUrlProfileKey(mx); + await mx.setExtendedProfileProperty(key, bannerMxc); + const userId = mx.getUserId(); + if (userId) clearBannerCache(userId); +} + +export async function deleteBannerUrl(mx: MatrixClient): Promise { + if (!(await mx.doesServerSupportExtendedProfiles())) { + throw new Error('Server does not support extended profile fields (MSC4133)'); + } + + const key = await getBannerUrlProfileKey(mx); + await mx.deleteExtendedProfileProperty(key); + const userId = mx.getUserId(); + if (userId) clearBannerCache(userId); +} + +let bannerCacheClearImpl: (userId: string) => void = () => {}; + +export function registerBannerCacheClear(fn: (userId: string) => void): void { + bannerCacheClearImpl = fn; +} + +function clearBannerCache(userId: string): void { + bannerCacheClearImpl(userId); +} + +export async function getColorPreferenceProfileKey(mx: MatrixClient): Promise { + if (await mx.isVersionSupported('v1.16')) { + return PROFILE_KEY_COLOR_PREFERENCE_STABLE; + } + return PROFILE_KEY_COLOR_PREFERENCE_UNSTABLE; +} + +export async function loadColorPreference( + mx: MatrixClient, + userId: string +): Promise { + if (await mx.doesServerSupportExtendedProfiles()) { + try { + const profile = await mx.getExtendedProfile(userId); + return extractColorPreferenceFromProfile(profile); + } catch { + // fall through to standard profile endpoint + } + } + + try { + const profile = (await mx.getProfileInfo(userId)) as Record; + return extractColorPreferenceFromProfile(profile); + } catch { + return undefined; + } +} + +export function extractMemberColorPreference(room: Room | undefined, userId: string): ColorPreference | undefined { + if (!room) return undefined; + const member = room.getMember(userId); + const content = member?.events.member?.getContent(); + if (!content) return undefined; + return extractColorPreferenceFromProfile(content as Record); +} + +export async function saveColorPreference(mx: MatrixClient, preference: ColorPreference): Promise { + if (!(await mx.doesServerSupportExtendedProfiles())) { + throw new Error('Server does not support extended profile fields (MSC4133)'); + } + + const key = await getColorPreferenceProfileKey(mx); + await mx.setExtendedProfileProperty(key, preference); + const userId = mx.getUserId(); + if (userId) clearPreferenceCache(userId); +} + +let preferenceCacheClearImpl: (userId: string) => void = () => {}; + +export function registerPreferenceCacheClear(fn: (userId: string) => void): void { + preferenceCacheClearImpl = fn; +} + +function clearPreferenceCache(userId: string): void { + preferenceCacheClearImpl(userId); +} + +export async function deleteColorPreference(mx: MatrixClient): Promise { + if (!(await mx.doesServerSupportExtendedProfiles())) { + throw new Error('Server does not support extended profile fields (MSC4133)'); + } + + const key = await getColorPreferenceProfileKey(mx); + await mx.deleteExtendedProfileProperty(key); + const userId = mx.getUserId(); + if (userId) clearPreferenceCache(userId); +} diff --git a/src/app/utils/registerDesktopMediaSaver.ts b/src/app/utils/registerDesktopMediaSaver.ts new file mode 100644 index 0000000..c0a750d --- /dev/null +++ b/src/app/utils/registerDesktopMediaSaver.ts @@ -0,0 +1,111 @@ +import { registerMediaSaver } from './saveMedia'; +import { isElectron, isTauri } from './tauri'; + +type ElectronSaveResult = { + success?: boolean; + canceled?: boolean; + path?: string; + error?: string; +}; + +type ElectronMediaApi = { + saveFile?: (payload: { + filename: string; + mimeType?: string; + data: Uint8Array; + }) => Promise; +}; + +async function blobToUint8Array(blob: Blob): Promise { + const buffer = await blob.arrayBuffer(); + return new Uint8Array(buffer); +} + +function guessFilters(filename: string, mimeType?: string): Array<{ name: string; extensions: string[] }> { + const ext = filename.includes('.') ? filename.split('.').pop()!.toLowerCase() : ''; + const mime = (mimeType || '').toLowerCase(); + + if (mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) { + return [ + { name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }, + { name: 'All Files', extensions: ['*'] }, + ]; + } + if (mime.startsWith('video/') || ['mp4', 'webm', 'mkv', 'mov'].includes(ext)) { + return [ + { name: 'Videos', extensions: ['mp4', 'webm', 'mkv', 'mov'] }, + { name: 'All Files', extensions: ['*'] }, + ]; + } + if (mime.startsWith('audio/') || ['mp3', 'ogg', 'wav', 'm4a', 'flac'].includes(ext)) { + return [ + { name: 'Audio', extensions: ['mp3', 'ogg', 'wav', 'm4a', 'flac'] }, + { name: 'All Files', extensions: ['*'] }, + ]; + } + if (mime === 'application/pdf' || ext === 'pdf') { + return [ + { name: 'PDF', extensions: ['pdf'] }, + { name: 'All Files', extensions: ['*'] }, + ]; + } + if (ext) { + return [ + { name: ext.toUpperCase(), extensions: [ext] }, + { name: 'All Files', extensions: ['*'] }, + ]; + } + return [{ name: 'All Files', extensions: ['*'] }]; +} + +async function saveWithElectron(blob: Blob, filename: string): Promise { + const media = (window.electron as { media?: ElectronMediaApi } | undefined)?.media; + if (!media?.saveFile) { + throw new Error('Electron media.saveFile is unavailable'); + } + + const data = await blobToUint8Array(blob); + const result = await media.saveFile({ + filename, + mimeType: blob.type || 'application/octet-stream', + data, + }); + + if (result?.canceled) return; + if (result?.success === false) { + throw new Error(result.error || 'Failed to save file'); + } +} + +async function saveWithTauri(blob: Blob, filename: string): Promise { + const { save } = await import('@tauri-apps/plugin-dialog'); + const { writeFile } = await import('@tauri-apps/plugin-fs'); + + const path = await save({ + defaultPath: filename, + filters: guessFilters(filename, blob.type), + }); + if (!path) return; + + const data = await blobToUint8Array(blob); + await writeFile(path, data); +} + +/** + * Wire Electron / Tauri native save dialogs into core saveMedia helpers. + * Call once at desktop app startup. No-ops in plain browser. + */ +export function registerDesktopMediaSaver(): void { + if (isElectron()) { + registerMediaSaver(async (blob, filename) => { + await saveWithElectron(blob, filename); + }); + return; + } + + if (isTauri()) { + registerMediaSaver(async (blob, filename) => { + await saveWithTauri(blob, filename); + }); + } +} diff --git a/src/ext.d.ts b/src/ext.d.ts index 82c2409..cf5e9da 100644 --- a/src/ext.d.ts +++ b/src/ext.d.ts @@ -56,6 +56,18 @@ interface ElectronAPI { clipboard?: { writeText: (text: string) => void; }; + media?: { + saveFile: (payload: { + filename: string; + mimeType?: string; + data: Uint8Array; + }) => Promise<{ + success?: boolean; + canceled?: boolean; + path?: string; + error?: string; + }>; + }; plugins?: PluginAPI; protocol?: { getStatus: () => Promise<{ @@ -95,6 +107,12 @@ interface ElectronAPI { error?: string; }>; }; + updater?: { + onUpdateDownloaded: (callback: (info: { + version: string; + releaseNotes?: unknown; + }) => void) => void; + }; } declare global { diff --git a/src/index.tsx b/src/index.tsx index dbf0e60..0c3e53c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -17,6 +17,7 @@ import { trimTrailingSlash } from './app/utils/common'; import App from './app/pages/App'; import { applySafeAreaInsets, isTauri } from './app/utils/tauri'; import { enableViewTransitionsForNavigation } from './app/utils/viewTransitions'; +import { registerDesktopMediaSaver } from './app/utils/registerDesktopMediaSaver'; // import i18n (needs to be bundled ;)) import './app/i18n'; @@ -29,6 +30,9 @@ applySafeAreaInsets(); // Enable View Transitions API for smooth navigation enableViewTransitionsForNavigation(); +// Desktop shells: native Save dialog instead of browser FileSaver downloads. +registerDesktopMediaSaver(); + // Register Service Worker if ('serviceWorker' in navigator) { const swUrl = diff --git a/vite.config.js b/vite.config.js index 87591f4..c6bcd87 100644 --- a/vite.config.js +++ b/vite.config.js @@ -53,6 +53,11 @@ const copyFiles = { src: 'public/font', dest: 'public/', }, + { + src: 'public/update/**/*', + dest: 'update', + rename: (_name, _ext, fullPath) => fullPath.replace(/^public[/\\]update[/\\]/, ''), + }, ], }; @@ -124,6 +129,37 @@ function servePublicAssets() { res.setHeader('Content-Type', contentType); res.setHeader('Cache-Control', 'no-cache'); + const fileStream = fs.createReadStream(resolvedPath); + fileStream.pipe(res); + } else { + res.writeHead(404); + res.end('File not found'); + } + } else if (req.url?.startsWith('/update/')) { + const fileName = req.url.replace('/update/', ''); + const resolvedPath = path.join(path.resolve(), 'public', 'update', fileName); + + if (fs.existsSync(resolvedPath) && !resolvedPath.includes('..')) { + const ext = path.extname(fileName).toLowerCase(); + const contentType = + ext === '.md' + ? 'text/markdown; charset=utf-8' + : ext === '.json' + ? 'application/json' + : ext === '.svg' + ? 'image/svg+xml' + : ext === '.png' + ? 'image/png' + : ext === '.jpg' || ext === '.jpeg' + ? 'image/jpeg' + : ext === '.webp' + ? 'image/webp' + : ext === '.gif' + ? 'image/gif' + : 'application/octet-stream'; + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'no-cache'); + const fileStream = fs.createReadStream(resolvedPath); fileStream.pipe(res); } else {