diff --git a/README.md b/README.md index c78a79d7..12a4cbd9 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,15 @@ Phanpy This is an alternative web client for [Mastodon](https://joinmastodon.org/). -🔗 **Production**: https://phanpy.social (`production` branch)
-🔗 **Development**: https://dev.phanpy.social (`main` branch, may break more often) +- 🏢 **Production**: https://phanpy.social + - `production` branch + - break less often + - slower fixes unless critical +- 🏗️ **Development**: https://dev.phanpy.social + - `main` branch + - may see new cool stuff sooner + - may break more often + - may be fixed much faster too Everything is designed and engineered for my own use case, following my taste and vision. This is a personal side project for me to learn about Mastodon and experiment with new UI/UX ideas. @@ -35,6 +42,7 @@ Everything is designed and engineered for my own use case, following my taste an - **Status actions (reply, boost, favourite, bookmark, etc) are hidden by default**.
They only appear in individual status page. This is to reduce clutter and distraction. It may result in lower engagement, but we're not chasing numbers here. - **Boost is represented with the rocket icon**.
The green double arrow icon (retweet for Twitter) doesn't look right for the term "boost". Green rocket looks weird, so I use purple. - **Short usernames (`@username`) are displayed in timelines, instead of the full account username (`@username@instance`)**.
Despite the [guideline](https://docs.joinmastodon.org/api/guidelines/#username) mentioned that "Decentralization must be transparent to the user", I don't think we should shove it to the face every single time. There are also some [screen-reader-related accessibility concerns](https://twitter.com/lifeofablindgrl/status/1595864647554502656) with the full username, though this web app is unfortunately not accessible yet. +- **No autoplay for video/GIF/whatever in timeline**.
The timeline is already a huge mess with lots of people, brands, news and media trying to grab your attention. Let's not make it worse. (Current exception now would be animated emojis.) - **Hash-based URLs**.
This web app is not meant to be a full-fledged replacement to Mastodon's existing front-end. There's no SEO, database, serverless or any long-running servers. I could be wrong one day. ## Development @@ -47,6 +55,7 @@ Prerequisites: Node.js 18+ - `npm run preview` - Preview the production build - `npm run fetch-instances` - Fetch instances list from [instances.social](https://instances.social/), save it to `src/data/instances.json` - requires `.env.dev` file with `INSTANCES_SOCIAL_SECRET_TOKEN` variable set +- `npm run sourcemap` - Run `source-map-explorer` on the production build ## Tech stack @@ -81,11 +90,13 @@ And here I am. Building a Mastodon web client. ## Alternative web clients -- [Pinafore](https://pinafore.social/) +- [Pinafore](https://pinafore.social/) ([retired](https://nolanlawson.com/2023/01/09/retiring-pinafore/)) +- [Cuckoo+](https://www.cuckoo.social/) +- [Sengi](https://nicolasconstant.github.io/sengi/) - [Soapbox](https://fe.soapbox.pub/) -- [Elk](https://m.webtoo.ls/@elk) +- [Elk](https://elk.zone/) - [Mastodeck](https://mastodeck.com/) -- +- [Tooty](https://github.com/n1k0/tooty) - [More...](https://github.com/tleb/awesome-mastodon#clients) ## License diff --git a/compose/index.html b/compose/index.html index 34b7cca1..b18ed422 100644 --- a/compose/index.html +++ b/compose/index.html @@ -6,6 +6,7 @@ Compose / Phanpy +
diff --git a/design/logo.afdesign b/design/logo.afdesign index feba6224..5c62634b 100644 Binary files a/design/logo.afdesign and b/design/logo.afdesign differ diff --git a/index.html b/index.html index 7d0514d4..38b1d8da 100644 --- a/index.html +++ b/index.html @@ -28,6 +28,7 @@ content="#242526" media="(prefers-color-scheme: dark)" /> +
diff --git a/package-lock.json b/package-lock.json index 53bac186..7606458b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,15 @@ "version": "0.1.0", "dependencies": { "@github/text-expander-element": "~2.3.0", + "@iconify-icons/mingcute": "~1.2.3", "dayjs": "~1.11.7", "dayjs-twitter": "~0.5.0", "fast-blurhash": "~1.1.2", + "fast-deep-equal": "~3.1.3", "history": "~5.3.0", - "iconify-icon": "~1.0.2", + "idb-keyval": "~6.2.0", "just-debounce-it": "~3.2.0", - "masto": "~5.1.1", + "masto": "~5.5.0", "mem": "~9.0.2", "preact": "~10.11.3", "preact-router": "~4.1.0", @@ -24,14 +26,15 @@ "string-length": "~5.0.1", "swiped-events": "~1.1.7", "toastify-js": "~1.12.0", + "uid": "~2.0.1", "use-resize-observer": "~9.1.0", - "valtio": "~1.8.2" + "valtio": "~1.9.0" }, "devDependencies": { "@preact/preset-vite": "~2.5.0", "@trivago/prettier-plugin-sort-imports": "~4.0.0", "autoprefixer": "~10.4.13", - "postcss": "~8.4.20", + "postcss": "~8.4.21", "postcss-dark-theme-class": "~0.7.3", "twitter-text": "~3.1.0", "vite": "~4.0.4", @@ -2071,6 +2074,14 @@ "@github/combobox-nav": "^2.0.2" } }, + "node_modules/@iconify-icons/mingcute": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@iconify-icons/mingcute/-/mingcute-1.2.3.tgz", + "integrity": "sha512-yZyioZhNy61SkLxQoyHThsfuyaOej9n84PUS+K69qaS1Dyj7/wHwYhWXseFCnzyzicaEHkCpt6H/hYV8fwmMLg==", + "dependencies": { + "@iconify/types": "*" + } + }, "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", @@ -2152,6 +2163,14 @@ "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" }, + "node_modules/@lukeed/csprng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.0.1.tgz", + "integrity": "sha512-uSvJdwQU5nK+Vdf6zxcWAY2A8r7uqe+gePwLWzJ+fsQehq18pc0I2hJKwypZ2aLM90+Er9u1xn4iLJPZ+xlL4g==", + "engines": { + "node": ">=8" + } + }, "node_modules/@mastojs/ponyfills": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@mastojs/ponyfills/-/ponyfills-1.0.4.tgz", @@ -2819,7 +2838,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dev": true, "dependencies": { "function-bind": "^1.1.1", "get-intrinsic": "^1.0.2" @@ -3242,8 +3260,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.2.12", @@ -3382,8 +3399,7 @@ "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "node_modules/function.prototype.name": { "version": "1.1.5", @@ -3425,7 +3441,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", - "dev": true, "dependencies": { "function-bind": "^1.1.1", "has": "^1.0.3", @@ -3520,7 +3535,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, "dependencies": { "function-bind": "^1.1.1" }, @@ -3562,7 +3576,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -3602,23 +3615,20 @@ "@babel/runtime": "^7.7.6" } }, - "node_modules/iconify-icon": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/iconify-icon/-/iconify-icon-1.0.2.tgz", - "integrity": "sha512-mehAvz2a4eUAlPo76Wul4zzsPNr3hbOHiauMhPrTVIdLOt0AnccnNloh1EeTO3tYeBv7iaJZfdCPHczvi+CkXQ==", - "dependencies": { - "@iconify/types": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/cyberalien" - } - }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", "dev": true }, + "node_modules/idb-keyval": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.0.tgz", + "integrity": "sha512-uw+MIyQn2jl3+hroD7hF8J7PUviBU7BPKWw4f/ISf32D4LoGu98yHjrzWWJDASu9QNrX10tCJqk9YY0ClWm8Ng==", + "dependencies": { + "safari-14-idb-fix": "^3.0.0" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -4046,9 +4056,9 @@ "dev": true }, "node_modules/json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "bin": { "json5": "lib/cli.js" @@ -4168,16 +4178,17 @@ } }, "node_modules/masto": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/masto/-/masto-5.1.1.tgz", - "integrity": "sha512-IvfdpCiayM4tM58aTf/tfkSq0MGW1kKEAwJvgVRbzmwlE4PBt1WnGvZXQg6CiLkcKBMTQaDjLR0sBaGmPrVGCQ==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/masto/-/masto-5.5.0.tgz", + "integrity": "sha512-EmAe76vYSR9tmUBiOqG7PwbrNFMVXaH7ce1LAr09MuXoS9RZfdEA4y7y3G0VhwTr4mGwnvWJu203CgAae7ZTEg==", "dependencies": { "@mastojs/ponyfills": "^1.0.4", "change-case": "^4.1.2", "eventemitter3": "^5.0.0", "isomorphic-ws": "^5.0.0", + "qs": "^6.11.0", "semver": "^7.3.7", - "ws": "^8.8.0" + "ws": "^8.12.0" } }, "node_modules/masto/node_modules/semver": { @@ -4363,7 +4374,6 @@ "version": "1.12.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -4473,9 +4483,9 @@ } }, "node_modules/postcss": { - "version": "8.4.20", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.20.tgz", - "integrity": "sha512-6Q04AXR1212bXr5fh03u8aAwbLxAQNGQ/Q1LNa0VfOI06ZAlhPHtQvE4OIdpj4kLThXilalPnmDSOD65DcHt+g==", + "version": "8.4.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", + "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", "dev": true, "funding": [ { @@ -4577,6 +4587,20 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4816,6 +4840,11 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safari-14-idb-fix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz", + "integrity": "sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog==" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -4891,7 +4920,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, "dependencies": { "call-bind": "^1.0.0", "get-intrinsic": "^1.0.2", @@ -5193,6 +5221,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/uid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.1.tgz", + "integrity": "sha512-PF+1AnZgycpAIEmNtjxGBVmKbZAQguaa4pBUq6KNaGEcpzZ2klCNZLM34tsjp76maN00TttiiUf6zkIBpJQm2A==", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -5351,15 +5390,15 @@ } }, "node_modules/valtio": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/valtio/-/valtio-1.8.2.tgz", - "integrity": "sha512-ypFWPi3aY04tojWAFPbTYBDw5iFaCDbKAJ2XqhmY2XOSorNtaCZJNg++FSssv8gMJwmPXfrU/RjncQtsoOHbUg==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/valtio/-/valtio-1.9.0.tgz", + "integrity": "sha512-mQLFsAlKbYascZygFQh6lXuDjU5WHLoeZ8He4HqMnWfasM96V6rDbeFkw1XeG54xycmDonr/Jb4xgviHtuySrA==", "dependencies": { "proxy-compare": "2.4.0", "use-sync-external-store": "1.2.0" }, "engines": { - "node": ">=12.7.0" + "node": ">=12.20.0" }, "peerDependencies": { "react": ">=16.8" @@ -5843,15 +5882,15 @@ "dev": true }, "node_modules/ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.12.0.tgz", + "integrity": "sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==", "engines": { "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -7185,6 +7224,14 @@ "@github/combobox-nav": "^2.0.2" } }, + "@iconify-icons/mingcute": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@iconify-icons/mingcute/-/mingcute-1.2.3.tgz", + "integrity": "sha512-yZyioZhNy61SkLxQoyHThsfuyaOej9n84PUS+K69qaS1Dyj7/wHwYhWXseFCnzyzicaEHkCpt6H/hYV8fwmMLg==", + "requires": { + "@iconify/types": "*" + } + }, "@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", @@ -7256,6 +7303,11 @@ "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" }, + "@lukeed/csprng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.0.1.tgz", + "integrity": "sha512-uSvJdwQU5nK+Vdf6zxcWAY2A8r7uqe+gePwLWzJ+fsQehq18pc0I2hJKwypZ2aLM90+Er9u1xn4iLJPZ+xlL4g==" + }, "@mastojs/ponyfills": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@mastojs/ponyfills/-/ponyfills-1.0.4.tgz", @@ -7770,7 +7822,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dev": true, "requires": { "function-bind": "^1.1.1", "get-intrinsic": "^1.0.2" @@ -8102,8 +8153,7 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-glob": { "version": "3.2.12", @@ -8215,8 +8265,7 @@ "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "function.prototype.name": { "version": "1.1.5", @@ -8246,7 +8295,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", - "dev": true, "requires": { "function-bind": "^1.1.1", "has": "^1.0.3", @@ -8317,7 +8365,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, "requires": { "function-bind": "^1.1.1" } @@ -8346,8 +8393,7 @@ "has-symbols": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" }, "has-tostringtag": { "version": "1.0.0", @@ -8375,20 +8421,20 @@ "@babel/runtime": "^7.7.6" } }, - "iconify-icon": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/iconify-icon/-/iconify-icon-1.0.2.tgz", - "integrity": "sha512-mehAvz2a4eUAlPo76Wul4zzsPNr3hbOHiauMhPrTVIdLOt0AnccnNloh1EeTO3tYeBv7iaJZfdCPHczvi+CkXQ==", - "requires": { - "@iconify/types": "^2.0.0" - } - }, "idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", "dev": true }, + "idb-keyval": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.0.tgz", + "integrity": "sha512-uw+MIyQn2jl3+hroD7hF8J7PUviBU7BPKWw4f/ISf32D4LoGu98yHjrzWWJDASu9QNrX10tCJqk9YY0ClWm8Ng==", + "requires": { + "safari-14-idb-fix": "^3.0.0" + } + }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -8692,9 +8738,9 @@ "dev": true }, "json5": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", - "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true }, "jsonfile": { @@ -8791,16 +8837,17 @@ } }, "masto": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/masto/-/masto-5.1.1.tgz", - "integrity": "sha512-IvfdpCiayM4tM58aTf/tfkSq0MGW1kKEAwJvgVRbzmwlE4PBt1WnGvZXQg6CiLkcKBMTQaDjLR0sBaGmPrVGCQ==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/masto/-/masto-5.5.0.tgz", + "integrity": "sha512-EmAe76vYSR9tmUBiOqG7PwbrNFMVXaH7ce1LAr09MuXoS9RZfdEA4y7y3G0VhwTr4mGwnvWJu203CgAae7ZTEg==", "requires": { "@mastojs/ponyfills": "^1.0.4", "change-case": "^4.1.2", "eventemitter3": "^5.0.0", "isomorphic-ws": "^5.0.0", + "qs": "^6.11.0", "semver": "^7.3.7", - "ws": "^8.8.0" + "ws": "^8.12.0" }, "dependencies": { "semver": { @@ -8936,8 +8983,7 @@ "object-inspect": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", - "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==", - "dev": true + "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==" }, "object-keys": { "version": "1.1.1", @@ -9023,9 +9069,9 @@ "dev": true }, "postcss": { - "version": "8.4.20", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.20.tgz", - "integrity": "sha512-6Q04AXR1212bXr5fh03u8aAwbLxAQNGQ/Q1LNa0VfOI06ZAlhPHtQvE4OIdpj4kLThXilalPnmDSOD65DcHt+g==", + "version": "8.4.21", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", + "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", "dev": true, "requires": { "nanoid": "^3.3.4", @@ -9081,6 +9127,14 @@ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", "dev": true }, + "qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "requires": { + "side-channel": "^1.0.4" + } + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -9245,6 +9299,11 @@ "queue-microtask": "^1.2.2" } }, + "safari-14-idb-fix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz", + "integrity": "sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog==" + }, "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -9300,7 +9359,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, "requires": { "call-bind": "^1.0.0", "get-intrinsic": "^1.0.2", @@ -9532,6 +9590,14 @@ "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", "dev": true }, + "uid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.1.tgz", + "integrity": "sha512-PF+1AnZgycpAIEmNtjxGBVmKbZAQguaa4pBUq6KNaGEcpzZ2klCNZLM34tsjp76maN00TttiiUf6zkIBpJQm2A==", + "requires": { + "@lukeed/csprng": "^1.0.0" + } + }, "unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -9643,9 +9709,9 @@ "requires": {} }, "valtio": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/valtio/-/valtio-1.8.2.tgz", - "integrity": "sha512-ypFWPi3aY04tojWAFPbTYBDw5iFaCDbKAJ2XqhmY2XOSorNtaCZJNg++FSssv8gMJwmPXfrU/RjncQtsoOHbUg==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/valtio/-/valtio-1.9.0.tgz", + "integrity": "sha512-mQLFsAlKbYascZygFQh6lXuDjU5WHLoeZ8He4HqMnWfasM96V6rDbeFkw1XeG54xycmDonr/Jb4xgviHtuySrA==", "requires": { "proxy-compare": "2.4.0", "use-sync-external-store": "1.2.0" @@ -10019,9 +10085,9 @@ "dev": true }, "ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.12.0.tgz", + "integrity": "sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==", "requires": {} }, "yallist": { diff --git a/package.json b/package.json index 4d4ecf31..6a45033c 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,15 @@ }, "dependencies": { "@github/text-expander-element": "~2.3.0", + "@iconify-icons/mingcute": "~1.2.3", "dayjs": "~1.11.7", "dayjs-twitter": "~0.5.0", "fast-blurhash": "~1.1.2", + "fast-deep-equal": "~3.1.3", "history": "~5.3.0", - "iconify-icon": "~1.0.2", + "idb-keyval": "~6.2.0", "just-debounce-it": "~3.2.0", - "masto": "~5.1.1", + "masto": "~5.5.0", "mem": "~9.0.2", "preact": "~10.11.3", "preact-router": "~4.1.0", @@ -26,14 +28,15 @@ "string-length": "~5.0.1", "swiped-events": "~1.1.7", "toastify-js": "~1.12.0", + "uid": "~2.0.1", "use-resize-observer": "~9.1.0", - "valtio": "~1.8.2" + "valtio": "~1.9.0" }, "devDependencies": { "@preact/preset-vite": "~2.5.0", "@trivago/prettier-plugin-sort-imports": "~4.0.0", "autoprefixer": "~10.4.13", - "postcss": "~8.4.20", + "postcss": "~8.4.21", "postcss-dark-theme-class": "~0.7.3", "twitter-text": "~3.1.0", "vite": "~4.0.4", diff --git a/public/sw.js b/public/sw.js index 8bf163f7..17ded602 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,7 +1,11 @@ import { CacheableResponsePlugin } from 'workbox-cacheable-response'; import { ExpirationPlugin } from 'workbox-expiration'; import { RegExpRoute, registerRoute, Route } from 'workbox-routing'; -import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies'; +import { + CacheFirst, + NetworkFirst, + StaleWhileRevalidate, +} from 'workbox-strategies'; self.__WB_DISABLE_DEV_LOGS = true; @@ -50,8 +54,9 @@ const apiRoute = new RegExpRoute( // Matches: // - statuses/:id/context - some contexts are really huge /^https?:\/\/[^\/]+\/api\/v\d+\/(statuses\/\d+\/context)/, - new StaleWhileRevalidate({ + new NetworkFirst({ cacheName: 'api', + networkTimeoutSeconds: 5, plugins: [ new ExpirationPlugin({ maxAgeSeconds: 5 * 60, // 5 minutes diff --git a/src/app.css b/src/app.css index 0e50fd3b..38429db5 100644 --- a/src/app.css +++ b/src/app.css @@ -4,7 +4,7 @@ body { padding: 0; background-color: var(--bg-color); color: var(--text-color); - /* overflow: hidden; */ + overflow-x: hidden; } #app { @@ -32,6 +32,10 @@ a.mention span { a.mention span { color: var(--text-color); } +a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { + color: var(--link-visited-color); + text-decoration-color: var(--link-visited-color); +} .deck-container { width: 100%; @@ -75,14 +79,14 @@ a.mention span { overscroll-behavior: contain; } -.deck header { +.deck > header { min-height: 3em; position: sticky; top: 0; background-color: var(--bg-blur-color); background-image: linear-gradient(to bottom, var(--bg-color), transparent); backdrop-filter: saturate(180%) blur(20px); - border-bottom: 1px solid var(--divider-color); + border-bottom: var(--hairline-width) solid var(--divider-color); z-index: 1; cursor: default; z-index: 10; @@ -93,25 +97,25 @@ a.mention span { transition: transform 0.5s ease-in-out; user-select: none; } -.deck header[hidden] { +.deck > header[hidden] { transform: translateY(-100%); pointer-events: none; user-select: none; } -.deck header > .header-side:last-of-type { +.deck > header > .header-side:last-of-type { text-align: right; grid-column: 3; } -.deck header :is(button, .button).plain { +.deck > header :is(button, .button).plain { backdrop-filter: none; } -.deck header h1 { +.deck > header h1 { margin: 0 8px; padding: 0; font-size: 1.2em; text-align: center; } -.deck header h1:first-child { +.deck > header h1:first-child { text-align: left; padding-left: 8px; } @@ -128,15 +132,15 @@ a.mention span { padding: 0; } .timeline.grow { - min-height: 100vh; - min-height: 100dvh; + /* min-height: 100vh; + min-height: 100dvh; */ padding-bottom: calc(env(safe-area-inset-bottom) + 16px); } .timeline > li { list-style: none; margin: 0; padding: 0; - border-bottom: 1px solid var(--divider-color); + border-bottom: var(--hairline-width) solid var(--divider-color); } .timeline.flat > li { border-bottom: none; @@ -364,15 +368,112 @@ a.mention span { background-color: var(--link-bg-hover-color); outline-offset: -2px; } -.status-link:active { +.status-link:active:not(:has(:is(.media, button):active)) { filter: brightness(0.95); } +.boost-carousel { + background: linear-gradient( + to bottom right, + var(--reblog-faded-color), + transparent 150% + ); + position: relative; +} +.boost-carousel:after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background-image: radial-gradient( + ellipse 50% 32px at bottom center, + var(--reblog-faded-color), + transparent + ), + linear-gradient(to top, var(--bg-color), transparent 64px); + background-repeat: no-repeat; + background-position: bottom center; +} +.boost-carousel .status-reblog { + background-image: none; +} +.boost-carousel header { + padding: 8px 16px 0; + display: flex; + justify-content: space-between; + align-items: center; +} +.boost-carousel h3 { + margin: 0; + padding: 0; + font-size: 14px; + text-transform: uppercase; + color: var(--reblog-color); + text-shadow: 0 1px var(--bg-color); +} +.boost-carousel ul { + display: flex; + overflow-x: auto; + overflow-y: hidden; + scroll-snap-type: x mandatory; + scroll-behavior: smooth; + margin: 0; + padding: 8px 16px; + gap: 16px; + align-items: flex-start; + counter-reset: index; +} +.boost-carousel ul > li { + scroll-snap-align: center; + scroll-snap-stop: always; + flex-shrink: 0; + display: flex; + width: 100%; + max-width: min(320px, calc(100% - 16px)); + list-style: none; + margin: 0; + padding: 0; + max-height: 65vh; + max-height: 65dvh; + counter-increment: index; + position: relative; +} +.boost-carousel ul > li:before { + content: counter(index); + position: absolute; + left: 0; + font-size: 10px; + color: var(--reblog-color); + padding: 8px; +} + .ui-state { padding: 16px; text-align: center; } +.status-boost-link { + display: block; + width: 100%; + text-decoration-line: none; + color: inherit; + user-select: none; + transition: background-color 0.2s ease-out; + -webkit-tap-highlight-color: transparent; + animation: appear 0.2s ease-out; + border: 1px solid var(--outline-color); + background-color: var(--bg-blur-color); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 1px var(--bg-color); +} +.status-boost-link:is(:hover, :focus) { + background-color: var(--link-bg-hover-color); +} +.status-boost-link:active:not(:has(:is(.media, button):active)) { + filter: brightness(0.95); +} + .deck-backdrop { position: fixed; top: 0; @@ -409,6 +510,7 @@ a.mention span { .decks { flex-grow: 1; + width: 100%; } .deck-close { @@ -432,7 +534,7 @@ a.mention span { @keyframes fade-from-top { 0% { - transform: translate(-50%, -100%); + transform: translate(-50%, -200%); opacity: 0; } 100% { @@ -443,7 +545,7 @@ a.mention span { .updates-button { position: absolute; z-index: 2; - animation: fade-from-top 2s ease-out; + animation: fade-from-top 0.3s ease-out; left: 50%; margin-top: 8px; transform: translate(-50%, 0); @@ -494,6 +596,13 @@ a.mention span { width: 100vw; height: 100vh; height: 100dvh; + background-color: var(--average-color-alpha); + background-image: radial-gradient( + closest-side, + var(--average-color) 10%, + var(--average-color-alpha) 40%, + transparent 100% + ); } .carousel > * :is(img, video) { width: auto; @@ -619,7 +728,7 @@ button.carousel-dot[disabled].active { transition: transform 0.3s ease-in-out; } #compose-button[hidden] .icon { - transform: rotate(90deg); + transform: rotate3d(0, 1, 0, 180deg); } #compose-button:is(:hover, :focus) { background-color: var(--button-bg-color); @@ -649,6 +758,13 @@ button.carousel-dot[disabled].active { animation: slide-up 0.3s var(--timing-function); border: 1px solid var(--outline-color); } +.sheet-max { + width: 90vw; + width: 90dvw; + max-width: none; + height: 90vh; + height: 90dvh; +} .sheet header { padding: 16px 16px 8px; padding-left: max(16px, env(safe-area-inset-left)); @@ -852,7 +968,7 @@ meter.donut:is(.danger, .explode):after { border: 0; background-color: transparent; } - .timeline-deck header { + .timeline-deck > header { min-height: 6em; border-bottom: 0; background-color: var(--bg-faded-blur-color); @@ -869,10 +985,10 @@ meter.donut:is(.danger, .explode):after { transparent ); } - .deck header h1 { + .deck > header h1 { font-size: 1.5em; } - .timeline-deck .timeline:not(.flat) li { + .timeline-deck .timeline:not(.flat) > li { border: 1px solid var(--divider-color); margin: 16px 0; background-color: var(--bg-color); @@ -886,4 +1002,8 @@ meter.donut:is(.danger, .explode):after { :is(.carousel-top-controls, .carousel-controls) { padding: 32px; } + li:has(.boost-carousel) { + width: 95vw; + transform: translateX(calc(-50% + 20em)); + } } diff --git a/src/app.jsx b/src/app.jsx index 903f8d3c..bd4f553c 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -11,7 +11,7 @@ import { useSnapshot } from 'valtio'; import Account from './components/account'; import Compose from './components/compose'; -import Icon from './components/icon'; +import Drafts from './components/drafts'; import Loader from './components/loader'; import Modal from './components/modal'; import Home from './pages/home'; @@ -21,8 +21,7 @@ import Settings from './pages/settings'; import Status from './pages/status'; import Welcome from './pages/welcome'; import { getAccessToken } from './utils/auth'; -import openCompose from './utils/open-compose'; -import states from './utils/states'; +import states, { saveStatus } from './utils/states'; import store from './utils/store'; const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env; @@ -133,7 +132,7 @@ function App() { if (currentModal) return; let timer = setTimeout(() => { const page = document.getElementById(`${currentDeck}-page`); - console.log('focus', currentDeck, page); + console.debug('FOCUS', currentDeck, page); if (page) { page.focus(); } @@ -188,7 +187,7 @@ function App() { { - console.log('router onChange', e); + console.debug('ROUTER onChange', e); // Special handling for Home and Notifications const { url } = e; if (/notifications/i.test(url)) { @@ -282,6 +281,17 @@ function App() { )} + {!!snapStates.showDrafts && ( + { + if (e.target === e.currentTarget) { + states.showDrafts = false; + } + }} + > + + + )} ); } @@ -302,23 +312,17 @@ async function startStream() { }); } - states.statuses.set(status.id, status); - if (status.reblog) { - states.statuses.set(status.reblog.id, status.reblog); - } + saveStatus(status); }, 5000); stream.on('update', handleNewStatus); stream.on('status.update', (status) => { console.log('STATUS.UPDATE', status); - states.statuses.set(status.id, status); - if (status.reblog) { - states.statuses.set(status.reblog.id, status.reblog); - } + saveStatus(status); }); stream.on('delete', (statusID) => { console.log('DELETE', statusID); - // states.statuses.delete(statusID); - const s = states.statuses.get(statusID); + // delete states.statuses[statusID]; + const s = states.statuses[statusID]; if (s) s._deleted = true; }); stream.on('notification', (notification) => { @@ -334,18 +338,7 @@ async function startStream() { states.notificationsNew.unshift(notification); } - if (notification.status && !states.statuses.has(notification.status.id)) { - states.statuses.set(notification.status.id, notification.status); - if ( - notification.status.reblog && - !states.statuses.has(notification.status.reblog.id) - ) { - states.statuses.set( - notification.status.reblog.id, - notification.status.reblog, - ); - } - } + saveStatus(notification.status, { override: false }); }); stream.ws.onclose = () => { @@ -397,10 +390,7 @@ function startVisibility() { newStatuses[0].id !== states.home[0].id ) { states.homeNew = newStatuses.map((status) => { - states.statuses.set(status.id, status); - if (status.reblog) { - states.statuses.set(status.reblog.id, status.reblog); - } + saveStatus(status); return { id: status.id, reblog: status.reblog?.id, @@ -422,24 +412,7 @@ function startVisibility() { states.notificationsNew.unshift(notification); } - if ( - notification.status && - !states.statuses.has(notification.status.id) - ) { - states.statuses.set( - notification.status.id, - notification.status, - ); - if ( - notification.status.reblog && - !states.statuses.has(notification.status.reblog.id) - ) { - states.statuses.set( - notification.status.reblog.id, - notification.status.reblog, - ); - } - } + saveStatus(notification.status, { override: false }); } } catch (e) { // Silently fail diff --git a/src/components/compose.css b/src/components/compose.css index 91787445..0d69efd1 100644 --- a/src/components/compose.css +++ b/src/components/compose.css @@ -413,11 +413,11 @@ display: flex; flex-direction: column; flex: 1; + gap: 8px; } #media-sheet textarea { width: 100%; height: 10em; - margin-top: 8px; } #media-sheet .media-preview { border: 2px solid var(--outline-color); @@ -443,3 +443,20 @@ object-fit: contain; vertical-align: middle; } + +@media (min-width: 50em) { + #media-sheet main { + flex-direction: row; + } + #media-sheet .media-preview { + flex: 2; + } + #media-sheet .media-preview > * { + max-height: none; + } + #media-sheet textarea { + flex: 1; + min-height: 100%; + height: auto; + } +} diff --git a/src/components/compose.jsx b/src/components/compose.jsx index d5d17e8d..78453357 100644 --- a/src/components/compose.jsx +++ b/src/components/compose.jsx @@ -1,19 +1,24 @@ import './compose.css'; import '@github/text-expander-element'; +import equal from 'fast-deep-equal'; import { forwardRef } from 'preact/compat'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useHotkeys } from 'react-hotkeys-hook'; import stringLength from 'string-length'; +import { uid } from 'uid/single'; import { useSnapshot } from 'valtio'; import supportedLanguages from '../data/status-supported-languages'; import urlRegex from '../data/url-regex'; +import db from '../utils/db'; import emojifyText from '../utils/emojify-text'; import openCompose from '../utils/open-compose'; import states from '../utils/states'; import store from '../utils/store'; +import { getCurrentAccount, getCurrentAccountNS } from '../utils/store-utils'; import useDebouncedCallback from '../utils/useDebouncedCallback'; +import useInterval from '../utils/useInterval'; import visibilityIconsMap from '../utils/visibility-icons-map'; import Avatar from './avatar'; @@ -79,19 +84,16 @@ function Compose({ }) { console.warn('RENDER COMPOSER'); const [uiState, setUIState] = useState('default'); + const UID = useRef(draftStatus?.uid || uid()); + console.log('Compose UID', UID.current); - const accounts = store.local.getJSON('accounts'); - const currentAccount = store.session.get('currentAccount'); - const currentAccountInfo = accounts.find( - (a) => a.info.id === currentAccount, - ).info; + const currentAccount = getCurrentAccount(); + const currentAccountInfo = currentAccount.info; const configuration = useMemo(() => { try { const instances = store.local.getJSON('instances'); - const currentInstance = accounts - .find((a) => a.info.id === currentAccount) - .instanceURL.toLowerCase(); + const currentInstance = currentAccount.instanceURL.toLowerCase(); const config = instances[currentInstance].configuration; console.log(config); return config; @@ -148,7 +150,7 @@ function Compose({ }; const focusTextarea = () => { setTimeout(() => { - console.log('focusing'); + console.debug('FOCUS textarea'); textareaRef.current?.focus(); }, 300); }; @@ -269,7 +271,7 @@ function Compose({ } // check if status contains only "@acct", if replying - const isSelf = replyToStatus?.account.id === currentAccount; + const isSelf = replyToStatus?.account.id === currentAccountInfo.id; const hasOnlyAcct = replyToStatus && value.trim() === `@${replyToStatus.account.acct}`; // TODO: check for mentions, or maybe just generic "@username", including multiple mentions like "@username1@username2" @@ -347,6 +349,128 @@ function Compose({ }, ); + const prevBackgroundDraft = useRef({}); + const draftKey = () => { + const ns = getCurrentAccountNS(); + return `${ns}#${UID.current}`; + }; + const saveUnsavedDraft = () => { + // Not enabling this for editing status + // I don't think this warrant a draft mode for a status that's already posted + // Maybe it could be a big edit change but it should be rare + if (editStatus) return; + const key = draftKey(); + const backgroundDraft = { + key, + replyTo: replyToStatus + ? { + /* Smaller payload of replyToStatus. Reasons: + - No point storing whole thing + - Could have media attachments + - Could be deleted/edited later + */ + id: replyToStatus.id, + account: { + id: replyToStatus.account.id, + username: replyToStatus.account.username, + acct: replyToStatus.account.acct, + }, + } + : null, + draftStatus: { + uid: UID.current, + status: textareaRef.current.value, + spoilerText: spoilerTextRef.current.value, + visibility, + language, + sensitive, + poll, + mediaAttachments, + }, + }; + if (!equal(backgroundDraft, prevBackgroundDraft.current) && !canClose()) { + console.debug('not equal', backgroundDraft, prevBackgroundDraft.current); + db.drafts + .set(key, { + ...backgroundDraft, + state: 'unsaved', + updatedAt: Date.now(), + }) + .then(() => { + console.debug('DRAFT saved', key, backgroundDraft); + }) + .catch((e) => { + console.error('DRAFT failed', key, e); + }); + prevBackgroundDraft.current = structuredClone(backgroundDraft); + } + }; + useInterval(saveUnsavedDraft, 5000); // background save every 5s + useEffect(() => { + saveUnsavedDraft(); + // If unmounted, means user discarded the draft + // Also means pop-out 🙈, but it's okay because the pop-out will persist the ID and re-create the draft + return () => { + db.drafts.del(draftKey()); + }; + }, []); + + useEffect(() => { + const handleItems = (e) => { + if (mediaAttachments.length >= maxMediaAttachments) { + alert(`You can only attach up to ${maxMediaAttachments} files.`); + return; + } + const { items } = e.clipboardData || e.dataTransfer; + const files = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind === 'file') { + const file = item.getAsFile(); + if (file && supportedMimeTypes.includes(file.type)) { + files.push(file); + } + } + } + console.log({ files }); + if (files.length > 0) { + e.preventDefault(); + e.stopPropagation(); + // Auto-cut-off files to avoid exceeding maxMediaAttachments + const max = maxMediaAttachments - mediaAttachments.length; + const allowedFiles = files.slice(0, max); + if (allowedFiles.length <= 0) { + alert(`You can only attach up to ${maxMediaAttachments} files.`); + return; + } + const mediaFiles = allowedFiles.map((file) => ({ + file, + type: file.type, + size: file.size, + url: URL.createObjectURL(file), + id: null, + description: null, + })); + setMediaAttachments([...mediaAttachments, ...mediaFiles]); + } + }; + window.addEventListener('paste', handleItems); + const handleDragover = (e) => { + // Prevent default if there's files + if (e.dataTransfer.items.length > 0) { + e.preventDefault(); + e.stopPropagation(); + } + }; + window.addEventListener('dragover', handleDragover); + window.addEventListener('drop', handleItems); + return () => { + window.removeEventListener('paste', handleItems); + window.removeEventListener('dragover', handleDragover); + window.removeEventListener('drop', handleItems); + }; + }, [mediaAttachments]); + return (
@@ -385,6 +509,7 @@ function Compose({ editStatus, replyToStatus, draftStatus: { + uid: UID.current, status: textareaRef.current.value, spoilerText: spoilerTextRef.current.value, visibility, @@ -460,6 +585,7 @@ function Compose({ editStatus, replyToStatus, draftStatus: { + uid: UID.current, status: textareaRef.current.value, spoilerText: spoilerTextRef.current.value, visibility, @@ -469,7 +595,7 @@ function Compose({ mediaAttachments, }, }; - window.opener.__COMPOSE__ = passData; + window.opener.__COMPOSE__ = passData; // Pass it here instead of `showCompose` due to some weird proxy issue again window.opener.__STATES__.showCompose = true; }, }); @@ -630,7 +756,9 @@ function Compose({ params, ); } else { - newStatus = await masto.v1.statuses.create(params); + newStatus = await masto.v1.statuses.create(params, { + idempotencyKey: UID.current, + }); } setUIState('default'); @@ -726,10 +854,11 @@ function Compose({ {mediaAttachments.length > 0 && (
{mediaAttachments.map((attachment, i) => { - const { id } = attachment; + const { id, file } = attachment; + const fileID = file?.size + file?.type + file?.name; return ( { @@ -1190,7 +1319,7 @@ function MediaAttachment({ } }} > -
+

{ diff --git a/src/components/drafts.css b/src/components/drafts.css new file mode 100644 index 00000000..2b093b8c --- /dev/null +++ b/src/components/drafts.css @@ -0,0 +1,94 @@ +.drafts-list { + margin: 1em 0; + padding: 0; + list-style: none; +} +.drafts-list > li { + margin: 8px 0 16px; + padding: 0; +} + +.mini-draft-meta { + font-size: 80%; + justify-content: space-between; + align-items: center; + display: flex; + padding: 8px 0; +} +.mini-draft-meta * { + vertical-align: middle; +} + +button.draft-item { + display: block; + width: 100%; + border: 0; + border-radius: 8px; + background-color: var(--bg-color); + color: var(--text-color); + border: 1px solid var(--link-faded-color); + text-align: left; + padding: 0; +} +button.draft-item:is(:hover, :focus) { + border-color: var(--link-color); + box-shadow: 0 0 0 3px var(--link-faded-color); + filter: none !important; +} + +.mini-draft { + display: flex; + gap: 0 8px; + font-size: 90%; + padding: 8px; +} + +.mini-draft-aside { + width: 64px; + aspect-ratio: 1 / 1; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--bg-faded-color); + border-radius: 4px; + flex-shrink: 0; + border: 1px solid var(--outline-color); +} +.mini-draft-aside.has-image { + background-image: var(--bg-image); + background-size: cover; + background-position: center; + background-repeat: no-repeat; +} +.mini-draft-aside.has-image > span { + background-color: var(--bg-faded-blur-color); + backdrop-filter: blur(8px); + padding: 4px 8px; + border-radius: 32px; +} +.mini-draft-aside.has-image > span * { + vertical-align: middle; +} + +.mini-draft-main { + flex-grow: 1; +} + +.mini-draft-spoiler, +.mini-draft-status { + text-overflow: ellipsis; + overflow: hidden; + display: -webkit-box; + display: box; + -webkit-box-orient: vertical; + box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + line-height: 1.1; +} +.mini-draft-spoiler + .mini-draft-status { + border-top: 1px dashed var(--text-insignificant-color); + padding-top: 4px; + margin-top: 4px; + color: var(--text-insignificant-color); +} diff --git a/src/components/drafts.jsx b/src/components/drafts.jsx new file mode 100644 index 00000000..96aca1ed --- /dev/null +++ b/src/components/drafts.jsx @@ -0,0 +1,240 @@ +import './drafts.css'; + +import { useEffect, useMemo, useReducer, useState } from 'react'; + +import db from '../utils/db'; +import states from '../utils/states'; +import { getCurrentAccountNS } from '../utils/store-utils'; + +import Icon from './icon'; +import Loader from './loader'; + +function Drafts() { + const [uiState, setUIState] = useState('default'); + const [drafts, setDrafts] = useState([]); + const [reloadCount, reload] = useReducer((c) => c + 1, 0); + + useEffect(() => { + setUIState('loading'); + (async () => { + try { + const keys = await db.drafts.keys(); + if (keys.length) { + const ns = getCurrentAccountNS(); + const ownKeys = keys.filter((key) => key.startsWith(ns)); + if (ownKeys.length) { + const drafts = await db.drafts.getMany(ownKeys); + drafts.sort( + (a, b) => + new Date(b.updatedAt).getTime() - + new Date(a.updatedAt).getTime(), + ); + setDrafts(drafts); + } else { + setDrafts([]); + } + } else { + setDrafts([]); + } + setUIState('default'); + } catch (e) { + console.error(e); + setUIState('error'); + } + })(); + }, [reloadCount]); + + const hasDrafts = drafts?.length > 0; + + return ( +
+
+

+ Unsent drafts

+ {hasDrafts && ( +
+ Looks like you have unsent drafts. Let's continue where you left + off. +
+ )} +
+
+ {hasDrafts ? ( + <> +
    + {drafts.map((draft) => { + const { updatedAt, key, draftStatus, replyTo } = draft; + const currentYear = new Date().getFullYear(); + const updatedAtDate = new Date(updatedAt); + return ( +
  • +
    + + {' '} + + + +
    + +
  • + ); + })} +
+

+ +

+ + ) : ( +

No drafts found.

+ )} +
+
+ ); +} + +function MiniDraft({ draft }) { + const { draftStatus, replyTo } = draft; + const { status, spoilerText, poll, mediaAttachments } = draftStatus; + const hasPoll = poll?.options?.length > 0; + const hasMedia = mediaAttachments?.length > 0; + const hasPollOrMedia = hasPoll || hasMedia; + const firstImageMedia = useMemo(() => { + if (!hasMedia) return; + const image = mediaAttachments.find((media) => /image/.test(media.type)); + if (!image) return; + const { file } = image; + const objectURL = URL.createObjectURL(file); + return objectURL; + }, [hasMedia, mediaAttachments]); + return ( + <> +
+ {hasPollOrMedia && ( +
+ {hasPoll && } + {hasMedia && ( + + {' '} + {mediaAttachments?.length} + + )} +
+ )} +
+ {!!spoilerText &&
{spoilerText}
} + {!!status &&
{status}
} +
+
+ + ); +} + +export default Drafts; diff --git a/src/components/icon.jsx b/src/components/icon.jsx index d9ab5008..ffd39a69 100644 --- a/src/components/icon.jsx +++ b/src/components/icon.jsx @@ -1,4 +1,4 @@ -import 'iconify-icon'; +import { useEffect, useState } from 'preact/hooks'; const SIZES = { s: 12, @@ -30,7 +30,7 @@ const ICONS = { notification: 'mingcute:notification-line', follow: 'mingcute:user-follow-line', 'follow-add': 'mingcute:user-add-line', - poll: 'mingcute:chart-bar-line', + poll: ['mingcute:chart-bar-line', '90deg'], pencil: 'mingcute:pencil-line', quill: 'mingcute:quill-pen-line', at: 'mingcute:at-line', @@ -43,12 +43,15 @@ const ICONS = { popin: ['mingcute:external-link-line', '180deg'], plus: 'mingcute:add-circle-line', 'chevron-left': 'mingcute:left-line', + 'chevron-right': 'mingcute:right-line', reply: ['mingcute:share-forward-line', '180deg', 'horizontal'], thread: 'mingcute:route-line', group: 'mingcute:group-line', bot: 'mingcute:android-2-line', }; +const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js'); + function Icon({ icon, size = 'm', alt, title, class: className = '' }) { if (!icon) return null; @@ -58,6 +61,16 @@ function Icon({ icon, size = 'm', alt, title, class: className = '' }) { if (Array.isArray(iconName)) { [iconName, rotate, flip] = iconName; } + + const [iconData, setIconData] = useState(null); + useEffect(async () => { + const name = iconName.replace('mingcute:', ''); + const icon = await modules[ + `/node_modules/@iconify-icons/mingcute/${name}.js` + ](); + setIconData(icon.default); + }, [iconName]); + return (
- - {alt} - + {iconData && ( + + )}
); } diff --git a/src/components/status.css b/src/components/status.css index 36c878f1..e921d0bf 100644 --- a/src/components/status.css +++ b/src/components/status.css @@ -149,7 +149,7 @@ margin: 4px 0 0 0; gap: 4px; align-items: center; - color: var(--reply-to-color); + color: var(--reply-to-text-color); background: var(--bg-color); border: 1px solid var(--reply-to-color); border-radius: 4px; @@ -187,7 +187,7 @@ .spoiler ~ *:not(.media-container, .card), .status .content-container.has-spoiler .spoiler ~ .card .meta-container { - filter: blur(6px) invert(0.5); + filter: blur(5px) invert(0.5); /* filter: url(#spoiler); */ text-rendering: optimizeSpeed; image-rendering: crisp-edges; @@ -196,6 +196,8 @@ pointer-events: none; user-select: none; contain: layout; + transform: scale(0.97); + transition: transform 0.1s ease-in-out; } .status .content-container.has-spoiler .spoiler ~ .media-container .media > *, .status .content-container.has-spoiler .spoiler ~ .card > img { @@ -280,7 +282,8 @@ display: none; } .status .content p { - margin-block: 0.75em; + /* 12px = 75% of 16px */ + margin-block: min(0.75em, 12px); } .status .content p:first-child { margin-block-start: 0; @@ -320,7 +323,7 @@ .status.large :is(.media-container, .media-container.media-gt2) { height: auto; min-height: 160px; - max-height: 50vh; + max-height: 60vh; } .status .media { border-radius: 8px; @@ -331,6 +334,15 @@ .status .media:only-child { grid-area: span 2 / span 2; } +.status:not(.large) .media:only-child { + max-width: 480px; +} +.status.large .media:only-child { + display: inline-block; + min-width: 160px; + min-height: 160px; + width: fit-content; +} .status .media:first-child:nth-last-child(3) { grid-area: span 2; } @@ -351,10 +363,15 @@ .status .media:is(:hover, :focus) { border-color: var(--outline-hover-color); } +.status .media:active { + filter: brightness(0.8); +} .status .media :is(img, video) { width: 100%; height: 100%; object-fit: cover; +} +.status .media { cursor: pointer; } @keyframes position-object { @@ -371,7 +388,7 @@ object-position: 50% 50%; } } -.status:not(.large) .media img:hover { +.status .media img:hover { animation: position-object 5s ease-in-out 1s 5; } .status .media video { @@ -379,12 +396,11 @@ height: 100%; object-fit: contain; } -.status .media-video, -.status .media-gif { +.status :is(.media-video, .media-audio, .media-gif) { position: relative; background-clip: padding-box; } -.status .media-video[data-formatted-duration]:before { +.status :is(.media-video, .media-audio)[data-formatted-duration]:before { pointer-events: none; content: '⏵'; width: 70px; @@ -396,29 +412,34 @@ top: 50%; transform: translate(-50%, -50%); color: var(--text-insignificant-color); - background-color: var(--bg-blur-color); + background-color: var(--backdrop-color); backdrop-filter: blur(6px) saturate(3) invert(0.2); display: flex; place-content: center; place-items: center; border-radius: 70px; + transition: all 0.2s ease-in-out; } -.status .media-video[data-formatted-duration]:hover:before { +.status :is(.media-video, .media-audio)[data-formatted-duration]:hover:before { color: var(--text-color); + background-color: var(--bg-blur-color); } -.status .media-video[data-formatted-duration]:after { +.status :is(.media-video, .media-audio)[data-formatted-duration]:after { font-size: 12px; pointer-events: none; content: attr(data-formatted-duration); position: absolute; bottom: 8px; - left: 8px; + right: 8px; color: var(--bg-color); background-color: var(--text-color); backdrop-filter: blur(6px) saturate(3) invert(0.2); border-radius: 4px; padding: 0 4px; } +.status .media-audio[data-formatted-duration]:after { + content: '♬ ' attr(data-formatted-duration); +} .status .media-gif[data-label]:not(:hover):after { font-size: 12px; font-weight: bold; @@ -426,7 +447,7 @@ content: attr(data-label); position: absolute; bottom: 8px; - left: 8px; + right: 8px; color: var(--bg-faded-color); background-color: var(--text-insignificant-color); backdrop-filter: blur(6px) saturate(3) invert(0.2); @@ -437,15 +458,34 @@ object-fit: cover; pointer-events: none; } +.status .media-contain { + min-width: 160px; + width: fit-content; +} .status .media-contain video { object-fit: contain !important; } -.status .media-audio { +/* .status .media-audio { border: 0; min-height: 0; } .status .media-audio audio { width: 100%; +} */ +.status .media-audio { + width: 100%; + height: 100%; + background-image: radial-gradient( + circle at center center, + var(--bg-color), + var(--bg-faded-color) + ), + repeating-radial-gradient( + circle at center center, + transparent, + var(--bg-faded-color) 16px + ); + background-blend-mode: multiply; } /* CARD */ @@ -459,29 +499,46 @@ color: inherit; align-items: stretch; background-color: var(--bg-color); - max-height: 160px; + max-width: 480px; + /* max-height: 160px; */ } -.status.large .card.link.large { +.status.large .card.large { border-radius: 16px; flex-direction: column; max-height: none; } -.card .image { +.card .card-image { + flex-shrink: 0; + width: 35%; + position: relative; + border-inline-end: 1px solid var(--outline-color); +} +.card .card-image img { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; +} +/* .card .image { width: 35%; height: auto; flex-grow: 1; border-inline-end: 1px solid var(--outline-color); object-fit: cover; aspect-ratio: 1 / 1; +} */ +.status.large .card .card-image { + aspect-ratio: 1 / 1; } -.status.large .card.link.large .image { +.status.large .card.large .card-image { + flex-grow: 1; aspect-ratio: 1.91 / 1; width: 100%; - max-height: 50vh; + max-height: 40vh; border-inline-end: 0; border-block-end: 1px solid var(--outline-color); } -.card:is(:hover, :focus) .image { +.card:is(:hover, :focus) img { animation: position-object 5s ease-in-out 1s 5; } .card p { @@ -493,8 +550,9 @@ flex-grow: 1; align-self: center; } -.card.large .meta-container { +.status.large .card.large .meta-container { align-self: flex-start; + flex-grow: 0; } .card .title { line-height: 1.25; @@ -564,7 +622,7 @@ a.card:is(:hover, :focus) { display: flex; gap: 8px; justify-content: space-between; - background-color: var(--bg-blur-color); + background-color: var(--bg-faded-color); background-image: linear-gradient( to right, var(--link-faded-color), @@ -572,9 +630,11 @@ a.card:is(:hover, :focus) { transparent var(--percentage), transparent ); + background-repeat: no-repeat; border-radius: 8px; - border: 1px solid rgba(128, 128, 128, 0.1); + border: 1px solid var(--outline-color); align-items: center; + text-shadow: 0 1px var(--bg-blur-color); } .poll-label { width: 100%; @@ -585,9 +645,11 @@ a.card:is(:hover, :focus) { .poll-option-votes { flex-shrink: 0; font-size: 90%; + opacity: 0.75; } .poll-option-leading .poll-option-votes { font-weight: bold; + opacity: 1; } .poll-vote-button { margin-top: 8px; @@ -637,7 +699,7 @@ a.card:is(:hover, :focus) { padding-bottom: 16px; margin-left: calc(-50px - 16px); color: var(--text-insignificant-color); - border-top: 1px solid var(--outline-color); + border-top: var(--hairline-width) solid var(--outline-color); margin-top: 8px; } .status .action.has-count { @@ -690,13 +752,13 @@ a.card:is(:hover, :focus) { border-color: var(--favourite-color); } @keyframes hearted { - 20% { + 15% { transform: scale(1.25) translateY(-1px); } - 45% { + 30% { transform: scale(1); } - 70% { + 45% { transform: scale(1.5) translateY(-2px); } 100% { diff --git a/src/components/status.jsx b/src/components/status.jsx index 425859e9..e3d524b5 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -2,6 +2,7 @@ import './status.css'; import { getBlurHashAverageColor } from 'fast-blurhash'; import mem from 'mem'; +import { memo } from 'preact/compat'; import { useEffect, useLayoutEffect, @@ -21,7 +22,7 @@ import NameText from '../components/name-text'; import enhanceContent from '../utils/enhance-content'; import htmlContentLength from '../utils/html-content-length'; import shortenNumber from '../utils/shorten-number'; -import states from '../utils/states'; +import states, { saveStatus } from '../utils/states'; import store from '../utils/store'; import useDebouncedCallback from '../utils/useDebouncedCallback'; import visibilityIconsMap from '../utils/visibility-icons-map'; @@ -61,7 +62,7 @@ function Status({ const snapStates = useSnapshot(states); if (!status) { - status = snapStates.statuses.get(statusID); + status = snapStates.statuses[statusID]; } if (!status) { return null; @@ -106,6 +107,8 @@ function Status({ _deleted, } = status; + console.debug('RENDER Status', id, status?.account.displayName); + const createdAtDate = new Date(createdAt); const editedAtDate = new Date(editedAt); @@ -122,20 +125,20 @@ function Status({ } const [inReplyToAccount, setInReplyToAccount] = useState(inReplyToAccountRef); if (!withinContext && !inReplyToAccount && inReplyToAccountId) { - const account = states.accounts.get(inReplyToAccountId); + const account = states.accounts[inReplyToAccountId]; if (account) { setInReplyToAccount(account); } else { memFetchAccount(inReplyToAccountId) .then((account) => { setInReplyToAccount(account); - states.accounts.set(account.id, account); + states.accounts[account.id] = account; }) .catch((e) => {}); } } - const showSpoiler = snapStates.spoilers.has(id) || false; + const showSpoiler = !!snapStates.spoilers[id] || false; const debugHover = (e) => { if (e.shiftKey) { @@ -248,7 +251,11 @@ function Status({ {/* */}{' '} {size !== 'l' && (uri ? ( - + ))}

- {!!inReplyToId && - !!inReplyToAccountId && - !withinContext && - size !== 's' && ( - <> - {inReplyToAccountId === status.account.id ? ( -
- - Thread + {!withinContext && size !== 's' && ( + <> + {inReplyToAccountId === status.account?.id || + !!snapStates.statusThreadNumber[id] ? ( +
+ + Thread + {snapStates.statusThreadNumber[id] + ? ` ${snapStates.statusThreadNumber[id]}/X` + : ''} +
+ ) : ( + !!inReplyToId && + !!inReplyToAccount && + (!!spoilerText || + !mentions.find((mention) => { + return mention.id === inReplyToAccountId; + })) && ( +
+ {' '} +
- ) : ( - !!inReplyToAccount && - (!!spoilerText || - !mentions.find((mention) => { - return mention.id === inReplyToAccountId; - })) && ( -
- {' '} - -
- ) - )} - - )} + ) + )} + + )}
@@ -388,7 +397,7 @@ function Status({ poll={poll} readOnly={readOnly} onUpdate={(newPoll) => { - states.statuses.get(id).poll = newPoll; + states.statuses[id].poll = newPoll; }} /> )} @@ -400,9 +409,9 @@ function Status({ e.preventDefault(); e.stopPropagation(); if (showSpoiler) { - states.spoilers.delete(id); + delete states.spoilers[id]; } else { - states.spoilers.set(id, true); + states.spoilers[id] = true; } }} > @@ -436,12 +445,7 @@ function Status({ !sensitive && !spoilerText && !poll && - !mediaAttachments.length && ( - - )} + !mediaAttachments.length && }
{size === 'l' && ( <> @@ -524,28 +528,24 @@ function Status({ } } // Optimistic - states.statuses.set(id, { + states.statuses[id] = { ...status, reblogged: !reblogged, reblogsCount: reblogsCount + (reblogged ? -1 : 1), - }); + }; if (reblogged) { const newStatus = await masto.v1.statuses.unreblog( id, ); - states.statuses.set(newStatus.id, newStatus); + saveStatus(newStatus); } else { const newStatus = await masto.v1.statuses.reblog(id); - states.statuses.set(newStatus.id, newStatus); - states.statuses.set( - newStatus.reblog.id, - newStatus.reblog, - ); + saveStatus(newStatus); } } catch (e) { console.error(e); // Revert optimistism - states.statuses.set(id, status); + states.statuses[id] = status; } }} /> @@ -562,25 +562,25 @@ function Status({ onClick={async () => { try { // Optimistic - states.statuses.set(statusID, { + states.statuses[statusID] = { ...status, favourited: !favourited, favouritesCount: favouritesCount + (favourited ? -1 : 1), - }); + }; if (favourited) { const newStatus = await masto.v1.statuses.unfavourite( id, ); - states.statuses.set(newStatus.id, newStatus); + saveStatus(newStatus); } else { const newStatus = await masto.v1.statuses.favourite(id); - states.statuses.set(newStatus.id, newStatus); + saveStatus(newStatus); } } catch (e) { console.error(e); // Revert optimistism - states.statuses.set(statusID, status); + states.statuses[statusID] = status; } }} /> @@ -595,23 +595,23 @@ function Status({ onClick={async () => { try { // Optimistic - states.statuses.set(statusID, { + states.statuses[statusID] = { ...status, bookmarked: !bookmarked, - }); + }; if (bookmarked) { const newStatus = await masto.v1.statuses.unbookmark( id, ); - states.statuses.set(newStatus.id, newStatus); + saveStatus(newStatus); } else { const newStatus = await masto.v1.statuses.bookmark(id); - states.statuses.set(newStatus.id, newStatus); + saveStatus(newStatus); } } catch (e) { console.error(e); // Revert optimistism - states.statuses.set(statusID, status); + states.statuses[statusID] = status; } }} /> @@ -754,20 +754,20 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
); } else if (type === 'gifv' || type === 'video') { - // 20 seconds, treat as a gif - const shortDuration = original.duration <= 20; - const isGIFV = type === 'gifv'; - const isGIF = isGIFV || shortDuration; - const loopable = original.duration <= 60; + const shortDuration = original.duration < 31; + const isGIF = type === 'gifv' && shortDuration; + // If GIF is too long, treat it as a video + const loopable = original.duration < 61; const formattedDuration = formatDuration(original.duration); const hoverAnimate = !showOriginal && !autoAnimate && isGIF; + const autoGIFAnimate = !showOriginal && autoAnimate && isGIF; return (
{} }) { } }} > - {showOriginal || autoAnimate ? ( + {showOriginal || autoGIFAnimate ? (
{} }) { preload="auto" autoplay muted="${isGIF}" - ${isGIFV ? '' : 'controls'} + ${isGIF ? '' : 'controls'} playsinline loop="${loopable}" > @@ -843,15 +843,30 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
); } else if (type === 'audio') { + const formattedDuration = formatDuration(original.duration); return ( -
-

{domain}

{} }) { •{' '} )} - {shortenNumber(votersCount)}{' '} - {votersCount === 1 ? 'voter' : 'voters'} - {votersCount !== votesCount && ( + {shortenNumber(votesCount)} vote + {votesCount === 1 ? '' : 's'} + {!!votersCount && votersCount !== votesCount && ( <> {' '} - • - {shortenNumber(votesCount)} - {' '} - vote - {votesCount === 1 ? '' : 's'} + •{' '} + {shortenNumber(votersCount)}{' '} + voter + {votersCount === 1 ? '' : 's'} )}{' '} • {expired ? 'Ended' : 'Ending'}{' '} @@ -1329,8 +1344,10 @@ function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) { a.info.id === currentAccount) || accounts[0]; - const instanceURL = account.instanceURL; - const accessToken = account.accessToken; + const { instanceURL, accessToken } = getCurrentAccount(); window.masto = await login({ url: `https://${instanceURL}`, accessToken, @@ -79,6 +74,8 @@ function App() { ); } + console.debug('OPEN COMPOSE'); + return ( , document.getElementById('app')); + +// Clean up iconify localStorage +// TODO: Remove this after few weeks? +setTimeout(() => { + try { + Object.keys(localStorage).forEach((key) => { + if (key.startsWith('iconify')) { + localStorage.removeItem(key); + } + }); + Object.keys(sessionStorage).forEach((key) => { + if (key.startsWith('iconify')) { + sessionStorage.removeItem(key); + } + }); + } catch (e) {} +}, 5000); diff --git a/src/pages/home.jsx b/src/pages/home.jsx index d3abe301..e4c333e8 100644 --- a/src/pages/home.jsx +++ b/src/pages/home.jsx @@ -1,13 +1,15 @@ import { Link } from 'preact-router/match'; +import { memo } from 'preact/compat'; import { useEffect, useRef, useState } from 'preact/hooks'; import { useHotkeys } from 'react-hotkeys-hook'; -import { InView } from 'react-intersection-observer'; import { useSnapshot } from 'valtio'; import Icon from '../components/icon'; import Loader from '../components/loader'; import Status from '../components/status'; -import states from '../utils/states'; +import db from '../utils/db'; +import states, { saveStatus } from '../utils/states'; +import { getCurrentAccountNS } from '../utils/store-utils'; import useDebouncedCallback from '../utils/useDebouncedCallback'; import useScroll from '../utils/useScroll'; @@ -18,6 +20,8 @@ function Home({ hidden }) { const [uiState, setUIState] = useState('default'); const [showMore, setShowMore] = useState(false); + console.debug('RENDER Home'); + const homeIterator = useRef( masto.v1.timelines.listHome({ limit: LIMIT, @@ -36,23 +40,78 @@ function Home({ hidden }) { return { done: true }; } const homeValues = allStatuses.value.map((status) => { - states.statuses.set(status.id, status); - if (status.reblog) { - states.statuses.set(status.reblog.id, status.reblog); - } + saveStatus(status); return { id: status.id, reblog: status.reblog?.id, reply: !!status.inReplyToAccountId, }; }); - if (firstLoad) { - states.home = homeValues; + + // BOOSTS CAROUSEL + if (snapStates.settings.boostsCarousel) { + let specialHome = []; + let boostStash = []; + let serialBoosts = 0; + for (let i = 0; i < homeValues.length; i++) { + const status = homeValues[i]; + if (status.reblog) { + boostStash.push(status); + serialBoosts++; + } else { + specialHome.push(status); + if (serialBoosts < 3) { + serialBoosts = 0; + } + } + } + // if boostStash is more than quarter of homeValues + // or if there are 3 or more boosts in a row + if (boostStash.length > homeValues.length / 4 || serialBoosts >= 3) { + // if boostStash is more than 3 quarter of homeValues + const boostStashID = boostStash.map((status) => status.id); + if (boostStash.length > (homeValues.length * 3) / 4) { + // insert boost array at the end of specialHome list + specialHome = [ + ...specialHome, + { id: boostStashID, boosts: boostStash }, + ]; + } else { + // insert boosts array in the middle of specialHome list + const half = Math.floor(specialHome.length / 2); + specialHome = [ + ...specialHome.slice(0, half), + { + id: boostStashID, + boosts: boostStash, + }, + ...specialHome.slice(half), + ]; + } + } else { + // Untouched, this is fine + specialHome = homeValues; + } + console.log({ + specialHome, + }); + if (firstLoad) { + states.home = specialHome; + } else { + states.home.push(...specialHome); + } } else { - states.home.push(...homeValues); + if (firstLoad) { + states.home = homeValues; + } else { + states.home.push(...homeValues); + } } + states.homeLastFetchTime = Date.now(); - return allStatuses; + return { + done: false, + }; } const loadingStatuses = useRef(false); @@ -80,106 +139,141 @@ function Home({ hidden }) { const scrollableRef = useRef(); - useHotkeys('j', () => { + useHotkeys('j, shift+j', (_, handler) => { // focus on next status after active status // Traverses .timeline li .status-link, focus on .status-link - const activeStatus = document.activeElement.closest('.status-link'); + const activeStatus = document.activeElement.closest( + '.status-link, .status-boost-link', + ); const activeStatusRect = activeStatus?.getBoundingClientRect(); + const allStatusLinks = Array.from( + scrollableRef.current.querySelectorAll( + '.status-link, .status-boost-link', + ), + ); if ( activeStatus && activeStatusRect.top < scrollableRef.current.clientHeight && activeStatusRect.bottom > 0 ) { - const nextStatus = activeStatus.parentElement.nextElementSibling; + const activeStatusIndex = allStatusLinks.indexOf(activeStatus); + let nextStatus = allStatusLinks[activeStatusIndex + 1]; + if (handler.shift) { + // get next status that's not .status-boost-link + nextStatus = allStatusLinks.find( + (statusLink, index) => + index > activeStatusIndex && + !statusLink.classList.contains('status-boost-link'), + ); + } if (nextStatus) { - const statusLink = nextStatus.querySelector('.status-link'); - if (statusLink) { - statusLink.focus(); - } + nextStatus.focus(); + nextStatus.scrollIntoViewIfNeeded?.(); } } else { // If active status is not in viewport, get the topmost status-link in viewport - const statusLinks = document.querySelectorAll( - '.timeline li .status-link', - ); - let topmostStatusLink; - for (const statusLink of statusLinks) { + const topmostStatusLink = allStatusLinks.find((statusLink) => { const statusLinkRect = statusLink.getBoundingClientRect(); - if (statusLinkRect.top >= 44) { - // 44 is the magic number for header height, not real - topmostStatusLink = statusLink; - break; - } - } + return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real + }); if (topmostStatusLink) { topmostStatusLink.focus(); + topmostStatusLink.scrollIntoViewIfNeeded?.(); } } }); - useHotkeys('k', () => { + useHotkeys('k. shift+k', () => { // focus on previous status after active status // Traverses .timeline li .status-link, focus on .status-link - const activeStatus = document.activeElement.closest('.status-link'); + const activeStatus = document.activeElement.closest( + '.status-link, .status-boost-link', + ); const activeStatusRect = activeStatus?.getBoundingClientRect(); + const allStatusLinks = Array.from( + scrollableRef.current.querySelectorAll( + '.status-link, .status-boost-link', + ), + ); if ( activeStatus && activeStatusRect.top < scrollableRef.current.clientHeight && activeStatusRect.bottom > 0 ) { - const prevStatus = activeStatus.parentElement.previousElementSibling; + const activeStatusIndex = allStatusLinks.indexOf(activeStatus); + let prevStatus = allStatusLinks[activeStatusIndex - 1]; + if (handler.shift) { + // get prev status that's not .status-boost-link + prevStatus = allStatusLinks.find( + (statusLink, index) => + index < activeStatusIndex && + !statusLink.classList.contains('status-boost-link'), + ); + } if (prevStatus) { - const statusLink = prevStatus.querySelector('.status-link'); - if (statusLink) { - statusLink.focus(); - } + prevStatus.focus(); + prevStatus.scrollIntoViewIfNeeded?.(); } } else { // If active status is not in viewport, get the topmost status-link in viewport - const statusLinks = document.querySelectorAll( - '.timeline li .status-link', - ); - let topmostStatusLink; - for (const statusLink of statusLinks) { + const topmostStatusLink = allStatusLinks.find((statusLink) => { const statusLinkRect = statusLink.getBoundingClientRect(); - if (statusLinkRect.top >= 44) { - // 44 is the magic number for header height, not real - topmostStatusLink = statusLink; - break; - } - } + return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real + }); if (topmostStatusLink) { topmostStatusLink.focus(); + topmostStatusLink.scrollIntoViewIfNeeded?.(); } } }); useHotkeys(['enter', 'o'], () => { // open active status - const activeStatus = document.activeElement.closest('.status-link'); + const activeStatus = document.activeElement.closest( + '.status-link, .status-boost-link', + ); if (activeStatus) { activeStatus.click(); } }); - const { scrollDirection, reachTop, nearReachTop, nearReachBottom } = - useScroll({ - scrollableElement: scrollableRef.current, - distanceFromTop: 0.1, - distanceFromBottom: 0.15, - }); + const { + scrollDirection, + reachStart, + nearReachStart, + nearReachEnd, + reachEnd, + } = useScroll({ + scrollableElement: scrollableRef.current, + distanceFromStart: 1, + distanceFromEnd: 3, + scrollThresholdStart: 44, + }); useEffect(() => { - if (nearReachBottom && showMore) { + if (nearReachEnd || (reachEnd && showMore)) { loadStatuses(); } - }, [nearReachBottom]); + }, [nearReachEnd, reachEnd]); useEffect(() => { - if (reachTop) { + if (reachStart) { loadStatuses(true); } - }, [reachTop]); + }, [reachStart]); + + useEffect(() => { + (async () => { + const keys = await db.drafts.keys(); + if (keys.length) { + const ns = getCurrentAccountNS(); + const ownKeys = keys.filter((key) => key.startsWith(ns)); + if (ownKeys.length) { + states.showDrafts = true; + } + } + })(); + }, []); return (

+ ); +} + +export default memo(Home); diff --git a/src/pages/notifications.jsx b/src/pages/notifications.jsx index cf425c83..738f6971 100644 --- a/src/pages/notifications.jsx +++ b/src/pages/notifications.jsx @@ -1,6 +1,7 @@ import './notifications.css'; import { Link } from 'preact-router/match'; +import { memo } from 'preact/compat'; import { useEffect, useRef, useState } from 'preact/hooks'; import { useSnapshot } from 'valtio'; @@ -84,32 +85,42 @@ function Notification({ notification }) {
{type !== 'mention' && ( -

- {!/poll|update/i.test(type) && ( - <> - {_accounts?.length > 1 ? ( - <> - {_accounts.length} people{' '} - - ) : ( - <> - {' '} - - )} - + <> +

+ {!/poll|update/i.test(type) && ( + <> + {_accounts?.length > 1 ? ( + <> + {_accounts.length} people{' '} + + ) : ( + <> + {' '} + + )} + + )} + {text} + {type === 'mention' && ( + + {' '} + •{' '} + + + )} +

+ {type === 'follow_request' && ( + { + loadNotifications(true); + }} + /> )} - {text} - {type === 'mention' && ( - - {' '} - •{' '} - - - )} -

+ )} {_accounts?.length > 1 && (

@@ -205,6 +216,8 @@ function Notifications() { const [showMore, setShowMore] = useState(false); const [onlyMentions, setOnlyMentions] = useState(false); + console.debug('RENDER Notifications'); + const notificationsIterator = useRef( masto.v1.notifications.list({ limit: LIMIT, @@ -224,7 +237,7 @@ function Notifications() { } const notificationsValues = allNotifications.value.map((notification) => { if (notification.status) { - states.statuses.set(notification.status.id, notification.status); + states.statuses[notification.status.id] = notification.status; } return notification; }); @@ -411,4 +424,50 @@ function Notifications() { ); } -export default Notifications; +function FollowRequestButtons({ accountID, onChange }) { + const [uiState, setUIState] = useState('default'); + return ( +

+ {' '} + +

+ ); +} + +export default memo(Notifications); diff --git a/src/pages/settings.css b/src/pages/settings.css index 6805b463..0f786504 100644 --- a/src/pages/settings.css +++ b/src/pages/settings.css @@ -1,12 +1,28 @@ +#settings-container { + background-color: var(--bg-faded-color); +} + #settings-container h2 { - font-size: 0.9em; + font-size: 85%; text-transform: uppercase; color: var(--text-insignificant-color); + font-weight: normal; } #settings-container h2 ~ h2 { margin-top: 2em; } +#settings-container :is(section, .section) { + background-color: var(--bg-color); + margin: 0 -16px; + padding: 8px 16px; + border-top: var(--hairline-width) solid var(--outline-color); + border-bottom: var(--hairline-width) solid var(--outline-color); +} +#settings-container :is(section, .section) > li + li { + border-top: var(--hairline-width) solid var(--outline-color); +} + #settings-container ul { margin: 0; padding: 0; @@ -82,3 +98,10 @@ #settings-container .radio-group label:has(input:checked) input:checked + span { color: inherit; } + +@media (min-width: 40em) { + #settings-container :is(section, .section) { + margin-inline: 0; + border-radius: 8px; + } +} diff --git a/src/pages/settings.jsx b/src/pages/settings.jsx index fbc6ef37..60b55d38 100644 --- a/src/pages/settings.jsx +++ b/src/pages/settings.jsx @@ -1,6 +1,7 @@ import './settings.css'; import { useRef, useState } from 'preact/hooks'; +import { useSnapshot } from 'valtio'; import Avatar from '../components/avatar'; import Icon from '../components/icon'; @@ -16,6 +17,7 @@ import store from '../utils/store'; */ function Settings({ onClose }) { + const snapStates = useSnapshot(states); // Accounts const accounts = store.local.getJSON('accounts'); const currentAccount = store.session.get('currentAccount'); @@ -31,187 +33,234 @@ function Settings({ onClose }) { */}

Accounts

-
    - {accounts.map((account, i) => { - const isCurrent = account.info.id === currentAccount; - const isDefault = i === (currentDefault || 0); - return ( -
  • -
    - {moreThanOneAccount && ( - - - - )} - - { - states.showAccount = `${account.info.username}@${account.instanceURL}`; - }} - /> -
    -
    - {isDefault && moreThanOneAccount && ( - <> - Default{' '} - - )} - {!isCurrent && ( - - )} +
    +
      + {accounts.map((account, i) => { + const isCurrent = account.info.id === currentAccount; + const isDefault = i === (currentDefault || 0); + return ( +
    • - {!isDefault && moreThanOneAccount && ( + {moreThanOneAccount && ( + + + + )} + + { + states.showAccount = `${account.info.username}@${account.instanceURL}`; + }} + /> +
      +
      + {isDefault && moreThanOneAccount && ( + <> + Default{' '} + + )} + {!isCurrent && ( )} - {isCurrent && ( - <> - {' '} +
      + {!isDefault && moreThanOneAccount && ( - - )} + )} + {isCurrent && ( + <> + {' '} + + + )} +
      +
    • + ); + })} +
    + {moreThanOneAccount && ( +

    + + Note: Default account will always be used for first load. + Switched accounts will persist during the session. + +

    + )} +

    + + Add new account + +

    +
    +

    Settings

    +
      +
    • +
      + +
      +
      +
      { + console.log(e); + e.preventDefault(); + const formData = new FormData(themeFormRef.current); + const theme = formData.get('theme'); + const html = document.documentElement; + + if (theme === 'auto') { + html.classList.remove('is-light', 'is-dark'); + } else { + html.classList.toggle('is-light', theme === 'light'); + html.classList.toggle('is-dark', theme === 'dark'); + } + document + .querySelector('meta[name="color-scheme"]') + .setAttribute('content', theme); + + if (theme === 'auto') { + store.local.del('theme'); + } else { + store.local.set('theme', theme); + } + }} + > +
      + + +
      -
    • - ); - })} + +
    +
  • +
  • + +
- {moreThanOneAccount && ( -

- - Note: Default account will always be used for first load. - Switched accounts will persist during the session. - -

- )} -

- - Add new account - -

-

Theme

-
{ - console.log(e); - e.preventDefault(); - const formData = new FormData(themeFormRef.current); - const theme = formData.get('theme'); - const html = document.documentElement; - - if (theme === 'auto') { - html.classList.remove('is-light', 'is-dark'); - } else { - html.classList.toggle('is-light', theme === 'light'); - html.classList.toggle('is-dark', theme === 'dark'); - } - document - .querySelector('meta[name="color-scheme"]') - .setAttribute('content', theme); - - if (theme === 'auto') { - store.local.del('theme'); - } else { - store.local.set('theme', theme); - } - }} - > -
- - - +

Hidden features

+
+
+
- +

About

-

- - Built - {' '} - by{' '} - - @cheeaun - - . -

- {__BUILD_TIME__ && ( +

- Last build: {' '} - {__COMMIT_HASH__ && ( - <> - ( - - {__COMMIT_HASH__} - - ) - - )} + + Built + {' '} + by{' '} + { + e.preventDefault(); + states.showAccount = 'cheeaun@mastodon.social'; + }} + > + @cheeaun + + .

- )} + {__BUILD_TIME__ && ( +

+ Last build: {' '} + {__COMMIT_HASH__ && ( + <> + ( + + {__COMMIT_HASH__} + + ) + + )} +

+ )} +
); diff --git a/src/pages/status.css b/src/pages/status.css index fe827489..032862f3 100644 --- a/src/pages/status.css +++ b/src/pages/status.css @@ -31,3 +31,11 @@ .hero-heading .insignificant { font-weight: normal; } + +.ancestors-indicator { + font-size: 70% !important; +} +.ancestors-indicator[hidden] { + opacity: 0; + pointer-events: none; +} diff --git a/src/pages/status.jsx b/src/pages/status.jsx index d6d065ed..c0ce9e73 100644 --- a/src/pages/status.jsx +++ b/src/pages/status.jsx @@ -1,15 +1,8 @@ import './status.css'; import debounce from 'just-debounce-it'; -import { route } from 'preact-router'; import { Link } from 'preact-router/match'; -import { - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'preact/hooks'; +import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useHotkeys } from 'react-hotkeys-hook'; import { InView } from 'react-intersection-observer'; import { useSnapshot } from 'valtio'; @@ -21,9 +14,11 @@ import RelativeTime from '../components/relative-time'; import Status from '../components/status'; import htmlContentLength from '../utils/html-content-length'; import shortenNumber from '../utils/shorten-number'; -import states from '../utils/states'; +import states, { saveStatus, threadifyStatus } from '../utils/states'; import store from '../utils/store'; +import { getCurrentAccount } from '../utils/store-utils'; import useDebouncedCallback from '../utils/useDebouncedCallback'; +import useScroll from '../utils/useScroll'; import useTitle from '../utils/useTitle'; const LIMIT = 40; @@ -32,7 +27,6 @@ function StatusPage({ id }) { const snapStates = useSnapshot(states); const [statuses, setStatuses] = useState([]); const [uiState, setUIState] = useState('default'); - const userInitiated = useRef(true); // Initial open is user-initiated const heroStatusRef = useRef(); const scrollableRef = useRef(); @@ -44,63 +38,63 @@ function StatusPage({ id }) { // console.log('onScroll'); if (!scrollableRef.current) return; const { scrollTop } = scrollableRef.current; - states.scrollPositions.set(id, scrollTop); + if (uiState !== 'loading') { + states.scrollPositions[id] = scrollTop; + } }, 100); scrollableRef.current.addEventListener('scroll', onScroll, { passive: true, }); onScroll(); return () => { + onScroll.cancel(); scrollableRef.current?.removeEventListener('scroll', onScroll); }; - }, [id]); + }, [id, uiState !== 'loading']); + const scrollOffsets = useRef(); + const cachedStatusesMap = useRef({}); const initContext = () => { + console.debug('initContext', id); setUIState('loading'); let heroTimer; - const cachedStatuses = store.session.getJSON('statuses-' + id); + const cachedStatuses = cachedStatusesMap.current[id]; if (cachedStatuses) { // Case 1: It's cached, let's restore them to make it snappy const reallyCachedStatuses = cachedStatuses.filter( - (s) => states.statuses.has(s.id), + (s) => states.statuses[s.id], // Some are not cached in the global state, so we need to filter them out ); setStatuses(reallyCachedStatuses); } else { - const heroIndex = statuses.findIndex((s) => s.id === id); - if (heroIndex !== -1) { - // Case 2: It's in current statuses. Slice off all descendant statuses after the hero status to be safe - const slicedStatuses = statuses.slice(0, heroIndex + 1); - setStatuses(slicedStatuses); - } else { - // Case 3: Not cached and not in statuses, let's start from scratch - setStatuses([{ id }]); - } + // const heroIndex = statuses.findIndex((s) => s.id === id); + // if (heroIndex !== -1) { + // // Case 2: It's in current statuses. Slice off all descendant statuses after the hero status to be safe + // const slicedStatuses = statuses.slice(0, heroIndex + 1); + // setStatuses(slicedStatuses); + // } else { + // Case 3: Not cached and not in statuses, let's start from scratch + setStatuses([{ id }]); + // } } (async () => { const heroFetch = () => masto.v1.statuses.fetch(id); const contextFetch = masto.v1.statuses.fetchContext(id); - const hasStatus = snapStates.statuses.has(id); - let heroStatus = snapStates.statuses.get(id); + const hasStatus = !!snapStates.statuses[id]; + let heroStatus = snapStates.statuses[id]; if (hasStatus) { - console.log('Hero status is cached'); - // NOTE: This might conflict if the user interacts with the status before the fetch is done, e.g. favouriting it - // heroTimer = setTimeout(async () => { - // try { - // heroStatus = await heroFetch(); - // states.statuses.set(id, heroStatus); - // } catch (e) { - // // Silent fail if status is cached - // console.error(e); - // } - // }, 1000); + console.debug('Hero status is cached'); } else { try { heroStatus = await heroFetch(); - states.statuses.set(id, heroStatus); + saveStatus(heroStatus); + // Give time for context to appear + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); } catch (e) { console.error(e); setUIState('error'); @@ -113,11 +107,11 @@ function StatusPage({ id }) { const { ancestors, descendants } = context; ancestors.forEach((status) => { - states.statuses.set(status.id, status); + states.statuses[status.id] = status; }); const nestedDescendants = []; descendants.forEach((status) => { - states.statuses.set(status.id, status); + states.statuses[status.id] = status; if (status.inReplyToAccountId === status.account.id) { // If replying to self, it's part of the thread, level 1 nestedDescendants.push(status); @@ -162,9 +156,18 @@ function StatusPage({ id }) { ]; setUIState('default'); + scrollOffsets.current = { + offsetTop: heroStatusRef.current?.offsetTop, + scrollTop: scrollableRef.current?.scrollTop, + }; console.log({ allStatuses }); setStatuses(allStatuses); - store.session.setJSON('statuses-' + id, allStatuses); + cachedStatusesMap.current[id] = allStatuses; + + // Let's threadify this one + // Note that all non-hero statuses will trigger saveStatus which will threadify them too + // By right, at this point, all descendant statuses should be cached + threadifyStatus(heroStatus); } catch (e) { console.error(e); setUIState('error'); @@ -177,16 +180,42 @@ function StatusPage({ id }) { }; useEffect(initContext, [id]); + useEffect(() => { + if (!statuses.length) return; + console.debug('STATUSES', statuses); + const scrollPosition = states.scrollPositions[id]; + console.debug('scrollPosition', scrollPosition); + if (!!scrollPosition) { + console.debug('Case 1', { + scrollPosition, + }); + scrollableRef.current.scrollTop = scrollPosition; + } else if (scrollOffsets.current) { + const newScrollOffsets = { + offsetTop: heroStatusRef.current?.offsetTop, + scrollTop: scrollableRef.current?.scrollTop, + }; + const newScrollTop = + newScrollOffsets.offsetTop - scrollOffsets.current.offsetTop; + console.debug('Case 2', { + scrollOffsets: scrollOffsets.current, + newScrollOffsets, + newScrollTop, + statuses: [...statuses], + }); + scrollableRef.current.scrollTop = newScrollTop; + } + + // RESET + scrollOffsets.current = null; + }, [statuses]); useEffect(() => { + if (snapStates.reloadStatusPage <= 0) return; // Delete the cache for the context (async () => { try { - const accounts = store.local.getJSON('accounts') || []; - const currentAccount = store.session.get('currentAccount'); - const account = - accounts.find((a) => a.info.id === currentAccount) || accounts[0]; - const instanceURL = account.instanceURL; + const { instanceURL } = getCurrentAccount(); const contextURL = `https://${instanceURL}/api/v1/statuses/${id}/context`; console.log('Clear cache', contextURL); const apiCache = await caches.open('api'); @@ -199,55 +228,16 @@ function StatusPage({ id }) { })(); }, [snapStates.reloadStatusPage]); - const firstLoad = useRef(true); + useEffect(() => { + return () => { + // RESET + states.scrollPositions = {}; + states.reloadStatusPage = 0; + cachedStatusesMap.current = {}; + }; + }, []); - useLayoutEffect(() => { - if (!statuses.length) return; - const isLoading = uiState === 'loading'; - if (userInitiated.current) { - const hasAncestors = statuses.findIndex((s) => s.id === id) > 0; // Cannot use `ancestor` key because the hero state is dynamic - if (!isLoading && hasAncestors) { - // Case 1: User initiated, has ancestors, after statuses are loaded, SNAP to hero status - console.log('Case 1'); - heroStatusRef.current?.scrollIntoView(); - } else if (isLoading && statuses.length > 1) { - if (firstLoad.current) { - // Case 2.1: User initiated, first load, don't smooth scroll anything - console.log('Case 2.1'); - heroStatusRef.current?.scrollIntoView(); - } else { - // Case 2.2: User initiated, while statuses are loading, SMOOTH-SCROLL to hero status - console.log('Case 2.2'); - heroStatusRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - } - } - } else { - const scrollPosition = states.scrollPositions.get(id); - if (scrollPosition && scrollableRef.current) { - // Case 3: Not user initiated (e.g. back/forward button), restore to saved scroll position - console.log('Case 3'); - scrollableRef.current.scrollTop = scrollPosition; - } - } - console.log('No case', { - isLoading, - userInitiated: userInitiated.current, - statusesLength: statuses.length, - firstLoad: firstLoad.current, - // scrollPosition, - }); - - if (!isLoading) { - // Reset user initiated flag after statuses are loaded - userInitiated.current = false; - firstLoad.current = false; - } - }, [statuses, uiState]); - - const heroStatus = snapStates.statuses.get(id); + const heroStatus = snapStates.statuses[id]; const heroDisplayName = useMemo(() => { // Remove shortcodes from display name if (!heroStatus) return ''; @@ -293,6 +283,7 @@ function StatusPage({ id }) { const hasManyStatuses = statuses.length > LIMIT; const hasDescendants = statuses.some((s) => s.descendant); + const ancestors = statuses.filter((s) => s.ancestor); const [heroInView, setHeroInView] = useState(true); const onView = useDebouncedCallback(setHeroInView, 100); @@ -307,6 +298,11 @@ function StatusPage({ id }) { location.hash = closeLink; }); + const { nearReachStart } = useScroll({ + scrollableElement: scrollableRef.current, + distanceFromStart: 0.5, + }); + return (
@@ -330,6 +326,10 @@ function StatusPage({ id }) { }); } }} + onDblClick={(e) => { + // reload statuses + states.reloadStatusPage++; + }} > {/*
@@ -356,7 +356,29 @@ function StatusPage({ id }) { ) : ( - 'Status' + <> + Status{' '} + + )}
@@ -399,9 +421,6 @@ function StatusPage({ id }) { status-link " href={`#/s/${statusID}`} - onClick={() => { - userInitiated.current = true; - }} > { - userInitiated.current = true; - }} /> )} {uiState === 'loading' && diff --git a/src/utils/db.js b/src/utils/db.js new file mode 100644 index 00000000..5db67ffd --- /dev/null +++ b/src/utils/db.js @@ -0,0 +1,28 @@ +import { + clear, + createStore, + del, + delMany, + get, + getMany, + keys, + set, +} from 'idb-keyval'; + +const draftsStore = createStore('drafts-db', 'drafts-store'); + +// Add additonal `draftsStore` parameter to all methods + +const drafts = { + set: (key, val) => set(key, val, draftsStore), + get: (key) => get(key, draftsStore), + getMany: (keys) => getMany(keys, draftsStore), + del: (key) => del(key, draftsStore), + delMany: (keys) => delMany(keys, draftsStore), + clear: () => clear(draftsStore), + keys: () => keys(draftsStore), +}; + +export default { + drafts, +}; diff --git a/src/utils/open-compose.js b/src/utils/open-compose.js index 48579c4e..c50a7bc5 100644 --- a/src/utils/open-compose.js +++ b/src/utils/open-compose.js @@ -5,9 +5,10 @@ export default function openCompose(opts) { const top = Math.max(0, (screenHeight - 450) / 2); const width = Math.min(screenWidth, 600); const height = Math.min(screenHeight, 450); + const winUID = opts.uid || Math.random(); const newWin = window.open( url, - 'compose' + Math.random(), + 'compose' + winUID, `width=${width},height=${height},left=${left},top=${top}`, ); diff --git a/src/utils/states.js b/src/utils/states.js index de3f3146..9726e008 100644 --- a/src/utils/states.js +++ b/src/utils/states.js @@ -1,22 +1,98 @@ -import { proxy } from 'valtio'; -import { proxyMap } from 'valtio/utils'; +import { proxy, subscribe } from 'valtio'; -export default proxy({ +import store from './store'; + +const states = proxy({ history: [], - statuses: proxyMap([]), + statuses: {}, + statusThreadNumber: {}, home: [], + specialHome: [], homeNew: [], homeLastFetchTime: null, notifications: [], notificationsNew: [], notificationsLastFetchTime: null, - accounts: new Map(), + accounts: {}, reloadStatusPage: 0, - spoilers: proxyMap([]), - scrollPositions: new Map(), + spoilers: {}, + scrollPositions: {}, // Modals showCompose: false, showSettings: false, showAccount: false, + showDrafts: false, composeCharacterCount: 0, + settings: { + boostsCarousel: store.local.get('settings:boostsCarousel') + ? store.local.get('settings:boostsCarousel') + : true, + }, }); +export default states; + +subscribe(states.settings, () => { + store.local.set( + 'settings:boostsCarousel', + states.settings.boostsCarousel ? '1' : '0', + ); +}); + +export function saveStatus(status, opts) { + const { override, skipThreading } = Object.assign( + { override: true, skipThreading: false }, + opts, + ); + if (!status) return; + if (!override && states.statuses[status.id]) return; + states.statuses[status.id] = status; + if (status.reblog) { + states.statuses[status.reblog.id] = status.reblog; + } + + // THREAD TRAVERSER + if (!skipThreading) { + requestAnimationFrame(() => { + threadifyStatus(status); + if (status.reblog) { + threadifyStatus(status.reblog); + } + }); + } +} + +export function threadifyStatus(status) { + // Return all statuses in the thread, via inReplyToId, if inReplyToAccountId === account.id + let fetchIndex = 0; + async function traverse(status, index = 0) { + const { inReplyToId, inReplyToAccountId } = status; + if (!inReplyToId || inReplyToAccountId !== status.account.id) { + return [status]; + } + if (inReplyToId && inReplyToAccountId !== status.account.id) { + throw 'Not a thread'; + // Possibly thread of replies by multiple people? + } + let prevStatus = states.statuses[inReplyToId]; + if (!prevStatus) { + if (fetchIndex++ > 3) throw 'Too many fetches for thread'; // Some people revive old threads + await new Promise((r) => setTimeout(r, 500 * fetchIndex)); // Be nice to rate limits + prevStatus = await masto.v1.statuses.fetch(inReplyToId); + saveStatus(prevStatus, { skipThreading: true }); + } + // Prepend so that first status in thread will be index 0 + return [...(await traverse(prevStatus, ++index)), status]; + } + return traverse(status) + .then((statuses) => { + if (statuses.length > 1) { + console.debug('THREAD', statuses); + statuses.forEach((status, index) => { + states.statusThreadNumber[status.id] = index + 1; + }); + } + }) + .catch((e) => { + console.error(e, status); + }); +} diff --git a/src/utils/store-utils.js b/src/utils/store-utils.js new file mode 100644 index 00000000..51763288 --- /dev/null +++ b/src/utils/store-utils.js @@ -0,0 +1,18 @@ +import store from './store'; + +export function getCurrentAccount() { + const accounts = store.local.getJSON('accounts') || []; + const currentAccount = store.session.get('currentAccount'); + const account = + accounts.find((a) => a.info.id === currentAccount) || accounts[0]; + return account; +} + +export function getCurrentAccountNS() { + const account = getCurrentAccount(); + const { + instanceURL, + info: { id }, + } = account; + return `${id}@${instanceURL}`; +} diff --git a/src/utils/useInterval.js b/src/utils/useInterval.js new file mode 100644 index 00000000..c26aa8e0 --- /dev/null +++ b/src/utils/useInterval.js @@ -0,0 +1,22 @@ +// useInterval with Preact +import { useEffect, useRef } from 'preact/hooks'; + +export default function useInterval(callback, delay) { + const savedCallback = useRef(); + + // Remember the latest callback. + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + // Set up the interval. + useEffect(() => { + function tick() { + savedCallback.current(); + } + if (delay !== null) { + let id = setInterval(tick, delay); + return () => clearInterval(id); + } + }, [delay]); +} diff --git a/src/utils/useScroll.js b/src/utils/useScroll.js index eb84024a..4c82a852 100644 --- a/src/utils/useScroll.js +++ b/src/utils/useScroll.js @@ -1,43 +1,85 @@ import { useEffect, useState } from 'preact/hooks'; export default function useScroll({ - scrollableElement = window, - distanceFromTop = 0, - distanceFromBottom = 0, - scrollThreshold = 10, + scrollableElement, + distanceFromStart = 1, // ratio of clientHeight/clientWidth + distanceFromEnd = 1, // ratio of clientHeight/clientWidth + scrollThresholdStart = 10, + scrollThresholdEnd = 10, + direction = 'vertical', } = {}) { const [scrollDirection, setScrollDirection] = useState(null); - const [reachTop, setReachTop] = useState(false); - const [nearReachTop, setNearReachTop] = useState(false); - const [nearReachBottom, setNearReachBottom] = useState(false); + const [reachStart, setReachStart] = useState(false); + const [reachEnd, setReachEnd] = useState(false); + const [nearReachStart, setNearReachStart] = useState(false); + const [nearReachEnd, setNearReachEnd] = useState(false); + const isVertical = direction === 'vertical'; + + if (!scrollableElement) { + // Better be explicit instead of auto-assign to window + return {}; + } useEffect(() => { - let previousScrollTop = scrollableElement.scrollTop; + let previousScrollStart = isVertical + ? scrollableElement.scrollTop + : scrollableElement.scrollLeft; function onScroll() { - const { scrollTop, scrollHeight, clientHeight } = scrollableElement; - const scrollDistance = Math.abs(scrollTop - previousScrollTop); - const distanceFromTopPx = - scrollHeight * Math.min(1, Math.max(0, distanceFromTop)); - const distanceFromBottomPx = - scrollHeight * Math.min(1, Math.max(0, distanceFromBottom)); + const { + scrollTop, + scrollLeft, + scrollHeight, + scrollWidth, + clientHeight, + clientWidth, + } = scrollableElement; + const scrollStart = isVertical ? scrollTop : scrollLeft; + const scrollDimension = isVertical ? scrollHeight : scrollWidth; + const clientDimension = isVertical ? clientHeight : clientWidth; + const scrollDistance = Math.abs(scrollStart - previousScrollStart); + const distanceFromStartPx = clientDimension * distanceFromStart; + const distanceFromEndPx = clientDimension * distanceFromEnd; - if (scrollDistance >= scrollThreshold) { - setScrollDirection(previousScrollTop < scrollTop ? 'down' : 'up'); - previousScrollTop = scrollTop; + if ( + scrollDistance >= + (previousScrollStart < scrollStart + ? scrollThresholdEnd + : scrollThresholdStart) + ) { + setScrollDirection(previousScrollStart < scrollStart ? 'end' : 'start'); + previousScrollStart = scrollStart; } - setReachTop(scrollTop === 0); - setNearReachTop(scrollTop <= distanceFromTopPx); - setNearReachBottom( - scrollTop + clientHeight >= scrollHeight - distanceFromBottomPx, + setReachStart(scrollStart === 0); + setReachEnd(scrollStart + clientDimension >= scrollDimension); + setNearReachStart(scrollStart <= distanceFromStartPx); + setNearReachEnd( + scrollStart + clientDimension >= scrollDimension - distanceFromEndPx, ); } scrollableElement.addEventListener('scroll', onScroll, { passive: true }); return () => scrollableElement.removeEventListener('scroll', onScroll); - }, [scrollableElement, distanceFromTop, distanceFromBottom, scrollThreshold]); + }, [ + scrollableElement, + distanceFromStart, + distanceFromEnd, + scrollThresholdStart, + scrollThresholdEnd, + ]); - return { scrollDirection, reachTop, nearReachTop, nearReachBottom }; + return { + scrollDirection, + reachStart, + reachEnd, + nearReachStart, + nearReachEnd, + init: () => { + if (scrollableElement) { + scrollableElement.dispatchEvent(new Event('scroll')); + } + }, + }; }