diff --git a/package-lock.json b/package-lock.json index b277079..8653a1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "impro", - "version": "0.6.59", + "version": "0.9.667", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "impro", - "version": "0.6.59", + "version": "0.9.667", "license": "MIT", "dependencies": { "@capacitor/app": "^7.0.0", @@ -22,6 +22,7 @@ "@capacitor/cli": "^7.0.0", "@import-maps/resolve": "^2.0.0", "@playwright/test": "^1.58.1", + "canvas": "^3.2.3", "dotenv": "^17.2.2", "es-module-lexer": "^1.4.1", "esbuild": "^0.25.10", @@ -3221,6 +3222,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/bplist-parser": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", @@ -3301,6 +3314,31 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -3359,6 +3397,21 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.3.tgz", + "integrity": "sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3617,6 +3670,22 @@ "dev": true, "license": "MIT" }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", @@ -3632,6 +3701,16 @@ } } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -3672,6 +3751,16 @@ "node": ">=4" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3831,6 +3920,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -4010,6 +4109,16 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", @@ -4199,6 +4308,13 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, "node_modules/fs-extra": { "version": "11.3.3", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", @@ -4299,6 +4415,13 @@ "node": ">=8.0.0" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT" + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -4527,6 +4650,27 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -4586,6 +4730,13 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -6098,6 +6249,19 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -6181,6 +6345,13 @@ "node": ">=10" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, "node_modules/moo": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", @@ -6212,6 +6383,13 @@ "dev": true, "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -6271,6 +6449,26 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -6849,6 +7047,34 @@ "node": ">=12" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", @@ -6952,6 +7178,17 @@ "dev": true, "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6999,6 +7236,32 @@ "node": ">= 0.6" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -7343,6 +7606,53 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -7665,6 +7975,43 @@ "node": ">=10" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tar/node_modules/minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", @@ -7869,6 +8216,19 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", diff --git a/package.json b/package.json index c72cc38..c4434bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.9.667", + "version": "0.10.0", "type": "module", "scripts": { "start": "rm -rf build && NODE_ENV=development eleventy --serve", @@ -20,6 +20,7 @@ "@capacitor/cli": "^7.0.0", "@import-maps/resolve": "^2.0.0", "@playwright/test": "^1.58.1", + "canvas": "^3.2.3", "dotenv": "^17.2.2", "es-module-lexer": "^1.4.1", "esbuild": "^0.25.10", diff --git a/src/css/style.css b/src/css/style.css index 4aa1ef2..4917b7d 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -6636,3 +6636,325 @@ toggle-switch { #notifications-view header .tab-bar-button { flex: 1; } + +.edit-profile-dialog { + border: var(--hair) solid var(--post-border-color); + border-radius: var(--sheet-border-radius) var(--sheet-border-radius) 0 0; + background-color: light-dark(var(--white), var(--mid-gray)); + color: var(--text-color); + max-width: 100%; + width: 100%; + position: fixed; + left: 0; + right: 0; + bottom: 0; + top: 16px; + height: auto; + max-height: calc(100vh - 16px); + box-shadow: var(--shadow-color-heavy); + animation: slide-up 0.15s ease-out; + transform: none; + margin: 0; + overflow: visible; + padding: 0; +} + +@media (min-width: 800px) { + .edit-profile-dialog { + max-width: 600px; + width: 90%; + top: 50%; + left: 50%; + right: auto; + bottom: auto; + height: auto; + max-height: none; + border-radius: var(--dialog-border-radius); + box-shadow: none; + animation: none; + transform: translate(-50%, -50%); + } +} + +.edit-profile-dialog::backdrop { + background-color: var(--overlay-color-medium); + animation: fade-in 0.2s ease-out; +} + +.edit-profile-dialog-content { + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; + max-height: calc(100vh - 56px); + overflow-y: auto; +} + +@media (min-width: 800px) { + .edit-profile-dialog-content { + max-height: 80vh; + } +} + +.edit-profile-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.edit-profile-dialog-header h2 { + margin: 0; + font-size: 18px; + position: absolute; + left: 50%; + transform: translateX(-50%); +} + +.edit-profile-dialog-header-button { + background: none; + border: none; + font-size: 16px; + cursor: pointer; + color: var(--link-color); + padding: 4px 0; +} + +.edit-profile-dialog-header-button:disabled { + opacity: 0.5; + cursor: default; +} + +.edit-profile-dialog-save-button:enabled { + color: var(--primary-button-color); +} + +.edit-profile-dialog-body { + display: flex; + flex-direction: column; +} + +.edit-profile-images-section { + position: relative; + padding-bottom: 40px; +} + +.edit-profile-banner-preview { + position: relative; + width: 100%; + aspect-ratio: 3 / 1; + border-radius: 8px; + overflow: hidden; + background-color: light-dark(var(--lightest-gray), var(--gray)); + cursor: pointer; +} + +.edit-profile-banner-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.edit-profile-banner-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--secondary-text-color); + font-size: 14px; +} + +.edit-profile-image-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.4); + color: white; + font-size: 14px; + font-weight: 600; + opacity: 0; + transition: opacity 0.15s; +} + +.edit-profile-banner-preview:hover .edit-profile-image-overlay, +.edit-profile-avatar-wrapper:hover .edit-profile-image-overlay { + opacity: 1; +} + +.edit-profile-camera-button { + position: absolute; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: light-dark(var(--lightest-gray), var(--dark-gray)); + border: 1px solid light-dark(var(--lighter-gray), var(--light-gray)); + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.edit-profile-camera-button .icon { + width: 14px; + height: 14px; + display: flex; +} + +.edit-profile-camera-button-banner { + bottom: 8px; + right: 8px; +} + +.edit-profile-camera-button-avatar { + bottom: 6px; + right: 0; +} + +.edit-profile-avatar-wrapper { + position: absolute; + bottom: 0; + left: 12px; + width: 84px; + height: 84px; + cursor: pointer; +} + +.edit-profile-avatar-preview { + position: relative; + width: 80px; + height: 80px; + border-radius: 50%; + overflow: hidden; + background-color: light-dark(var(--light-gray), var(--dark-gray)); + border: 2px solid light-dark(var(--less-white), var(--dark-gray)); +} + +.edit-profile-avatar-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.edit-profile-avatar-placeholder { + width: 100%; + height: 100%; + object-fit: cover; +} + +.edit-profile-field { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 16px; +} + +.edit-profile-field label { + font-size: 14px; + font-weight: 600; +} + +.edit-profile-input, +.edit-profile-textarea { + padding: 10px 12px; + border: var(--hair) solid var(--post-border-color); + border-radius: 8px; + background-color: light-dark(var(--white), var(--dark-gray)); + color: var(--text-color); + font-size: 16px; + font-family: inherit; + resize: vertical; +} + +.edit-profile-input:focus, +.edit-profile-textarea:focus { + outline: 2px solid var(--highlight-color); + outline-offset: -1px; +} + +.edit-profile-char-count { + font-size: 12px; + color: var(--secondary-text-color); + text-align: right; +} + +.edit-profile-char-count.overflow { + color: var(--danger-color, #dc3545); + font-weight: 600; +} + +.edit-profile-error { + color: var(--danger-color, #dc3545); + font-size: 14px; + padding: 8px 12px; + border-radius: 8px; + background-color: light-dark(#fef2f2, #3b1111); +} + +/* Image Cropper */ + +image-cropper { + display: flex; + flex-direction: column; + width: 100%; + height: 300px; +} + +.image-cropper-canvas { + width: 100%; + flex: 1; + min-height: 0; + cursor: grab; + touch-action: none; +} + +.image-cropper-canvas:active { + cursor: grabbing; +} + +.image-cropper-slider-container { + display: flex; + align-items: center; + justify-content: center; + padding: 12px 20px; + background: #000; +} + +.image-cropper-slider { + width: 100%; + max-width: 300px; + height: 4px; + appearance: none; + -webkit-appearance: none; + background: rgba(255, 255, 255, 0.3); + border-radius: 2px; + outline: none; +} + +.image-cropper-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + cursor: pointer; +} + +.image-cropper-slider::-moz-range-thumb { + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + border: none; + cursor: pointer; +} + +.edit-profile-cropper-content { + max-height: calc(100vh - 56px); +} + +.edit-profile-cropper-container { + border-radius: 8px; + overflow: hidden; +} diff --git a/src/js/api.js b/src/js/api.js index 30202bc..5599b7f 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -920,6 +920,34 @@ export class Api { return res.data.blob; } + async getProfileRecord() { + const res = await this.request("com.atproto.repo.getRecord", { + query: { + repo: this.session.did, + collection: "app.bsky.actor.profile", + rkey: "self", + }, + }); + return res.data; + } + + async putProfileRecord(record, swapRecord) { + const res = await this.request("com.atproto.repo.putRecord", { + method: "POST", + body: { + repo: this.session.did, + collection: "app.bsky.actor.profile", + rkey: "self", + record: { + $type: "app.bsky.actor.profile", + ...record, + }, + swapRecord: swapRecord ?? null, + }, + }); + return res.data; + } + async createModerationReport({ reasonType, reason, subject, labelerDid }) { const body = { reasonType, diff --git a/src/js/components/context-menu.js b/src/js/components/context-menu.js index ab9708a..9250200 100644 --- a/src/js/components/context-menu.js +++ b/src/js/components/context-menu.js @@ -10,19 +10,40 @@ class ContextMenu extends Component { return; } this.scrollLock = new ScrollLock(this); - this._children = getChildrenFragment(this); + this._childNodes = [...this.childNodes]; this.innerHTML = ""; this.isOpen = false; this.render(); this._initialized = true; + + this._observer = new MutationObserver(() => { + this._refreshChildren(); + }); + this._observer.observe(this, { childList: true }); } disconnectedCallback() { // If scroll is still prevented, restore it this.scrollLock.unlock(); + if (this._observer) { + this._observer.disconnect(); + } + } + + _refreshChildren() { + const container = this.querySelector(".context-menu-container"); + const newChildren = [...this.childNodes].filter( + (node) => node !== container, + ); + if (newChildren.length === 0) return; + + this._childNodes = newChildren; + this.render(); } render() { + this._observer?.disconnect(); + render( html`
{ this.close(); }} - > + > + ${this._childNodes} +
`, this, ); - const dialog = this.querySelector(".context-menu"); - dialog.appendChild(this._children); + this._observer?.observe(this, { childList: true }); } open(x, y) { diff --git a/src/js/components/edit-profile-dialog.js b/src/js/components/edit-profile-dialog.js new file mode 100644 index 0000000..0d1f648 --- /dev/null +++ b/src/js/components/edit-profile-dialog.js @@ -0,0 +1,477 @@ +import { html, render } from "/js/lib/lit-html.js"; +import { Component } from "/js/components/component.js"; +import { ScrollLock } from "/js/scrollLock.js"; +import { classnames, graphemeCount } from "/js/utils.js"; +import { compressImage } from "/js/imageUtils.js"; +import "/js/components/image-cropper.js"; +import "/js/components/context-menu.js"; +import "/js/components/context-menu-item.js"; +import "/js/components/context-menu-item-group.js"; +import { cameraIconTemplate } from "/js/templates/icons/cameraIcon.template.js"; +import { confirm } from "/js/modals.js"; + +const MAX_DISPLAY_NAME_LENGTH = 64; +const MAX_DESCRIPTION_LENGTH = 256; + +class EditProfileDialog extends Component { + connectedCallback() { + if (this.initialized) { + return; + } + this.scrollLock = new ScrollLock(this); + this._displayName = ""; + this._description = ""; + this._currentAvatar = null; + this._currentBanner = null; + this._newAvatarDataUrl = null; + this._newBannerDataUrl = null; + this._removeAvatar = false; + this._removeBanner = false; + this._saving = false; + this._error = null; + this._croppingTarget = null; // "avatar" or "banner" + this._croppingImageSrc = null; + this._isOpen = false; + this._profile = null; + this.innerHTML = ""; + this.render(); + this.initialized = true; + } + + setProfile(profile) { + this._profile = profile; + this._displayName = profile.displayName || ""; + this._description = profile.description || ""; + this._currentAvatar = profile.avatar || null; + this._currentBanner = profile.banner || null; + this._newAvatarDataUrl = null; + this._newBannerDataUrl = null; + this._removeAvatar = false; + this._removeBanner = false; + this._saving = false; + this._error = null; + this._croppingTarget = null; + this._croppingImageSrc = null; + this.render(); + } + + get _isDirty() { + if (!this._profile) return false; + return ( + this._displayName !== (this._profile.displayName || "") || + this._description !== (this._profile.description || "") || + this._newAvatarDataUrl !== null || + this._newBannerDataUrl !== null || + this._removeAvatar || + this._removeBanner + ); + } + + get _isDisplayNameTooLong() { + return graphemeCount(this._displayName) > MAX_DISPLAY_NAME_LENGTH; + } + + get _isDescriptionTooLong() { + return graphemeCount(this._description) > MAX_DESCRIPTION_LENGTH; + } + + get _canSave() { + return ( + this._isDirty && + !this._saving && + !this._isDisplayNameTooLong && + !this._isDescriptionTooLong + ); + } + + render() { + const isCropping = !!this._croppingTarget; + + const displayNameCount = graphemeCount(this._displayName); + const descriptionCount = graphemeCount(this._description); + const avatarSrc = this._removeAvatar + ? null + : this._newAvatarDataUrl || this._currentAvatar; + const bannerSrc = this._removeBanner + ? null + : this._newBannerDataUrl || this._currentBanner; + + render( + html` { + if (!isCropping && event.target.tagName === "DIALOG") { + this.close(); + } + }} + @cancel=${(event) => { + event.preventDefault(); + if (isCropping) { + this._croppingTarget = null; + this._croppingImageSrc = null; + this.render(); + } else { + this.close(); + } + }} + > + ${isCropping + ? html`
+
+ +

Edit image

+ +
+
+ +
+
` + : html`
+
+ +

Edit profile

+ +
+ +
+
+
this._openImageMenu(event, "banner")} + > + ${bannerSrc + ? html`Banner preview` + : html`
`} +
+
+ ${cameraIconTemplate()} +
+
+ +
this._openImageMenu(event, "avatar")} + > +
+ ${avatarSrc + ? html`Avatar preview` + : html``} +
+
+
+ ${cameraIconTemplate()} +
+
+
+ + + + this._pickImage("banner")} + > + Upload from Files + + + ${bannerSrc + ? html` + { + this._newBannerDataUrl = null; + this._removeBanner = true; + this.render(); + }} + > + Remove Banner + + ` + : ""} + + + + + this._pickImage("avatar")} + > + Upload from Files + + + ${avatarSrc + ? html` + { + this._newAvatarDataUrl = null; + this._removeAvatar = true; + this.render(); + }} + > + Remove Avatar + + ` + : ""} + + +
+ + { + this._displayName = event.target.value; + this.render(); + }} + data-testid="edit-profile-display-name" + /> +
+ ${displayNameCount}/${MAX_DISPLAY_NAME_LENGTH} +
+
+ +
+ + +
+ ${descriptionCount}/${MAX_DESCRIPTION_LENGTH} +
+
+ + ${this._error + ? html`
${this._error}
` + : ""} +
+
`} + + this._handleFileSelect(event)} + @cancel=${(event) => { + event.stopPropagation(); + }} + /> +
`, + this, + ); + + if (this._isOpen) { + const dialog = this.querySelector(".edit-profile-dialog"); + if (dialog && !dialog.open) { + dialog.showModal(); + } + } + } + + _openImageMenu(event, target) { + const menuClass = + target === "avatar" + ? ".edit-profile-avatar-menu" + : ".edit-profile-banner-menu"; + const buttonClass = + target === "avatar" + ? ".edit-profile-camera-button-avatar" + : ".edit-profile-camera-button-banner"; + const menu = this.querySelector(menuClass); + const cameraButton = this.querySelector(buttonClass); + if (menu && cameraButton) { + const rect = cameraButton.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.bottom; + menu.open(x, y); + } + } + + _pickImage(target) { + this._pendingPickTarget = target; + const input = this.querySelector(".edit-profile-file-input"); + if (input) { + input.click(); + } + } + + async _handleFileSelect(event) { + const file = event.target.files?.[0]; + if (!file || !file.type.startsWith("image/")) { + event.target.value = ""; + return; + } + + const dataUrl = await this._readFileAsDataUrl(file); + event.target.value = ""; + + this._croppingTarget = this._pendingPickTarget; + this._croppingImageSrc = dataUrl; + this._pendingPickTarget = null; + this.render(); + } + + _readFileAsDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => resolve(event.target.result); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } + + async _applyCrop() { + const cropper = this.querySelector("image-cropper"); + if (!cropper) return; + + const croppedDataUrl = cropper.cropImage(); + if (!croppedDataUrl) return; + + if (this._croppingTarget === "avatar") { + this._newAvatarDataUrl = croppedDataUrl; + this._removeAvatar = false; + } else { + this._newBannerDataUrl = croppedDataUrl; + this._removeBanner = false; + } + + this._croppingTarget = null; + this._croppingImageSrc = null; + this.render(); + } + + async _save() { + this._saving = true; + this._error = null; + this.render(); + + try { + let avatarBlob = null; + let bannerBlob = null; + + if (this._newAvatarDataUrl) { + const compressed = await compressImage(this._newAvatarDataUrl); + avatarBlob = compressed.blob; + } + if (this._newBannerDataUrl) { + const compressed = await compressImage(this._newBannerDataUrl); + bannerBlob = compressed.blob; + } + + this.dispatchEvent( + new CustomEvent("profile-save", { + detail: { + profileUpdates: { + displayName: this._displayName, + description: this._description, + avatarBlob, + bannerBlob, + removeAvatar: this._removeAvatar, + removeBanner: this._removeBanner, + }, + }, + }), + ); + } catch (error) { + console.error("Error saving profile:", error); + this._error = "Failed to save profile. Please try again."; + this._saving = false; + this.render(); + } + } + + open() { + this._isOpen = true; + this.scrollLock.lock(); + const dialog = this.querySelector(".edit-profile-dialog"); + if (dialog) { + dialog.showModal(); + } + } + + async close() { + if (this._isDirty) { + const discard = await confirm( + "Are you sure you want to discard your changes?", + { + title: "Discard changes?", + confirmButtonStyle: "danger", + confirmButtonText: "Discard", + }, + ); + if (!discard) return; + } + this._isOpen = false; + this.scrollLock.unlock(); + const dialog = this.querySelector(".edit-profile-dialog"); + if (dialog) { + dialog.close(); + } + this.dispatchEvent(new CustomEvent("edit-profile-closed")); + } + + setError(error) { + this._error = error; + this._saving = false; + this.render(); + } +} + +EditProfileDialog.register(); diff --git a/src/js/components/image-alt-text-dialog.js b/src/js/components/image-alt-text-dialog.js index fd47f85..37e71ee 100644 --- a/src/js/components/image-alt-text-dialog.js +++ b/src/js/components/image-alt-text-dialog.js @@ -1,6 +1,6 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; -import { classnames } from "/js/utils.js"; +import { classnames, graphemeCount } from "/js/utils.js"; import { ScrollLock } from "/js/scrollLock.js"; class ImageAltTextDialog extends Component { @@ -26,7 +26,7 @@ class ImageAltTextDialog extends Component { } render() { - const currentCharCount = this.value.length; + const currentCharCount = graphemeCount(this.value); const maxChars = 2000; const charCountPercentage = Math.min( Math.round((currentCharCount / maxChars) * 100), diff --git a/src/js/components/image-cropper.js b/src/js/components/image-cropper.js new file mode 100644 index 0000000..e31a377 --- /dev/null +++ b/src/js/components/image-cropper.js @@ -0,0 +1,350 @@ +import { Component } from "/js/components/component.js"; + +const MIN_SCALE = 1; +const MAX_SCALE = 5; + +// Claude wrote this +class ImageCropper extends Component { + connectedCallback() { + if (this.initialized) { + return; + } + this._scale = 1; + this._offsetX = 0; + this._offsetY = 0; + this._dragging = false; + this._dragStartX = 0; + this._dragStartY = 0; + this._dragStartOffsetX = 0; + this._dragStartOffsetY = 0; + this._img = null; + + this.innerHTML = ""; + const canvas = document.createElement("canvas"); + canvas.className = "image-cropper-canvas"; + this.appendChild(canvas); + this._canvas = canvas; + this._ctx = canvas.getContext("2d") || null; + + const sliderContainer = document.createElement("div"); + sliderContainer.className = "image-cropper-slider-container"; + const slider = document.createElement("input"); + slider.type = "range"; + slider.className = "image-cropper-slider"; + slider.min = "0"; + slider.max = "100"; + slider.value = "0"; + slider.step = "1"; + sliderContainer.appendChild(slider); + this.appendChild(sliderContainer); + this._slider = slider; + + this._bindEvents(); + this.initialized = true; + + if (this.getAttribute("src")) { + this.loadImage(this.getAttribute("src")); + } + } + + static get observedAttributes() { + return ["src", "aspect-ratio", "circular"]; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (!this.initialized) return; + if (name === "src" && newValue !== oldValue) { + this.loadImage(newValue); + } else if (name === "aspect-ratio" || name === "circular") { + this._draw(); + } + } + + get aspectRatio() { + return parseFloat(this.getAttribute("aspect-ratio")) || 1; + } + + get circular() { + return this.hasAttribute("circular"); + } + + async loadImage(src) { + const img = document.createElement("img"); + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + img.src = src; + }); + this._img = img; + this._scale = 1; + this._offsetX = 0; + this._offsetY = 0; + this._draw(); + this._fitInitialScale(); + this._draw(); + } + + _fitInitialScale() { + if (!this._img) return; + this._scale = this._getMinScale(); + this._offsetX = 0; + this._offsetY = 0; + this._syncSlider(); + } + + _getCropRect() { + const rect = this._canvas.getBoundingClientRect(); + const padding = 20; + const maxWidth = rect.width - padding * 2; + const maxHeight = rect.height - padding * 2; + const aspectRatio = this.aspectRatio; + + let cropWidth, cropHeight; + if (maxWidth / maxHeight > aspectRatio) { + cropHeight = maxHeight; + cropWidth = cropHeight * aspectRatio; + } else { + cropWidth = maxWidth; + cropHeight = cropWidth / aspectRatio; + } + + return { + x: (rect.width - cropWidth) / 2, + y: (rect.height - cropHeight) / 2, + width: cropWidth, + height: cropHeight, + }; + } + + _clampOffset() { + if (!this._img) return; + const rect = this._canvas.getBoundingClientRect(); + const padding = 20; + const maxWidth = rect.width - padding * 2; + const maxHeight = rect.height - padding * 2; + const aspectRatio = this.aspectRatio; + + let cropWidth, cropHeight; + if (maxWidth / maxHeight > aspectRatio) { + cropHeight = maxHeight; + cropWidth = cropHeight * aspectRatio; + } else { + cropWidth = maxWidth; + cropHeight = cropWidth / aspectRatio; + } + + const imgWidth = this._img.width * this._scale; + const imgHeight = this._img.height * this._scale; + + const maxOffsetX = Math.max(0, (imgWidth - cropWidth) / 2); + const maxOffsetY = Math.max(0, (imgHeight - cropHeight) / 2); + this._offsetX = Math.max(-maxOffsetX, Math.min(maxOffsetX, this._offsetX)); + this._offsetY = Math.max(-maxOffsetY, Math.min(maxOffsetY, this._offsetY)); + } + + _draw() { + if (!this._ctx) return; + const canvas = this._canvas; + const ctx = this._ctx; + const rect = this._canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + canvas.style.width = `${rect.width}px`; + canvas.style.height = `${rect.height}px`; + ctx.scale(dpr, dpr); + + const displayWidth = rect.width; + const displayHeight = rect.height; + + ctx.fillStyle = "#000000"; + ctx.fillRect(0, 0, displayWidth, displayHeight); + + if (!this._img) return; + + const cropRect = this._getCropRect(); + const cropX = cropRect.x; + const cropY = cropRect.y; + const cropWidth = cropRect.width; + const cropHeight = cropRect.height; + + const imgWidth = this._img.width * this._scale; + const imgHeight = this._img.height * this._scale; + const imgX = displayWidth / 2 - imgWidth / 2 + this._offsetX; + const imgY = displayHeight / 2 - imgHeight / 2 + this._offsetY; + + ctx.drawImage(this._img, imgX, imgY, imgWidth, imgHeight); + + // Darken area outside the crop zone + ctx.fillStyle = "rgba(0, 0, 0, 0.6)"; + + if (this.circular) { + // Draw darkened overlay with circular cutout + ctx.beginPath(); + ctx.rect(0, 0, displayWidth, displayHeight); + const radius = cropWidth / 2; + const cx = cropX + cropWidth / 2; + const cy = cropY + cropHeight / 2; + ctx.arc(cx, cy, radius, 0, Math.PI * 2, true); + ctx.fill("evenodd"); + + // Draw circle border + ctx.strokeStyle = "rgba(255, 255, 255, 0.8)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.stroke(); + } else { + // Draw darkened overlay with rectangular cutout + ctx.beginPath(); + ctx.rect(0, 0, displayWidth, displayHeight); + ctx.rect(cropX, cropY, cropWidth, cropHeight); + ctx.fill("evenodd"); + + // Draw rect border + ctx.strokeStyle = "rgba(255, 255, 255, 0.8)"; + ctx.lineWidth = 2; + ctx.strokeRect(cropX, cropY, cropWidth, cropHeight); + } + } + + _bindEvents() { + const canvas = this._canvas; + + canvas.addEventListener("pointerdown", (event) => { + this._dragging = true; + this._dragStartX = event.clientX; + this._dragStartY = event.clientY; + this._dragStartOffsetX = this._offsetX; + this._dragStartOffsetY = this._offsetY; + canvas.setPointerCapture(event.pointerId); + event.preventDefault(); + }); + + canvas.addEventListener("pointermove", (event) => { + if (!this._dragging) return; + this._offsetX = + this._dragStartOffsetX + (event.clientX - this._dragStartX); + this._offsetY = + this._dragStartOffsetY + (event.clientY - this._dragStartY); + this._clampOffset(); + this._draw(); + }); + + canvas.addEventListener("pointerup", () => { + this._dragging = false; + }); + + canvas.addEventListener("pointercancel", () => { + this._dragging = false; + }); + + this._slider.addEventListener("input", () => { + const sliderValue = parseFloat(this._slider.value) / 100; + const minScale = this._getMinScale(); + this._scale = minScale + sliderValue * (MAX_SCALE - minScale); + this._clampOffset(); + this._draw(); + }); + } + + _getMinScale() { + if (!this._img) return MIN_SCALE; + const rect = this._canvas.getBoundingClientRect(); + const padding = 20; + const maxWidth = rect.width - padding * 2; + const maxHeight = rect.height - padding * 2; + const aspectRatio = this.aspectRatio; + + let cropWidth, cropHeight; + if (maxWidth / maxHeight > aspectRatio) { + cropHeight = maxHeight; + cropWidth = cropHeight * aspectRatio; + } else { + cropWidth = maxWidth; + cropHeight = cropWidth / aspectRatio; + } + + const minScaleX = cropWidth / this._img.width; + const minScaleY = cropHeight / this._img.height; + return Math.max(minScaleX, minScaleY); + } + + _fitMinScale() { + const minScale = this._getMinScale(); + if (this._scale < minScale) { + this._scale = minScale; + } + } + + _syncSlider() { + const minScale = this._getMinScale(); + const range = MAX_SCALE - minScale; + const value = range > 0 ? ((this._scale - minScale) / range) * 100 : 0; + this._slider.value = String(Math.round(value)); + } + + cropImage() { + if (!this._img) return null; + const rect = this._canvas.getBoundingClientRect(); + const padding = 20; + const maxWidth = rect.width - padding * 2; + const maxHeight = rect.height - padding * 2; + const aspectRatio = this.aspectRatio; + + let cropWidth, cropHeight; + if (maxWidth / maxHeight > aspectRatio) { + cropHeight = maxHeight; + cropWidth = cropHeight * aspectRatio; + } else { + cropWidth = maxWidth; + cropHeight = cropWidth / aspectRatio; + } + + const cropX = (rect.width - cropWidth) / 2; + const cropY = (rect.height - cropHeight) / 2; + + const imgWidth = this._img.width * this._scale; + const imgHeight = this._img.height * this._scale; + const imgX = rect.width / 2 - imgWidth / 2 + this._offsetX; + const imgY = rect.height / 2 - imgHeight / 2 + this._offsetY; + + // Calculate source region in image coordinates + const srcX = (cropX - imgX) / this._scale; + const srcY = (cropY - imgY) / this._scale; + const srcWidth = cropWidth / this._scale; + const srcHeight = cropHeight / this._scale; + + // Output at a reasonable resolution, scaling proportionally + const maxOutputDimension = 2000; + const scaleFactor = Math.min( + 1, + maxOutputDimension / srcWidth, + maxOutputDimension / srcHeight, + ); + const outputWidth = Math.round(srcWidth * scaleFactor); + const outputHeight = Math.round(srcHeight * scaleFactor); + + const outputCanvas = document.createElement("canvas"); + outputCanvas.width = outputWidth; + outputCanvas.height = outputHeight; + const outputCtx = outputCanvas.getContext("2d"); + outputCtx.fillStyle = "#ffffff"; + outputCtx.fillRect(0, 0, outputWidth, outputHeight); + outputCtx.drawImage( + this._img, + srcX, + srcY, + srcWidth, + srcHeight, + 0, + 0, + outputWidth, + outputHeight, + ); + + return outputCanvas.toDataURL("image/jpeg", 0.9); + } +} + +ImageCropper.register(); diff --git a/src/js/components/post-composer.js b/src/js/components/post-composer.js index 5832cfe..8ba42d8 100644 --- a/src/js/components/post-composer.js +++ b/src/js/components/post-composer.js @@ -3,7 +3,12 @@ import { Component } from "/js/components/component.js"; import { avatarTemplate } from "/js/templates/avatar.template.js"; import { postHeaderTextTemplate } from "/js/templates/postHeaderText.template.js"; import { richTextTemplate } from "/js/templates/richText.template.js"; -import { classnames, enableDragToDismiss, sanitizeUri } from "/js/utils.js"; +import { + classnames, + enableDragToDismiss, + graphemeCount, + sanitizeUri, +} from "/js/utils.js"; import { externalLinkTemplate } from "/js/templates/externalLink.template.js"; import { confirm } from "/js/modals.js"; import { ScrollLock } from "/js/scrollLock.js"; @@ -142,7 +147,7 @@ class PostComposer extends Component { render() { const promptText = this.replyTo ? "Write your reply" : "What's up?"; - const currentCharCount = this._postText.length; + const currentCharCount = graphemeCount(this._postText); const charCountPercentage = Math.min( Math.round((currentCharCount / 300) * 100), 100, diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index d62060e..65d7747 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -564,6 +564,92 @@ export class Mutations { } } + async updateProfile( + profile, + { + displayName, + description, + avatarBlob, + bannerBlob, + removeAvatar, + removeBanner, + }, + ) { + const patchId = this.patchStore.addProfilePatch(profile.did, { + type: "updateProfile", + displayName, + description, + avatar: removeAvatar ? null : undefined, + banner: removeBanner ? null : undefined, + }); + try { + const [avatarRef, bannerRef] = await Promise.all([ + avatarBlob ? this.api.uploadBlob(avatarBlob) : null, + bannerBlob ? this.api.uploadBlob(bannerBlob) : null, + ]); + + let existingRecord = {}; + let swapCid = null; + try { + const recordData = await this.api.getProfileRecord(); + existingRecord = recordData.value || {}; + swapCid = recordData.cid; + } catch (error) { + if (error.status === 400) { + // No existing record is ok + } else { + throw error; + } + } + + const updatedRecord = { ...existingRecord }; + if (displayName !== undefined) { + updatedRecord.displayName = displayName; + } + if (description !== undefined) { + updatedRecord.description = description; + } + if (avatarRef) { + updatedRecord.avatar = avatarRef; + } else if (removeAvatar) { + delete updatedRecord.avatar; + } + if (bannerRef) { + updatedRecord.banner = bannerRef; + } else if (removeBanner) { + delete updatedRecord.banner; + } + + await this.api.putProfileRecord(updatedRecord, swapCid); + + const updatedProfile = { + ...profile, + displayName: displayName || "", + description: description || "", + }; + if (removeAvatar) { + updatedProfile.avatar = undefined; + } + if (removeBanner) { + updatedProfile.banner = undefined; + } + this.dataStore.setProfile(profile.did, updatedProfile); + + const currentUser = this.dataStore.getCurrentUser(); + if (currentUser && currentUser.did === profile.did) { + this.dataStore.setCurrentUser({ + ...currentUser, + ...updatedProfile, + }); + } + } catch (error) { + console.error(error); + throw error; + } finally { + this.patchStore.removeProfilePatch(profile.did, patchId); + } + } + async createPost({ postText, facets, diff --git a/src/js/dataLayer/patchStore.js b/src/js/dataLayer/patchStore.js index 8b08144..85c872b 100644 --- a/src/js/dataLayer/patchStore.js +++ b/src/js/dataLayer/patchStore.js @@ -216,6 +216,16 @@ export class PatchStore { activitySubscription: patchBody.activitySubscription, }, }; + case "updateProfile": + return { + ...profile, + displayName: patchBody.displayName ?? profile.displayName, + description: patchBody.description ?? profile.description, + avatar: + patchBody.avatar !== undefined ? patchBody.avatar : profile.avatar, + banner: + patchBody.banner !== undefined ? patchBody.banner : profile.banner, + }; default: throw new Error("Unknown patch type", patchBody.type); } diff --git a/src/js/imageUtils.js b/src/js/imageUtils.js new file mode 100644 index 0000000..4a4a2e3 --- /dev/null +++ b/src/js/imageUtils.js @@ -0,0 +1,92 @@ +const MAX_IMAGE_HEIGHT = 2000; +const MAX_IMAGE_WIDTH = 2000; +const MAX_IMAGE_SIZE = 1000000; // 1MB + +export async function loadImageFromDataUrl(dataUrl) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = dataUrl; + }); +} + +export function constrainImageSize({ width, height, maxWidth, maxHeight }) { + if (width <= maxWidth && height <= maxHeight) { + return { width, height }; + } + const ratio = Math.min(maxWidth / width, maxHeight / height); + return { + width: Math.round(width * ratio), + height: Math.round(height * ratio), + }; +} + +export function estimateDataUrlSize(dataUrl) { + const base64 = dataUrl.split(",")[1]; + return Math.round((base64.length * 3) / 4); +} + +export function drawImageToCanvas({ img, width, height, quality }) { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, width, height); + ctx.drawImage(img, 0, 0, width, height); + return canvas.toDataURL("image/jpeg", quality); +} + +export function dataUrlToBlob(dataUrl) { + const [header, base64] = dataUrl.split(","); + const mimeType = header.match(/:(.*?);/)[1]; + const binary = atob(base64); + const array = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + array[i] = binary.charCodeAt(i); + } + return new Blob([array], { type: mimeType }); +} + +export async function compressImage(dataUrl) { + const img = await loadImageFromDataUrl(dataUrl); + const { width, height } = constrainImageSize({ + width: img.width, + height: img.height, + maxWidth: MAX_IMAGE_WIDTH, + maxHeight: MAX_IMAGE_HEIGHT, + }); + + // Same as social-app: Binary search to find the optimal JPEG quality under the size limit + let minQuality = 0; + let maxQuality = 100; + let bestDataUrl = null; + + while (maxQuality - minQuality > 1) { + const quality = Math.round((minQuality + maxQuality) / 2); + const result = drawImageToCanvas({ + img, + width, + height, + quality: quality / 100, + }); + + if (estimateDataUrlSize(result) <= MAX_IMAGE_SIZE) { + bestDataUrl = result; + minQuality = quality; + } else { + maxQuality = quality; + } + } + + if (!bestDataUrl) { + bestDataUrl = drawImageToCanvas({ img, width, height, quality: 0 }); + } + + return { + blob: dataUrlToBlob(bestDataUrl), + width, + height, + }; +} diff --git a/src/js/postCreator.js b/src/js/postCreator.js index fa9949e..36bd3a4 100644 --- a/src/js/postCreator.js +++ b/src/js/postCreator.js @@ -1,99 +1,5 @@ import { wait } from "/js/utils.js"; - -const MAX_IMAGE_HEIGHT = 2000; -const MAX_IMAGE_WIDTH = 2000; -const MAX_IMAGE_SIZE = 1000000; // 1MB - -async function loadImageFromDataUrl(dataUrl) { - return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = reject; - img.src = dataUrl; - }); -} - -function constrainImageSize({ width, height, maxWidth, maxHeight }) { - // Constrain to fit maxWidth and maxHeight while maintaining aspect ratio - if (width <= maxWidth && height <= maxHeight) { - return { width, height }; - } - const ratio = Math.min(maxWidth / width, maxHeight / height); - return { - width: Math.round(width * ratio), - height: Math.round(height * ratio), - }; -} - -function estimateDataUrlSize(dataUrl) { - const base64 = dataUrl.split(",")[1]; - return Math.round((base64.length * 3) / 4); -} - -function drawImageToCanvas({ img, width, height, quality }) { - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext("2d"); - ctx.fillStyle = "#ffffff"; - ctx.fillRect(0, 0, width, height); - ctx.drawImage(img, 0, 0, width, height); - return canvas.toDataURL("image/jpeg", quality); -} - -function dataUrlToBlob(dataUrl) { - const [header, base64] = dataUrl.split(","); - const mimeType = header.match(/:(.*?);/)[1]; - const binary = atob(base64); - const array = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - array[i] = binary.charCodeAt(i); - } - return new Blob([array], { type: mimeType }); -} - -async function compressImage(dataUrl) { - const img = await loadImageFromDataUrl(dataUrl); - const { width, height } = constrainImageSize({ - width: img.width, - height: img.height, - maxWidth: MAX_IMAGE_WIDTH, - maxHeight: MAX_IMAGE_HEIGHT, - }); - - // Same as social-app: Binary search to find the optimal JPEG quality under the size limit - let minQuality = 0; - let maxQuality = 100; - let bestDataUrl = null; - - while (maxQuality - minQuality > 1) { - const quality = Math.round((minQuality + maxQuality) / 2); - const result = drawImageToCanvas({ - img, - width, - height, - quality: quality / 100, - }); - - if (estimateDataUrlSize(result) <= MAX_IMAGE_SIZE) { - bestDataUrl = result; - minQuality = quality; - } else { - maxQuality = quality; - } - } - - // If no acceptable quality found, use the lowest quality result - if (!bestDataUrl) { - bestDataUrl = drawImageToCanvas({ img, width, height, quality: 0 }); - } - - return { - blob: dataUrlToBlob(bestDataUrl), - width, - height, - }; -} +import { compressImage } from "/js/imageUtils.js"; export class PostCreator { constructor(api) { diff --git a/src/js/templates/icons/cameraIcon.template.js b/src/js/templates/icons/cameraIcon.template.js new file mode 100644 index 0000000..8bb7796 --- /dev/null +++ b/src/js/templates/icons/cameraIcon.template.js @@ -0,0 +1,16 @@ +import { html } from "/js/lib/lit-html.js"; + +// https://github.com/halfmage/majesticons/blob/main/solid/camera.svg + +export function cameraIconTemplate() { + return html`
+ + + +
`; +} diff --git a/src/js/templates/profileCard.template.js b/src/js/templates/profileCard.template.js index 169332f..96d53ed 100644 --- a/src/js/templates/profileCard.template.js +++ b/src/js/templates/profileCard.template.js @@ -98,6 +98,7 @@ export function profileCardTemplate({ onClickSubscribe = noop, onClickPostNotifications = noop, onClickReport = noop, + onClickEditProfile = noop, }) { const isFollowing = profile.viewer?.following; const isBlocking = !!profile.viewer?.blocking; @@ -157,7 +158,13 @@ export function profileCardTemplate({ : null} ${(() => { if (isCurrentUser) { - return null; + return html``; } if (isBlockedBy && !isBlocking) { return null; diff --git a/src/js/utils.js b/src/js/utils.js index a21682d..082ef08 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -114,6 +114,18 @@ export function getIndexFromByteIndex(text, byteIndex) { return decoder.decode(slicedBytes).length; } +const graphemeSegmenter = + typeof Intl !== "undefined" && Intl.Segmenter + ? new Intl.Segmenter("en", { granularity: "grapheme" }) + : null; + +export function graphemeCount(str) { + if (graphemeSegmenter) { + return [...graphemeSegmenter.segment(str)].length; + } + return [...str].length; +} + export function formatLargeNumber(number) { if (number >= 1000) { const stringified = String(number / 1000); diff --git a/src/js/views/profile.view.js b/src/js/views/profile.view.js index 914afde..bac160f 100644 --- a/src/js/views/profile.view.js +++ b/src/js/views/profile.view.js @@ -17,6 +17,7 @@ import { AUTHOR_FEED_PAGE_SIZE, BSKY_LABELER_DID } from "/js/config.js"; import { showToast } from "/js/toasts.js"; import { tabBarTemplate } from "/js/templates/tabBar.template.js"; import { feedGeneratorListItemTemplate } from "/js/templates/feedGeneratorListItem.template.js"; +import "/js/components/edit-profile-dialog.js"; class ProfileView extends View { async render({ @@ -91,6 +92,19 @@ class ProfileView extends View { }, ); + async function handleEditProfile(profile) { + const dialog = document.createElement("edit-profile-dialog"); + dialog.addEventListener("profile-save", (event) => + handleSaveProfile(profile, event.detail.profileUpdates), + ); + dialog.addEventListener("edit-profile-closed", () => { + dialog.remove(); + }); + root.querySelector("main").appendChild(dialog); + dialog.setProfile(profile); + dialog.open(); + } + const tabScrollState = new Map(); async function scrollAndReloadFeed() { @@ -315,6 +329,7 @@ class ProfileView extends View { ), onClickReport: (profile) => profileInteractionHandler.handleReport(profile), + onClickEditProfile: () => handleEditProfile(profile), })} ${isBlocking || isBlockedBy ? html`
@@ -398,6 +413,20 @@ class ProfileView extends View { return html`
`; } + async function handleSaveProfile(profile, profileUpdates) { + const dialog = root.querySelector("edit-profile-dialog"); + try { + await dataLayer.mutations.updateProfile(profile, profileUpdates); + showToast("Profile updated"); + dialog.close(); + renderPage(); + dataLayer.requests.loadProfile(profileDid); + } catch (error) { + console.error("Failed to update profile:", error); + dialog?.setError("Failed to save profile. Please try again."); + } + } + function renderPage() { const profile = dataLayer.selectors.getProfile(profileDid); const currentUser = dataLayer.selectors.getCurrentUser(); diff --git a/tests/e2e/factories.js b/tests/e2e/factories.js index 47a52a2..fc36e7b 100644 --- a/tests/e2e/factories.js +++ b/tests/e2e/factories.js @@ -58,6 +58,8 @@ export function createProfile({ handle, displayName, description, + avatar, + banner, followersCount, followsCount, postsCount, @@ -69,7 +71,8 @@ export function createProfile({ handle, displayName, description: description || "", - avatar: "", + avatar: avatar || "", + banner: banner || "", viewer: { muted: false, blockedBy: false, ...viewer }, labels: [], createdAt: "2025-01-01T00:00:00.000Z", diff --git a/tests/e2e/mockServer.js b/tests/e2e/mockServer.js index 2d05543..3a07b5a 100644 --- a/tests/e2e/mockServer.js +++ b/tests/e2e/mockServer.js @@ -1359,6 +1359,61 @@ export class MockServer { }); }); + await page.route("**/xrpc/com.atproto.repo.getRecord*", (route) => { + const url = new URL(route.request().url()); + const collection = url.searchParams.get("collection"); + const rkey = url.searchParams.get("rkey"); + if (collection === "app.bsky.actor.profile" && rkey === "self") { + const profile = this.profiles.get(userProfile.did) || userProfile; + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + uri: `at://${userProfile.did}/${collection}/${rkey}`, + cid: "bafyreiprofilerecord", + value: { + $type: "app.bsky.actor.profile", + displayName: profile.displayName || "", + description: profile.description || "", + }, + }), + }); + } + return route.fulfill({ status: 404, body: "{}" }); + }); + + await page.route("**/xrpc/com.atproto.repo.putRecord*", (route) => { + const body = route.request().postDataJSON(); + const collection = body?.collection; + if (collection === "app.bsky.actor.profile") { + const record = body?.record || {}; + const profile = this.profiles.get(userProfile.did) || { + ...userProfile, + }; + profile.displayName = record.displayName || ""; + profile.description = record.description || ""; + if (record.avatar) { + profile.avatar = "mock-avatar-url"; + } else if (!record.avatar && record.avatar !== undefined) { + profile.avatar = ""; + } + if (record.banner) { + profile.banner = "mock-banner-url"; + } else if (!record.banner && record.banner !== undefined) { + profile.banner = ""; + } + this.profiles.set(userProfile.did, profile); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + uri: `at://${userProfile.did}/${collection}/self`, + cid: "bafyreiupdatedrecord", + }), + }); + }); + await page.route("**/xrpc/com.atproto.repo.uploadBlob*", (route) => route.fulfill({ status: 200, diff --git a/tests/e2e/specs/flows/editProfile.test.js b/tests/e2e/specs/flows/editProfile.test.js new file mode 100644 index 0000000..3755dc4 --- /dev/null +++ b/tests/e2e/specs/flows/editProfile.test.js @@ -0,0 +1,524 @@ +import { test, expect } from "../../base.js"; +import { login } from "../../helpers.js"; +import { userProfile } from "../../fixtures.js"; +import { MockServer } from "../../mockServer.js"; +import { createProfile } from "../../factories.js"; + +test.describe("Edit profile flow", () => { + test("should open edit dialog, update display name and description, and save", async ({ + page, + }) => { + const mockServer = new MockServer(); + const currentUser = createProfile({ + did: userProfile.did, + handle: userProfile.handle, + displayName: "Test User", + description: "Original description", + followersCount: 10, + followsCount: 5, + postsCount: 3, + }); + mockServer.addProfile(currentUser); + await mockServer.setup(page); + + await login(page); + await page.goto(`/profile/${userProfile.did}`); + + const profileView = page.locator("#profile-view"); + await expect( + profileView.locator('[data-testid="profile-name"]'), + ).toContainText("Test User", { timeout: 10000 }); + + // Click the "Edit Profile" button + await profileView.locator('[data-testid="edit-profile-button"]').click(); + + // Wait for the dialog to appear + const dialog = page.locator("edit-profile-dialog"); + await expect( + dialog.locator('[data-testid="edit-profile-display-name"]'), + ).toBeVisible({ timeout: 5000 }); + + // Verify fields are pre-populated + await expect( + dialog.locator('[data-testid="edit-profile-display-name"]'), + ).toHaveValue("Test User"); + await expect( + dialog.locator('[data-testid="edit-profile-description"]'), + ).toHaveValue("Original description"); + + // Clear and type new display name + await dialog + .locator('[data-testid="edit-profile-display-name"]') + .fill("Updated User"); + + // Clear and type new description + await dialog + .locator('[data-testid="edit-profile-description"]') + .fill("Updated description"); + + // Click Save + await dialog.locator('[data-testid="edit-profile-save-button"]').click(); + + // Verify the toast shows success + await expect(page.locator(".toast")).toContainText("Profile updated", { + timeout: 5000, + }); + + // Verify the profile view reflects the updated name + await expect( + profileView.locator('[data-testid="profile-name"]'), + ).toContainText("Updated User", { timeout: 10000 }); + }); + + test("should not allow saving when no changes are made", async ({ page }) => { + const mockServer = new MockServer(); + const currentUser = createProfile({ + did: userProfile.did, + handle: userProfile.handle, + displayName: "Test User", + description: "My description", + followersCount: 10, + followsCount: 5, + postsCount: 3, + }); + mockServer.addProfile(currentUser); + await mockServer.setup(page); + + await login(page); + await page.goto(`/profile/${userProfile.did}`); + + const profileView = page.locator("#profile-view"); + await expect( + profileView.locator('[data-testid="edit-profile-button"]'), + ).toBeVisible({ timeout: 10000 }); + + await profileView.locator('[data-testid="edit-profile-button"]').click(); + + const dialog = page.locator("edit-profile-dialog"); + await expect( + dialog.locator('[data-testid="edit-profile-display-name"]'), + ).toBeVisible({ timeout: 5000 }); + + // Save button should be disabled when no changes are made + await expect( + dialog.locator('[data-testid="edit-profile-save-button"]'), + ).toBeDisabled(); + }); + + test("should show character count overflow for display name", async ({ + page, + }) => { + const mockServer = new MockServer(); + const currentUser = createProfile({ + did: userProfile.did, + handle: userProfile.handle, + displayName: "Test User", + description: "", + followersCount: 10, + followsCount: 5, + postsCount: 3, + }); + mockServer.addProfile(currentUser); + await mockServer.setup(page); + + await login(page); + await page.goto(`/profile/${userProfile.did}`); + + const profileView = page.locator("#profile-view"); + await expect( + profileView.locator('[data-testid="edit-profile-button"]'), + ).toBeVisible({ timeout: 10000 }); + + await profileView.locator('[data-testid="edit-profile-button"]').click(); + + const dialog = page.locator("edit-profile-dialog"); + await expect( + dialog.locator('[data-testid="edit-profile-display-name"]'), + ).toBeVisible({ timeout: 5000 }); + + // Type a display name that exceeds the 64 character limit + const longName = "A".repeat(65); + await dialog + .locator('[data-testid="edit-profile-display-name"]') + .fill(longName); + + // The character count should show overflow styling + await expect( + dialog.locator(".edit-profile-char-count.overflow"), + ).toBeVisible(); + + // Save button should be disabled + await expect( + dialog.locator('[data-testid="edit-profile-save-button"]'), + ).toBeDisabled(); + }); + + test("should close dialog when cancel is clicked", async ({ page }) => { + const mockServer = new MockServer(); + const currentUser = createProfile({ + did: userProfile.did, + handle: userProfile.handle, + displayName: "Test User", + description: "", + followersCount: 10, + followsCount: 5, + postsCount: 3, + }); + mockServer.addProfile(currentUser); + await mockServer.setup(page); + + await login(page); + await page.goto(`/profile/${userProfile.did}`); + + const profileView = page.locator("#profile-view"); + await expect( + profileView.locator('[data-testid="edit-profile-button"]'), + ).toBeVisible({ timeout: 10000 }); + + await profileView.locator('[data-testid="edit-profile-button"]').click(); + + const dialog = page.locator("edit-profile-dialog"); + await expect( + dialog.locator('[data-testid="edit-profile-display-name"]'), + ).toBeVisible({ timeout: 5000 }); + + // Click Cancel + await dialog + .locator(".edit-profile-dialog-header-button", { hasText: "Cancel" }) + .click(); + + // Dialog should be closed + await expect(dialog.locator(".edit-profile-dialog[open]")).toHaveCount(0, { + timeout: 5000, + }); + + // Profile should remain unchanged + await expect( + profileView.locator('[data-testid="profile-name"]'), + ).toContainText("Test User"); + }); + + test("should open context menu with upload option when clicking avatar", async ({ + page, + }) => { + const mockServer = new MockServer(); + const currentUser = createProfile({ + did: userProfile.did, + handle: userProfile.handle, + displayName: "Test User", + description: "", + avatar: "http://localhost/mock-avatar.jpg", + }); + mockServer.addProfile(currentUser); + await mockServer.setup(page); + + await login(page); + await page.goto(`/profile/${userProfile.did}`); + + const profileView = page.locator("#profile-view"); + await expect( + profileView.locator('[data-testid="edit-profile-button"]'), + ).toBeVisible({ timeout: 10000 }); + + await profileView.locator('[data-testid="edit-profile-button"]').click(); + + const dialog = page.locator("edit-profile-dialog"); + await expect( + dialog.locator('[data-testid="edit-profile-display-name"]'), + ).toBeVisible({ timeout: 5000 }); + + // Click avatar preview to open context menu + await dialog.locator(".edit-profile-avatar-preview").click(); + + // Context menu should appear with "Upload from Files" and "Remove Avatar" + const menu = dialog.locator(".edit-profile-avatar-menu"); + await expect( + menu.locator("context-menu-item", { hasText: "Upload from Files" }), + ).toBeVisible({ timeout: 5000 }); + await expect( + menu.locator("context-menu-item", { hasText: "Remove Avatar" }), + ).toBeVisible(); + }); + + test("should not show remove option when no avatar exists", async ({ + page, + }) => { + const mockServer = new MockServer(); + const currentUser = createProfile({ + did: userProfile.did, + handle: userProfile.handle, + displayName: "Test User", + description: "", + }); + mockServer.addProfile(currentUser); + await mockServer.setup(page); + + await login(page); + await page.goto(`/profile/${userProfile.did}`); + + const profileView = page.locator("#profile-view"); + await expect( + profileView.locator('[data-testid="edit-profile-button"]'), + ).toBeVisible({ timeout: 10000 }); + + await profileView.locator('[data-testid="edit-profile-button"]').click(); + + const dialog = page.locator("edit-profile-dialog"); + await expect( + dialog.locator('[data-testid="edit-profile-display-name"]'), + ).toBeVisible({ timeout: 5000 }); + + // Click avatar preview to open context menu + await dialog.locator(".edit-profile-avatar-preview").click(); + + // Context menu should only have "Upload from Files" + const menu = dialog.locator(".edit-profile-avatar-menu"); + await expect( + menu.locator("context-menu-item", { hasText: "Upload from Files" }), + ).toBeVisible({ timeout: 5000 }); + await expect( + menu.locator("context-menu-item", { hasText: "Remove Avatar" }), + ).toHaveCount(0); + }); + + test("should remove avatar via context menu and save", async ({ page }) => { + const mockServer = new MockServer(); + const currentUser = createProfile({ + did: userProfile.did, + handle: userProfile.handle, + displayName: "Test User", + description: "", + avatar: "http://localhost/mock-avatar.jpg", + }); + mockServer.addProfile(currentUser); + await mockServer.setup(page); + + await login(page); + await page.goto(`/profile/${userProfile.did}`); + + const profileView = page.locator("#profile-view"); + await expect( + profileView.locator('[data-testid="edit-profile-button"]'), + ).toBeVisible({ timeout: 10000 }); + + await profileView.locator('[data-testid="edit-profile-button"]').click(); + + const dialog = page.locator("edit-profile-dialog"); + await expect( + dialog.locator('[data-testid="edit-profile-display-name"]'), + ).toBeVisible({ timeout: 5000 }); + + // Avatar image should be visible in the preview + await expect( + dialog.locator( + ".edit-profile-avatar-preview img:not(.edit-profile-avatar-placeholder)", + ), + ).toBeVisible(); + + // Click avatar preview to open context menu + await dialog.locator(".edit-profile-avatar-preview").click(); + + // Click "Remove Avatar" + const menu = dialog.locator(".edit-profile-avatar-menu"); + await menu + .locator("context-menu-item", { hasText: "Remove Avatar" }) + .click(); + + // Avatar image should be removed from the preview (placeholder may still exist) + await expect( + dialog.locator( + ".edit-profile-avatar-preview img:not(.edit-profile-avatar-placeholder)", + ), + ).toHaveCount(0); + + // Save + await dialog.locator('[data-testid="edit-profile-save-button"]').click(); + + await expect(page.locator(".toast")).toContainText("Profile updated", { + timeout: 5000, + }); + }); + + test("should remove banner via context menu and save", async ({ page }) => { + const mockServer = new MockServer(); + const currentUser = createProfile({ + did: userProfile.did, + handle: userProfile.handle, + displayName: "Test User", + description: "", + banner: "http://localhost/mock-banner.jpg", + }); + mockServer.addProfile(currentUser); + await mockServer.setup(page); + + await login(page); + await page.goto(`/profile/${userProfile.did}`); + + const profileView = page.locator("#profile-view"); + await expect( + profileView.locator('[data-testid="edit-profile-button"]'), + ).toBeVisible({ timeout: 10000 }); + + await profileView.locator('[data-testid="edit-profile-button"]').click(); + + const dialog = page.locator("edit-profile-dialog"); + await expect( + dialog.locator('[data-testid="edit-profile-display-name"]'), + ).toBeVisible({ timeout: 5000 }); + + // Banner image should be visible in the preview + await expect( + dialog.locator(".edit-profile-banner-preview img"), + ).toBeVisible(); + + // Click banner preview to open context menu + await dialog.locator(".edit-profile-banner-preview").click(); + + // Click "Remove Banner" + const menu = dialog.locator(".edit-profile-banner-menu"); + await menu + .locator("context-menu-item", { hasText: "Remove Banner" }) + .click(); + + // Banner image should be removed from the preview + await expect( + dialog.locator(".edit-profile-banner-preview img"), + ).toHaveCount(0); + + // Save + await dialog.locator('[data-testid="edit-profile-save-button"]').click(); + + await expect(page.locator(".toast")).toContainText("Profile updated", { + timeout: 5000, + }); + }); + + test("should return to edit dialog when canceling the image cropper", async ({ + page, + }) => { + const mockServer = new MockServer(); + const currentUser = createProfile({ + did: userProfile.did, + handle: userProfile.handle, + displayName: "Test User", + description: "Original description", + }); + mockServer.addProfile(currentUser); + await mockServer.setup(page); + + await login(page); + await page.goto(`/profile/${userProfile.did}`); + + const profileView = page.locator("#profile-view"); + await expect( + profileView.locator('[data-testid="edit-profile-button"]'), + ).toBeVisible({ timeout: 10000 }); + + await profileView.locator('[data-testid="edit-profile-button"]').click(); + + const dialog = page.locator("edit-profile-dialog"); + await expect( + dialog.locator('[data-testid="edit-profile-display-name"]'), + ).toBeVisible({ timeout: 5000 }); + + // Click avatar preview to open context menu + await dialog.locator(".edit-profile-avatar-preview").click(); + + // Click "Upload from Files" to trigger file picker + const menu = dialog.locator(".edit-profile-avatar-menu"); + const fileInput = dialog.locator(".edit-profile-file-input"); + + // Set a file on the hidden file input to trigger the cropper + const [fileChooser] = await Promise.all([ + page.waitForEvent("filechooser"), + menu + .locator("context-menu-item", { hasText: "Upload from Files" }) + .click(), + ]); + + await fileChooser.setFiles({ + name: "test-image.png", + mimeType: "image/png", + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFklEQVQYV2P8/5+hnoEIwDiqEF8oAABkvQMBzcSaKwAAAABJRU5ErkJggg==", + "base64", + ), + }); + + // Wait for cropper to appear + await expect(dialog.locator("image-cropper")).toBeVisible({ + timeout: 5000, + }); + + // Click Cancel on the cropper + await dialog + .locator(".edit-profile-dialog-header-button", { hasText: "Cancel" }) + .click(); + + // The edit dialog should still be visible with the original fields + await expect( + dialog.locator('[data-testid="edit-profile-display-name"]'), + ).toBeVisible({ timeout: 5000 }); + await expect( + dialog.locator('[data-testid="edit-profile-display-name"]'), + ).toHaveValue("Test User"); + await expect( + dialog.locator('[data-testid="edit-profile-description"]'), + ).toHaveValue("Original description"); + + // The cropper should be gone + await expect(dialog.locator("image-cropper")).toHaveCount(0); + }); + + test("should show error when save fails", async ({ page }) => { + const mockServer = new MockServer(); + const currentUser = createProfile({ + did: userProfile.did, + handle: userProfile.handle, + displayName: "Test User", + description: "Original description", + followersCount: 10, + followsCount: 5, + postsCount: 3, + }); + mockServer.addProfile(currentUser); + await mockServer.setup(page); + + await login(page); + + // Override the putRecord route to return an error + await page.route("**/xrpc/com.atproto.repo.putRecord*", (route) => + route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "InternalServerError" }), + }), + ); + + await page.goto(`/profile/${userProfile.did}`); + + const profileView = page.locator("#profile-view"); + await expect( + profileView.locator('[data-testid="edit-profile-button"]'), + ).toBeVisible({ timeout: 10000 }); + + await profileView.locator('[data-testid="edit-profile-button"]').click(); + + const dialog = page.locator("edit-profile-dialog"); + await expect( + dialog.locator('[data-testid="edit-profile-display-name"]'), + ).toBeVisible({ timeout: 5000 }); + + await dialog + .locator('[data-testid="edit-profile-display-name"]') + .fill("New Name"); + + await dialog.locator('[data-testid="edit-profile-save-button"]').click(); + + // Error message should appear in the dialog + await expect(dialog.locator(".edit-profile-error")).toContainText( + "Failed to save profile", + { timeout: 5000 }, + ); + }); +}); diff --git a/tests/unit/specs/components/context-menu.test.js b/tests/unit/specs/components/context-menu.test.js index 340dc44..936c235 100644 --- a/tests/unit/specs/components/context-menu.test.js +++ b/tests/unit/specs/components/context-menu.test.js @@ -1,11 +1,34 @@ import { TestSuite } from "../../testSuite.js"; import { assert, assertEquals } from "../../testHelpers.js"; -import "/js/components/context-menu.js"; + +// Mock MutationObserver before importing the component +let mutationCallbacks = []; +globalThis.MutationObserver = class { + constructor(callback) { + this._callback = callback; + this._connected = false; + mutationCallbacks.push(this); + } + observe() { + this._connected = true; + } + disconnect() { + this._connected = false; + } + _trigger() { + if (this._connected) { + this._callback(); + } + } +}; + +await import("/js/components/context-menu.js"); const t = new TestSuite("ContextMenu"); t.beforeEach(() => { document.body.innerHTML = ""; + mutationCallbacks = []; }); function connectElement(element) { @@ -120,6 +143,72 @@ t.describe("ContextMenu - container click", (it) => { }); }); +t.describe("ContextMenu - child updates", (it) => { + it("should update dialog contents when children change", () => { + const element = document.createElement("context-menu"); + element.innerHTML = "Item A"; + connectElement(element); + const observer = mutationCallbacks[mutationCallbacks.length - 1]; + + const itemA = element.querySelector(".context-menu .item-a"); + assert(itemA !== null, "Initial child should be in dialog"); + + const newChild = document.createElement("span"); + newChild.className = "item-b"; + newChild.textContent = "Item B"; + element.appendChild(newChild); + observer._trigger(); + + const itemB = element.querySelector(".context-menu .item-b"); + assert(itemB !== null, "New child should be moved into dialog"); + assertEquals(itemB.textContent, "Item B"); + }); + + it("should include all children when full set is provided", () => { + const element = document.createElement("context-menu"); + element.innerHTML = "Original"; + connectElement(element); + const observer = mutationCallbacks[mutationCallbacks.length - 1]; + + // Simulate lit-html re-rendering all children (old + new) + const original = document.createElement("span"); + original.className = "original"; + original.textContent = "Original"; + const added = document.createElement("span"); + added.className = "added"; + added.textContent = "Added"; + element.appendChild(original); + element.appendChild(added); + observer._trigger(); + + const items = element.querySelectorAll(".context-menu span"); + assertEquals(items.length, 2); + assertEquals(items[0].textContent, "Original"); + assertEquals(items[1].textContent, "Added"); + }); + + it("should replace dialog contents when children are replaced", () => { + const element = document.createElement("context-menu"); + element.innerHTML = + "KeepRemove"; + connectElement(element); + const observer = mutationCallbacks[mutationCallbacks.length - 1]; + + const dialog = element.querySelector(".context-menu"); + assertEquals(dialog.querySelectorAll("span").length, 2); + + const replacement = document.createElement("span"); + replacement.className = "only"; + replacement.textContent = "Only"; + element.appendChild(replacement); + observer._trigger(); + + const spans = dialog.querySelectorAll("span"); + assertEquals(spans.length, 1); + assertEquals(spans[0].textContent, "Only"); + }); +}); + t.describe("ContextMenu - reinitialization protection", (it) => { it("should not reinitialize when connectedCallback is called multiple times", () => { const element = document.createElement("context-menu"); diff --git a/tests/unit/specs/components/edit-profile-dialog.test.js b/tests/unit/specs/components/edit-profile-dialog.test.js new file mode 100644 index 0000000..0e54500 --- /dev/null +++ b/tests/unit/specs/components/edit-profile-dialog.test.js @@ -0,0 +1,432 @@ +import { TestSuite } from "../../testSuite.js"; +import { assert, assertEquals } from "../../testHelpers.js"; +import "/js/components/edit-profile-dialog.js"; + +const t = new TestSuite("EditProfileDialog"); + +const mockProfile = { + did: "did:plc:test123", + displayName: "Test User", + description: "A test bio", + handle: "testuser.bsky.social", + avatar: "https://example.com/avatar.jpg", + banner: "https://example.com/banner.jpg", + viewer: {}, +}; + +function connectElement(element) { + const container = document.createElement("div"); + container.className = "page-visible"; + container.appendChild(element); + document.body.appendChild(container); +} + +t.beforeEach(() => { + document.body.innerHTML = ""; +}); + +t.describe("EditProfileDialog - rendering", (it) => { + it("should render dialog element", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + const dialog = element.querySelector(".edit-profile-dialog"); + assert(dialog !== null, "Dialog should be rendered"); + assertEquals(dialog.tagName, "DIALOG"); + }); + + it("should render header with title", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + const header = element.querySelector(".edit-profile-dialog-header h2"); + assert(header !== null, "Header should be rendered"); + assertEquals(header.textContent, "Edit profile"); + }); + + it("should render display name input", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + const input = element.querySelector( + "[data-testid='edit-profile-display-name']", + ); + assert(input !== null, "Display name input should be rendered"); + assertEquals(input.tagName, "INPUT"); + }); + + it("should render description textarea", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + const textarea = element.querySelector( + "[data-testid='edit-profile-description']", + ); + assert(textarea !== null, "Description textarea should be rendered"); + assertEquals(textarea.tagName, "TEXTAREA"); + }); + + it("should render save button", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + const saveButton = element.querySelector( + "[data-testid='edit-profile-save-button']", + ); + assert(saveButton !== null, "Save button should be rendered"); + }); + + it("should render cancel button", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + const cancelButton = element.querySelector( + ".edit-profile-dialog-header-button", + ); + assert(cancelButton !== null, "Cancel button should be rendered"); + assertEquals(cancelButton.textContent.trim(), "Cancel"); + }); +}); + +t.describe("EditProfileDialog - pre-filling", (it) => { + it("should pre-fill display name from profile", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile(mockProfile); + const input = element.querySelector( + "[data-testid='edit-profile-display-name']", + ); + assertEquals(input.value, "Test User"); + }); + + it("should pre-fill description from profile", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile(mockProfile); + const textarea = element.querySelector( + "[data-testid='edit-profile-description']", + ); + assertEquals(textarea.value, "A test bio"); + }); + + it("should show avatar preview from profile", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile(mockProfile); + const avatarImg = element.querySelector(".edit-profile-avatar-preview img"); + assert(avatarImg !== null, "Avatar image should be rendered"); + assertEquals(avatarImg.src, "https://example.com/avatar.jpg"); + }); + + it("should show banner preview from profile", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile(mockProfile); + const bannerImg = element.querySelector(".edit-profile-banner-preview img"); + assert(bannerImg !== null, "Banner image should be rendered"); + assertEquals(bannerImg.src, "https://example.com/banner.jpg"); + }); + + it("should handle profile with no display name", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile({ ...mockProfile, displayName: "" }); + const input = element.querySelector( + "[data-testid='edit-profile-display-name']", + ); + assertEquals(input.value, ""); + }); + + it("should handle profile with no avatar", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile({ ...mockProfile, avatar: null }); + const avatarImg = element.querySelector( + ".edit-profile-avatar-preview img:not(.edit-profile-avatar-placeholder)", + ); + assertEquals(avatarImg, null); + const placeholder = element.querySelector( + ".edit-profile-avatar-placeholder", + ); + assert(placeholder !== null, "Avatar placeholder should be shown"); + assertEquals(placeholder.tagName, "IMG"); + assert( + placeholder.src.includes("avatar-fallback.svg"), + "Placeholder should use avatar fallback SVG", + ); + }); +}); + +t.describe("EditProfileDialog - character counts", (it) => { + it("should show character count for display name", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile(mockProfile); + const charCount = element.querySelectorAll(".edit-profile-char-count")[0]; + assert(charCount !== null, "Char count should be rendered"); + assert(charCount.textContent.includes("/64"), "Should show max of 64"); + }); + + it("should show character count for description", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile(mockProfile); + const charCounts = element.querySelectorAll(".edit-profile-char-count"); + assert(charCounts.length >= 2, "Should have at least 2 char counts"); + assert( + charCounts[1].textContent.includes("/256"), + "Should show max of 256", + ); + }); +}); + +t.describe("EditProfileDialog - validation", (it) => { + it("should disable save button when no changes made", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile(mockProfile); + const saveButton = element.querySelector( + "[data-testid='edit-profile-save-button']", + ); + assertEquals(saveButton.disabled, true); + }); + + it("should enable save button when display name changes", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile(mockProfile); + + const input = element.querySelector( + "[data-testid='edit-profile-display-name']", + ); + input.value = "New Name"; + input.dispatchEvent(new Event("input", { bubbles: true })); + + const saveButton = element.querySelector( + "[data-testid='edit-profile-save-button']", + ); + assertEquals(saveButton.disabled, false); + }); + + it("should disable save when display name exceeds 64 characters", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile({ ...mockProfile, displayName: "" }); + + const input = element.querySelector( + "[data-testid='edit-profile-display-name']", + ); + input.value = "a".repeat(65); + input.dispatchEvent(new Event("input", { bubbles: true })); + + const saveButton = element.querySelector( + "[data-testid='edit-profile-save-button']", + ); + assertEquals(saveButton.disabled, true); + }); + + it("should show overflow class when display name too long", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile({ ...mockProfile, displayName: "" }); + + const input = element.querySelector( + "[data-testid='edit-profile-display-name']", + ); + input.value = "a".repeat(65); + input.dispatchEvent(new Event("input", { bubbles: true })); + + const charCount = element.querySelectorAll(".edit-profile-char-count")[0]; + assert( + charCount.classList.contains("overflow"), + "Should have overflow class", + ); + }); + + it("should disable save when description exceeds 256 characters", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile({ ...mockProfile, description: "" }); + + const textarea = element.querySelector( + "[data-testid='edit-profile-description']", + ); + textarea.value = "a".repeat(257); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + + const saveButton = element.querySelector( + "[data-testid='edit-profile-save-button']", + ); + assertEquals(saveButton.disabled, true); + }); +}); + +t.describe("EditProfileDialog - image context menu", (it) => { + it("should render avatar context menu", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile(mockProfile); + + const menu = element.querySelector(".edit-profile-avatar-menu"); + assert(menu !== null, "Avatar context menu should exist"); + }); + + it("should include remove option when avatar exists", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile(mockProfile); + + const menu = element.querySelector(".edit-profile-avatar-menu"); + const items = menu.querySelectorAll("context-menu-item"); + assertEquals(items.length, 2); + assert( + items[0].textContent.includes("Upload from Files"), + "First item should be upload", + ); + assert( + items[1].textContent.includes("Remove Avatar"), + "Second item should be remove", + ); + }); + + it("should not include remove option when no avatar", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile({ ...mockProfile, avatar: null }); + + const menu = element.querySelector(".edit-profile-avatar-menu"); + const items = menu.querySelectorAll("context-menu-item"); + assertEquals(items.length, 1); + assert( + items[0].textContent.includes("Upload from Files"), + "Only upload option should exist", + ); + }); + + it("should remove avatar when remove option clicked", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile(mockProfile); + + const menu = element.querySelector(".edit-profile-avatar-menu"); + const items = menu.querySelectorAll("context-menu-item"); + items[1].click(); + + const avatarImg = element.querySelector( + ".edit-profile-avatar-preview img:not(.edit-profile-avatar-placeholder)", + ); + assertEquals(avatarImg, null); + const placeholder = element.querySelector( + ".edit-profile-avatar-placeholder", + ); + assert( + placeholder !== null, + "Avatar placeholder should be shown after removal", + ); + }); +}); + +t.describe("EditProfileDialog - profile-save event", (it) => { + it("should include profileUpdates with correct attributes", async () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile(mockProfile); + + const displayNameInput = element.querySelector( + "[data-testid='edit-profile-display-name']", + ); + displayNameInput.value = "Updated Name"; + displayNameInput.dispatchEvent(new Event("input", { bubbles: true })); + + const descriptionTextarea = element.querySelector( + "[data-testid='edit-profile-description']", + ); + descriptionTextarea.value = "Updated bio"; + descriptionTextarea.dispatchEvent(new Event("input", { bubbles: true })); + + const eventPromise = new Promise((resolve) => { + element.addEventListener("profile-save", (event) => { + resolve(event.detail); + }); + }); + + const saveButton = element.querySelector( + "[data-testid='edit-profile-save-button']", + ); + saveButton.click(); + + const detail = await eventPromise; + assert( + detail.profileUpdates !== undefined, + "detail should have profileUpdates", + ); + assertEquals(detail.profileUpdates.displayName, "Updated Name"); + assertEquals(detail.profileUpdates.description, "Updated bio"); + assertEquals(detail.profileUpdates.avatarBlob, null); + assertEquals(detail.profileUpdates.bannerBlob, null); + assertEquals(detail.profileUpdates.removeAvatar, false); + assertEquals(detail.profileUpdates.removeBanner, false); + }); + + it("should set removeAvatar when avatar is removed", async () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile(mockProfile); + + const avatarMenu = element.querySelector(".edit-profile-avatar-menu"); + const avatarItems = avatarMenu.querySelectorAll("context-menu-item"); + avatarItems[1].click(); + + const eventPromise = new Promise((resolve) => { + element.addEventListener("profile-save", (event) => { + resolve(event.detail); + }); + }); + + const saveButton = element.querySelector( + "[data-testid='edit-profile-save-button']", + ); + saveButton.click(); + + const detail = await eventPromise; + assertEquals(detail.profileUpdates.removeAvatar, true); + assertEquals(detail.profileUpdates.removeBanner, false); + }); + + it("should set removeBanner when banner is removed", async () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile(mockProfile); + + const bannerMenu = element.querySelector(".edit-profile-banner-menu"); + const bannerItems = bannerMenu.querySelectorAll("context-menu-item"); + bannerItems[1].click(); + + const eventPromise = new Promise((resolve) => { + element.addEventListener("profile-save", (event) => { + resolve(event.detail); + }); + }); + + const saveButton = element.querySelector( + "[data-testid='edit-profile-save-button']", + ); + saveButton.click(); + + const detail = await eventPromise; + assertEquals(detail.profileUpdates.removeBanner, true); + assertEquals(detail.profileUpdates.removeAvatar, false); + }); +}); + +t.describe("EditProfileDialog - close", (it) => { + it("should dispatch edit-profile-closed event on close", () => { + const element = document.createElement("edit-profile-dialog"); + connectElement(element); + element.setProfile(mockProfile); + + let closedEventFired = false; + element.addEventListener("edit-profile-closed", () => { + closedEventFired = true; + }); + + element.close(); + assertEquals(closedEventFired, true); + }); +}); + +await t.run(); diff --git a/tests/unit/specs/components/image-cropper.test.js b/tests/unit/specs/components/image-cropper.test.js new file mode 100644 index 0000000..be06047 --- /dev/null +++ b/tests/unit/specs/components/image-cropper.test.js @@ -0,0 +1,408 @@ +import { createCanvas, Image as CanvasImage } from "canvas"; +import { TestSuite } from "../../testSuite.js"; +import { assert, assertEquals } from "../../testHelpers.js"; +import "/js/components/image-cropper.js"; + +const t = new TestSuite("ImageCropper"); + +function connectElement(element) { + const container = document.createElement("div"); + container.className = "page-visible"; + container.appendChild(element); + document.body.appendChild(container); +} + +// Create a test image using node-canvas. Returns a canvas Image instance +// that can be assigned directly to cropper._img. +function createTestImage(width, height, color = "red") { + const canvas = createCanvas(width, height); + const ctx = canvas.getContext("2d"); + ctx.fillStyle = color; + ctx.fillRect(0, 0, width, height); + const img = new CanvasImage(); + img.src = canvas.toBuffer("image/png"); + return img; +} + +// Mock getBoundingClientRect since jsdom doesn't do layout. +function mockElementSize(element, width, height) { + const rect = { + x: 0, + y: 0, + top: 0, + left: 0, + right: width, + bottom: height, + width, + height, + }; + element.getBoundingClientRect = () => rect; + if (element._canvas) { + element._canvas.getBoundingClientRect = () => rect; + } +} + +// Inject a test image into the cropper, bypassing the browser Image loading +// path which doesn't work in jsdom. +function injectTestImage(element, width, height) { + const img = createTestImage(width, height); + element._img = img; + element._fitInitialScale(); + element._draw(); + return img; +} + +t.beforeEach(() => { + document.body.innerHTML = ""; +}); + +t.describe("ImageCropper - rendering", (it) => { + it("should render canvas element when connected", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + const canvas = element.querySelector(".image-cropper-canvas"); + assert(canvas !== null, "Canvas should be rendered"); + assertEquals(canvas.tagName, "CANVAS"); + }); + + it("should accept aspect-ratio attribute", () => { + const element = document.createElement("image-cropper"); + element.setAttribute("aspect-ratio", "3"); + connectElement(element); + assertEquals(element.aspectRatio, 3); + }); + + it("should default aspect-ratio to 1", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + assertEquals(element.aspectRatio, 1); + }); + + it("should accept circular attribute", () => { + const element = document.createElement("image-cropper"); + element.setAttribute("circular", ""); + connectElement(element); + assertEquals(element.circular, true); + }); + + it("should not be circular by default", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + assertEquals(element.circular, false); + }); + + it("should initialize with default state", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + assertEquals(element._scale, 1); + assertEquals(element._offsetX, 0); + assertEquals(element._offsetY, 0); + assertEquals(element._dragging, false); + assertEquals(element._img, null); + }); +}); + +t.describe("ImageCropper - image loading", (it) => { + it("should accept an image via _img assignment", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + mockElementSize(element, 300, 300); + + injectTestImage(element, 100, 100); + assert(element._img !== null, "Image should be set"); + assertEquals(element._img.width, 100); + assertEquals(element._img.height, 100); + }); + + it("should fit initial scale so image covers crop area", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + mockElementSize(element, 300, 300); + + // Small image into large cropper — scale must be > 1 + injectTestImage(element, 50, 50); + assert(element._scale >= 1, "Scale should be >= 1 to fill crop area"); + }); +}); + +t.describe("ImageCropper - cropImage", (it) => { + it("should return a data URL after cropping", () => { + const element = document.createElement("image-cropper"); + element.setAttribute("aspect-ratio", "1"); + connectElement(element); + mockElementSize(element, 200, 200); + + injectTestImage(element, 100, 100); + const result = element.cropImage(); + assert(result !== null, "cropImage should return a value"); + assert( + result.startsWith("data:image/jpeg"), + "cropImage should return a JPEG data URL", + ); + }); + + it("should return null when no image is loaded", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + const result = element.cropImage(); + assertEquals(result, null); + }); + + it("should produce output with correct aspect ratio for 1:1 crop", () => { + const element = document.createElement("image-cropper"); + element.setAttribute("aspect-ratio", "1"); + connectElement(element); + mockElementSize(element, 200, 200); + + // Wide image — crop should be square + injectTestImage(element, 200, 100); + const result = element.cropImage(); + assert(result !== null, "Should produce output"); + + // Decode the result to check dimensions + const outputImg = new CanvasImage(); + const base64 = result.split(",")[1]; + outputImg.src = Buffer.from(base64, "base64"); + assertEquals(outputImg.width, outputImg.height); + }); + + it("should produce output with correct aspect ratio for 3:1 crop", () => { + const element = document.createElement("image-cropper"); + element.setAttribute("aspect-ratio", "3"); + connectElement(element); + mockElementSize(element, 300, 200); + + injectTestImage(element, 300, 300); + const result = element.cropImage(); + assert(result !== null, "Should produce output"); + + const outputImg = new CanvasImage(); + const base64 = result.split(",")[1]; + outputImg.src = Buffer.from(base64, "base64"); + // Width should be 3x height + const ratio = outputImg.width / outputImg.height; + assert( + Math.abs(ratio - 3) < 0.1, + `Aspect ratio should be ~3, got ${ratio}`, + ); + }); + + it("should crop a wide image to square", () => { + const element = document.createElement("image-cropper"); + element.setAttribute("aspect-ratio", "1"); + connectElement(element); + mockElementSize(element, 200, 200); + + // Very wide image + injectTestImage(element, 400, 100); + const result = element.cropImage(); + assert(result !== null, "Should produce output"); + + const outputImg = new CanvasImage(); + const base64 = result.split(",")[1]; + outputImg.src = Buffer.from(base64, "base64"); + assertEquals(outputImg.width, outputImg.height); + }); + + it("should crop a tall image to square", () => { + const element = document.createElement("image-cropper"); + element.setAttribute("aspect-ratio", "1"); + connectElement(element); + mockElementSize(element, 200, 200); + + // Very tall image + injectTestImage(element, 100, 400); + const result = element.cropImage(); + assert(result !== null, "Should produce output"); + + const outputImg = new CanvasImage(); + const base64 = result.split(",")[1]; + outputImg.src = Buffer.from(base64, "base64"); + assertEquals(outputImg.width, outputImg.height); + }); +}); + +t.describe("ImageCropper - pointer events", (it) => { + it("should set dragging to true on pointerdown", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + const canvas = element.querySelector(".image-cropper-canvas"); + + const event = new Event("pointerdown", { bubbles: true }); + event.clientX = 100; + event.clientY = 100; + event.pointerId = 1; + canvas.setPointerCapture = () => {}; + canvas.dispatchEvent(event); + + assertEquals(element._dragging, true); + }); + + it("should set dragging to false on pointerup", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + const canvas = element.querySelector(".image-cropper-canvas"); + + const downEvent = new Event("pointerdown", { bubbles: true }); + downEvent.clientX = 100; + downEvent.clientY = 100; + downEvent.pointerId = 1; + canvas.setPointerCapture = () => {}; + canvas.dispatchEvent(downEvent); + + canvas.dispatchEvent(new Event("pointerup", { bubbles: true })); + assertEquals(element._dragging, false); + }); + + it("should set dragging to false on pointercancel", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + const canvas = element.querySelector(".image-cropper-canvas"); + + const downEvent = new Event("pointerdown", { bubbles: true }); + downEvent.clientX = 100; + downEvent.clientY = 100; + downEvent.pointerId = 1; + canvas.setPointerCapture = () => {}; + canvas.dispatchEvent(downEvent); + + canvas.dispatchEvent(new Event("pointercancel", { bubbles: true })); + assertEquals(element._dragging, false); + }); + + it("should track drag start position", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + const canvas = element.querySelector(".image-cropper-canvas"); + + const event = new Event("pointerdown", { bubbles: true }); + event.clientX = 150; + event.clientY = 200; + event.pointerId = 1; + canvas.setPointerCapture = () => {}; + canvas.dispatchEvent(event); + + assertEquals(element._dragStartX, 150); + assertEquals(element._dragStartY, 200); + }); + + it("should update offset on pointermove while dragging", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + mockElementSize(element, 200, 200); + + injectTestImage(element, 1000, 1000); + + // Zoom in first so there's room to pan + element._scale = 3; + element._clampOffset(); + element._draw(); + + const canvas = element.querySelector(".image-cropper-canvas"); + + const downEvent = new Event("pointerdown", { bubbles: true }); + downEvent.clientX = 100; + downEvent.clientY = 100; + downEvent.pointerId = 1; + canvas.setPointerCapture = () => {}; + canvas.dispatchEvent(downEvent); + + const moveEvent = new Event("pointermove", { bubbles: true }); + moveEvent.clientX = 130; + moveEvent.clientY = 120; + canvas.dispatchEvent(moveEvent); + + assert( + element._offsetX !== 0 || element._offsetY !== 0, + "Offset should change after drag when zoomed in", + ); + }); +}); + +t.describe("ImageCropper - slider zoom", (it) => { + it("should render a zoom slider", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + const slider = element.querySelector(".image-cropper-slider"); + assert(slider !== null, "Slider should be rendered"); + assertEquals(slider.type, "range"); + }); + + it("should increase scale when slider is moved right", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + mockElementSize(element, 200, 200); + + injectTestImage(element, 100, 100); + const initialScale = element._scale; + + const slider = element.querySelector(".image-cropper-slider"); + slider.value = "50"; + slider.dispatchEvent(new Event("input", { bubbles: true })); + + assert( + element._scale > initialScale, + `Scale should increase from ${initialScale}, got ${element._scale}`, + ); + }); + + it("should reach maximum scale at slider value 100", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + mockElementSize(element, 200, 200); + + injectTestImage(element, 100, 100); + + const slider = element.querySelector(".image-cropper-slider"); + slider.value = "100"; + slider.dispatchEvent(new Event("input", { bubbles: true })); + + assertEquals(element._scale, 5); + }); + + it("should be at minimum scale at slider value 0", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + mockElementSize(element, 200, 200); + + injectTestImage(element, 100, 100); + const minScale = element._getMinScale(); + + const slider = element.querySelector(".image-cropper-slider"); + slider.value = "0"; + slider.dispatchEvent(new Event("input", { bubbles: true })); + + assertEquals(element._scale, minScale); + }); + + it("should sync slider position when image is loaded", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + mockElementSize(element, 200, 200); + + injectTestImage(element, 100, 100); + + const slider = element.querySelector(".image-cropper-slider"); + assertEquals(slider.value, "0"); + }); +}); + +t.describe("ImageCropper - attribute changes", (it) => { + it("should update aspect ratio when attribute changes", () => { + const element = document.createElement("image-cropper"); + connectElement(element); + assertEquals(element.aspectRatio, 1); + + element.setAttribute("aspect-ratio", "3"); + assertEquals(element.aspectRatio, 3); + }); + + it("should handle non-numeric aspect ratio gracefully", () => { + const element = document.createElement("image-cropper"); + element.setAttribute("aspect-ratio", "invalid"); + connectElement(element); + assertEquals(element.aspectRatio, 1); + }); +}); + +await t.run(); diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js index 2c1fb61..9452097 100644 --- a/tests/unit/specs/dataLayer/mutations.test.js +++ b/tests/unit/specs/dataLayer/mutations.test.js @@ -779,4 +779,237 @@ t.describe("removeMutedWord", (it) => { }); }); +t.describe("updateProfile", (it) => { + const testProfile = { + did: "did:plc:test123", + displayName: "Old Name", + description: "Old bio", + avatar: "https://example.com/avatar.jpg", + banner: "https://example.com/banner.jpg", + viewer: {}, + }; + + function createMutationsWithMockApi(mockApi) { + const dataStore = new DataStore(); + const patchStore = new PatchStore(); + const mockPreferencesProvider = { + requirePreferences: () => Preferences.createLoggedOutPreferences(), + }; + dataStore.setProfile(testProfile.did, testProfile); + dataStore.setCurrentUser(testProfile); + return { + mutations: new Mutations( + mockApi, + dataStore, + patchStore, + mockPreferencesProvider, + ), + dataStore, + patchStore, + }; + } + + it("should call getProfileRecord and putProfileRecord", async () => { + let getRecordCalled = false; + let putRecordCalled = false; + let putRecordArgs = null; + const mockApi = { + getProfileRecord: async () => { + getRecordCalled = true; + return { + value: { displayName: "Old Name", description: "Old bio" }, + cid: "cid123", + }; + }, + putProfileRecord: async (record, swapRecord) => { + putRecordCalled = true; + putRecordArgs = { record, swapRecord }; + return {}; + }, + uploadBlob: async () => ({ + ref: { $link: "blob-link" }, + mimeType: "image/jpeg", + size: 100, + }), + }; + + const { mutations } = createMutationsWithMockApi(mockApi); + await mutations.updateProfile(testProfile, { + displayName: "New Name", + description: "New bio", + }); + + assertEquals(getRecordCalled, true); + assertEquals(putRecordCalled, true); + assertEquals(putRecordArgs.record.displayName, "New Name"); + assertEquals(putRecordArgs.record.description, "New bio"); + assertEquals(putRecordArgs.swapRecord, "cid123"); + }); + + it("should upload avatar blob when provided", async () => { + let uploadBlobCalled = false; + const mockApi = { + getProfileRecord: async () => ({ value: {}, cid: "cid123" }), + putProfileRecord: async () => ({}), + uploadBlob: async (blob) => { + uploadBlobCalled = true; + return { + ref: { $link: "avatar-blob" }, + mimeType: "image/jpeg", + size: 100, + }; + }, + }; + + const { mutations } = createMutationsWithMockApi(mockApi); + const fakeBlob = new Blob(["test"], { type: "image/jpeg" }); + await mutations.updateProfile(testProfile, { + displayName: "Test", + description: "Test", + avatarBlob: fakeBlob, + }); + + assertEquals(uploadBlobCalled, true); + }); + + it("should upload banner blob when provided", async () => { + let uploadBlobCallCount = 0; + const mockApi = { + getProfileRecord: async () => ({ value: {}, cid: "cid123" }), + putProfileRecord: async () => ({}), + uploadBlob: async () => { + uploadBlobCallCount++; + return { + ref: { $link: "blob-link" }, + mimeType: "image/jpeg", + size: 100, + }; + }, + }; + + const { mutations } = createMutationsWithMockApi(mockApi); + const fakeBlob = new Blob(["test"], { type: "image/jpeg" }); + await mutations.updateProfile(testProfile, { + displayName: "Test", + description: "Test", + bannerBlob: fakeBlob, + }); + + assertEquals(uploadBlobCallCount, 1); + }); + + it("should update dataStore on success", async () => { + const mockApi = { + getProfileRecord: async () => ({ value: {}, cid: "cid123" }), + putProfileRecord: async () => ({}), + uploadBlob: async () => ({ + ref: { $link: "blob" }, + mimeType: "image/jpeg", + size: 100, + }), + }; + + const { mutations, dataStore } = createMutationsWithMockApi(mockApi); + await mutations.updateProfile(testProfile, { + displayName: "Updated Name", + description: "Updated bio", + }); + + const updatedProfile = dataStore.getProfile(testProfile.did); + assertEquals(updatedProfile.displayName, "Updated Name"); + assertEquals(updatedProfile.description, "Updated bio"); + }); + + it("should apply optimistic patch during update", () => { + const mockApi = { + getProfileRecord: async () => new Promise(() => {}), // Never resolves + putProfileRecord: async () => ({}), + uploadBlob: async () => ({ + ref: { $link: "blob" }, + mimeType: "image/jpeg", + size: 100, + }), + }; + + const { mutations, patchStore } = createMutationsWithMockApi(mockApi); + mutations.updateProfile(testProfile, { + displayName: "Optimistic Name", + description: "Optimistic bio", + }); + + const patchedProfile = patchStore.applyProfilePatches(testProfile); + assertEquals(patchedProfile.displayName, "Optimistic Name"); + assertEquals(patchedProfile.description, "Optimistic bio"); + }); + + it("should clear optimistic patch after completion", async () => { + const mockApi = { + getProfileRecord: async () => ({ value: {}, cid: "cid123" }), + putProfileRecord: async () => ({}), + uploadBlob: async () => ({ + ref: { $link: "blob" }, + mimeType: "image/jpeg", + size: 100, + }), + }; + + const { mutations, patchStore, dataStore } = + createMutationsWithMockApi(mockApi); + await mutations.updateProfile(testProfile, { + displayName: "New Name", + description: "New bio", + }); + + const updatedProfile = dataStore.getProfile(testProfile.did); + const patchedProfile = patchStore.applyProfilePatches(updatedProfile); + assertEquals(patchedProfile.displayName, updatedProfile.displayName); + }); + + it("should rethrow non-400 errors from getProfileRecord", async () => { + const mockApi = { + getProfileRecord: async () => { + throw { status: 500, message: "Internal Server Error" }; + }, + putProfileRecord: async () => ({}), + uploadBlob: async () => ({ + ref: { $link: "blob" }, + mimeType: "image/jpeg", + size: 100, + }), + }; + + const { mutations } = createMutationsWithMockApi(mockApi); + try { + await mutations.updateProfile(testProfile, { + displayName: "New Name", + description: "New bio", + }); + throw new Error("Expected updateProfile to throw"); + } catch (error) { + assertEquals(error.status, 500); + } + }); + + it("should update currentUser when editing own profile", async () => { + const mockApi = { + getProfileRecord: async () => ({ value: {}, cid: "cid123" }), + putProfileRecord: async () => ({}), + uploadBlob: async () => ({ + ref: { $link: "blob" }, + mimeType: "image/jpeg", + size: 100, + }), + }; + + const { mutations, dataStore } = createMutationsWithMockApi(mockApi); + await mutations.updateProfile(testProfile, { + displayName: "Updated User", + description: "Updated bio", + }); + + const currentUser = dataStore.getCurrentUser(); + assertEquals(currentUser.displayName, "Updated User"); + }); +}); + await t.run(); diff --git a/tests/unit/specs/imageUtils.test.js b/tests/unit/specs/imageUtils.test.js new file mode 100644 index 0000000..4c5e6f3 --- /dev/null +++ b/tests/unit/specs/imageUtils.test.js @@ -0,0 +1,133 @@ +import { TestSuite } from "../testSuite.js"; +import { assert, assertEquals } from "../testHelpers.js"; +import { + constrainImageSize, + estimateDataUrlSize, + dataUrlToBlob, +} from "/js/imageUtils.js"; + +const t = new TestSuite("imageUtils"); + +t.describe("constrainImageSize", (it) => { + it("should return original size when within bounds", () => { + const result = constrainImageSize({ + width: 500, + height: 400, + maxWidth: 2000, + maxHeight: 2000, + }); + assertEquals(result.width, 500); + assertEquals(result.height, 400); + }); + + it("should scale down when width exceeds max", () => { + const result = constrainImageSize({ + width: 4000, + height: 2000, + maxWidth: 2000, + maxHeight: 2000, + }); + assertEquals(result.width, 2000); + assertEquals(result.height, 1000); + }); + + it("should scale down when height exceeds max", () => { + const result = constrainImageSize({ + width: 1000, + height: 4000, + maxWidth: 2000, + maxHeight: 2000, + }); + assertEquals(result.width, 500); + assertEquals(result.height, 2000); + }); + + it("should scale down when both dimensions exceed max", () => { + const result = constrainImageSize({ + width: 6000, + height: 4000, + maxWidth: 2000, + maxHeight: 2000, + }); + assert(result.width <= 2000, `Width ${result.width} should be <= 2000`); + assert(result.height <= 2000, `Height ${result.height} should be <= 2000`); + }); + + it("should preserve aspect ratio when scaling down", () => { + const result = constrainImageSize({ + width: 4000, + height: 2000, + maxWidth: 2000, + maxHeight: 2000, + }); + const originalRatio = 4000 / 2000; + const resultRatio = result.width / result.height; + assert( + Math.abs(originalRatio - resultRatio) < 0.01, + `Aspect ratio should be preserved: expected ${originalRatio}, got ${resultRatio}`, + ); + }); + + it("should handle square images", () => { + const result = constrainImageSize({ + width: 3000, + height: 3000, + maxWidth: 2000, + maxHeight: 2000, + }); + assertEquals(result.width, 2000); + assertEquals(result.height, 2000); + }); + + it("should handle different max width and height", () => { + const result = constrainImageSize({ + width: 4000, + height: 2000, + maxWidth: 1000, + maxHeight: 2000, + }); + assertEquals(result.width, 1000); + assertEquals(result.height, 500); + }); +}); + +t.describe("estimateDataUrlSize", (it) => { + it("should estimate size of a base64 data URL", () => { + // 4 base64 chars = 3 bytes + const dataUrl = "data:image/jpeg;base64,AAAA"; + const size = estimateDataUrlSize(dataUrl); + assertEquals(size, 3); + }); + + it("should estimate larger data URLs", () => { + const base64 = "A".repeat(1000); + const dataUrl = `data:image/jpeg;base64,${base64}`; + const size = estimateDataUrlSize(dataUrl); + assertEquals(size, 750); + }); +}); + +t.describe("dataUrlToBlob", (it) => { + it("should convert a JPEG data URL to a Blob", () => { + const dataUrl = "data:image/jpeg;base64,/9j/4AAQ"; + const blob = dataUrlToBlob(dataUrl); + assertEquals(blob.type, "image/jpeg"); + assert(blob.size > 0, "Blob should have content"); + }); + + it("should convert a PNG data URL to a Blob", () => { + const dataUrl = "data:image/png;base64,iVBORw0KGgo="; + const blob = dataUrlToBlob(dataUrl); + assertEquals(blob.type, "image/png"); + assert(blob.size > 0, "Blob should have content"); + }); + + it("should produce a blob with the correct byte length", () => { + // "AQID" is base64 for bytes [1, 2, 3] + const dataUrl = "data:image/jpeg;base64,AQID"; + const blob = dataUrlToBlob(dataUrl); + assertEquals(blob.size, 3); + }); +}); + +await t.run(); diff --git a/tests/unit/specs/templates/profileCard.template.test.js b/tests/unit/specs/templates/profileCard.template.test.js index a0a9012..072ace5 100644 --- a/tests/unit/specs/templates/profileCard.template.test.js +++ b/tests/unit/specs/templates/profileCard.template.test.js @@ -426,6 +426,68 @@ t.describe("profileCardTemplate - authentication states", (it) => { ); }); + it("should render edit profile button for current user", () => { + const profile = { + ...mockProfile, + viewer: { following: false, followedBy: false }, + }; + const result = profileCardTemplate({ + profile, + isAuthenticated: true, + isCurrentUser: true, + onClickEditProfile: () => {}, + }); + const container = document.createElement("div"); + render(result, container); + const editButton = container.querySelector( + "[data-testid='edit-profile-button']", + ); + assert(editButton !== null); + assertEquals(editButton.textContent.trim(), "Edit Profile"); + }); + + it("should not render edit profile button for other users", () => { + const profile = { + ...mockProfile, + viewer: { following: false, followedBy: false }, + }; + const result = profileCardTemplate({ + profile, + isAuthenticated: true, + isCurrentUser: false, + onClickFollow: () => {}, + }); + const container = document.createElement("div"); + render(result, container); + assertEquals( + container.querySelector("[data-testid='edit-profile-button']"), + null, + ); + }); + + it("should call onClickEditProfile when edit button clicked", () => { + let editProfileCalled = false; + const profile = { + ...mockProfile, + viewer: { following: false, followedBy: false }, + }; + const result = profileCardTemplate({ + profile, + isAuthenticated: true, + isCurrentUser: true, + onClickEditProfile: () => { + editProfileCalled = true; + }, + }); + const container = document.createElement("div"); + render(result, container); + const editButton = container.querySelector( + "[data-testid='edit-profile-button']", + ); + editButton.click(); + assert(editProfileCalled); + }); + it("should render chat button for authenticated user viewing other profile", () => { const profile = { ...mockProfile,