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.
+
+
+
+## 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 }) => (
+
+ ),
+ }}
+ >
+ {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 (
+ }>
+
+
+
+
+
+
+ );
+}
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 {