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