Merge pull request #47 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2023-01-17 18:05:25 +08:00 committed by GitHub
commit 6ecc015199
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1986 additions and 698 deletions

View file

@ -11,8 +11,15 @@ Phanpy
This is an alternative web client for [Mastodon](https://joinmastodon.org/).
🔗 **Production**: https://phanpy.social (`production` branch)<br>
🔗 **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**.<br>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**.<br>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`)**.<br>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**.<br>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**.<br>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

View file

@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Compose / Phanpy</title>
<meta name="color-scheme" content="dark light" />
<meta name="google" content="notranslate" />
</head>
<body>
<div id="app-standalone"></div>

Binary file not shown.

View file

@ -28,6 +28,7 @@
content="#242526"
media="(prefers-color-scheme: dark)"
/>
<meta name="google" content="notranslate" />
</head>
<body>
<div id="app"></div>

224
package-lock.json generated
View file

@ -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": {

View file

@ -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",

View file

@ -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

View file

@ -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));
}
}

View file

@ -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() {
<Router
history={createHashHistory()}
onChange={(e) => {
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() {
<Account account={snapStates.showAccount} />
</Modal>
)}
{!!snapStates.showDrafts && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showDrafts = false;
}
}}
>
<Drafts />
</Modal>
)}
</>
);
}
@ -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

View file

@ -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;
}
}

View file

@ -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<space>", including multiple mentions like "@username1<space>@username2<space>"
@ -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 (
<div id="compose-container" class={standalone ? 'standalone' : ''}>
<div class="compose-top">
@ -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 && (
<div class="media-attachments">
{mediaAttachments.map((attachment, i) => {
const { id } = attachment;
const { id, file } = attachment;
const fileID = file?.size + file?.type + file?.name;
return (
<MediaAttachment
key={i + id}
key={id || fileID || i}
attachment={attachment}
disabled={uiState === 'loading'}
onDescriptionChange={(value) => {
@ -1190,7 +1319,7 @@ function MediaAttachment({
}
}}
>
<div id="media-sheet" class="sheet">
<div id="media-sheet" class="sheet sheet-max">
<header>
<h2>
{

94
src/components/drafts.css Normal file
View file

@ -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);
}

240
src/components/drafts.jsx Normal file
View file

@ -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 (
<div class="sheet">
<header>
<h2>
Unsent drafts <Loader abrupt hidden={uiState !== 'loading'} />
</h2>
{hasDrafts && (
<div class="insignificant">
Looks like you have unsent drafts. Let's continue where you left
off.
</div>
)}
</header>
<main>
{hasDrafts ? (
<>
<ul class="drafts-list">
{drafts.map((draft) => {
const { updatedAt, key, draftStatus, replyTo } = draft;
const currentYear = new Date().getFullYear();
const updatedAtDate = new Date(updatedAt);
return (
<li key={updatedAt}>
<div class="mini-draft-meta">
<b>
<Icon icon={replyTo ? 'reply' : 'quill'} size="s" />{' '}
<time>
{!!replyTo && (
<>
@{replyTo.account.acct}
<br />
</>
)}
{Intl.DateTimeFormat('en', {
// Show year if not current year
year:
updatedAtDate.getFullYear() === currentYear
? undefined
: 'numeric',
month: 'short',
day: 'numeric',
weekday: 'short',
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
}).format(updatedAtDate)}
</time>
</b>
<button
type="button"
class="small light"
disabled={uiState === 'loading'}
onClick={() => {
(async () => {
try {
const yes = confirm(
'Are you sure you want to delete this draft?',
);
if (yes) {
await db.drafts.del(key);
reload();
}
} catch (e) {
alert('Error deleting draft! Please try again.');
}
})();
}}
>
Delete&hellip;
</button>
</div>
<button
type="button"
disabled={uiState === 'loading'}
class="draft-item"
onClick={async () => {
// console.log({ draftStatus });
let replyToStatus;
if (replyTo) {
setUIState('loading');
try {
replyToStatus = await masto.v1.statuses.fetch(
replyTo.id,
);
} catch (e) {
console.error(e);
alert('Error fetching reply-to status!');
setUIState('default');
return;
}
setUIState('default');
}
window.__COMPOSE__ = {
draftStatus,
replyToStatus,
};
states.showCompose = true;
states.showDrafts = false;
}}
>
<MiniDraft draft={draft} />
</button>
</li>
);
})}
</ul>
<p>
<button
type="button"
class="light danger"
disabled={uiState === 'loading'}
onClick={() => {
(async () => {
const yes = confirm(
'Are you sure you want to delete all drafts?',
);
if (yes) {
setUIState('loading');
try {
await db.drafts.delMany(
drafts.map((draft) => draft.key),
);
setUIState('default');
reload();
} catch (e) {
console.error(e);
alert('Error deleting drafts! Please try again.');
setUIState('error');
}
}
})();
}}
>
Delete all drafts&hellip;
</button>
</p>
</>
) : (
<p>No drafts found.</p>
)}
</main>
</div>
);
}
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 (
<>
<div class="mini-draft">
{hasPollOrMedia && (
<div
class={`mini-draft-aside ${firstImageMedia ? 'has-image' : ''}`}
style={
firstImageMedia
? {
'--bg-image': `url(${firstImageMedia})`,
}
: {}
}
>
{hasPoll && <Icon icon="poll" />}
{hasMedia && (
<span>
<Icon icon="attachment" />{' '}
<small>{mediaAttachments?.length}</small>
</span>
)}
</div>
)}
<div class="mini-draft-main">
{!!spoilerText && <div class="mini-draft-spoiler">{spoilerText}</div>}
{!!status && <div class="mini-draft-status">{status}</div>}
</div>
</div>
</>
);
}
export default Drafts;

View file

@ -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 (
<div
class={`icon ${className}`}
@ -70,15 +83,19 @@ function Icon({ icon, size = 'm', alt, title, class: className = '' }) {
lineHeight: 0,
}}
>
<iconify-icon
width={iconSize}
height={iconSize}
icon={iconName}
rotate={rotate}
flip={flip}
>
{alt}
</iconify-icon>
{iconData && (
<svg
width={iconSize}
height={iconSize}
viewBox={`0 0 ${iconData.width} ${iconData.height}`}
dangerouslySetInnerHTML={{ __html: iconData.body }}
style={{
transform: `${rotate ? `rotate(${rotate})` : ''} ${
flip ? `scaleX(-1)` : ''
}`,
}}
/>
)}
</div>
);
}

View file

@ -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% {

View file

@ -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({
{/* </span> */}{' '}
{size !== 'l' &&
(uri ? (
<a href={uri} target="_blank" class="time">
<a
href={`#/s/${id}
`}
class="time"
>
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibility}
@ -267,30 +274,32 @@ function Status({
</span>
))}
</div>
{!!inReplyToId &&
!!inReplyToAccountId &&
!withinContext &&
size !== 's' && (
<>
{inReplyToAccountId === status.account.id ? (
<div class="status-thread-badge">
<Icon icon="thread" size="s" />
Thread
{!withinContext && size !== 's' && (
<>
{inReplyToAccountId === status.account?.id ||
!!snapStates.statusThreadNumber[id] ? (
<div class="status-thread-badge">
<Icon icon="thread" size="s" />
Thread
{snapStates.statusThreadNumber[id]
? ` ${snapStates.statusThreadNumber[id]}/X`
: ''}
</div>
) : (
!!inReplyToId &&
!!inReplyToAccount &&
(!!spoilerText ||
!mentions.find((mention) => {
return mention.id === inReplyToAccountId;
})) && (
<div class="status-reply-badge">
<Icon icon="reply" />{' '}
<NameText account={inReplyToAccount} short />
</div>
) : (
!!inReplyToAccount &&
(!!spoilerText ||
!mentions.find((mention) => {
return mention.id === inReplyToAccountId;
})) && (
<div class="status-reply-badge">
<Icon icon="reply" />{' '}
<NameText account={inReplyToAccount} short />
</div>
)
)}
</>
)}
)
)}
</>
)}
<div
class={`content-container ${
sensitive || spoilerText ? 'has-spoiler' : ''
@ -321,9 +330,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;
}
}}
>
@ -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 && (
<Card
card={card}
size={!poll && !mediaAttachments.length ? 'l' : 'm'}
/>
)}
!mediaAttachments.length && <Card card={card} />}
</div>
{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 = () => {} }) {
</div>
);
} 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 (
<div
class={`media media-${isGIF ? 'gif' : 'video'} ${
autoAnimate ? 'media-contain' : ''
autoGIFAnimate ? 'media-contain' : ''
}`}
data-formatted-duration={formattedDuration}
data-label={isGIF && !showOriginal && !autoAnimate ? 'GIF' : ''}
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
style={{
backgroundColor:
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
@ -795,7 +795,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
}
}}
>
{showOriginal || autoAnimate ? (
{showOriginal || autoGIFAnimate ? (
<div
style={{
width: '100%',
@ -811,7 +811,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
preload="auto"
autoplay
muted="${isGIF}"
${isGIFV ? '' : 'controls'}
${isGIF ? '' : 'controls'}
playsinline
loop="${loopable}"
></video>
@ -843,15 +843,30 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
</div>
);
} else if (type === 'audio') {
const formattedDuration = formatDuration(original.duration);
return (
<div class="media media-audio">
<audio src={remoteUrl || url} preload="none" controls />
<div
class="media media-audio"
data-formatted-duration={formattedDuration}
onClick={onClick}
>
{showOriginal ? (
<audio src={remoteUrl || url} preload="none" controls autoplay />
) : previewUrl ? (
<img
src={previewUrl}
alt={description}
width={width}
height={height}
loading="lazy"
/>
) : null}
</div>
);
}
}
function Card({ card, size }) {
function Card({ card }) {
const {
blurhash,
title,
@ -876,7 +891,7 @@ function Card({ card, size }) {
const hasText = title || providerName || authorName;
const isLandscape = width / height >= 1.2;
size = size === 'l' && isLandscape ? 'large' : '';
const size = isLandscape ? 'large' : '';
if (hasText && image) {
const domain = new URL(url).hostname.replace(/^www\./, '');
@ -887,19 +902,20 @@ function Card({ card, size }) {
rel="nofollow noopener noreferrer"
class={`card link ${size}`}
>
<img
class="image"
src={image}
width={width}
height={height}
loading="lazy"
alt=""
onError={(e) => {
try {
e.target.style.display = 'none';
} catch (e) {}
}}
/>
<div class="card-image">
<img
src={image}
width={width}
height={height}
loading="lazy"
alt=""
onError={(e) => {
try {
e.target.style.display = 'none';
} catch (e) {}
}}
/>
</div>
<div class="meta-container">
<p class="meta domain">{domain}</p>
<p
@ -1122,16 +1138,15 @@ function Poll({ poll, lang, readOnly, onUpdate = () => {} }) {
&bull;{' '}
</>
)}
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
{votersCount === 1 ? 'voter' : 'voters'}
{votersCount !== votesCount && (
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
{votesCount === 1 ? '' : 's'}
{!!votersCount && votersCount !== votesCount && (
<>
{' '}
&bull; <span title={votesCount}>
{shortenNumber(votesCount)}
</span>{' '}
vote
{votesCount === 1 ? '' : 's'}
&bull;{' '}
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
voter
{votersCount === 1 ? '' : 's'}
</>
)}{' '}
&bull; {expired ? 'Ended' : 'Ending'}{' '}
@ -1329,8 +1344,10 @@ function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) {
<InView
class="carousel-item"
style={{
backgroundColor:
rgbAverageColor && `rgba(${rgbAverageColor.join(',')}, .5)`,
'--average-color': `rgb(${rgbAverageColor?.join(',')})`,
'--average-color-alpha': `rgba(${rgbAverageColor?.join(
',',
)}, .5)`,
}}
tabindex="0"
key={media.id}
@ -1441,4 +1458,4 @@ function formatDuration(time) {
}
}
export default Status;
export default memo(Status);

View file

@ -7,7 +7,7 @@ import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import Compose from './components/compose';
import store from './utils/store';
import { getCurrentAccount } from './utils/store-utils';
import useTitle from './utils/useTitle';
if (window.opener) {
@ -18,12 +18,7 @@ if (window.opener) {
if (window.masto) return;
console.warn('window.masto not found. Trying to log in...');
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 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 (
<Compose
editStatus={editStatus}

View file

@ -1,5 +1,6 @@
:root {
text-size-adjust: none;
--hairline-width: 1px;
--blue-color: royalblue;
--purple-color: blueviolet;
@ -16,6 +17,7 @@
--link-light-color: #4169e199;
--link-faded-color: #4169e155;
--link-bg-hover-color: #f0f2f599;
--link-visited-color: mediumslateblue;
--focus-ring-color: var(--link-color);
--button-bg-color: var(--blue-color);
--button-bg-blur-color: #4169e1aa;
@ -24,6 +26,7 @@
--reblog-color: var(--purple-color);
--reblog-faded-color: #892be220;
--reply-to-color: var(--orange-color);
--reply-to-text-color: #b36200;
--favourite-color: var(--red-color);
--reply-to-faded-color: #ffa6001a;
--outline-color: rgba(128, 128, 128, 0.2);
@ -37,12 +40,19 @@
--timing-function: cubic-bezier(0.3, 0.5, 0, 1);
}
@media (min-resolution: 2dppx) {
:root {
--hairline-width: 0.5px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--blue-color: CornflowerBlue;
--purple-color: mediumpurple;
--green-color: lightgreen;
--orange-color: orange;
--reply-to-text-color: var(--reply-to-color);
--bg-color: #242526;
--bg-faded-color: #18191a;
--bg-blur-color: #0009;
@ -231,6 +241,9 @@ select.plain {
background-color: transparent;
}
pre {
tab-size: 2;
}
pre code,
code {
font-size: 90%;

View file

@ -9,3 +9,20 @@ if (import.meta.env.DEV) {
}
render(<App />, 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);

View file

@ -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 (
<div
@ -190,7 +284,7 @@ function Home({ hidden }) {
tabIndex="-1"
>
<button
hidden={scrollDirection === 'down' && !nearReachTop}
hidden={scrollDirection === 'end' && !nearReachStart}
type="button"
id="compose-button"
onClick={(e) => {
@ -209,7 +303,7 @@ function Home({ hidden }) {
</button>
<div class="timeline-deck deck">
<header
hidden={scrollDirection === 'down' && !nearReachTop}
hidden={scrollDirection === 'end' && !nearReachStart}
onClick={() => {
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}}
@ -247,17 +341,19 @@ function Home({ hidden }) {
</div>
</header>
{snapStates.homeNew.length > 0 &&
scrollDirection === 'up' &&
!nearReachTop &&
!nearReachBottom && (
scrollDirection === 'start' &&
!nearReachStart &&
!nearReachEnd && (
<button
class="updates-button"
type="button"
onClick={() => {
const uniqueHomeNew = snapStates.homeNew.filter(
(status) => !states.home.some((s) => s.id === status.id),
);
states.home.unshift(...uniqueHomeNew);
if (!snapStates.settings.boostsCarousel) {
const uniqueHomeNew = snapStates.homeNew.filter(
(status) => !states.home.some((s) => s.id === status.id),
);
states.home.unshift(...uniqueHomeNew);
}
loadStatuses(true);
states.homeNew = [];
@ -273,8 +369,15 @@ function Home({ hidden }) {
{snapStates.home.length ? (
<>
<ul class="timeline">
{snapStates.home.map(({ id: statusID, reblog }) => {
{snapStates.home.map(({ id: statusID, reblog, boosts }) => {
const actualStatusID = reblog || statusID;
if (boosts) {
return (
<li key={statusID}>
<BoostsCarousel boosts={boosts} />
</li>
);
}
return (
<li key={statusID}>
<Link
@ -352,4 +455,64 @@ function Home({ hidden }) {
);
}
export default Home;
function BoostsCarousel({ boosts }) {
const carouselRef = useRef();
const { reachStart, reachEnd, init } = useScroll({
scrollableElement: carouselRef.current,
direction: 'horizontal',
});
useEffect(() => {
init?.();
}, []);
return (
<div class="boost-carousel">
<header>
<h3>{boosts.length} Boosts</h3>
<span>
<button
type="button"
class="small plain2"
disabled={reachStart}
onClick={() => {
carouselRef.current?.scrollBy({
left: -Math.min(320, carouselRef.current?.offsetWidth),
behavior: 'smooth',
});
}}
>
<Icon icon="chevron-left" />
</button>{' '}
<button
type="button"
class="small plain2"
disabled={reachEnd}
onClick={() => {
carouselRef.current?.scrollBy({
left: Math.min(320, carouselRef.current?.offsetWidth),
behavior: 'smooth',
});
}}
>
<Icon icon="chevron-right" />
</button>
</span>
</header>
<ul ref={carouselRef}>
{boosts.map((boost) => {
const { id: statusID, reblog } = boost;
const actualStatusID = reblog || statusID;
return (
<li>
<a class="status-boost-link" href={`#/s/${actualStatusID}`}>
<Status statusID={statusID} size="s" />
</a>
</li>
);
})}
</ul>
</div>
);
}
export default memo(Home);

View file

@ -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 }) {
</div>
<div class="notification-content">
{type !== 'mention' && (
<p>
{!/poll|update/i.test(type) && (
<>
{_accounts?.length > 1 ? (
<>
<b>{_accounts.length} people</b>{' '}
</>
) : (
<>
<NameText account={account} showAvatar />{' '}
</>
)}
</>
<>
<p>
{!/poll|update/i.test(type) && (
<>
{_accounts?.length > 1 ? (
<>
<b>{_accounts.length} people</b>{' '}
</>
) : (
<>
<NameText account={account} showAvatar />{' '}
</>
)}
</>
)}
{text}
{type === 'mention' && (
<span class="insignificant">
{' '}
{' '}
<RelativeTime
datetime={notification.createdAt}
format="micro"
/>
</span>
)}
</p>
{type === 'follow_request' && (
<FollowRequestButtons
accountID={account.id}
onChange={() => {
loadNotifications(true);
}}
/>
)}
{text}
{type === 'mention' && (
<span class="insignificant">
{' '}
{' '}
<RelativeTime
datetime={notification.createdAt}
format="micro"
/>
</span>
)}
</p>
</>
)}
{_accounts?.length > 1 && (
<p class="avatars-stack">
@ -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 (
<p>
<button
type="button"
disabled={uiState === 'loading'}
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v1.followRequests.authorize(accountID);
onChange();
} catch (e) {
console.error(e);
setUIState('default');
}
})();
}}
>
Accept
</button>{' '}
<button
type="button"
disabled={uiState === 'loading'}
class="light danger"
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v1.followRequests.reject(accountID);
onChange();
} catch (e) {
console.error(e);
setUIState('default');
}
})();
}}
>
Reject
</button>
<Loader hidden={uiState !== 'loading'} />
</p>
);
}
export default memo(Notifications);

View file

@ -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;
}
}

View file

@ -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 }) {
<Icon icon="x" alt="Close" />
</button> */}
<h2>Accounts</h2>
<ul class="accounts-list">
{accounts.map((account, i) => {
const isCurrent = account.info.id === currentAccount;
const isDefault = i === (currentDefault || 0);
return (
<li>
<div>
{moreThanOneAccount && (
<span class={`current ${isCurrent ? 'is-current' : ''}`}>
<Icon icon="check-circle" alt="Current" />
</span>
)}
<Avatar url={account.info.avatarStatic} size="xxl" />
<NameText
account={account.info}
showAcct
onClick={() => {
states.showAccount = `${account.info.username}@${account.instanceURL}`;
}}
/>
</div>
<div class="actions">
{isDefault && moreThanOneAccount && (
<>
<span class="tag">Default</span>{' '}
</>
)}
{!isCurrent && (
<button
type="button"
class="light"
onClick={() => {
store.session.set('currentAccount', account.info.id);
location.reload();
}}
>
<Icon icon="transfer" /> Switch
</button>
)}
<section>
<ul class="accounts-list">
{accounts.map((account, i) => {
const isCurrent = account.info.id === currentAccount;
const isDefault = i === (currentDefault || 0);
return (
<li>
<div>
{!isDefault && moreThanOneAccount && (
{moreThanOneAccount && (
<span class={`current ${isCurrent ? 'is-current' : ''}`}>
<Icon icon="check-circle" alt="Current" />
</span>
)}
<Avatar url={account.info.avatarStatic} size="xxl" />
<NameText
account={account.info}
showAcct
onClick={() => {
states.showAccount = `${account.info.username}@${account.instanceURL}`;
}}
/>
</div>
<div class="actions">
{isDefault && moreThanOneAccount && (
<>
<span class="tag">Default</span>{' '}
</>
)}
{!isCurrent && (
<button
type="button"
class="plain small"
class="light"
onClick={() => {
// Move account to the top of the list
accounts.splice(i, 1);
accounts.unshift(account);
store.local.setJSON('accounts', accounts);
setCurrentDefault(i);
store.session.set('currentAccount', account.info.id);
location.reload();
}}
>
Set as default
<Icon icon="transfer" /> Switch
</button>
)}
{isCurrent && (
<>
{' '}
<div>
{!isDefault && moreThanOneAccount && (
<button
type="button"
class="plain small"
onClick={() => {
const yes = confirm(
'Are you sure you want to log out?',
);
if (!yes) return;
// Move account to the top of the list
accounts.splice(i, 1);
accounts.unshift(account);
store.local.setJSON('accounts', accounts);
location.reload();
setCurrentDefault(i);
}}
>
Log out
Set as default
</button>
</>
)}
)}
{isCurrent && (
<>
{' '}
<button
type="button"
class="plain small"
onClick={() => {
const yes = confirm(
'Are you sure you want to log out?',
);
if (!yes) return;
accounts.splice(i, 1);
store.local.setJSON('accounts', accounts);
location.reload();
}}
>
Log out
</button>
</>
)}
</div>
</div>
</li>
);
})}
</ul>
{moreThanOneAccount && (
<p>
<small>
Note: <i>Default</i> account will always be used for first load.
Switched accounts will persist during the session.
</small>
</p>
)}
<p style={{ textAlign: 'end' }}>
<a href="/#/login" class="button" onClick={onClose}>
Add new account
</a>
</p>
</section>
<h2>Settings</h2>
<ul class="section">
<li>
<div>
<label>Appearance</label>
</div>
<div>
<form
ref={themeFormRef}
onInput={(e) => {
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);
}
}}
>
<div class="radio-group">
<label>
<input
type="radio"
name="theme"
value="light"
defaultChecked={currentTheme === 'light'}
/>
<span>Light</span>
</label>
<label>
<input
type="radio"
name="theme"
value="dark"
defaultChecked={currentTheme === 'dark'}
/>
<span>Dark</span>
</label>
<label>
<input
type="radio"
name="theme"
value="auto"
defaultChecked={
currentTheme !== 'light' && currentTheme !== 'dark'
}
/>
<span>Auto</span>
</label>
</div>
</li>
);
})}
</form>
</div>
</li>
<li>
<label>
<input
type="checkbox"
checked={snapStates.settings.boostsCarousel}
onChange={(e) => {
states.settings.boostsCarousel = e.target.checked;
}}
/>{' '}
Boosts carousel (experimental)
</label>
</li>
</ul>
{moreThanOneAccount && (
<p>
<small>
Note: <i>Default</i> account will always be used for first load.
Switched accounts will persist during the session.
</small>
</p>
)}
<p style={{ textAlign: 'end' }}>
<a href="/#/login" class="button" onClick={onClose}>
Add new account
</a>
</p>
<h2>Theme</h2>
<form
ref={themeFormRef}
onInput={(e) => {
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);
}
}}
>
<div class="radio-group">
<label>
<input
type="radio"
name="theme"
value="light"
defaultChecked={currentTheme === 'light'}
/>
<span>Light</span>
</label>
<label>
<input
type="radio"
name="theme"
value="dark"
defaultChecked={currentTheme === 'dark'}
/>
<span>Dark</span>
</label>
<label>
<input
type="radio"
name="theme"
value="auto"
defaultChecked={
currentTheme !== 'light' && currentTheme !== 'dark'
}
/>
<span>Auto</span>
</label>
<h2>Hidden features</h2>
<section>
<div>
<button
type="button"
class="light"
onClick={() => {
states.showDrafts = true;
states.showSettings = false;
}}
>
Unsent drafts
</button>
</div>
</form>
</section>
<h2>About</h2>
<p>
<a href="https://github.com/cheeaun/phanpy" target="_blank">
Built
</a>{' '}
by{' '}
<a href="https://mastodon.social/@cheeaun" target="_blank">
@cheeaun
</a>
.
</p>
{__BUILD_TIME__ && (
<section>
<p>
Last build: <RelativeTime datetime={new Date(__BUILD_TIME__)} />{' '}
{__COMMIT_HASH__ && (
<>
(
<a
href={`https://github.com/cheeaun/phanpy/commit/${__COMMIT_HASH__}`}
target="_blank"
>
<code>{__COMMIT_HASH__}</code>
</a>
)
</>
)}
<a href="https://github.com/cheeaun/phanpy" target="_blank">
Built
</a>{' '}
by{' '}
<a
href="https://mastodon.social/@cheeaun"
// target="_blank"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'cheeaun@mastodon.social';
}}
>
@cheeaun
</a>
.
</p>
)}
{__BUILD_TIME__ && (
<p>
Last build: <RelativeTime datetime={new Date(__BUILD_TIME__)} />{' '}
{__COMMIT_HASH__ && (
<>
(
<a
href={`https://github.com/cheeaun/phanpy/commit/${__COMMIT_HASH__}`}
target="_blank"
>
<code>{__COMMIT_HASH__}</code>
</a>
)
</>
)}
</p>
)}
</section>
</main>
</div>
);

View file

@ -31,3 +31,11 @@
.hero-heading .insignificant {
font-weight: normal;
}
.ancestors-indicator {
font-size: 70% !important;
}
.ancestors-indicator[hidden] {
opacity: 0;
pointer-events: none;
}

View file

@ -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 (
<div class="deck-backdrop">
<Link href={closeLink}></Link>
@ -330,6 +326,10 @@ function StatusPage({ id }) {
});
}
}}
onDblClick={(e) => {
// reload statuses
states.reloadStatusPage++;
}}
>
{/* <div>
<Link class="button plain deck-close" href={closeLink}>
@ -356,7 +356,29 @@ function StatusPage({ id }) {
</span>
</span>
) : (
'Status'
<>
Status{' '}
<button
type="button"
class="ancestors-indicator light small"
onClick={(e) => {
// Scroll to top
e.preventDefault();
e.stopPropagation();
scrollableRef.current.scrollTo({
top: 0,
behavior: 'smooth',
});
}}
hidden={!ancestors.length || nearReachStart}
>
<Icon icon="arrow-up" />
<Icon icon="comment" />{' '}
<span class="insignificant">
{shortenNumber(ancestors.length)}
</span>
</button>
</>
)}
</h1>
<div class="header-side">
@ -399,9 +421,6 @@ function StatusPage({ id }) {
status-link
"
href={`#/s/${statusID}`}
onClick={() => {
userInitiated.current = true;
}}
>
<Status
statusID={statusID}
@ -424,9 +443,6 @@ function StatusPage({ id }) {
<SubComments
hasManyStatuses={hasManyStatuses}
replies={replies}
onStatusLinkClick={() => {
userInitiated.current = true;
}}
/>
)}
{uiState === 'loading' &&

28
src/utils/db.js Normal file
View file

@ -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,
};

View file

@ -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}`,
);

View file

@ -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);
});
}

18
src/utils/store-utils.js Normal file
View file

@ -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}`;
}

22
src/utils/useInterval.js Normal file
View file

@ -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]);
}

View file

@ -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'));
}
},
};
}