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``,
+ 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,