commit
6ecc015199
21
README.md
21
README.md
|
@ -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
|
||||
|
|
|
@ -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.
|
@ -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
224
package-lock.json
generated
|
@ -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": {
|
||||
|
|
11
package.json
11
package.json
|
@ -11,13 +11,15 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@github/text-expander-element": "~2.3.0",
|
||||
"@iconify-icons/mingcute": "~1.2.3",
|
||||
"dayjs": "~1.11.7",
|
||||
"dayjs-twitter": "~0.5.0",
|
||||
"fast-blurhash": "~1.1.2",
|
||||
"fast-deep-equal": "~3.1.3",
|
||||
"history": "~5.3.0",
|
||||
"iconify-icon": "~1.0.2",
|
||||
"idb-keyval": "~6.2.0",
|
||||
"just-debounce-it": "~3.2.0",
|
||||
"masto": "~5.1.1",
|
||||
"masto": "~5.5.0",
|
||||
"mem": "~9.0.2",
|
||||
"preact": "~10.11.3",
|
||||
"preact-router": "~4.1.0",
|
||||
|
@ -26,14 +28,15 @@
|
|||
"string-length": "~5.0.1",
|
||||
"swiped-events": "~1.1.7",
|
||||
"toastify-js": "~1.12.0",
|
||||
"uid": "~2.0.1",
|
||||
"use-resize-observer": "~9.1.0",
|
||||
"valtio": "~1.8.2"
|
||||
"valtio": "~1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "~2.5.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "~4.0.0",
|
||||
"autoprefixer": "~10.4.13",
|
||||
"postcss": "~8.4.20",
|
||||
"postcss": "~8.4.21",
|
||||
"postcss-dark-theme-class": "~0.7.3",
|
||||
"twitter-text": "~3.1.0",
|
||||
"vite": "~4.0.4",
|
||||
|
|
|
@ -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
|
||||
|
|
156
src/app.css
156
src/app.css
|
@ -4,7 +4,7 @@ body {
|
|||
padding: 0;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
/* overflow: hidden; */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
|
@ -32,6 +32,10 @@ a.mention span {
|
|||
a.mention span {
|
||||
color: var(--text-color);
|
||||
}
|
||||
a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||
color: var(--link-visited-color);
|
||||
text-decoration-color: var(--link-visited-color);
|
||||
}
|
||||
|
||||
.deck-container {
|
||||
width: 100%;
|
||||
|
@ -75,14 +79,14 @@ a.mention span {
|
|||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.deck header {
|
||||
.deck > header {
|
||||
min-height: 3em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: var(--bg-blur-color);
|
||||
background-image: linear-gradient(to bottom, var(--bg-color), transparent);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
border-bottom: var(--hairline-width) solid var(--divider-color);
|
||||
z-index: 1;
|
||||
cursor: default;
|
||||
z-index: 10;
|
||||
|
@ -93,25 +97,25 @@ a.mention span {
|
|||
transition: transform 0.5s ease-in-out;
|
||||
user-select: none;
|
||||
}
|
||||
.deck header[hidden] {
|
||||
.deck > header[hidden] {
|
||||
transform: translateY(-100%);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
.deck header > .header-side:last-of-type {
|
||||
.deck > header > .header-side:last-of-type {
|
||||
text-align: right;
|
||||
grid-column: 3;
|
||||
}
|
||||
.deck header :is(button, .button).plain {
|
||||
.deck > header :is(button, .button).plain {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.deck header h1 {
|
||||
.deck > header h1 {
|
||||
margin: 0 8px;
|
||||
padding: 0;
|
||||
font-size: 1.2em;
|
||||
text-align: center;
|
||||
}
|
||||
.deck header h1:first-child {
|
||||
.deck > header h1:first-child {
|
||||
text-align: left;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
@ -128,15 +132,15 @@ a.mention span {
|
|||
padding: 0;
|
||||
}
|
||||
.timeline.grow {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
/* min-height: 100vh;
|
||||
min-height: 100dvh; */
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 16px);
|
||||
}
|
||||
.timeline > li {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
border-bottom: var(--hairline-width) solid var(--divider-color);
|
||||
}
|
||||
.timeline.flat > li {
|
||||
border-bottom: none;
|
||||
|
@ -364,15 +368,112 @@ a.mention span {
|
|||
background-color: var(--link-bg-hover-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.status-link:active {
|
||||
.status-link:active:not(:has(:is(.media, button):active)) {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.boost-carousel {
|
||||
background: linear-gradient(
|
||||
to bottom right,
|
||||
var(--reblog-faded-color),
|
||||
transparent 150%
|
||||
);
|
||||
position: relative;
|
||||
}
|
||||
.boost-carousel:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(
|
||||
ellipse 50% 32px at bottom center,
|
||||
var(--reblog-faded-color),
|
||||
transparent
|
||||
),
|
||||
linear-gradient(to top, var(--bg-color), transparent 64px);
|
||||
background-repeat: no-repeat;
|
||||
background-position: bottom center;
|
||||
}
|
||||
.boost-carousel .status-reblog {
|
||||
background-image: none;
|
||||
}
|
||||
.boost-carousel header {
|
||||
padding: 8px 16px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.boost-carousel h3 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
color: var(--reblog-color);
|
||||
text-shadow: 0 1px var(--bg-color);
|
||||
}
|
||||
.boost-carousel ul {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-behavior: smooth;
|
||||
margin: 0;
|
||||
padding: 8px 16px;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
counter-reset: index;
|
||||
}
|
||||
.boost-carousel ul > li {
|
||||
scroll-snap-align: center;
|
||||
scroll-snap-stop: always;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: min(320px, calc(100% - 16px));
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 65vh;
|
||||
max-height: 65dvh;
|
||||
counter-increment: index;
|
||||
position: relative;
|
||||
}
|
||||
.boost-carousel ul > li:before {
|
||||
content: counter(index);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
font-size: 10px;
|
||||
color: var(--reblog-color);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.ui-state {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-boost-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-decoration-line: none;
|
||||
color: inherit;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease-out;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
animation: appear 0.2s ease-out;
|
||||
border: 1px solid var(--outline-color);
|
||||
background-color: var(--bg-blur-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px var(--bg-color);
|
||||
}
|
||||
.status-boost-link:is(:hover, :focus) {
|
||||
background-color: var(--link-bg-hover-color);
|
||||
}
|
||||
.status-boost-link:active:not(:has(:is(.media, button):active)) {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.deck-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
@ -409,6 +510,7 @@ a.mention span {
|
|||
|
||||
.decks {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.deck-close {
|
||||
|
@ -432,7 +534,7 @@ a.mention span {
|
|||
|
||||
@keyframes fade-from-top {
|
||||
0% {
|
||||
transform: translate(-50%, -100%);
|
||||
transform: translate(-50%, -200%);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
|
@ -443,7 +545,7 @@ a.mention span {
|
|||
.updates-button {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
animation: fade-from-top 2s ease-out;
|
||||
animation: fade-from-top 0.3s ease-out;
|
||||
left: 50%;
|
||||
margin-top: 8px;
|
||||
transform: translate(-50%, 0);
|
||||
|
@ -494,6 +596,13 @@ a.mention span {
|
|||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
background-color: var(--average-color-alpha);
|
||||
background-image: radial-gradient(
|
||||
closest-side,
|
||||
var(--average-color) 10%,
|
||||
var(--average-color-alpha) 40%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
.carousel > * :is(img, video) {
|
||||
width: auto;
|
||||
|
@ -619,7 +728,7 @@ button.carousel-dot[disabled].active {
|
|||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
#compose-button[hidden] .icon {
|
||||
transform: rotate(90deg);
|
||||
transform: rotate3d(0, 1, 0, 180deg);
|
||||
}
|
||||
#compose-button:is(:hover, :focus) {
|
||||
background-color: var(--button-bg-color);
|
||||
|
@ -649,6 +758,13 @@ button.carousel-dot[disabled].active {
|
|||
animation: slide-up 0.3s var(--timing-function);
|
||||
border: 1px solid var(--outline-color);
|
||||
}
|
||||
.sheet-max {
|
||||
width: 90vw;
|
||||
width: 90dvw;
|
||||
max-width: none;
|
||||
height: 90vh;
|
||||
height: 90dvh;
|
||||
}
|
||||
.sheet header {
|
||||
padding: 16px 16px 8px;
|
||||
padding-left: max(16px, env(safe-area-inset-left));
|
||||
|
@ -852,7 +968,7 @@ meter.donut:is(.danger, .explode):after {
|
|||
border: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
.timeline-deck header {
|
||||
.timeline-deck > header {
|
||||
min-height: 6em;
|
||||
border-bottom: 0;
|
||||
background-color: var(--bg-faded-blur-color);
|
||||
|
@ -869,10 +985,10 @@ meter.donut:is(.danger, .explode):after {
|
|||
transparent
|
||||
);
|
||||
}
|
||||
.deck header h1 {
|
||||
.deck > header h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.timeline-deck .timeline:not(.flat) li {
|
||||
.timeline-deck .timeline:not(.flat) > li {
|
||||
border: 1px solid var(--divider-color);
|
||||
margin: 16px 0;
|
||||
background-color: var(--bg-color);
|
||||
|
@ -886,4 +1002,8 @@ meter.donut:is(.danger, .explode):after {
|
|||
:is(.carousel-top-controls, .carousel-controls) {
|
||||
padding: 32px;
|
||||
}
|
||||
li:has(.boost-carousel) {
|
||||
width: 95vw;
|
||||
transform: translateX(calc(-50% + 20em));
|
||||
}
|
||||
}
|
||||
|
|
71
src/app.jsx
71
src/app.jsx
|
@ -11,7 +11,7 @@ import { useSnapshot } from 'valtio';
|
|||
|
||||
import Account from './components/account';
|
||||
import Compose from './components/compose';
|
||||
import Icon from './components/icon';
|
||||
import Drafts from './components/drafts';
|
||||
import Loader from './components/loader';
|
||||
import Modal from './components/modal';
|
||||
import Home from './pages/home';
|
||||
|
@ -21,8 +21,7 @@ import Settings from './pages/settings';
|
|||
import Status from './pages/status';
|
||||
import Welcome from './pages/welcome';
|
||||
import { getAccessToken } from './utils/auth';
|
||||
import openCompose from './utils/open-compose';
|
||||
import states from './utils/states';
|
||||
import states, { saveStatus } from './utils/states';
|
||||
import store from './utils/store';
|
||||
|
||||
const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
|
||||
|
@ -133,7 +132,7 @@ function App() {
|
|||
if (currentModal) return;
|
||||
let timer = setTimeout(() => {
|
||||
const page = document.getElementById(`${currentDeck}-page`);
|
||||
console.log('focus', currentDeck, page);
|
||||
console.debug('FOCUS', currentDeck, page);
|
||||
if (page) {
|
||||
page.focus();
|
||||
}
|
||||
|
@ -188,7 +187,7 @@ function App() {
|
|||
<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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
94
src/components/drafts.css
Normal 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
240
src/components/drafts.jsx
Normal 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…
|
||||
</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…
|
||||
</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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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% {
|
||||
|
|
|
@ -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 = () => {} }) {
|
|||
•{' '}
|
||||
</>
|
||||
)}
|
||||
<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 && (
|
||||
<>
|
||||
{' '}
|
||||
• <span title={votesCount}>
|
||||
{shortenNumber(votesCount)}
|
||||
</span>{' '}
|
||||
vote
|
||||
{votesCount === 1 ? '' : 's'}
|
||||
•{' '}
|
||||
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
|
||||
voter
|
||||
{votersCount === 1 ? '' : 's'}
|
||||
</>
|
||||
)}{' '}
|
||||
• {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);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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%;
|
||||
|
|
17
src/main.jsx
17
src/main.jsx
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -31,3 +31,11 @@
|
|||
.hero-heading .insignificant {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.ancestors-indicator {
|
||||
font-size: 70% !important;
|
||||
}
|
||||
.ancestors-indicator[hidden] {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
|
@ -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
28
src/utils/db.js
Normal 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,
|
||||
};
|
|
@ -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}`,
|
||||
);
|
||||
|
||||
|
|
|
@ -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
18
src/utils/store-utils.js
Normal 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
22
src/utils/useInterval.js
Normal 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]);
|
||||
}
|
|
@ -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'));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue