Merge pull request #42 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2023-01-06 23:24:13 +08:00 committed by GitHub
commit a7a3d5605b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1565 additions and 754 deletions

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View file

@ -79,10 +79,13 @@ It's been **more than 15 years**.
And here I am. Building a Mastodon web client.
## Alternative clients
## Alternative web clients
- [Pinafore](https://pinafore.social/)
- [Soapbox](https://fe.soapbox.pub/)
- [Elk](https://m.webtoo.ls/@elk)
- [Mastodeck](https://mastodeck.com/)
-
- [More...](https://github.com/tleb/awesome-mastodon#clients)
## License

228
package-lock.json generated
View file

@ -8,13 +8,14 @@
"name": "phanpy",
"version": "0.1.0",
"dependencies": {
"@github/relative-time-element": "~4.1.5",
"@github/text-expander-element": "~2.3.0",
"dayjs": "~1.11.7",
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
"history": "~5.3.0",
"iconify-icon": "~1.0.2",
"just-debounce-it": "~3.2.0",
"masto": "~5.1.0",
"masto": "~5.1.1",
"mem": "~9.0.2",
"preact": "~10.11.3",
"preact-router": "~4.1.0",
@ -24,7 +25,7 @@
"swiped-events": "~1.1.7",
"toastify-js": "~1.12.0",
"use-resize-observer": "~9.1.0",
"valtio": "~1.8.0"
"valtio": "~1.8.2"
},
"devDependencies": {
"@preact/preset-vite": "~2.5.0",
@ -33,10 +34,11 @@
"postcss": "~8.4.20",
"postcss-dark-theme-class": "~0.7.3",
"twitter-text": "~3.1.0",
"vite": "~4.0.3",
"vite": "~4.0.4",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-html-env": "~1.2.7",
"vite-plugin-pwa": "~0.14.0",
"vite-plugin-pwa": "~0.14.1",
"vite-plugin-remove-console": "~1.3.0",
"workbox-cacheable-response": "~6.5.4",
"workbox-expiration": "~6.5.4",
"workbox-routing": "~6.5.4",
@ -311,7 +313,7 @@
"version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
"integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==",
"devOptional": true,
"dev": true,
"dependencies": {
"@babel/types": "^7.18.6"
},
@ -433,7 +435,7 @@
"version": "7.19.4",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz",
"integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==",
"devOptional": true,
"dev": true,
"engines": {
"node": ">=6.9.0"
}
@ -442,7 +444,7 @@
"version": "7.19.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
"integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==",
"devOptional": true,
"dev": true,
"engines": {
"node": ">=6.9.0"
}
@ -1694,7 +1696,7 @@
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.20.5.tgz",
"integrity": "sha512-c9fst/h2/dcF7H+MJKZ2T0KjEQ8hY/BNnDk/H3XY8C4Aw/eWQXWn/lWntHF9ooUBnGmEvbfGrTgLWc+um0YDUg==",
"devOptional": true,
"dev": true,
"dependencies": {
"@babel/helper-string-parser": "^7.19.4",
"@babel/helper-validator-identifier": "^7.19.1",
@ -2061,11 +2063,6 @@
"resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.1.5.tgz",
"integrity": "sha512-dmG1PuppNKHnBBEcfylWDwj9SSxd/E/qd8mC1G/klQC3s7ps5q6JZ034mwkkG0LKfI+Y+UgEua/ROD776N400w=="
},
"node_modules/@github/relative-time-element": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@github/relative-time-element/-/relative-time-element-4.1.5.tgz",
"integrity": "sha512-WAf1EQV5Sn6jGuAIQur/ztKlEV9R+VHDNwqEbeaOb6s9fiwM5z7+ujlWNZtgFkDp3lF0H8D/f0vdiPlfHz0ZTQ=="
},
"node_modules/@github/text-expander-element": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@github/text-expander-element/-/text-expander-element-2.3.0.tgz",
@ -3000,6 +2997,19 @@
"node": ">=8"
}
},
"node_modules/dayjs": {
"version": "1.11.7",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz",
"integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ=="
},
"node_modules/dayjs-twitter": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/dayjs-twitter/-/dayjs-twitter-0.5.0.tgz",
"integrity": "sha512-SZ7qEUISstBLUXdlGAbLrwr6zfRM9kaCfbq4uVTerM/HXzuHiiGzzUqAJVhxt+3tf69E+ocmQdP6YYpOINv05w==",
"dependencies": {
"duration-js": "^4.0.0"
}
},
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@ -3059,6 +3069,11 @@
"tslib": "^2.0.3"
}
},
"node_modules/duration-js": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/duration-js/-/duration-js-4.0.0.tgz",
"integrity": "sha512-qoXjOsH97r+NrOa6sK5V2cwBOouVG/LI9jwgwKvjVkyqGpZ72yilWjjzFJYPqqbvNZDwpRMaLEUFE+PTefvOEA=="
},
"node_modules/ejs": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.8.tgz",
@ -3140,7 +3155,7 @@
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.16.7.tgz",
"integrity": "sha512-P6OBFYFSQOGzfApqCeYKqfKRRbCIRsdppTXFo4aAvtiW3o8TTyiIplBvHJI171saPAiy3WlawJHCveJVIOIx1A==",
"devOptional": true,
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
@ -3368,7 +3383,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
"devOptional": true
"dev": true
},
"node_modules/function.prototype.name": {
"version": "1.1.5",
@ -3505,7 +3520,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"devOptional": true,
"dev": true,
"dependencies": {
"function-bind": "^1.1.1"
},
@ -3678,7 +3693,7 @@
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz",
"integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==",
"devOptional": true,
"dev": true,
"dependencies": {
"has": "^1.0.3"
},
@ -4153,9 +4168,9 @@
}
},
"node_modules/masto": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/masto/-/masto-5.1.0.tgz",
"integrity": "sha512-/Rvi44BKv9AGGv08Oo63dA2WHE3kwCUtNb1/W0brK9alLaCSboOwTjoWtK46ovjmsm8TugNtKqj2lscxwcFhDQ==",
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/masto/-/masto-5.1.1.tgz",
"integrity": "sha512-IvfdpCiayM4tM58aTf/tfkSq0MGW1kKEAwJvgVRbzmwlE4PBt1WnGvZXQg6CiLkcKBMTQaDjLR0sBaGmPrVGCQ==",
"dependencies": {
"@mastojs/ponyfills": "^1.0.4",
"change-case": "^4.1.2",
@ -4274,7 +4289,7 @@
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
"integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
"devOptional": true,
"dev": true,
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@ -4437,13 +4452,13 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"devOptional": true
"dev": true
},
"node_modules/picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
"devOptional": true
"dev": true
},
"node_modules/picomatch": {
"version": "2.3.1",
@ -4461,7 +4476,7 @@
"version": "8.4.20",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.20.tgz",
"integrity": "sha512-6Q04AXR1212bXr5fh03u8aAwbLxAQNGQ/Q1LNa0VfOI06ZAlhPHtQvE4OIdpj4kLThXilalPnmDSOD65DcHt+g==",
"devOptional": true,
"dev": true,
"funding": [
{
"type": "opencollective",
@ -4549,9 +4564,9 @@
}
},
"node_modules/proxy-compare": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.3.0.tgz",
"integrity": "sha512-c3L2CcAi7f7pvlD0D7xsF+2CQIW8C3HaYx2Pfgq8eA4HAl3GAH6/dVYsyBbYF/0XJs2ziGLrzmz5fmzPm6A0pQ=="
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.4.0.tgz",
"integrity": "sha512-FD8KmQUQD6Mfpd0hywCOzcon/dbkFP8XBd9F1ycbKtvVsfv6TsFUKJ2eC0Iz2y+KzlkdT1Z8SY6ZSgm07zOyqg=="
},
"node_modules/punycode": {
"version": "2.1.1",
@ -4739,7 +4754,7 @@
"version": "1.22.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
"integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==",
"devOptional": true,
"dev": true,
"dependencies": {
"is-core-module": "^2.9.0",
"path-parse": "^1.0.7",
@ -4766,7 +4781,7 @@
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.7.4.tgz",
"integrity": "sha512-jN9rx3k5pfg9H9al0r0y1EYKSeiRANZRYX32SuNXAnKzh6cVyf4LZVto1KAuDnbHT03E1CpsgqDKaqQ8FZtgxw==",
"devOptional": true,
"dev": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@ -4908,7 +4923,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
"devOptional": true,
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -5044,7 +5059,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"devOptional": true,
"dev": true,
"engines": {
"node": ">= 0.4"
},
@ -5106,7 +5121,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
"integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
"devOptional": true,
"dev": true,
"engines": {
"node": ">=4"
}
@ -5336,50 +5351,30 @@
}
},
"node_modules/valtio": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-1.8.0.tgz",
"integrity": "sha512-lNw7wM0Qb9iBzXMju+XCn+UiIlf5uCe5pcI8XRqcvxEZ/mnRXyKXoOodPDKB8cIAVekA3Q3zWA7rboCdS4ea7g==",
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-1.8.2.tgz",
"integrity": "sha512-ypFWPi3aY04tojWAFPbTYBDw5iFaCDbKAJ2XqhmY2XOSorNtaCZJNg++FSssv8gMJwmPXfrU/RjncQtsoOHbUg==",
"dependencies": {
"proxy-compare": "2.3.0",
"proxy-compare": "2.4.0",
"use-sync-external-store": "1.2.0"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@babel/helper-module-imports": ">=7.12",
"@babel/types": ">=7.13",
"aslemammad-vite-plugin-macro": ">=1.0.0-alpha.1",
"babel-plugin-macros": ">=3.0",
"react": ">=16.8",
"vite": ">=2.8.6"
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@babel/helper-module-imports": {
"optional": true
},
"@babel/types": {
"optional": true
},
"aslemammad-vite-plugin-macro": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
},
"react": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/vite": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.0.3.tgz",
"integrity": "sha512-HvuNv1RdE7deIfQb8mPk51UKjqptO/4RXZ5yXSAvurd5xOckwS/gg8h9Tky3uSbnjYTgUm0hVCet1cyhKd73ZA==",
"devOptional": true,
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.0.4.tgz",
"integrity": "sha512-xevPU7M8FU0i/80DMR+YhgrzR5KS2ORy1B4xcX/cXLsvnUWvfHuqMmVU6N0YiJ4JWGRJJsLCgjEzKjG9/GKoSw==",
"dev": true,
"dependencies": {
"esbuild": "^0.16.3",
"postcss": "^8.4.20",
@ -5449,9 +5444,9 @@
}
},
"node_modules/vite-plugin-pwa": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.14.0.tgz",
"integrity": "sha512-3wZx47PLWTckOQhc8Y6YZjAbNZ89Ovh4TdCT97MGhgl7aFd2LUekVnAmIgFwgMqyxzJ93nmkPF/ALpEW/i2qCg==",
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.14.1.tgz",
"integrity": "sha512-5zx7yhQ8RTLwV71+GA9YsQQ63ALKG8XXIMqRJDdZkR8ZYftFcRgnzM7wOWmQZ/DATspyhPih5wCdcZnAIsM+mA==",
"dev": true,
"dependencies": {
"@rollup/plugin-replace": "^5.0.1",
@ -5471,6 +5466,12 @@
"workbox-window": "^6.5.4"
}
},
"node_modules/vite-plugin-remove-console": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/vite-plugin-remove-console/-/vite-plugin-remove-console-1.3.0.tgz",
"integrity": "sha512-5a/OLYB6yNRHMuHj9rBQRYMQ1NBKffxA8BaD77urUBLcGOWMHFHALjh6C26wZfZd41KytSwLp6DhvNKU78mNJg==",
"dev": true
},
"node_modules/webidl-conversions": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
@ -6068,7 +6069,7 @@
"version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
"integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==",
"devOptional": true,
"dev": true,
"requires": {
"@babel/types": "^7.18.6"
}
@ -6160,13 +6161,13 @@
"version": "7.19.4",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz",
"integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==",
"devOptional": true
"dev": true
},
"@babel/helper-validator-identifier": {
"version": "7.19.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
"integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==",
"devOptional": true
"dev": true
},
"@babel/helper-validator-option": {
"version": "7.18.6",
@ -7010,7 +7011,7 @@
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.20.5.tgz",
"integrity": "sha512-c9fst/h2/dcF7H+MJKZ2T0KjEQ8hY/BNnDk/H3XY8C4Aw/eWQXWn/lWntHF9ooUBnGmEvbfGrTgLWc+um0YDUg==",
"devOptional": true,
"dev": true,
"requires": {
"@babel/helper-string-parser": "^7.19.4",
"@babel/helper-validator-identifier": "^7.19.1",
@ -7176,11 +7177,6 @@
"resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.1.5.tgz",
"integrity": "sha512-dmG1PuppNKHnBBEcfylWDwj9SSxd/E/qd8mC1G/klQC3s7ps5q6JZ034mwkkG0LKfI+Y+UgEua/ROD776N400w=="
},
"@github/relative-time-element": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@github/relative-time-element/-/relative-time-element-4.1.5.tgz",
"integrity": "sha512-WAf1EQV5Sn6jGuAIQur/ztKlEV9R+VHDNwqEbeaOb6s9fiwM5z7+ujlWNZtgFkDp3lF0H8D/f0vdiPlfHz0ZTQ=="
},
"@github/text-expander-element": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@github/text-expander-element/-/text-expander-element-2.3.0.tgz",
@ -7918,6 +7914,19 @@
"integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==",
"dev": true
},
"dayjs": {
"version": "1.11.7",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz",
"integrity": "sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ=="
},
"dayjs-twitter": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/dayjs-twitter/-/dayjs-twitter-0.5.0.tgz",
"integrity": "sha512-SZ7qEUISstBLUXdlGAbLrwr6zfRM9kaCfbq4uVTerM/HXzuHiiGzzUqAJVhxt+3tf69E+ocmQdP6YYpOINv05w==",
"requires": {
"duration-js": "^4.0.0"
}
},
"debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@ -7957,6 +7966,11 @@
"tslib": "^2.0.3"
}
},
"duration-js": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/duration-js/-/duration-js-4.0.0.tgz",
"integrity": "sha512-qoXjOsH97r+NrOa6sK5V2cwBOouVG/LI9jwgwKvjVkyqGpZ72yilWjjzFJYPqqbvNZDwpRMaLEUFE+PTefvOEA=="
},
"ejs": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.8.tgz",
@ -8020,7 +8034,7 @@
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.16.7.tgz",
"integrity": "sha512-P6OBFYFSQOGzfApqCeYKqfKRRbCIRsdppTXFo4aAvtiW3o8TTyiIplBvHJI171saPAiy3WlawJHCveJVIOIx1A==",
"devOptional": true,
"dev": true,
"requires": {
"@esbuild/android-arm": "0.16.7",
"@esbuild/android-arm64": "0.16.7",
@ -8202,7 +8216,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
"devOptional": true
"dev": true
},
"function.prototype.name": {
"version": "1.1.5",
@ -8303,7 +8317,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"devOptional": true,
"dev": true,
"requires": {
"function-bind": "^1.1.1"
}
@ -8431,7 +8445,7 @@
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz",
"integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==",
"devOptional": true,
"dev": true,
"requires": {
"has": "^1.0.3"
}
@ -8777,9 +8791,9 @@
}
},
"masto": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/masto/-/masto-5.1.0.tgz",
"integrity": "sha512-/Rvi44BKv9AGGv08Oo63dA2WHE3kwCUtNb1/W0brK9alLaCSboOwTjoWtK46ovjmsm8TugNtKqj2lscxwcFhDQ==",
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/masto/-/masto-5.1.1.tgz",
"integrity": "sha512-IvfdpCiayM4tM58aTf/tfkSq0MGW1kKEAwJvgVRbzmwlE4PBt1WnGvZXQg6CiLkcKBMTQaDjLR0sBaGmPrVGCQ==",
"requires": {
"@mastojs/ponyfills": "^1.0.4",
"change-case": "^4.1.2",
@ -8867,7 +8881,7 @@
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
"integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==",
"devOptional": true
"dev": true
},
"no-case": {
"version": "3.0.4",
@ -8994,13 +9008,13 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"devOptional": true
"dev": true
},
"picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
"devOptional": true
"dev": true
},
"picomatch": {
"version": "2.3.1",
@ -9012,7 +9026,7 @@
"version": "8.4.20",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.20.tgz",
"integrity": "sha512-6Q04AXR1212bXr5fh03u8aAwbLxAQNGQ/Q1LNa0VfOI06ZAlhPHtQvE4OIdpj4kLThXilalPnmDSOD65DcHt+g==",
"devOptional": true,
"dev": true,
"requires": {
"nanoid": "^3.3.4",
"picocolors": "^1.0.0",
@ -9057,9 +9071,9 @@
"dev": true
},
"proxy-compare": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.3.0.tgz",
"integrity": "sha512-c3L2CcAi7f7pvlD0D7xsF+2CQIW8C3HaYx2Pfgq8eA4HAl3GAH6/dVYsyBbYF/0XJs2ziGLrzmz5fmzPm6A0pQ=="
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.4.0.tgz",
"integrity": "sha512-FD8KmQUQD6Mfpd0hywCOzcon/dbkFP8XBd9F1ycbKtvVsfv6TsFUKJ2eC0Iz2y+KzlkdT1Z8SY6ZSgm07zOyqg=="
},
"punycode": {
"version": "2.1.1",
@ -9200,7 +9214,7 @@
"version": "1.22.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
"integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==",
"devOptional": true,
"dev": true,
"requires": {
"is-core-module": "^2.9.0",
"path-parse": "^1.0.7",
@ -9217,7 +9231,7 @@
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.7.4.tgz",
"integrity": "sha512-jN9rx3k5pfg9H9al0r0y1EYKSeiRANZRYX32SuNXAnKzh6cVyf4LZVto1KAuDnbHT03E1CpsgqDKaqQ8FZtgxw==",
"devOptional": true,
"dev": true,
"requires": {
"fsevents": "~2.3.2"
}
@ -9312,7 +9326,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
"devOptional": true
"dev": true
},
"source-map-support": {
"version": "0.5.21",
@ -9415,7 +9429,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"devOptional": true
"dev": true
},
"swiped-events": {
"version": "1.1.7",
@ -9456,7 +9470,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
"integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
"devOptional": true
"dev": true
},
"to-regex-range": {
"version": "5.0.1",
@ -9629,19 +9643,19 @@
"requires": {}
},
"valtio": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-1.8.0.tgz",
"integrity": "sha512-lNw7wM0Qb9iBzXMju+XCn+UiIlf5uCe5pcI8XRqcvxEZ/mnRXyKXoOodPDKB8cIAVekA3Q3zWA7rboCdS4ea7g==",
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/valtio/-/valtio-1.8.2.tgz",
"integrity": "sha512-ypFWPi3aY04tojWAFPbTYBDw5iFaCDbKAJ2XqhmY2XOSorNtaCZJNg++FSssv8gMJwmPXfrU/RjncQtsoOHbUg==",
"requires": {
"proxy-compare": "2.3.0",
"proxy-compare": "2.4.0",
"use-sync-external-store": "1.2.0"
}
},
"vite": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.0.3.tgz",
"integrity": "sha512-HvuNv1RdE7deIfQb8mPk51UKjqptO/4RXZ5yXSAvurd5xOckwS/gg8h9Tky3uSbnjYTgUm0hVCet1cyhKd73ZA==",
"devOptional": true,
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.0.4.tgz",
"integrity": "sha512-xevPU7M8FU0i/80DMR+YhgrzR5KS2ORy1B4xcX/cXLsvnUWvfHuqMmVU6N0YiJ4JWGRJJsLCgjEzKjG9/GKoSw==",
"dev": true,
"requires": {
"esbuild": "^0.16.3",
"fsevents": "~2.3.2",
@ -9665,9 +9679,9 @@
"requires": {}
},
"vite-plugin-pwa": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.14.0.tgz",
"integrity": "sha512-3wZx47PLWTckOQhc8Y6YZjAbNZ89Ovh4TdCT97MGhgl7aFd2LUekVnAmIgFwgMqyxzJ93nmkPF/ALpEW/i2qCg==",
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.14.1.tgz",
"integrity": "sha512-5zx7yhQ8RTLwV71+GA9YsQQ63ALKG8XXIMqRJDdZkR8ZYftFcRgnzM7wOWmQZ/DATspyhPih5wCdcZnAIsM+mA==",
"dev": true,
"requires": {
"@rollup/plugin-replace": "^5.0.1",
@ -9679,6 +9693,12 @@
"workbox-window": "^6.5.4"
}
},
"vite-plugin-remove-console": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/vite-plugin-remove-console/-/vite-plugin-remove-console-1.3.0.tgz",
"integrity": "sha512-5a/OLYB6yNRHMuHj9rBQRYMQ1NBKffxA8BaD77urUBLcGOWMHFHALjh6C26wZfZd41KytSwLp6DhvNKU78mNJg==",
"dev": true
},
"webidl-conversions": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",

View file

@ -7,16 +7,17 @@
"build": "vite build",
"preview": "vite preview",
"fetch-instances": "env $(cat .env.dev | grep -v \"#\" | xargs) node scripts/fetch-instances-list.js",
"source-map-explorer": "npx source-map-explorer dist/assets/*.js"
"sourcemap": "npx source-map-explorer dist/assets/*.js"
},
"dependencies": {
"@github/relative-time-element": "~4.1.5",
"@github/text-expander-element": "~2.3.0",
"dayjs": "~1.11.7",
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
"history": "~5.3.0",
"iconify-icon": "~1.0.2",
"just-debounce-it": "~3.2.0",
"masto": "~5.1.0",
"masto": "~5.1.1",
"mem": "~9.0.2",
"preact": "~10.11.3",
"preact-router": "~4.1.0",
@ -26,7 +27,7 @@
"swiped-events": "~1.1.7",
"toastify-js": "~1.12.0",
"use-resize-observer": "~9.1.0",
"valtio": "~1.8.0"
"valtio": "~1.8.2"
},
"devDependencies": {
"@preact/preset-vite": "~2.5.0",
@ -35,10 +36,11 @@
"postcss": "~8.4.20",
"postcss-dark-theme-class": "~0.7.3",
"twitter-text": "~3.1.0",
"vite": "~4.0.3",
"vite": "~4.0.4",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-html-env": "~1.2.7",
"vite-plugin-pwa": "~0.14.0",
"vite-plugin-pwa": "~0.14.1",
"vite-plugin-remove-console": "~1.3.0",
"workbox-cacheable-response": "~6.5.4",
"workbox-expiration": "~6.5.4",
"workbox-routing": "~6.5.4",

View file

@ -3,6 +3,8 @@ import { ExpirationPlugin } from 'workbox-expiration';
import { RegExpRoute, registerRoute, Route } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
self.__WB_DISABLE_DEV_LOGS = true;
const imageRoute = new Route(
({ request, sameOrigin }) => {
const isRemote = !sameOrigin;
@ -44,20 +46,20 @@ const apiExtendedRoute = new RegExpRoute(
);
registerRoute(apiExtendedRoute);
// Not caching API requests, doesn't seem to be necessary fo now
//
// const apiRoute = new RegExpRoute(
// /^https?:\/\/[^\/]+\/api\//,
// new StaleWhileRevalidate({
// cacheName: 'api',
// plugins: [
// new ExpirationPlugin({
// maxAgeSeconds: 60, // 1 minute
// }),
// new CacheableResponsePlugin({
// statuses: [0, 200],
// }),
// ],
// }),
// );
// registerRoute(apiRoute);
const apiRoute = new RegExpRoute(
// Matches:
// - statuses/:id/context - some contexts are really huge
/^https?:\/\/[^\/]+\/api\/v\d+\/(statuses\/\d+\/context)/,
new StaleWhileRevalidate({
cacheName: 'api',
plugins: [
new ExpirationPlugin({
maxAgeSeconds: 5 * 60, // 5 minutes
}),
new CacheableResponsePlugin({
statuses: [0, 200],
}),
],
}),
);
registerRoute(apiRoute);

View file

@ -3,7 +3,8 @@ import fs from 'fs';
const { INSTANCES_SOCIAL_SECRET_TOKEN } = process.env;
const params = new URLSearchParams({
count: 200,
count: 0,
min_users: 1_000,
sort_by: 'active_users',
sort_order: 'desc',
});

View file

@ -90,6 +90,13 @@ a.mention span {
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
user-select: none;
transition: transform 0.5s ease-in-out;
user-select: none;
}
.deck header[hidden] {
transform: translateY(-100%);
pointer-events: none;
user-select: none;
}
.deck header > .header-side:last-of-type {
text-align: right;
@ -348,8 +355,10 @@ a.mention span {
display: block;
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;
}
.status-link:is(:hover, :focus) {
background-color: var(--link-bg-hover-color);
@ -357,7 +366,6 @@ a.mention span {
}
.status-link:active {
filter: brightness(0.95);
transform: translateY(0.5px);
}
.ui-state {
@ -374,18 +382,17 @@ a.mention span {
z-index: 1000;
display: flex;
background-color: var(--backdrop-color);
animation: appear 0.2s ease-out;
}
.deck-backdrop > a {
flex-grow: 1;
backdrop-filter: saturate(0.75);
/* backdrop-filter: saturate(0.75); */
}
@keyframes slide-in {
0% {
opacity: 0.5;
transform: translate3d(100%, 0, 0);
}
100% {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@ -402,7 +409,6 @@ a.mention span {
.decks {
flex-grow: 1;
transition: transform 0.5s var(--timing-function);
}
.deck-close {
@ -436,7 +442,7 @@ a.mention span {
}
.updates-button {
position: absolute;
z-index: 1;
z-index: 2;
animation: fade-from-top 2s ease-out;
left: 50%;
margin-top: 8px;
@ -602,7 +608,18 @@ button.carousel-dot[disabled].active {
z-index: 1;
box-shadow: 0 3px 8px -1px var(--bg-faded-blur-color),
0 10px 36px -4px var(--button-bg-blur-color);
transition: background-color 0.2s ease-in-out;
transition: all 0.3s ease-in-out;
}
#compose-button[hidden] {
transform: translateY(200%);
pointer-events: none;
user-select: none;
}
#compose-button .icon {
transition: transform 0.3s ease-in-out;
}
#compose-button[hidden] .icon {
transform: rotate(90deg);
}
#compose-button:is(:hover, :focus) {
background-color: var(--button-bg-color);
@ -610,7 +627,6 @@ button.carousel-dot[disabled].active {
}
#compose-button:active {
filter: brightness(0.75);
transform: translateY(1px);
}
#compose-button .icon {
filter: drop-shadow(0 1px 2px var(--button-bg-color));
@ -637,6 +653,10 @@ button.carousel-dot[disabled].active {
padding: 16px 16px 8px;
padding-left: max(16px, env(safe-area-inset-left));
padding-right: max(16px, env(safe-area-inset-right));
user-select: none;
}
.sheet header :is(h1, h2, h3) {
margin: 0;
}
.sheet main {
overflow: auto;
@ -800,6 +820,14 @@ meter.donut:is(.danger, .explode):after {
filter: brightness(0.8);
}
/* AVATARS STACK */
.avatars-stack {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
@media (min-width: 40em) {
html,
body {
@ -808,7 +836,11 @@ meter.donut:is(.danger, .explode):after {
#app {
display: flex;
}
.decks {
transition: transform 0.4s var(--timing-function);
}
.decks:has(~ .deck-backdrop) {
transition: transform 0.4s ease-out;
transform: translate3d(-5vw, 0, 0);
}
.deck-backdrop .deck {

View file

@ -82,7 +82,7 @@ function App() {
let account = accounts.find((a) => a.info.id === mastoAccount.id);
if (account) {
account.info = mastoAccount;
account.instanceURL = instanceURL;
account.instanceURL = instanceURL.toLowerCase();
account.accessToken = accessToken;
} else {
account = {
@ -166,7 +166,7 @@ function App() {
console.log(info);
const { uri, domain } = info;
const instances = store.local.getJSON('instances') || {};
instances[domain || uri] = info;
instances[(domain || uri).toLowerCase()] = info;
store.local.setJSON('instances', instances);
})();
});
@ -177,31 +177,12 @@ function App() {
return (
<>
{isLoggedIn && currentDeck && (
<>
<button
type="button"
id="compose-button"
onClick={(e) => {
if (e.shiftKey) {
const newWin = openCompose();
if (!newWin) {
alert('Looks like your browser is blocking popups.');
states.showCompose = true;
}
} else {
states.showCompose = true;
}
}}
>
<Icon icon="quill" size="xxl" alt="Compose" />
</button>
<div class="decks">
{/* Home will never be unmounted */}
<Home hidden={currentDeck !== 'home'} />
{/* Notifications can be unmounted */}
{currentDeck === 'notifications' && <Notifications />}
</div>
</>
)}
{!isLoggedIn && uiState === 'loading' && <Loader />}
<Router
@ -237,13 +218,22 @@ function App() {
replyToStatus={
typeof snapStates.showCompose !== 'boolean'
? snapStates.showCompose.replyToStatus
: null
: window.__COMPOSE__?.replyToStatus || null
}
editStatus={
states.showCompose?.editStatus ||
window.__COMPOSE__?.editStatus ||
null
}
draftStatus={
states.showCompose?.draftStatus ||
window.__COMPOSE__?.draftStatus ||
null
}
editStatus={states.showCompose?.editStatus || null}
draftStatus={states.showCompose?.draftStatus || null}
onClose={(results) => {
const { newStatus } = results || {};
states.showCompose = false;
window.__COMPOSE__ = null;
if (newStatus) {
states.reloadStatusPage++;
setTimeout(() => {

View file

@ -197,17 +197,19 @@ function Account({ account }) {
{relationshipUIState !== 'loading' && relationship && (
<button
type="button"
class={`${following ? 'light' : ''} swap`}
data-swap-state={following ? 'danger' : ''}
class={`${following || requested ? 'light swap' : ''}`}
data-swap-state={following || requested ? 'danger' : ''}
disabled={relationshipUIState === 'loading'}
onClick={() => {
setRelationshipUIState('loading');
(async () => {
try {
let newRelationship;
if (following) {
if (following || requested) {
const yes = confirm(
'Are you sure that you want to unfollow this account?',
requested
? 'Are you sure that you want to withdraw follow request?'
: 'Are you sure that you want to unfollow this account?',
);
if (yes) {
newRelationship = await masto.v1.accounts.unfollow(
@ -231,10 +233,18 @@ function Account({ account }) {
<span>Following</span>
<span>Unfollow</span>
</>
) : requested ? (
<>
<span>Requested</span>
<span>Withdraw</span>
</>
) : locked ? (
<>
<Icon icon="lock" /> <span>Follow</span>
</>
) : (
'Follow'
)}
{/* {following ? 'Unfollow…' : 'Follow'} */}
</button>
)}
</p>

View file

@ -28,7 +28,7 @@
max-width: 100%;
height: 4em;
min-height: 4em;
max-height: 10em;
max-height: 50vh;
resize: vertical;
line-height: 1.4;
}
@ -255,16 +255,34 @@
align-items: stretch;
}
#compose-container .media-preview {
flex-shrink: 1;
flex-shrink: 0;
border: 1px solid var(--outline-color);
border-radius: 4px;
overflow: hidden;
width: 80px;
height: 80px;
/* checkerboard background */
background-image: linear-gradient(
45deg,
var(--img-bg-color) 25%,
transparent 25%
),
linear-gradient(-45deg, var(--img-bg-color) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--img-bg-color) 75%),
linear-gradient(-45deg, transparent 75%, var(--img-bg-color) 75%);
background-size: 10px 10px;
background-position: 0 0, 0 5px, 5px -5px, -5px 0px;
}
#compose-container .media-preview > * {
min-width: 80px;
width: 80px !important;
width: 80px;
height: 80px;
object-fit: contain;
background-color: var(--img-bg-color);
border-radius: 8px;
border: 1px solid var(--outline-color);
vertical-align: middle;
pointer-events: none;
}
#compose-container .media-preview:hover {
box-shadow: 0 0 0 2px var(--link-light-color);
cursor: pointer;
}
#compose-container .media-attachment textarea {
height: 80px;
@ -389,3 +407,39 @@
display: none;
}
}
#media-sheet main {
padding-top: 8px;
display: flex;
flex-direction: column;
flex: 1;
}
#media-sheet textarea {
width: 100%;
height: 10em;
margin-top: 8px;
}
#media-sheet .media-preview {
border: 2px solid var(--outline-color);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 16px var(--img-bg-color);
/* checkerboard background */
background-image: linear-gradient(
45deg,
var(--img-bg-color) 25%,
transparent 25%
),
linear-gradient(-45deg, var(--img-bg-color) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--img-bg-color) 75%),
linear-gradient(-45deg, transparent 75%, var(--img-bg-color) 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
#media-sheet .media-preview > * {
width: 100%;
height: 100%;
max-height: 50vh;
object-fit: contain;
vertical-align: middle;
}

View file

@ -1,19 +1,25 @@
import './compose.css';
import '@github/text-expander-element';
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 { useSnapshot } from 'valtio';
import supportedLanguages from '../data/status-supported-languages';
import urlRegex from '../data/url-regex';
import emojifyText from '../utils/emojify-text';
import openCompose from '../utils/open-compose';
import states from '../utils/states';
import store from '../utils/store';
import useDebouncedCallback from '../utils/useDebouncedCallback';
import visibilityIconsMap from '../utils/visibility-icons-map';
import Avatar from './avatar';
import Icon from './icon';
import Loader from './loader';
import Modal from './modal';
import Status from './status';
const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => {
@ -53,6 +59,16 @@ menu.className = 'text-expander-menu';
const DEFAULT_LANG = 'en';
// https://github.com/mastodon/mastodon/blob/c4a429ed47e85a6bbf0d470a41cc2f64cf120c19/app/javascript/mastodon/features/compose/util/counter.js
const urlRegexObj = new RegExp(urlRegex.source, urlRegex.flags);
const usernameRegex = /(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/gi;
const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx';
function countableText(inputText) {
return inputText
.replace(urlRegexObj, urlPlaceholder)
.replace(usernameRegex, '$1@$3');
}
function Compose({
onClose,
replyToStatus,
@ -61,6 +77,7 @@ function Compose({
standalone,
hasOpener,
}) {
console.warn('RENDER COMPOSER');
const [uiState, setUIState] = useState('default');
const accounts = store.local.getJSON('accounts');
@ -72,9 +89,9 @@ function Compose({
const configuration = useMemo(() => {
try {
const instances = store.local.getJSON('instances');
const currentInstance = accounts.find(
(a) => a.info.id === currentAccount,
).instanceURL;
const currentInstance = accounts
.find((a) => a.info.id === currentAccount)
.instanceURL.toLowerCase();
const config = instances[currentInstance].configuration;
console.log(config);
return config;
@ -222,130 +239,6 @@ function Compose({
}
}, [draftStatus, editStatus, replyToStatus]);
const textExpanderRef = useRef();
const textExpanderTextRef = useRef('');
useEffect(() => {
if (textExpanderRef.current) {
const handleChange = (e) => {
// console.log('text-expander-change', e);
const { key, provide, text } = e.detail;
textExpanderTextRef.current = text;
if (text === '') {
provide(
Promise.resolve({
matched: false,
}),
);
return;
}
if (key === ':') {
// const emojis = customEmojis.current.filter((emoji) =>
// emoji.shortcode.startsWith(text),
// );
const emojis = filterShortcodes(customEmojis.current, text);
let html = '';
emojis.forEach((emoji) => {
const { shortcode, url } = emoji;
html += `
<li role="option" data-value="${encodeHTML(shortcode)}">
<img src="${encodeHTML(
url,
)}" width="16" height="16" alt="" loading="lazy" />
:${encodeHTML(shortcode)}:
</li>`;
});
// console.log({ emojis, html });
menu.innerHTML = html;
provide(
Promise.resolve({
matched: emojis.length > 0,
fragment: menu,
}),
);
return;
}
const type = {
'@': 'accounts',
'#': 'hashtags',
}[key];
provide(
new Promise((resolve) => {
const searchResults = masto.v2.search({
type,
q: text,
limit: 5,
});
searchResults.then((value) => {
if (text !== textExpanderTextRef.current) {
return;
}
console.log({ value, type, v: value[type] });
const results = value[type];
console.log('RESULTS', value, results);
let html = '';
results.forEach((result) => {
const {
name,
avatarStatic,
displayName,
username,
acct,
emojis,
} = result;
const displayNameWithEmoji = emojifyText(displayName, emojis);
// const item = menuItem.cloneNode();
if (acct) {
html += `
<li role="option" data-value="${encodeHTML(acct)}">
<span class="avatar">
<img src="${encodeHTML(
avatarStatic,
)}" width="16" height="16" alt="" loading="lazy" />
</span>
<span>
<b>${displayNameWithEmoji || username}</b>
<br>@${encodeHTML(acct)}
</span>
</li>
`;
} else {
html += `
<li role="option" data-value="${encodeHTML(name)}">
<span>#<b>${encodeHTML(name)}</b></span>
</li>
`;
}
menu.innerHTML = html;
});
console.log('MENU', results, menu);
resolve({
matched: results.length > 0,
fragment: menu,
});
});
}),
);
};
textExpanderRef.current.addEventListener(
'text-expander-change',
handleChange,
);
textExpanderRef.current.addEventListener('text-expander-value', (e) => {
const { key, item } = e.detail;
if (key === ':') {
e.detail.value = `:${item.dataset.value}:`;
} else {
e.detail.value = `${key}${item.dataset.value}`;
}
});
}
}, []);
const formRef = useRef();
const beforeUnloadCopy =
@ -431,19 +324,28 @@ function Compose({
});
}, []);
const [charCount, setCharCount] = useState(
textareaRef.current?.value?.length +
spoilerTextRef.current?.value?.length || 0,
);
const leftChars = maxCharacters - charCount;
const getCharCount = () => {
const { value } = textareaRef.current;
const { value: spoilerText } = spoilerTextRef.current;
return stringLength(countableText(value)) + stringLength(spoilerText);
};
const updateCharCount = () => {
setCharCount(getCharCount());
const count = getCharCount();
states.composerCharacterCount = count;
};
useEffect(updateCharCount, []);
useHotkeys(
'esc',
() => {
if (!standalone && confirmClose()) {
onClose();
}
},
{
enableOnFormTags: true,
},
);
return (
<div id="compose-container" class={standalone ? 'standalone' : ''}>
@ -463,21 +365,21 @@ function Compose({
disabled={uiState === 'loading'}
onClick={() => {
// If there are non-ID media attachments (not yet uploaded), show confirmation dialog because they are not going to be passed to the new window
const containNonIDMediaAttachments =
mediaAttachments.length > 0 &&
mediaAttachments.some((media) => !media.id);
if (containNonIDMediaAttachments) {
const yes = confirm(
'You have media attachments that are not yet uploaded. Opening a new window will discard them and you will need to re-attach them. Are you sure you want to continue?',
);
if (!yes) {
return;
}
}
// const containNonIDMediaAttachments =
// mediaAttachments.length > 0 &&
// mediaAttachments.some((media) => !media.id);
// if (containNonIDMediaAttachments) {
// const yes = confirm(
// 'You have media attachments that are not yet uploaded. Opening a new window will discard them and you will need to re-attach them. Are you sure you want to continue?',
// );
// if (!yes) {
// return;
// }
// }
const mediaAttachmentsWithIDs = mediaAttachments.filter(
(media) => media.id,
);
// const mediaAttachmentsWithIDs = mediaAttachments.filter(
// (media) => media.id,
// );
const newWin = openCompose({
editStatus,
@ -489,7 +391,7 @@ function Compose({
language,
sensitive,
poll,
mediaAttachments: mediaAttachmentsWithIDs,
mediaAttachments,
},
});
@ -524,17 +426,17 @@ function Compose({
disabled={uiState === 'loading'}
onClick={() => {
// If there are non-ID media attachments (not yet uploaded), show confirmation dialog because they are not going to be passed to the new window
const containNonIDMediaAttachments =
mediaAttachments.length > 0 &&
mediaAttachments.some((media) => !media.id);
if (containNonIDMediaAttachments) {
const yes = confirm(
'You have media attachments that are not yet uploaded. Opening a new window will discard them and you will need to re-attach them. Are you sure you want to continue?',
);
if (!yes) {
return;
}
}
// const containNonIDMediaAttachments =
// mediaAttachments.length > 0 &&
// mediaAttachments.some((media) => !media.id);
// if (containNonIDMediaAttachments) {
// const yes = confirm(
// 'You have media attachments that are not yet uploaded. Opening a new window will discard them and you will need to re-attach them. Are you sure you want to continue?',
// );
// if (!yes) {
// return;
// }
// }
if (!window.opener) {
alert('Looks like you closed the parent window.');
@ -548,13 +450,13 @@ function Compose({
if (!yes) return;
}
const mediaAttachmentsWithIDs = mediaAttachments.filter(
(media) => media.id,
);
// const mediaAttachmentsWithIDs = mediaAttachments.filter(
// (media) => media.id,
// );
onClose({
fn: () => {
window.opener.__STATES__.showCompose = {
const passData = {
editStatus,
replyToStatus,
draftStatus: {
@ -564,9 +466,11 @@ function Compose({
language,
sensitive,
poll,
mediaAttachments: mediaAttachmentsWithIDs,
mediaAttachments,
},
};
window.opener.__COMPOSE__ = passData;
window.opener.__STATES__.showCompose = true;
},
});
}}
@ -766,7 +670,7 @@ function Compose({
name="sensitive"
type="checkbox"
checked={sensitive}
disabled={uiState === 'loading' || !!editStatus}
disabled={uiState === 'loading'}
onChange={(e) => {
const sensitive = e.target.checked;
setSensitive(sensitive);
@ -803,8 +707,7 @@ function Compose({
</select>
</label>{' '}
</div>
<text-expander ref={textExpanderRef} keys="@ # :">
<textarea
<Textarea
ref={textareaRef}
placeholder={
replyToStatus
@ -814,30 +717,12 @@ function Compose({
: 'What are you doing?'
}
required={mediaAttachments.length === 0}
autoCapitalize="sentences"
autoComplete="on"
autoCorrect="on"
spellCheck="true"
dir="auto"
rows="6"
cols="50"
name="status"
disabled={uiState === 'loading'}
onInput={(e) => {
const { scrollHeight, offsetHeight, clientHeight, value } =
e.target;
const offset = offsetHeight - clientHeight;
e.target.style.height = value
? scrollHeight + offset + 'px'
: null;
onInput={() => {
updateCharCount();
}}
style={{
maxHeight: `${maxCharacters / 50}em`,
'--text-weight': (1 + charCount / 140).toFixed(1) || 1,
}}
></textarea>
</text-expander>
maxCharacters={maxCharacters}
/>
{mediaAttachments.length > 0 && (
<div class="media-attachments">
{mediaAttachments.map((attachment, i) => {
@ -942,26 +827,8 @@ function Compose({
</button>{' '}
<div class="spacer" />
{uiState === 'loading' && <Loader abrupt />}{' '}
{uiState !== 'loading' && charCount > maxCharacters / 2 && (
<>
<meter
class={`donut ${
leftChars <= -10
? 'explode'
: leftChars <= 0
? 'danger'
: leftChars <= 20
? 'warning'
: ''
}`}
value={charCount}
max={maxCharacters}
data-left={leftChars}
style={{
'--percentage': (charCount / maxCharacters) * 100,
}}
/>{' '}
</>
{uiState !== 'loading' && (
<CharCountMeter maxCharacters={maxCharacters} />
)}
<label class="toolbar-button">
<span class="icon-text">
@ -997,32 +864,269 @@ function Compose({
);
}
const Textarea = forwardRef((props, ref) => {
const [text, setText] = useState(ref.current?.value || '');
const { maxCharacters, ...textareaProps } = props;
const snapStates = useSnapshot(states);
const charCount = snapStates.composerCharacterCount;
const textExpanderRef = useRef();
const textExpanderTextRef = useRef('');
useEffect(() => {
let handleChange, handleValue, handleCommited;
if (textExpanderRef.current) {
handleChange = (e) => {
// console.log('text-expander-change', e);
const { key, provide, text } = e.detail;
textExpanderTextRef.current = text;
if (text === '') {
provide(
Promise.resolve({
matched: false,
}),
);
return;
}
if (key === ':') {
// const emojis = customEmojis.current.filter((emoji) =>
// emoji.shortcode.startsWith(text),
// );
const emojis = filterShortcodes(customEmojis.current, text);
let html = '';
emojis.forEach((emoji) => {
const { shortcode, url } = emoji;
html += `
<li role="option" data-value="${encodeHTML(shortcode)}">
<img src="${encodeHTML(
url,
)}" width="16" height="16" alt="" loading="lazy" />
:${encodeHTML(shortcode)}:
</li>`;
});
// console.log({ emojis, html });
menu.innerHTML = html;
provide(
Promise.resolve({
matched: emojis.length > 0,
fragment: menu,
}),
);
return;
}
const type = {
'@': 'accounts',
'#': 'hashtags',
}[key];
provide(
new Promise((resolve) => {
const searchResults = masto.v2.search({
type,
q: text,
limit: 5,
});
searchResults.then((value) => {
if (text !== textExpanderTextRef.current) {
return;
}
console.log({ value, type, v: value[type] });
const results = value[type];
console.log('RESULTS', value, results);
let html = '';
results.forEach((result) => {
const {
name,
avatarStatic,
displayName,
username,
acct,
emojis,
} = result;
const displayNameWithEmoji = emojifyText(displayName, emojis);
// const item = menuItem.cloneNode();
if (acct) {
html += `
<li role="option" data-value="${encodeHTML(acct)}">
<span class="avatar">
<img src="${encodeHTML(
avatarStatic,
)}" width="16" height="16" alt="" loading="lazy" />
</span>
<span>
<b>${displayNameWithEmoji || username}</b>
<br>@${encodeHTML(acct)}
</span>
</li>
`;
} else {
html += `
<li role="option" data-value="${encodeHTML(name)}">
<span>#<b>${encodeHTML(name)}</b></span>
</li>
`;
}
menu.innerHTML = html;
});
console.log('MENU', results, menu);
resolve({
matched: results.length > 0,
fragment: menu,
});
});
}),
);
};
textExpanderRef.current.addEventListener(
'text-expander-change',
handleChange,
);
handleValue = (e) => {
const { key, item } = e.detail;
if (key === ':') {
e.detail.value = `:${item.dataset.value}:`;
} else {
e.detail.value = `${key}${item.dataset.value}`;
}
};
textExpanderRef.current.addEventListener(
'text-expander-value',
handleValue,
);
handleCommited = (e) => {
const { input } = e.detail;
setText(input.value);
};
textExpanderRef.current.addEventListener(
'text-expander-committed',
handleCommited,
);
}
return () => {
if (textExpanderRef.current) {
textExpanderRef.current.removeEventListener(
'text-expander-change',
handleChange,
);
textExpanderRef.current.removeEventListener(
'text-expander-value',
handleValue,
);
textExpanderRef.current.removeEventListener(
'text-expander-committed',
handleCommited,
);
}
};
}, []);
return (
<text-expander ref={textExpanderRef} keys="@ # :">
<textarea
autoCapitalize="sentences"
autoComplete="on"
autoCorrect="on"
spellCheck="true"
dir="auto"
rows="6"
cols="50"
{...textareaProps}
ref={ref}
name="status"
value={text}
onInput={(e) => {
const { scrollHeight, offsetHeight, clientHeight, value } = e.target;
setText(value);
const offset = offsetHeight - clientHeight;
e.target.style.height = value ? scrollHeight + offset + 'px' : null;
props.onInput?.(e);
}}
style={{
width: '100%',
height: '4em',
'--text-weight': (1 + charCount / 140).toFixed(1) || 1,
}}
/>
</text-expander>
);
});
function CharCountMeter({ maxCharacters = 500 }) {
const snapStates = useSnapshot(states);
const charCount = snapStates.composerCharacterCount;
const leftChars = maxCharacters - charCount;
if (charCount <= maxCharacters / 2) {
return null;
}
return (
<meter
class={`donut ${
leftChars <= -10
? 'explode'
: leftChars <= 0
? 'danger'
: leftChars <= 20
? 'warning'
: ''
}`}
value={charCount}
max={maxCharacters}
data-left={leftChars}
style={{
'--percentage': (charCount / maxCharacters) * 100,
}}
/>
);
}
function MediaAttachment({
attachment,
disabled,
onDescriptionChange = () => {},
onRemove = () => {},
}) {
const { url, type, id, description } = attachment;
const { url, type, id } = attachment;
console.log({ attachment });
const [description, setDescription] = useState(attachment.description);
const suffixType = type.split('/')[0];
return (
<div class="media-attachment">
<div class="media-preview">
{suffixType === 'image' ? (
<img src={url} alt="" />
) : suffixType === 'video' || suffixType === 'gifv' ? (
<video src={url} playsinline muted />
) : suffixType === 'audio' ? (
<audio src={url} controls />
) : null}
</div>
const debouncedOnDescriptionChange = useDebouncedCallback(
onDescriptionChange,
500,
);
const [showModal, setShowModal] = useState(false);
const textareaRef = useRef(null);
useEffect(() => {
let timer;
if (showModal && textareaRef.current) {
timer = setTimeout(() => {
textareaRef.current.focus();
}, 100);
}
return () => {
clearTimeout(timer);
};
}, [showModal]);
const descTextarea = (
<>
{!!id ? (
<div class="media-desc">
<span class="tag">Uploaded</span>
<p title={description}>{description || <i>No description</i>}</p>
<p title={description}>
{attachment.description || <i>No description</i>}
</p>
</div>
) : (
<textarea
ref={textareaRef}
value={description || ''}
placeholder={
{
@ -1041,10 +1145,32 @@ function MediaAttachment({
// TODO: Un-hard-code this maxlength, ref: https://github.com/mastodon/mastodon/blob/b59fb28e90bc21d6fd1a6bafd13cfbd81ab5be54/app/models/media_attachment.rb#L39
onInput={(e) => {
const { value } = e.target;
onDescriptionChange(value);
setDescription(value);
debouncedOnDescriptionChange(value);
}}
></textarea>
)}
</>
);
return (
<>
<div class="media-attachment">
<div
class="media-preview"
onClick={() => {
setShowModal(true);
}}
>
{suffixType === 'image' ? (
<img src={url} alt="" />
) : suffixType === 'video' || suffixType === 'gifv' ? (
<video src={url} playsinline muted />
) : suffixType === 'audio' ? (
<audio src={url} controls />
) : null}
</div>
{descTextarea}
<div class="media-aside">
<button
type="button"
@ -1056,6 +1182,42 @@ function MediaAttachment({
</button>
</div>
</div>
{showModal && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowModal(false);
}
}}
>
<div id="media-sheet" class="sheet">
<header>
<h2>
{
{
image: 'Edit image description',
video: 'Edit video description',
audio: 'Edit audio description',
}[suffixType]
}
</h2>
</header>
<main tabIndex="-1">
<div class="media-preview">
{suffixType === 'image' ? (
<img src={url} alt="" />
) : suffixType === 'video' || suffixType === 'gifv' ? (
<video src={url} playsinline controls />
) : suffixType === 'audio' ? (
<audio src={url} controls />
) : null}
</div>
{descTextarea}
</main>
</div>
</Modal>
)}
</>
);
}
@ -1205,16 +1367,6 @@ function encodeHTML(str) {
});
}
// https://github.com/mastodon/mastodon/blob/c4a429ed47e85a6bbf0d470a41cc2f64cf120c19/app/javascript/mastodon/features/compose/util/counter.js
const urlRegexObj = new RegExp(urlRegex.source, urlRegex.flags);
const usernameRegex = /(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/gi;
const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx';
function countableText(inputText) {
return inputText
.replace(urlRegexObj, urlPlaceholder)
.replace(usernameRegex, '$1@$3');
}
function removeNullUndefined(obj) {
for (let key in obj) {
if (obj[key] === null || obj[key] === undefined) {

View file

@ -16,6 +16,8 @@ function NameText({ account, showAvatar, showAcct, short, external, onClick }) {
username.toLowerCase().trim() ===
(displayName || '')
.replace(/(\:(\w|\+|\-)+\:)(?=|[\!\.\?]|$)/g, '') // Remove shortcodes, regex from https://regex101.com/r/iE9uV0/1
.replace(/\s+/g, '') // E.g. "My name" === "myname"
.replace(/[^a-z0-9]/gi, '') // Remove non-alphanumeric characters
.toLowerCase()
.trim()
) {

View file

@ -0,0 +1,58 @@
// Twitter-style relative time component
// Seconds = 1s
// Minutes = 1m
// Hours = 1h
// Days = 1d
// After 7 days, use DD/MM/YYYY or MM/DD/YYYY
import dayjs from 'dayjs';
import dayjsTwitter from 'dayjs-twitter';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useEffect, useState } from 'preact/hooks';
dayjs.extend(dayjsTwitter);
dayjs.extend(localizedFormat);
dayjs.extend(relativeTime);
const dtf = new Intl.DateTimeFormat();
export default function RelativeTime({ datetime, format }) {
if (!datetime) return null;
const date = dayjs(datetime);
const [dateStr, setDateStr] = useState('');
useEffect(() => {
let timer, raf;
const update = () => {
raf = requestAnimationFrame(() => {
let str;
if (format === 'micro') {
// If date <= 1 day ago or day is within this year
const now = dayjs();
const dayDiff = now.diff(date, 'day');
if (dayDiff <= 1 || now.year() === date.year()) {
str = date.twitter();
} else {
str = dtf.format(date.toDate());
}
} else {
str = date.fromNow();
}
setDateStr(str);
timer = setTimeout(update, 30_000);
});
};
raf = requestAnimationFrame(update);
return () => {
clearTimeout(timer);
cancelAnimationFrame(raf);
};
}, [date]);
return (
<time datetime={date.toISOString()} title={date.format('LLLL')}>
{dateStr}
</time>
);
}

View file

@ -103,6 +103,9 @@
justify-content: space-between;
white-space: nowrap;
}
.status.small > .container > .meta {
margin-bottom: 4px;
}
.status > .container > .meta > * {
min-width: 0;
overflow: hidden;
@ -179,33 +182,56 @@
gap: 8px;
align-items: center;
}
.status .content-container.has-spoiler .spoiler ~ * {
/* filter: blur(6px) invert(0.5); */
filter: url(#spoiler);
transform: translate3d(-5px, -5px, 0);
.status
.content-container.has-spoiler
.spoiler
~ *:not(.media-container, .card),
.status .content-container.has-spoiler .spoiler ~ .card .meta-container {
filter: blur(6px) invert(0.5);
/* filter: url(#spoiler); */
text-rendering: optimizeSpeed;
image-rendering: crisp-edges;
image-rendering: pixelated;
/* transform: translate3d(-5px, -5px, 0); */
pointer-events: none;
user-select: none;
contain: layout;
}
@media (prefers-color-scheme: dark) {
.status .content-container.has-spoiler .spoiler ~ * {
.status .content-container.has-spoiler .spoiler ~ .media-container .media > *,
.status .content-container.has-spoiler .spoiler ~ .card > img {
filter: blur(32px);
image-rendering: crisp-edges;
image-rendering: pixelated;
animation: none !important;
}
/* @media (prefers-color-scheme: dark) {
.status
.content-container.has-spoiler
.spoiler
~ *:not(.media-container, .card),
.status .content-container.has-spoiler .spoiler ~ .card .meta-container {
filter: url(#spoiler-dark);
}
}
.status .content-container.has-spoiler .spoiler ~ .content ~ * {
opacity: 0.5;
}
} */
.status .content-container.show-spoiler .spoiler {
border-style: dotted;
}
.status .content-container.show-spoiler .spoiler ~ * {
.status
.content-container.show-spoiler
.spoiler
~ *:not(.media-container, .card),
.status .content-container.show-spoiler .spoiler ~ .card .meta-container {
filter: none !important;
transform: none;
pointer-events: auto;
user-select: auto;
text-rendering: auto;
image-rendering: auto;
}
.status .content-container.has-spoiler .spoiler ~ .content ~ * {
opacity: 1;
.status .content-container.show-spoiler .spoiler ~ .media-container .media > *,
.status .content-container.show-spoiler .spoiler ~ .card > img {
filter: none;
image-rendering: auto;
}
.timeline-deck .status .content {
@ -353,48 +379,67 @@
height: 100%;
object-fit: contain;
}
.status .media-video {
.status .media-video,
.status .media-gif {
position: relative;
background-clip: padding-box;
}
.status .media-video:before {
/* draw a circle in the middle */
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
.status .media-video[data-formatted-duration]:before {
pointer-events: none;
content: '⏵';
width: 70px;
height: 70px;
border-radius: 50%;
font-size: 50px;
position: absolute;
text-indent: 3px;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: var(--text-insignificant-color);
background-color: var(--bg-blur-color);
backdrop-filter: blur(6px) saturate(3) invert(0.2);
z-index: 1;
display: flex;
place-content: center;
place-items: center;
border-radius: 70px;
}
.status .media-video:after {
/* show play icon */
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-35%, -50%);
width: 0;
height: 0;
border-style: solid;
border-width: 15px 0 15px 26px;
border-color: transparent transparent transparent
var(--text-insignificant-color);
.status .media-video[data-formatted-duration]:hover:before {
color: var(--text-color);
}
.status .media-video[data-formatted-duration]:after {
font-size: 12px;
pointer-events: none;
opacity: 0.75;
z-index: 2;
content: attr(data-formatted-duration);
position: absolute;
bottom: 8px;
left: 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-video:hover:after {
opacity: 1;
.status .media-gif[data-label]:not(:hover):after {
font-size: 12px;
font-weight: bold;
pointer-events: none;
content: attr(data-label);
position: absolute;
bottom: 8px;
left: 8px;
color: var(--bg-faded-color);
background-color: var(--text-insignificant-color);
backdrop-filter: blur(6px) saturate(3) invert(0.2);
border-radius: 4px;
padding: 0 4px;
}
.status .media-gif video {
object-fit: cover;
pointer-events: none;
}
.status .media-contain video {
object-fit: contain !important;
}
.status .media-audio {
border: 0;
min-height: 0;
@ -434,6 +479,7 @@
width: 100%;
max-height: 50vh;
border-inline-end: 0;
border-block-end: 1px solid var(--outline-color);
}
.card:is(:hover, :focus) .image {
animation: position-object 5s ease-in-out 1s 5;
@ -447,6 +493,9 @@
flex-grow: 1;
align-self: center;
}
.card.large .meta-container {
align-self: flex-start;
}
.card .title {
line-height: 1.25;
font-weight: normal;

View file

@ -28,6 +28,7 @@ import visibilityIconsMap from '../utils/visibility-icons-map';
import Avatar from './avatar';
import Icon from './icon';
import RelativeTime from './relative-time';
function fetchAccount(id) {
return masto.v1.accounts.fetch(id);
@ -94,6 +95,7 @@ function Status({
filtered,
card,
createdAt,
inReplyToId,
inReplyToAccountId,
content,
mentions,
@ -252,14 +254,7 @@ function Status({
alt={visibility}
size="s"
/>{' '}
<relative-time
datetime={createdAtDate.toISOString()}
format="micro"
threshold="P1D"
prefix=""
>
{createdAtDate.toLocaleString()}
</relative-time>
<RelativeTime datetime={createdAtDate} format="micro" />
</a>
) : (
<span class="time">
@ -268,18 +263,14 @@ function Status({
alt={visibility}
size="s"
/>{' '}
<relative-time
datetime={createdAtDate.toISOString()}
format="micro"
threshold="P1D"
prefix=""
>
{createdAtDate.toLocaleString()}
</relative-time>
<RelativeTime datetime={createdAtDate} format="micro" />
</span>
))}
</div>
{inReplyToAccountId && !withinContext && size !== 's' && (
{!!inReplyToId &&
!!inReplyToAccountId &&
!withinContext &&
size !== 's' && (
<>
{inReplyToAccountId === status.account.id ? (
<div class="status-thread-badge">
@ -288,9 +279,10 @@ function Status({
</div>
) : (
!!inReplyToAccount &&
(!!spoilerText ||
!mentions.find((mention) => {
return mention.id === inReplyToAccountId;
}) && (
})) && (
<div class="status-reply-badge">
<Icon icon="reply" />{' '}
<NameText account={inReplyToAccount} short />
@ -430,6 +422,7 @@ function Status({
<Media
key={media.id}
media={media}
autoAnimate={size === 'l'}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -440,8 +433,10 @@ function Status({
</div>
)}
{!!card &&
(size === 'l' ||
(size === 'm' && !poll && !mediaAttachments.length)) && (
!sensitive &&
!spoilerText &&
!poll &&
!mediaAttachments.length && (
<Card
card={card}
size={!poll && !mediaAttachments.length ? 'l' : 'm'}
@ -658,7 +653,6 @@ function Status({
index={showMediaModal}
onClose={() => {
setShowMediaModal(false);
statusRef.current?.focus();
}}
/>
</Modal>
@ -695,7 +689,7 @@ video = Video clip
audio = Audio track
*/
function Media({ media, showOriginal, onClick = () => {} }) {
function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
const { blurhash, description, meta, previewUrl, remoteUrl, url, type } =
media;
const { original, small, focus } = meta || {};
@ -748,7 +742,7 @@ function Media({ media, showOriginal, onClick = () => {} }) {
alt={description}
width={width}
height={height}
loading="lazy"
loading={showOriginal ? 'eager' : 'lazy'}
style={
!showOriginal && {
backgroundColor:
@ -762,17 +756,24 @@ function Media({ media, showOriginal, onClick = () => {} }) {
} else if (type === 'gifv' || type === 'video') {
// 20 seconds, treat as a gif
const shortDuration = original.duration <= 20;
const isGIF = type === 'gifv' || shortDuration;
const isGIFV = type === 'gifv';
const isGIF = isGIFV || shortDuration;
const loopable = original.duration <= 60;
const formattedDuration = formatDuration(original.duration);
const hoverAnimate = !showOriginal && !autoAnimate && isGIF;
return (
<div
class={`media media-${isGIF ? 'gif' : 'video'}`}
class={`media media-${isGIF ? 'gif' : 'video'} ${
autoAnimate ? 'media-contain' : ''
}`}
data-formatted-duration={formattedDuration}
data-label={isGIF && !showOriginal && !autoAnimate ? 'GIF' : ''}
style={{
backgroundColor:
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
}}
onClick={(e) => {
if (!showOriginal && isGIF) {
if (hoverAnimate) {
try {
videoRef.current.pause();
} catch (e) {}
@ -780,22 +781,26 @@ function Media({ media, showOriginal, onClick = () => {} }) {
onClick(e);
}}
onMouseEnter={() => {
if (!showOriginal && isGIF) {
if (hoverAnimate) {
try {
videoRef.current.play();
} catch (e) {}
}
}}
onMouseLeave={() => {
if (!showOriginal && isGIF) {
if (hoverAnimate) {
try {
videoRef.current.pause();
} catch (e) {}
}
}}
>
{showOriginal ? (
{showOriginal || autoAnimate ? (
<div
style={{
width: '100%',
height: '100%',
}}
dangerouslySetInnerHTML={{
__html: `
<video
@ -806,7 +811,7 @@ function Media({ media, showOriginal, onClick = () => {} }) {
preload="auto"
autoplay
muted="${isGIF}"
${isGIF ? '' : 'controls'}
${isGIFV ? '' : 'controls'}
playsinline
loop="${loopable}"
></video>
@ -1130,9 +1135,7 @@ function Poll({ poll, lang, readOnly, onUpdate = () => {} }) {
</>
)}{' '}
&bull; {expired ? 'Ended' : 'Ending'}{' '}
{!!expiresAtDate && (
<relative-time datetime={expiresAtDate.toISOString()} />
)}
{!!expiresAtDate && <RelativeTime datetime={expiresAtDate} />}
</p>
)}
</div>
@ -1423,4 +1426,19 @@ function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) {
);
}
function formatDuration(time) {
if (!time) return;
let hours = Math.floor(time / 3600);
let minutes = Math.floor((time % 3600) / 60);
let seconds = Math.round(time % 60);
if (hours === 0) {
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
} else {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
.toString()
.padStart(2, '0')}`;
}
}
export default Status;

View file

@ -2,7 +2,6 @@ import './index.css';
import './app.css';
import '@github/relative-time-element';
import { login } from 'masto';
import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';

View file

@ -1,202 +1,435 @@
[
"mastodon.social",
"mstdn.social",
"mastodon.world",
"mas.to",
"pawoo.net",
"mastodon.online",
"infosec.exchange",
"mstdn.jp",
"mastodonapp.uk",
"hachyderm.io",
"techhub.social",
"fosstodon.org",
"universeodon.com",
"mastodon.lol",
"mastodonapp.uk",
"infosec.exchange",
"mastodon.uno",
"techhub.social",
"mastodon.sdf.org",
"fosstodon.org",
"troet.cafe",
"masto.ai",
"mastodon.uno",
"mastodon.nl",
"mstdn.party",
"c.im",
"hachyderm.io",
"m.cmx.im",
"masto.ai",
"mstdn.ca",
"sfba.social",
"home.social",
"c.im",
"kolektiva.social",
"mastodon.scot",
"ohai.social",
"m.cmx.im",
"sfba.social",
"fedibird.com",
"piaille.fr",
"home.social",
"mindly.social",
"mastodon.nl",
"toot.community",
"aus.social",
"thu.closed.social",
"mastodon.gamedev.place",
"nerdculture.de",
"mastodon.scot",
"mindly.social",
"ohai.social",
"mastodon.cloud",
"mastodon.ie",
"toot.community",
"det.social",
"mastodon.au",
"aus.social",
"nrw.social",
"mastodon.art",
"chaos.social",
"social.vivaldi.net",
"mastodon.ie",
"norden.social",
"sueden.social",
"mastodon.top",
"mastodon.au",
"mastodontech.de",
"mas.todon.de",
"ioc.exchange",
"alive.bar",
"tkz.one",
"sueden.social",
"mastodon.nu",
"mastodon.top",
"mastouille.fr",
"mastodontech.de",
"o3o.ca",
"social.tchncs.de",
"mastodon.nu",
"social.cologne",
"mastouille.fr",
"o3o.ca",
"mathstodon.xyz",
"noagendasocial.com",
"newsie.social",
"masto.es",
"planet.moe",
"social.vivaldi.net",
"ravenation.club",
"wxw.moe",
"mathstodon.xyz",
"social.cologne",
"mastodon.nz",
"qoto.org",
"hessen.social",
"sigmoid.social",
"mastodon.com.tr",
"ruhr.social",
"hessen.social",
"muenchen.social",
"mamot.fr",
"twit.social",
"dice.camp",
"meow.social",
"www.masto.pt",
"social.anoxinon.de",
"www.sociale.network",
"masto.es",
"masto.nu",
"tech.lgbt",
"ruhr.social",
"mastodon.green",
"mstdn.plus",
"wxw.moe",
"qoto.org",
"mamot.fr",
"tkz.one",
"dice.camp",
"social.anoxinon.de",
"mastodon.nz",
"twit.social",
"ravenation.club",
"planet.moe",
"mstdn.science",
"med-mastodon.com",
"econtwitter.net",
"fediscience.org",
"toot.io",
"masthead.social",
"glasgow.social",
"ieji.de",
"social.dev-wiki.de",
"mastodont.cat",
"toot.wales",
"ieji.de",
"ecoevo.social",
"ro-mastodon.puyo.jp",
"noc.social",
"indieweb.social",
"zirk.us",
"twingyeo.kr",
"noc.social",
"social.linux.pizza",
"mastodont.cat",
"social.dev-wiki.de",
"mastodonczech.cz",
"climatejustice.social",
"eldritch.cafe",
"g0v.social",
"socel.net",
"dju.social",
"mastodontti.fi",
"101010.pl",
"framapiaf.org",
"wien.rocks",
"botsin.space",
"mastodon.bida.im",
"bildung.social",
"pouet.chapril.org",
"urbanists.social",
"wandering.shop",
"masto.pt",
"union.place",
"metalhead.club",
"ruby.social",
"hiveway.net",
"h4.io",
"genomic.social",
"mastodon-belgium.be",
"mastodon.xyz",
"octodon.social",
"pol.social",
"tooot.im",
"berlin.social",
"sciences.social",
"mstdn.guru",
"qdon.space",
"mastodon.radio",
"lile.cl",
"masto.nu",
"witches.live",
"cyberplace.social",
"indieweb.social",
"mastodonners.nl",
"muenster.im",
"lor.sh",
"phpc.social",
"pewtix.com",
"social.librem.one",
"convo.casa",
"twingyeo.kr",
"sself.co",
"urbanists.social",
"glasgow.social",
"botsin.space",
"eldritch.cafe",
"climatejustice.social",
"theblower.au",
"framapiaf.org",
"artsio.com",
"mastodon.iriseden.eu",
"socel.net",
"g0v.social",
"mastodonczech.cz",
"mastodontti.fi",
"wandering.shop",
"thu.closed.social",
"mastodon.bida.im",
"geekdom.social",
"stranger.social",
"cupoftea.social",
"bildung.social",
"awscommunity.social",
"mas.town",
"ruby.social",
"sciences.social",
"wien.rocks",
"respublicae.eu",
"metalhead.club",
"pouet.chapril.org",
"genomic.social",
"dju.social",
"101010.pl",
"graphics.social",
"defcon.social",
"mastodon.xyz",
"bark.lgbt",
"witches.live",
"climatejustice.rocks",
"rollenspiel.social",
"peoplemaking.games",
"berlin.social",
"masto.pt",
"litmind.club",
"livellosegreto.it",
"mstdn.guru",
"nerdculture.de",
"journa.host",
"octodon.social",
"union.place",
"mastodon-belgium.be",
"mastodon.radio",
"pol.social",
"rheinneckar.social",
"hometech.social",
"androiddev.social",
"social.librem.one",
"kinky.business",
"mastodon.fun",
"me.ns.ci",
"mastodon.eus",
"phpc.social",
"mast.lat",
"muenster.im",
"mastodon.chasem.dev",
"tooot.im",
"musician.social",
"dresden.network",
"hostux.social",
"scholar.social",
"freiburg.social",
"todon.eu",
"writing.exchange",
"swiss.social",
"h4.io",
"toot.aquilenet.fr",
"digitalcourage.social",
"rheinneckar.social",
"discuss.systems",
"defcon.social",
"snabelen.no",
"toad.social",
"poweredbygay.social",
"hostux.social",
"mastodon.se",
"mastodon.me.uk",
"rubber.social",
"fulda.social",
"vis.social",
"toot.funami.tech",
"mast.dragon-fly.club",
"pewtix.com",
"mastodon.berlin",
"lor.sh",
"mastodon.fun",
"me.ns.ci",
"snabelen.no",
"freiburg.social",
"disabled.social",
"medibubble.org",
"mastodon.technology",
"spore.social",
"qdon.space",
"beta.qdon.space",
"scholar.social",
"vmst.io",
"mstdn.io",
"equestria.social",
"vocalodon.net",
"mastodon.ml",
"libretooth.gr",
"astrodon.social",
"masto.nobigtech.es",
"hci.social",
"mastodon.eus",
"todon.eu",
"discuss.systems",
"tooting.ch",
"dizl.de",
"best-friends.chat",
"romancelandia.club",
"queer.party",
"tilde.zone",
"xarxa.cloud",
"abdl.link",
"bitcoinhackers.org",
"photog.social",
"macaw.social",
"paquita.masto.host",
"fulda.social",
"lile.cl",
"medibubble.org",
"writing.exchange",
"historians.social",
"vocalodon.net",
"vis.social",
"yiff.life",
"sociale.network",
"fur.lgbt",
"peoplemaking.games",
"hcommons.social",
"mstdn.io",
"libretooth.gr",
"m.sclo.nl",
"pettingzoo.co",
"mastodon.zaclys.com",
"equestria.social",
"best-friends.chat",
"ursal.zone",
"eupolicy.social",
"gruene.social",
"artisan.chat",
"graz.social",
"bitcoinhackers.org",
"uiuxdev.social",
"queer.party",
"mastodon.ml",
"aethy.com",
"abdl.link",
"mastodon.com.py",
"mapstodon.space",
"typo.social",
"cryptodon.lol",
"tilde.zone",
"computerfairi.es",
"social.coop",
"mstdn.id",
"social.sciences.re",
"ludosphere.fr",
"social.politicaconciencia.org",
"oslo.town",
"scicomm.xyz",
"mast.dragon-fly.club",
"dragon-fly.club",
"floss.social",
"creators.social",
"tabletop.social",
"photog.social",
"bonn.social",
"openbiblio.social",
"mastodon.la",
"halifaxsocial.ca",
"sciencemastodon.com",
"mastodon.coffee",
"mastorol.es",
"federated.press",
"toot.funami.tech",
"mastodon.gal",
"tabletop.social",
"shakedown.social",
"dizl.de",
"romancelandia.club",
"oslo.town",
"graz.social",
"sociale.network",
"todon.nl",
"nofan.xyz",
"data-folks.masto.host",
"scicomm.xyz",
"layer8.space",
"artisan.chat",
"freeradical.zone",
"toot.cat",
"fandom.ink",
"twiukraine.com",
"eupolicy.social",
"xarxa.cloud",
"bsd.network",
"weirder.earth",
"linuxrocks.online",
"mastodon.cat",
"girlcock.club",
"bolha.us",
"zeroes.ca",
"douchi.space",
"cybre.space",
"mastodon.la",
"sunny.garden",
"bbq.snoot.com",
"liker.social",
"vulpine.club",
"imastodon.net",
"mstdn.maud.io",
"freeatlantis.com",
"is.nota.live",
"mastodon.org.uk",
"mastodon.arch-linux.cz",
"mona.do",
"tyrol.social",
"mstdn.id",
"mastodon.uy",
"mastodon.in.th",
"kurry.social",
"toot.cafe",
"shelter.moe",
"social.politicaconciencia.org",
"h-net.social",
"mstdn.mx",
"kopiti.am",
"mastodon.vlaanderen",
"mao.mastodonhub.com",
"cloud-native.social",
"mograph.social",
"oc.todon.fr",
"ura-mstdn.com",
"uri.life",
"liberdon.com",
"kinkyelephant.com",
"nojack.easydns.ca",
"mastodon.be",
"podcastindex.social",
"blacktwitter.io",
"awoo.space",
"woof.group",
"ani.work",
"colorid.es",
"seo.chat",
"mental.social",
"plural.cafe",
"ika.queloud.net",
"mastodon.com.br",
"mstdn.tokyocameraclub.com",
"donphan.social",
"gensokyo.town",
"ichiji.social",
"sunbeam.city",
"mstdn.kemono-friends.info",
"littlefo.rest",
"kirakiratter.com",
"uwu.social",
"elekk.xyz",
"hispagatos.space",
"hello.2heng.xin",
"the.fores.top",
"mstdn.fr",
"mastodon.mnetwork.co.kr",
"mastodon.gougere.fr",
"dobbs.town",
"gameliberty.club",
"gensokyo.social",
"mathtod.online",
"mastodon.cc",
"iztasocial.site",
"mastodon.pirateparty.be",
"dingdash.com",
"mastodon.partipirate.org",
"oulipo.social",
"anticapitalist.party",
"kemonodon.club",
"toot.turbo.chat",
"photodn.net",
"otogamer.me",
"bear.community",
"tablegame.mstdn.cloud",
"anarchism.space",
"ffxiv-mastodon.com",
"lgbt.io",
"lou.lt",
"social.chinwag.org",
"chinwag.org",
"aleph.land",
"social.slat.org",
"mastodon.juggler.jp",
"eigadon.net",
"vocalounge.cafe",
"acg.mn",
"acg.debula.ml",
"eletusk.club",
"otoya.space",
"social.coletivos.org",
"mastodon.cipherbliss.com",
"truthsocial.co.in",
"mstdn.osaka",
"social.targaryen.house",
"catdon.life",
"stereodon.social",
"social.opendesktop.org",
"nasface.cz",
"toot.site",
"fetswing.org",
"vipgirlfriend.xxx",
"mastodon.elte.hu",
"bgme.me",
"kinbaku.club",
"m.rthome.me",
"animalliberation.social",
"mastodon.librelabucm.org",
"mastodon.gza.jp",
"med-mammoth.com",
"hearthtodon.com",
"counter.social",
"kfem.cat",
"federated.press"
"pet123.club",
"beta.woof.group",
"explosion.party",
"id.cc",
"freespeechextremist.com",
"cawfee.club",
"1234.as",
"fedi.absturztau.be",
"fsmi.social",
"go5.dev",
"poa.st",
"patriot.online",
"seaofog.com",
"libranet.de",
"tea.codes",
"pixelfed.social",
"shitposter.club",
"squeet.me",
"shared.graphics",
"glindr.org",
"pxlmo.com",
"pixel.tchncs.de",
"love.alicecomplex.com",
"friendica.eskimo.com",
"meatbag.app",
"fediverse.bbad.com",
"pix.toot.wales",
"fgc.network",
"bookrastinating.com",
"pixey.org",
"pixelfed.tokyo",
"chudbuds.lol",
"freeframe.masto.host",
"varishangout.net",
"friendica.vrije-mens.org",
"bae.st",
"brighteon.social",
"pixelfed.uno",
"helladoge.com",
"donotban.com",
"bookwyrm.social",
"spinster.xyz",
"pixelfed.de",
"metapixl.com",
"venera.social",
"blob.cat",
"onevery.ignorelist.com",
"cliq.buzz",
"pxl.roflcopter.fr",
"p.1069-3.com",
"www2.patriot.online",
"gc2.jp",
"soap.shitposter.club",
"www.mastodon.scot"
]

View file

@ -3,7 +3,7 @@
--blue-color: royalblue;
--purple-color: blueviolet;
--green-color: green;
--green-color: darkgreen;
--orange-color: darkorange;
--red-color: orangered;
--bg-color: #fff;
@ -41,7 +41,7 @@
:root {
--blue-color: CornflowerBlue;
--purple-color: mediumpurple;
--green-color: limegreen;
--green-color: lightgreen;
--orange-color: orange;
--bg-color: #242526;
--bg-faded-color: #18191a;
@ -123,6 +123,7 @@ button,
line-height: 1;
vertical-align: middle;
text-decoration: none;
user-select: none;
}
:is(button, .button) > * {
vertical-align: middle;

View file

@ -1,6 +1,5 @@
import './index.css';
import '@github/relative-time-element';
import { render } from 'preact';
import { App } from './app';

View file

@ -8,6 +8,8 @@ import Icon from '../components/icon';
import Loader from '../components/loader';
import Status from '../components/status';
import states from '../utils/states';
import useDebouncedCallback from '../utils/useDebouncedCallback';
import useScroll from '../utils/useScroll';
const LIMIT = 20;
@ -27,6 +29,7 @@ function Home({ hidden }) {
homeIterator.current = masto.v1.timelines.listHome({
limit: LIMIT,
});
states.homeNew = [];
}
const allStatuses = await homeIterator.current.next();
if (allStatuses.value <= 0) {
@ -52,7 +55,10 @@ function Home({ hidden }) {
return allStatuses;
}
const loadStatuses = (firstLoad) => {
const loadingStatuses = useRef(false);
const loadStatuses = useDebouncedCallback((firstLoad) => {
if (loadingStatuses.current) return;
loadingStatuses.current = true;
setUIState('loading');
(async () => {
try {
@ -62,9 +68,11 @@ function Home({ hidden }) {
} catch (e) {
console.warn(e);
setUIState('error');
} finally {
loadingStatuses.current = false;
}
})();
};
}, 1000);
useEffect(() => {
loadStatuses(true);
@ -154,6 +162,25 @@ function Home({ hidden }) {
}
});
const { scrollDirection, reachTop, nearReachTop, nearReachBottom } =
useScroll({
scrollableElement: scrollableRef.current,
distanceFromTop: 0.1,
distanceFromBottom: 0.15,
});
useEffect(() => {
if (nearReachBottom && showMore) {
loadStatuses();
}
}, [nearReachBottom]);
useEffect(() => {
if (reachTop) {
loadStatuses(true);
}
}, [reachTop]);
return (
<div
id="home-page"
@ -162,8 +189,27 @@ function Home({ hidden }) {
ref={scrollableRef}
tabIndex="-1"
>
<button
hidden={scrollDirection === 'down' && !nearReachTop}
type="button"
id="compose-button"
onClick={(e) => {
if (e.shiftKey) {
const newWin = openCompose();
if (!newWin) {
alert('Looks like your browser is blocking popups.');
states.showCompose = true;
}
} else {
states.showCompose = true;
}
}}
>
<Icon icon="quill" size="xxl" alt="Compose" />
</button>
<div class="timeline-deck deck">
<header
hidden={scrollDirection === 'down' && !nearReachTop}
onClick={() => {
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}}
@ -200,7 +246,10 @@ function Home({ hidden }) {
</a>
</div>
</header>
{snapStates.homeNew.length > 0 && (
{snapStates.homeNew.length > 0 &&
scrollDirection === 'up' &&
!nearReachTop &&
!nearReachBottom && (
<button
class="updates-button"
type="button"
@ -240,7 +289,7 @@ function Home({ hidden }) {
})}
{showMore && (
<>
<InView
{/* <InView
as="li"
style={{
height: '20vh',
@ -250,9 +299,15 @@ function Home({ hidden }) {
}}
root={scrollableRef.current}
rootMargin="100px 0px"
> */}
<li
style={{
height: '20vh',
}}
>
<Status skeleton />
</InView>
</li>
{/* </InView> */}
<li
style={{
height: '25vh',

View file

@ -3,7 +3,7 @@ import './login.css';
import { useEffect, useRef, useState } from 'preact/hooks';
import Loader from '../components/loader';
import instancesList from '../data/instances.json';
import instancesListURL from '../data/instances.json?url';
import { getAuthorizationURL, registerApplication } from '../utils/auth';
import store from '../utils/store';
import useTitle from '../utils/useTitle';
@ -14,16 +14,30 @@ function Login() {
const cachedInstanceURL = store.local.get('instanceURL');
const [uiState, setUIState] = useState('default');
const [instancesList, setInstancesList] = useState([]);
useEffect(() => {
(async () => {
try {
const res = await fetch(instancesListURL);
const data = await res.json();
setInstancesList(data);
} catch (e) {
// Silently fail
console.error(e);
}
})();
}, []);
useEffect(() => {
if (cachedInstanceURL) {
instanceURLRef.current.value = cachedInstanceURL;
instanceURLRef.current.value = cachedInstanceURL.toLowerCase();
}
}, []);
const onSubmit = (e) => {
e.preventDefault();
const { elements } = e.target;
let instanceURL = elements.instanceURL.value;
let instanceURL = elements.instanceURL.value.toLowerCase();
// Remove protocol from instance URL
instanceURL = instanceURL.replace(/(^\w+:|^)\/\//, '');
store.local.set('instanceURL', instanceURL);
@ -68,6 +82,10 @@ function Login() {
ref={instanceURLRef}
disabled={uiState === 'loading'}
list="instances-list"
autocorrect="off"
autocapitalize="off"
autocomplete="off"
spellcheck="false"
/>
<datalist id="instances-list">
{instancesList.map((instance) => (

View file

@ -20,41 +20,56 @@
opacity: 0.75;
color: var(--text-insignificant-color);
}
.notification-type.notification-mention {
color: var(--reply-to-color);
}
.notification-type.notification-favourite {
color: var(--favourite-color);
}
.notification-type.notification-reblog {
color: var(--reblog-color);
}
.notification-type.notification-poll,
.notification-type.notification-mention {
.notification-type.notification-poll {
color: var(--link-color);
}
.notification .status-link {
border-radius: 8px 8px 0 0;
border-radius: 8px;
border: 1px solid var(--outline-color);
max-height: 160px;
overflow: hidden;
filter: saturate(0.25);
}
.notification .status-link:not(.status-type-mention) > .status {
max-height: 160px;
overflow: hidden;
/* fade out mask gradient bottom */
mask-image: linear-gradient(
rgba(0, 0, 0, 1),
rgba(0, 0, 0, 1) 50%,
transparent
rgba(0, 0, 0, 1) 130px,
rgba(0, 0, 0, 0.5) 145px,
transparent 159px
);
filter: saturate(0.25);
}
.notification .status-link.status-type-mention {
max-height: 320px;
filter: none;
background-color: var(--bg-color);
margin-top: calc(-16px - 1px);
border-color: var(--reply-to-color);
box-shadow: 0 0 0 3px var(--reply-to-faded-color);
}
.notification .status-link:is(:hover, :focus) {
background-color: var(--bg-blur-color);
filter: saturate(1);
border-color: var(--outline-hover-color);
}
.notification .status-link.status-type-mention:is(:hover, :focus) {
border-color: var(--reply-to-color);
box-shadow: 0 0 5px var(--reply-to-color);
}
.notification .status-link:active {
filter: brightness(0.95);
}
.notification .status-link > * {
pointer-events: none;
}

View file

@ -8,6 +8,7 @@ import Avatar from '../components/avatar';
import Icon from '../components/icon';
import Loader from '../components/loader';
import NameText from '../components/name-text';
import RelativeTime from '../components/relative-time';
import Status from '../components/status';
import states from '../utils/states';
import store from '../utils/store';
@ -102,18 +103,16 @@ function Notification({ notification }) {
<span class="insignificant">
{' '}
{' '}
<relative-time
<RelativeTime
datetime={notification.createdAt}
format="micro"
threshold="P1D"
prefix=""
/>
</span>
)}
</p>
)}
{_accounts?.length > 1 && (
<p>
<p class="avatars-stack">
{_accounts.map((account, i) => (
<>
<a
@ -127,7 +126,7 @@ function Notification({ notification }) {
<Avatar
url={account.avatarStatic}
size={
_accounts.length < 10
_accounts.length < 30
? 'xl'
: _accounts.length < 100
? 'l'
@ -164,28 +163,23 @@ function NotificationsList({ notifications, emptyCopy }) {
// Create new flat list of notifications
// Combine sibling notifications based on type and status id, ignore the id
// Concat all notification.account into an array of _accounts
const cleanNotifications = [
{
...notifications[0],
_accounts: [notifications[0].account],
},
];
for (let i = 1, j = 0; i < notifications.length; i++) {
const notificationsMap = {};
const cleanNotifications = [];
for (let i = 0, j = 0; i < notifications.length; i++) {
const notification = notifications[i];
const cleanNotification = cleanNotifications[j];
const { status, account, type } = notification;
if (
account &&
cleanNotification?.account &&
cleanNotification?.status?.id === status?.id &&
cleanNotification?.type === type
) {
cleanNotification._accounts.push(account);
// const cleanNotification = cleanNotifications[j];
const { status, account, type, created_at } = notification;
const createdAt = new Date(created_at).toLocaleDateString();
const key = `${status?.id}-${type}-${createdAt}`;
const mappedNotification = notificationsMap[key];
if (mappedNotification?.account) {
mappedNotification._accounts.push(account);
} else {
cleanNotifications[++j] = {
let n = (notificationsMap[key] = {
...notification,
_accounts: [account],
};
});
cleanNotifications[j++] = n;
}
}
// console.log({ notifications, cleanNotifications });
@ -222,6 +216,7 @@ function Notifications() {
notificationsIterator.current = masto.v1.notifications.list({
limit: LIMIT,
});
states.notificationsNew = [];
}
const allNotifications = await notificationsIterator.current.next();
if (allNotifications.value <= 0) {
@ -257,7 +252,6 @@ function Notifications() {
useEffect(() => {
loadNotifications(true);
states.notificationsNew = [];
}, []);
const scrollableRef = useRef();

View file

@ -5,6 +5,7 @@ import { useRef, useState } from 'preact/hooks';
import Avatar from '../components/avatar';
import Icon from '../components/icon';
import NameText from '../components/name-text';
import RelativeTime from '../components/relative-time';
import states from '../utils/states';
import store from '../utils/store';
@ -196,8 +197,7 @@ function Settings({ onClose }) {
</p>
{__BUILD_TIME__ && (
<p>
Last build:{' '}
<relative-time datetime={new Date(__BUILD_TIME__).toISOString()} />{' '}
Last build: <RelativeTime datetime={new Date(__BUILD_TIME__)} />{' '}
{__COMMIT_HASH__ && (
<>
(

View file

@ -17,6 +17,7 @@ import { useSnapshot } from 'valtio';
import Icon from '../components/icon';
import Loader from '../components/loader';
import NameText from '../components/name-text';
import RelativeTime from '../components/relative-time';
import Status from '../components/status';
import htmlContentLength from '../utils/html-content-length';
import shortenNumber from '../utils/shorten-number';
@ -54,7 +55,7 @@ function StatusPage({ id }) {
};
}, [id]);
useEffect(() => {
const initContext = () => {
setUIState('loading');
let heroTimer;
@ -173,7 +174,30 @@ function StatusPage({ id }) {
return () => {
clearTimeout(heroTimer);
};
}, [id, snapStates.reloadStatusPage]);
};
useEffect(initContext, [id]);
useEffect(() => {
// 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 contextURL = `https://${instanceURL}/api/v1/statuses/${id}/context`;
console.log('Clear cache', contextURL);
const apiCache = await caches.open('api');
await apiCache.delete(contextURL, { ignoreVary: true });
return initContext();
} catch (e) {
console.error(e);
}
})();
}, [snapStates.reloadStatusPage]);
const firstLoad = useRef(true);
@ -280,7 +304,7 @@ function StatusPage({ id }) {
}, [heroInView]);
useHotkeys(['esc', 'backspace'], () => {
route(closeLink);
location.hash = closeLink;
});
return (
@ -325,11 +349,9 @@ function StatusPage({ id }) {
<NameText showAvatar account={heroStatus.account} short />{' '}
<span class="insignificant">
&bull;{' '}
<relative-time
<RelativeTime
datetime={heroStatus.createdAt}
format="micro"
threshold="P1D"
prefix=""
/>
</span>
</span>

View file

@ -18,4 +18,5 @@ export default proxy({
showCompose: false,
showSettings: false,
showAccount: false,
composeCharacterCount: 0,
});

43
src/utils/useScroll.js Normal file
View file

@ -0,0 +1,43 @@
import { useEffect, useState } from 'preact/hooks';
export default function useScroll({
scrollableElement = window,
distanceFromTop = 0,
distanceFromBottom = 0,
scrollThreshold = 10,
} = {}) {
const [scrollDirection, setScrollDirection] = useState(null);
const [reachTop, setReachTop] = useState(false);
const [nearReachTop, setNearReachTop] = useState(false);
const [nearReachBottom, setNearReachBottom] = useState(false);
useEffect(() => {
let previousScrollTop = scrollableElement.scrollTop;
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));
if (scrollDistance >= scrollThreshold) {
setScrollDirection(previousScrollTop < scrollTop ? 'down' : 'up');
previousScrollTop = scrollTop;
}
setReachTop(scrollTop === 0);
setNearReachTop(scrollTop <= distanceFromTopPx);
setNearReachBottom(
scrollTop + clientHeight >= scrollHeight - distanceFromBottomPx,
);
}
scrollableElement.addEventListener('scroll', onScroll, { passive: true });
return () => scrollableElement.removeEventListener('scroll', onScroll);
}, [scrollableElement, distanceFromTop, distanceFromBottom, scrollThreshold]);
return { scrollDirection, reachTop, nearReachTop, nearReachBottom };
}

View file

@ -6,12 +6,11 @@ import { defineConfig, loadEnv, splitVendorChunkPlugin } from 'vite';
import htmlPlugin from 'vite-plugin-html-config';
import VitePluginHtmlEnv from 'vite-plugin-html-env';
import { VitePWA } from 'vite-plugin-pwa';
import removeConsole from 'vite-plugin-remove-console';
const {
VITE_CLIENT_NAME: CLIENT_NAME,
NODE_ENV,
VITE_APP_ERROR_LOGGING,
} = loadEnv('production', process.cwd());
const { NODE_ENV } = process.env;
const { VITE_CLIENT_NAME: CLIENT_NAME, VITE_APP_ERROR_LOGGING: ERROR_LOGGING } =
loadEnv('production', process.cwd());
const commitHash = execSync('git rev-parse --short HEAD').toString().trim();
@ -31,8 +30,9 @@ export default defineConfig({
preact(),
splitVendorChunkPlugin(),
VitePluginHtmlEnv(),
removeConsole(),
htmlPlugin({
headScripts: VITE_APP_ERROR_LOGGING ? [rollbarCode] : [],
headScripts: ERROR_LOGGING ? [rollbarCode] : [],
}),
VitePWA({
manifest: {