this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #286 from cheeaun/main

Update from main

authored by

Chee Aun and committed by
GitHub
74991c32 f558a8cd

+2428 -1124
+126 -53
package-lock.json
··· 15 15 "@justinribeiro/lite-youtube": "~1.5.0", 16 16 "@szhsin/react-menu": "~4.1.0", 17 17 "@uidotdev/usehooks": "~2.4.1", 18 + "compare-versions": "~6.1.0", 18 19 "dayjs": "~1.11.10", 19 20 "dayjs-twitter": "~0.5.0", 20 21 "fast-blurhash": "~1.1.2", ··· 22 23 "idb-keyval": "~6.2.1", 23 24 "just-debounce-it": "~3.2.0", 24 25 "lz-string": "~1.5.0", 25 - "masto": "~6.3.3", 26 + "masto": "~6.4.2", 26 27 "moize": "~6.1.6", 27 28 "p-retry": "~6.1.0", 28 29 "p-throttle": "~5.1.0", 29 - "preact": "~10.18.1", 30 + "preact": "~10.18.2", 30 31 "react-hotkeys-hook": "~4.4.1", 31 32 "react-intersection-observer": "~9.5.2", 32 33 "react-quick-pinch-zoom": "~5.0.0", ··· 45 46 "@trivago/prettier-plugin-sort-imports": "~4.2.1", 46 47 "postcss": "~8.4.31", 47 48 "postcss-dark-theme-class": "~1.0.0", 48 - "postcss-preset-env": "~9.2.0", 49 + "postcss-preset-env": "~9.3.0", 49 50 "twitter-text": "~3.1.0", 50 51 "vite": "~4.5.0", 51 52 "vite-plugin-generate-file": "~0.0.4", 52 53 "vite-plugin-html-config": "~1.0.11", 53 - "vite-plugin-pwa": "~0.16.5", 54 + "vite-plugin-pwa": "~0.16.6", 54 55 "vite-plugin-remove-console": "~2.1.1", 55 56 "workbox-cacheable-response": "~7.0.0", 56 57 "workbox-expiration": "~7.0.0", ··· 1988 1989 } 1989 1990 }, 1990 1991 "node_modules/@csstools/postcss-cascade-layers": { 1991 - "version": "4.0.0", 1992 - "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-4.0.0.tgz", 1993 - "integrity": "sha512-dVPVVqQG0FixjM9CG/+8eHTsCAxRKqmNh6H69IpruolPlnEF1611f2AoLK8TijTSAsqBSclKd4WHs1KUb/LdJw==", 1992 + "version": "4.0.1", 1993 + "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-4.0.1.tgz", 1994 + "integrity": "sha512-UYFuFL9GgVnftg9v7tBvVEBRLaBeAD66euD+yYy5fYCUld9ZIWTJNCE30hm6STMEdt6FL5xzeVw1lAZ1tpvUEg==", 1994 1995 "dev": true, 1995 1996 "funding": [ 1996 1997 { ··· 2299 2300 "postcss": "^8.4" 2300 2301 } 2301 2302 }, 2303 + "node_modules/@csstools/postcss-logical-overflow": { 2304 + "version": "1.0.0", 2305 + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-overflow/-/postcss-logical-overflow-1.0.0.tgz", 2306 + "integrity": "sha512-cIrZ8f7bGGvr+W53nEuMspcwaeaI2YTmz6LZ4yiAO5z14/PQgOOv+Pn+qjvPOPoadeY2BmpaoTzZKvdAQuM17w==", 2307 + "dev": true, 2308 + "funding": [ 2309 + { 2310 + "type": "github", 2311 + "url": "https://github.com/sponsors/csstools" 2312 + }, 2313 + { 2314 + "type": "opencollective", 2315 + "url": "https://opencollective.com/csstools" 2316 + } 2317 + ], 2318 + "engines": { 2319 + "node": "^14 || ^16 || >=18" 2320 + }, 2321 + "peerDependencies": { 2322 + "postcss": "^8.4" 2323 + } 2324 + }, 2325 + "node_modules/@csstools/postcss-logical-overscroll-behavior": { 2326 + "version": "1.0.0", 2327 + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-overscroll-behavior/-/postcss-logical-overscroll-behavior-1.0.0.tgz", 2328 + "integrity": "sha512-e89S2LWjnxf0SB2wNUAbqDyFb/Fow/tlOe1XqOLbNx4rf3LrQokM9qldVx7sarnddml3ORE5LDUmlKpPOOeJTA==", 2329 + "dev": true, 2330 + "funding": [ 2331 + { 2332 + "type": "github", 2333 + "url": "https://github.com/sponsors/csstools" 2334 + }, 2335 + { 2336 + "type": "opencollective", 2337 + "url": "https://opencollective.com/csstools" 2338 + } 2339 + ], 2340 + "engines": { 2341 + "node": "^14 || ^16 || >=18" 2342 + }, 2343 + "peerDependencies": { 2344 + "postcss": "^8.4" 2345 + } 2346 + }, 2302 2347 "node_modules/@csstools/postcss-logical-resize": { 2303 2348 "version": "2.0.0", 2304 2349 "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-resize/-/postcss-logical-resize-2.0.0.tgz", ··· 3870 3915 "node": ">=4.0.0" 3871 3916 } 3872 3917 }, 3918 + "node_modules/compare-versions": { 3919 + "version": "6.1.0", 3920 + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.0.tgz", 3921 + "integrity": "sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg==" 3922 + }, 3873 3923 "node_modules/concat-map": { 3874 3924 "version": "0.0.1", 3875 3925 "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", ··· 3997 4047 } 3998 4048 }, 3999 4049 "node_modules/cssdb": { 4000 - "version": "7.8.0", 4001 - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.8.0.tgz", 4002 - "integrity": "sha512-SkeezZOQr5AHt9MgJgSFNyiuJwg1p8AwoVln6JwaQJsyxduRW9QJ+HP/gAQzbsz8SIqINtYvpJKjxTRI67zxLg==", 4050 + "version": "7.9.0", 4051 + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.9.0.tgz", 4052 + "integrity": "sha512-WPMT9seTQq6fPAa1yN4zjgZZeoTriSN2LqW9C+otjar12DQIWA4LuSfFrvFJiKp4oD0xIk1vumDLw8K9ur4NBw==", 4003 4053 "dev": true, 4004 4054 "funding": [ 4005 4055 { ··· 4264 4314 } 4265 4315 }, 4266 4316 "node_modules/events-to-async": { 4267 - "version": "2.0.0", 4268 - "resolved": "https://registry.npmjs.org/events-to-async/-/events-to-async-2.0.0.tgz", 4269 - "integrity": "sha512-NiZEr4g51nI4/lz/6NdwMqK/TLIctlnp9TQ3wCJjlRp47VgrthUZE4nrk2UhfZ8VzoQ/Xyth+G6MKioLCt0FVA==" 4317 + "version": "2.0.1", 4318 + "resolved": "https://registry.npmjs.org/events-to-async/-/events-to-async-2.0.1.tgz", 4319 + "integrity": "sha512-RtnLYrMbXp4JkZIoZu+3VTqV21bNVBlJBZ4NmtwvMNqSE3qouhxv2gvLE4JJDaQc54ioPkrX74V6x+hp/hqjkQ==" 4270 4320 }, 4271 4321 "node_modules/fast-blurhash": { 4272 4322 "version": "1.1.2", ··· 5257 5307 } 5258 5308 }, 5259 5309 "node_modules/masto": { 5260 - "version": "6.3.3", 5261 - "resolved": "https://registry.npmjs.org/masto/-/masto-6.3.3.tgz", 5262 - "integrity": "sha512-hmDsiscImeZfpkS+5oEWk3w5mkbxERFKN/UpuaoKZpVWWoGWCNnO7iPfQHygs/phP7PQqS6pVHlE5ylqSylf6A==", 5310 + "version": "6.4.2", 5311 + "resolved": "https://registry.npmjs.org/masto/-/masto-6.4.2.tgz", 5312 + "integrity": "sha512-aIxhsTkl0pc755Sf9NMuglY0VM9HuU/tec60e4oLsyyzAkE7ELwufh6anErO84n8g3eWu/u0LGucTujIiDaAhg==", 5263 5313 "dependencies": { 5264 5314 "change-case": "^4.1.2", 5265 - "events-to-async": "^2.0.0", 5315 + "events-to-async": "^2.0.1", 5266 5316 "isomorphic-ws": "^5.0.0", 5267 5317 "ts-custom-error": "^3.3.1", 5268 5318 "ws": "^8.13.0" ··· 6087 6137 } 6088 6138 }, 6089 6139 "node_modules/postcss-preset-env": { 6090 - "version": "9.2.0", 6091 - "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-9.2.0.tgz", 6092 - "integrity": "sha512-Lnr4C5gb7t5Cc8akQMJzNdJkqw7s7s7BHUaQSgsuf+CTY9Lsz5lqQTft5yNZr59JyCLz0aFNSAqSLm/xRtcTpg==", 6140 + "version": "9.3.0", 6141 + "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-9.3.0.tgz", 6142 + "integrity": "sha512-ycw6doPrqV6QxDCtgiyGDef61bEfiSc59HGM4gOw/wxQxmKnhuEery61oOC/5ViENz/ycpRsuhTexs1kUBTvVw==", 6093 6143 "dev": true, 6094 6144 "funding": [ 6095 6145 { ··· 6102 6152 } 6103 6153 ], 6104 6154 "dependencies": { 6105 - "@csstools/postcss-cascade-layers": "^4.0.0", 6155 + "@csstools/postcss-cascade-layers": "^4.0.1", 6106 6156 "@csstools/postcss-color-function": "^3.0.7", 6107 6157 "@csstools/postcss-color-mix-function": "^2.0.7", 6108 6158 "@csstools/postcss-exponential-functions": "^1.0.1", ··· 6114 6164 "@csstools/postcss-initial": "^1.0.0", 6115 6165 "@csstools/postcss-is-pseudo-class": "^4.0.3", 6116 6166 "@csstools/postcss-logical-float-and-clear": "^2.0.0", 6167 + "@csstools/postcss-logical-overflow": "^1.0.0", 6168 + "@csstools/postcss-logical-overscroll-behavior": "^1.0.0", 6117 6169 "@csstools/postcss-logical-resize": "^2.0.0", 6118 6170 "@csstools/postcss-logical-viewport-units": "^2.0.3", 6119 6171 "@csstools/postcss-media-minmax": "^1.1.0", ··· 6133 6185 "css-blank-pseudo": "^6.0.0", 6134 6186 "css-has-pseudo": "^6.0.0", 6135 6187 "css-prefers-color-scheme": "^9.0.0", 6136 - "cssdb": "^7.8.0", 6188 + "cssdb": "^7.9.0", 6137 6189 "postcss-attribute-case-insensitive": "^6.0.2", 6138 6190 "postcss-clamp": "^4.1.0", 6139 6191 "postcss-color-functional-notation": "^6.0.2", ··· 6241 6293 "dev": true 6242 6294 }, 6243 6295 "node_modules/preact": { 6244 - "version": "10.18.1", 6245 - "resolved": "https://registry.npmjs.org/preact/-/preact-10.18.1.tgz", 6246 - "integrity": "sha512-mKUD7RRkQQM6s7Rkmi7IFkoEHjuFqRQUaXamO61E6Nn7vqF/bo7EZCmSyrUnp2UWHw0O7XjZ2eeXis+m7tf4lg==", 6296 + "version": "10.18.2", 6297 + "resolved": "https://registry.npmjs.org/preact/-/preact-10.18.2.tgz", 6298 + "integrity": "sha512-X/K43vocUHDg0XhWVmTTMbec4LT/iBMh+csCEqJk+pJqegaXsvjdqN80ZZ3L+93azWCnWCZ+WGwYb8SplxeNjA==", 6247 6299 "funding": { 6248 6300 "type": "opencollective", 6249 6301 "url": "https://opencollective.com/preact" ··· 7394 7446 } 7395 7447 }, 7396 7448 "node_modules/vite-plugin-pwa": { 7397 - "version": "0.16.5", 7398 - "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.16.5.tgz", 7399 - "integrity": "sha512-Ahol4dwhMP2UHPQXkllSlXbihOaDFnvBIDPmAxoSZ1EObBUJGP4CMRyCyAVkIHjd6/H+//vH0DM2ON+XxHr81g==", 7449 + "version": "0.16.6", 7450 + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.16.6.tgz", 7451 + "integrity": "sha512-bQPDOWvhPMwydMoWqohXvIzvrq4X8iuCF+q95qEiaM4yC0ybViGKWMnWcpWp0vcnoLk7QvxHDlK65KUZvqB3Sg==", 7400 7452 "dev": true, 7401 7453 "dependencies": { 7402 7454 "debug": "^4.3.4", ··· 7412 7464 "url": "https://github.com/sponsors/antfu" 7413 7465 }, 7414 7466 "peerDependencies": { 7415 - "vite": "^3.1.0 || ^4.0.0", 7467 + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0-0", 7416 7468 "workbox-build": "^7.0.0", 7417 7469 "workbox-window": "^7.0.0" 7418 7470 } ··· 9113 9165 "requires": {} 9114 9166 }, 9115 9167 "@csstools/postcss-cascade-layers": { 9116 - "version": "4.0.0", 9117 - "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-4.0.0.tgz", 9118 - "integrity": "sha512-dVPVVqQG0FixjM9CG/+8eHTsCAxRKqmNh6H69IpruolPlnEF1611f2AoLK8TijTSAsqBSclKd4WHs1KUb/LdJw==", 9168 + "version": "4.0.1", 9169 + "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-4.0.1.tgz", 9170 + "integrity": "sha512-UYFuFL9GgVnftg9v7tBvVEBRLaBeAD66euD+yYy5fYCUld9ZIWTJNCE30hm6STMEdt6FL5xzeVw1lAZ1tpvUEg==", 9119 9171 "dev": true, 9120 9172 "requires": { 9121 9173 "@csstools/selector-specificity": "^3.0.0", ··· 9231 9283 "version": "2.0.0", 9232 9284 "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-float-and-clear/-/postcss-logical-float-and-clear-2.0.0.tgz", 9233 9285 "integrity": "sha512-Wki4vxsF6icRvRz8eF9bPpAvwaAt0RHwhVOyzfoFg52XiIMjb6jcbHkGxwpJXP4DVrnFEwpwmrz5aTRqOW82kg==", 9286 + "dev": true, 9287 + "requires": {} 9288 + }, 9289 + "@csstools/postcss-logical-overflow": { 9290 + "version": "1.0.0", 9291 + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-overflow/-/postcss-logical-overflow-1.0.0.tgz", 9292 + "integrity": "sha512-cIrZ8f7bGGvr+W53nEuMspcwaeaI2YTmz6LZ4yiAO5z14/PQgOOv+Pn+qjvPOPoadeY2BmpaoTzZKvdAQuM17w==", 9293 + "dev": true, 9294 + "requires": {} 9295 + }, 9296 + "@csstools/postcss-logical-overscroll-behavior": { 9297 + "version": "1.0.0", 9298 + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-overscroll-behavior/-/postcss-logical-overscroll-behavior-1.0.0.tgz", 9299 + "integrity": "sha512-e89S2LWjnxf0SB2wNUAbqDyFb/Fow/tlOe1XqOLbNx4rf3LrQokM9qldVx7sarnddml3ORE5LDUmlKpPOOeJTA==", 9234 9300 "dev": true, 9235 9301 "requires": {} 9236 9302 }, ··· 10184 10250 "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", 10185 10251 "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", 10186 10252 "dev": true 10253 + }, 10254 + "compare-versions": { 10255 + "version": "6.1.0", 10256 + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.0.tgz", 10257 + "integrity": "sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg==" 10187 10258 }, 10188 10259 "concat-map": { 10189 10260 "version": "0.0.1", ··· 10256 10327 "requires": {} 10257 10328 }, 10258 10329 "cssdb": { 10259 - "version": "7.8.0", 10260 - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.8.0.tgz", 10261 - "integrity": "sha512-SkeezZOQr5AHt9MgJgSFNyiuJwg1p8AwoVln6JwaQJsyxduRW9QJ+HP/gAQzbsz8SIqINtYvpJKjxTRI67zxLg==", 10330 + "version": "7.9.0", 10331 + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.9.0.tgz", 10332 + "integrity": "sha512-WPMT9seTQq6fPAa1yN4zjgZZeoTriSN2LqW9C+otjar12DQIWA4LuSfFrvFJiKp4oD0xIk1vumDLw8K9ur4NBw==", 10262 10333 "dev": true 10263 10334 }, 10264 10335 "cssesc": { ··· 10453 10524 "dev": true 10454 10525 }, 10455 10526 "events-to-async": { 10456 - "version": "2.0.0", 10457 - "resolved": "https://registry.npmjs.org/events-to-async/-/events-to-async-2.0.0.tgz", 10458 - "integrity": "sha512-NiZEr4g51nI4/lz/6NdwMqK/TLIctlnp9TQ3wCJjlRp47VgrthUZE4nrk2UhfZ8VzoQ/Xyth+G6MKioLCt0FVA==" 10527 + "version": "2.0.1", 10528 + "resolved": "https://registry.npmjs.org/events-to-async/-/events-to-async-2.0.1.tgz", 10529 + "integrity": "sha512-RtnLYrMbXp4JkZIoZu+3VTqV21bNVBlJBZ4NmtwvMNqSE3qouhxv2gvLE4JJDaQc54ioPkrX74V6x+hp/hqjkQ==" 10459 10530 }, 10460 10531 "fast-blurhash": { 10461 10532 "version": "1.1.2", ··· 11188 11259 } 11189 11260 }, 11190 11261 "masto": { 11191 - "version": "6.3.3", 11192 - "resolved": "https://registry.npmjs.org/masto/-/masto-6.3.3.tgz", 11193 - "integrity": "sha512-hmDsiscImeZfpkS+5oEWk3w5mkbxERFKN/UpuaoKZpVWWoGWCNnO7iPfQHygs/phP7PQqS6pVHlE5ylqSylf6A==", 11262 + "version": "6.4.2", 11263 + "resolved": "https://registry.npmjs.org/masto/-/masto-6.4.2.tgz", 11264 + "integrity": "sha512-aIxhsTkl0pc755Sf9NMuglY0VM9HuU/tec60e4oLsyyzAkE7ELwufh6anErO84n8g3eWu/u0LGucTujIiDaAhg==", 11194 11265 "requires": { 11195 11266 "change-case": "^4.1.2", 11196 - "events-to-async": "^2.0.0", 11267 + "events-to-async": "^2.0.1", 11197 11268 "isomorphic-ws": "^5.0.0", 11198 11269 "ts-custom-error": "^3.3.1", 11199 11270 "ws": "^8.13.0" ··· 11620 11691 } 11621 11692 }, 11622 11693 "postcss-preset-env": { 11623 - "version": "9.2.0", 11624 - "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-9.2.0.tgz", 11625 - "integrity": "sha512-Lnr4C5gb7t5Cc8akQMJzNdJkqw7s7s7BHUaQSgsuf+CTY9Lsz5lqQTft5yNZr59JyCLz0aFNSAqSLm/xRtcTpg==", 11694 + "version": "9.3.0", 11695 + "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-9.3.0.tgz", 11696 + "integrity": "sha512-ycw6doPrqV6QxDCtgiyGDef61bEfiSc59HGM4gOw/wxQxmKnhuEery61oOC/5ViENz/ycpRsuhTexs1kUBTvVw==", 11626 11697 "dev": true, 11627 11698 "requires": { 11628 - "@csstools/postcss-cascade-layers": "^4.0.0", 11699 + "@csstools/postcss-cascade-layers": "^4.0.1", 11629 11700 "@csstools/postcss-color-function": "^3.0.7", 11630 11701 "@csstools/postcss-color-mix-function": "^2.0.7", 11631 11702 "@csstools/postcss-exponential-functions": "^1.0.1", ··· 11637 11708 "@csstools/postcss-initial": "^1.0.0", 11638 11709 "@csstools/postcss-is-pseudo-class": "^4.0.3", 11639 11710 "@csstools/postcss-logical-float-and-clear": "^2.0.0", 11711 + "@csstools/postcss-logical-overflow": "^1.0.0", 11712 + "@csstools/postcss-logical-overscroll-behavior": "^1.0.0", 11640 11713 "@csstools/postcss-logical-resize": "^2.0.0", 11641 11714 "@csstools/postcss-logical-viewport-units": "^2.0.3", 11642 11715 "@csstools/postcss-media-minmax": "^1.1.0", ··· 11656 11729 "css-blank-pseudo": "^6.0.0", 11657 11730 "css-has-pseudo": "^6.0.0", 11658 11731 "css-prefers-color-scheme": "^9.0.0", 11659 - "cssdb": "^7.8.0", 11732 + "cssdb": "^7.9.0", 11660 11733 "postcss-attribute-case-insensitive": "^6.0.2", 11661 11734 "postcss-clamp": "^4.1.0", 11662 11735 "postcss-color-functional-notation": "^6.0.2", ··· 11727 11800 "dev": true 11728 11801 }, 11729 11802 "preact": { 11730 - "version": "10.18.1", 11731 - "resolved": "https://registry.npmjs.org/preact/-/preact-10.18.1.tgz", 11732 - "integrity": "sha512-mKUD7RRkQQM6s7Rkmi7IFkoEHjuFqRQUaXamO61E6Nn7vqF/bo7EZCmSyrUnp2UWHw0O7XjZ2eeXis+m7tf4lg==" 11803 + "version": "10.18.2", 11804 + "resolved": "https://registry.npmjs.org/preact/-/preact-10.18.2.tgz", 11805 + "integrity": "sha512-X/K43vocUHDg0XhWVmTTMbec4LT/iBMh+csCEqJk+pJqegaXsvjdqN80ZZ3L+93azWCnWCZ+WGwYb8SplxeNjA==" 11733 11806 }, 11734 11807 "prettier": { 11735 11808 "version": "2.8.0", ··· 12507 12580 "requires": {} 12508 12581 }, 12509 12582 "vite-plugin-pwa": { 12510 - "version": "0.16.5", 12511 - "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.16.5.tgz", 12512 - "integrity": "sha512-Ahol4dwhMP2UHPQXkllSlXbihOaDFnvBIDPmAxoSZ1EObBUJGP4CMRyCyAVkIHjd6/H+//vH0DM2ON+XxHr81g==", 12583 + "version": "0.16.6", 12584 + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.16.6.tgz", 12585 + "integrity": "sha512-bQPDOWvhPMwydMoWqohXvIzvrq4X8iuCF+q95qEiaM4yC0ybViGKWMnWcpWp0vcnoLk7QvxHDlK65KUZvqB3Sg==", 12513 12586 "dev": true, 12514 12587 "requires": { 12515 12588 "debug": "^4.3.4",
+5 -4
package.json
··· 17 17 "@justinribeiro/lite-youtube": "~1.5.0", 18 18 "@szhsin/react-menu": "~4.1.0", 19 19 "@uidotdev/usehooks": "~2.4.1", 20 + "compare-versions": "~6.1.0", 20 21 "dayjs": "~1.11.10", 21 22 "dayjs-twitter": "~0.5.0", 22 23 "fast-blurhash": "~1.1.2", ··· 24 25 "idb-keyval": "~6.2.1", 25 26 "just-debounce-it": "~3.2.0", 26 27 "lz-string": "~1.5.0", 27 - "masto": "~6.3.3", 28 + "masto": "~6.4.2", 28 29 "moize": "~6.1.6", 29 30 "p-retry": "~6.1.0", 30 31 "p-throttle": "~5.1.0", 31 - "preact": "~10.18.1", 32 + "preact": "~10.18.2", 32 33 "react-hotkeys-hook": "~4.4.1", 33 34 "react-intersection-observer": "~9.5.2", 34 35 "react-quick-pinch-zoom": "~5.0.0", ··· 47 48 "@trivago/prettier-plugin-sort-imports": "~4.2.1", 48 49 "postcss": "~8.4.31", 49 50 "postcss-dark-theme-class": "~1.0.0", 50 - "postcss-preset-env": "~9.2.0", 51 + "postcss-preset-env": "~9.3.0", 51 52 "twitter-text": "~3.1.0", 52 53 "vite": "~4.5.0", 53 54 "vite-plugin-generate-file": "~0.0.4", 54 55 "vite-plugin-html-config": "~1.0.11", 55 - "vite-plugin-pwa": "~0.16.5", 56 + "vite-plugin-pwa": "~0.16.6", 56 57 "vite-plugin-remove-console": "~2.1.1", 57 58 "workbox-cacheable-response": "~7.0.0", 58 59 "workbox-expiration": "~7.0.0",
+162 -38
src/app.css
··· 56 56 overscroll-behavior: contain; 57 57 scroll-behavior: smooth; 58 58 background-color: var(--bg-color); 59 + flex-grow: 1; 59 60 /* This `transform` fixes carousel blocking vertical scrolling for pointer devices on iPad */ 60 61 transform: translateZ(0); 61 62 } ··· 79 80 scroll-padding-top: 3em; 80 81 } 81 82 83 + :is(#home-page, #welcome, #columns, #loader-root) ~ .deck-container { 84 + z-index: 10; 85 + position: fixed; 86 + inset: 0; 87 + } 88 + :is(#home-page, #welcome, #columns, #loader-root):has(~ .deck-container) { 89 + display: block; 90 + position: absolute; 91 + user-select: none; 92 + pointer-events: none; 93 + opacity: 0; 94 + /* This causes scrollTop to be reset to 0 when the page is hidden */ 95 + /* content-visibility: hidden; */ 96 + } 97 + 82 98 .deck { 83 99 min-height: 100vh; 84 100 min-height: 100dvh; ··· 110 126 user-select: none; 111 127 transition: transform 0.5s ease-in-out; 112 128 user-select: none; 129 + 130 + .header-double-lines { 131 + font-size: 90% !important; 132 + cursor: pointer; 133 + 134 + div { 135 + font-weight: normal; 136 + color: var(--text-insignificant-color); 137 + } 138 + } 113 139 } 114 140 .deck > header[hidden] { 115 141 display: block; ··· 209 235 .timeline { 210 236 margin: 0 auto; 211 237 padding: 0; 238 + 239 + &.timeline-media { 240 + --grid-gap: 8px; 241 + display: grid; 242 + grid-template-columns: 1fr; 243 + grid-auto-rows: fit-content; 244 + gap: var(--grid-gap); 245 + padding: var(--grid-gap); 246 + 247 + &:not(#columns &) { 248 + background-color: var(--bg-faded-color); 249 + } 250 + 251 + @media (min-width: 320px) { 252 + grid-template-columns: 1fr 1fr; 253 + } 254 + 255 + @media (min-width: 40em) { 256 + &:not(#columns &) { 257 + --grid-gap: 16px; 258 + grid-template-columns: 1fr 1fr 1fr; 259 + 260 + @media (min-width: 40em) { 261 + width: 95vw; 262 + max-width: calc(320px * 3.3); 263 + transform: translateX(calc(-50% + var(--main-width) / 2)); 264 + } 265 + } 266 + 267 + #columns & { 268 + padding-inline: 0; 269 + } 270 + } 271 + 272 + li { 273 + padding: 0 !important; 274 + margin: 0 !important; 275 + border: 0 !important; 276 + overflow: visible !important; 277 + background-color: transparent !important; 278 + box-shadow: none !important; 279 + } 280 + 281 + @supports (grid-template-rows: masonry) { 282 + grid-template-rows: masonry; 283 + masonry-auto-flow: pack; 284 + 285 + .media-post a { 286 + aspect-ratio: revert !important; 287 + 288 + video, 289 + img, 290 + audio { 291 + min-height: 88px; /* for extreme dimensions */ 292 + } 293 + } 294 + } 295 + } 212 296 } 213 297 .timeline.grow { 214 298 /* min-height: 100vh; ··· 275 359 box-shadow: inset 0 -3px var(--comment-line-color), 276 360 inset 0 3px var(--comment-line-color); 277 361 overscroll-behavior-x: contain; 278 - 279 - .status-link { 280 - width: fit-content; 281 - } 362 + touch-action: pan-x; 282 363 } 283 364 .timeline.contextual .replies[data-comments-level='4'] { 284 365 overflow-x: auto; ··· 471 552 border-radius: 8px; 472 553 cursor: pointer; 473 554 text-transform: uppercase; 474 - font-weight: 500; 475 555 font-size: 12px; 476 556 color: var(--text-insignificant-color); 477 557 user-select: none; ··· 479 559 position: relative; 480 560 list-style: none; 481 561 white-space: nowrap; 562 + 563 + b { 564 + font-weight: 500; 565 + color: var(--text-color); 566 + } 482 567 } 483 568 .timeline.contextual > li .replies > .replies-summary::-webkit-details-marker { 484 569 display: none; ··· 488 573 } 489 574 .timeline.contextual > li .replies > .replies-summary .avatars { 490 575 margin-right: 8px; 576 + 577 + > *:not(:first-child) { 578 + margin: 0 0 0 -4px; 579 + } 491 580 } 492 581 .timeline.contextual > li .replies > .replies-summary:active, 493 582 .timeline.contextual > li .replies[open] > .replies-summary { ··· 928 1017 .deck-backdrop .deck .status { 929 1018 max-width: var(--main-width); 930 1019 } 931 - .deck-backdrop .deck .menu-switch-view { 1020 + .deck-backdrop .deck :is(.button-switch-view, .menu-switch-view) { 932 1021 display: none; 933 1022 } 934 1023 @media (min-width: 40em) { 1024 + .deck-backdrop .deck .button-switch-view { 1025 + display: inline-block; 1026 + } 935 1027 .deck-backdrop .deck .menu-switch-view { 936 1028 display: flex; 937 1029 } ··· 1217 1309 } 1218 1310 body:has(.status-deck) .media-post-link { 1219 1311 display: none; 1312 + } 1313 + .media-modal-count-1 .button-label { 1314 + display: inline; 1220 1315 } 1221 1316 1222 1317 /* ✨ New */ ··· 1673 1768 ).danger 1674 1769 .icon { 1675 1770 opacity: 1; 1771 + } 1772 + 1773 + .szh-menu 1774 + .szh-menu__item--type-checkbox:not(.szh-menu__item--disabled):not( 1775 + .szh-menu__item--hover 1776 + ) { 1777 + .icon { 1778 + opacity: 0.15; 1779 + } 1780 + 1781 + &.szh-menu__item--checked { 1782 + color: var(--link-color); 1783 + 1784 + .icon { 1785 + opacity: 1; 1786 + color: inherit; 1787 + } 1788 + } 1676 1789 } 1677 1790 1678 1791 .szh-menu .menu-wrap { ··· 1843 1956 } 1844 1957 } 1845 1958 1846 - .deck-container { 1847 - width: 100%; 1848 - flex-grow: 1; 1849 - } 1850 - :is(#home-page, #welcome, #columns, #loader-root) ~ .deck-container { 1851 - z-index: 10; 1852 - position: fixed; 1853 - inset: 0; 1854 - } 1855 - :is(#home-page, #welcome, #columns, #loader-root):has(~ .deck-container) { 1856 - display: block; 1857 - position: absolute; 1858 - user-select: none; 1859 - pointer-events: none; 1860 - opacity: 0; 1861 - /* This causes scrollTop to be reset to 0 when the page is hidden */ 1862 - /* content-visibility: hidden; */ 1863 - } 1864 - 1865 1959 /* 404 */ 1866 1960 1867 1961 #not-found-page { ··· 1882 1976 1883 1977 /* ACCOUNT STATUSES */ 1884 1978 1885 - .header-account { 1886 - font-size: 90% !important; 1887 - cursor: pointer; 1979 + @keyframes peekaboo-header { 1980 + from { 1981 + opacity: 0; 1982 + transform: translateY(10%); 1983 + } 1984 + to { 1985 + opacity: 1; 1986 + transform: translateY(0); 1987 + } 1888 1988 } 1889 - .header-account div { 1890 - font-weight: normal; 1891 - color: var(--text-insignificant-color); 1989 + 1990 + @supports (animation-timeline: scroll()) { 1991 + .header-account { 1992 + animation: peekaboo-header 1s linear both; 1993 + animation-timeline: scroll(); 1994 + animation-range: 0 150px; 1995 + } 1892 1996 } 1893 1997 1894 1998 /* LINK LISTS? */ ··· 1917 2021 display: flex; 1918 2022 align-items: center; 1919 2023 gap: 8px; 2024 + 2025 + .count { 2026 + font-size: 80%; 2027 + display: inline-block; 2028 + color: var(--text-insignificant-color); 2029 + min-width: 16px; 2030 + min-height: 16px; 2031 + padding: 4px; 2032 + background-color: var(--bg-color); 2033 + border-radius: 4px; 2034 + 2035 + @media (min-width: 40em) { 2036 + background-color: var(--bg-faded-color); 2037 + } 2038 + } 1920 2039 } 1921 2040 ul.link-list li:first-child a { 1922 2041 border-top-left-radius: var(--radius); ··· 2009 2128 #columns .header-grid input { 2010 2129 pointer-events: none; 2011 2130 } 2012 - #columns 2013 - .header-grid 2014 - .header-side:first-of-type 2015 - :is(button, .button) 2016 - ~ :is(button, .button), 2017 - #columns .deck-container:not(:first-of-type) .header-grid .header-side > * { 2018 - display: none; 2131 + #columns { 2132 + /* Any buttons except nav menu button on first header-side, on 1st column */ 2133 + .deck-container:first-of-type 2134 + .header-grid 2135 + .header-side:first-of-type 2136 + > *:not(.nav-menu-button), 2137 + /* Any buttons on last header-side, on 1st column */ 2138 + .deck-container:first-of-type .header-grid .header-side:last-of-type > *, 2139 + /* Any buttons on any header-side, on columns after 1st */ 2140 + .deck-container:not(:first-of-type) .header-grid .header-side > * { 2141 + display: none; 2142 + } 2019 2143 } 2020 2144 @media (min-width: 40em) { 2021 2145 #columns {
+81 -7
src/app.jsx
··· 10 10 } from 'preact/hooks'; 11 11 import { matchPath, Route, Routes, useLocation } from 'react-router-dom'; 12 12 import 'swiped-events'; 13 - import { subscribe, useSnapshot } from 'valtio'; 13 + import { subscribe } from 'valtio'; 14 14 15 15 import BackgroundService from './components/background-service'; 16 16 import ComposeButton from './components/compose-button'; ··· 49 49 } from './utils/api'; 50 50 import { getAccessToken } from './utils/auth'; 51 51 import focusDeck from './utils/focus-deck'; 52 - import states, { initStates } from './utils/states'; 52 + import states, { initStates, statusKey } from './utils/states'; 53 53 import store from './utils/store'; 54 54 import { getCurrentAccount } from './utils/store-utils'; 55 55 import './utils/toast-alert'; 56 56 57 57 window.__STATES__ = states; 58 + window.__STATES_STATS__ = () => { 59 + const keys = [ 60 + 'statuses', 61 + 'accounts', 62 + 'spoilers', 63 + 'unfurledLinks', 64 + 'statusQuotes', 65 + ]; 66 + const counts = {}; 67 + keys.forEach((key) => { 68 + counts[key] = Object.keys(states[key]).length; 69 + }); 70 + console.warn('STATE stats', counts); 71 + 72 + const { statuses } = states; 73 + const unmountedPosts = []; 74 + for (const key in statuses) { 75 + const $post = document.querySelector(`[data-state-post-id="${key}"]`); 76 + if (!$post) { 77 + unmountedPosts.push(key); 78 + } 79 + } 80 + console.warn('Unmounted posts', unmountedPosts.length, unmountedPosts); 81 + }; 82 + 83 + // Experimental "garbage collection" for states 84 + // Every 15 minutes 85 + // Only posts for now 86 + setInterval(() => { 87 + if (!window.__IDLE__) return; 88 + const { statuses, unfurledLinks, notifications } = states; 89 + let keysCount = 0; 90 + const { instance } = api(); 91 + for (const key in statuses) { 92 + try { 93 + const $post = document.querySelector(`[data-state-post-id~="${key}"]`); 94 + const postInNotifications = notifications.some( 95 + (n) => key === statusKey(n.status?.id, instance), 96 + ); 97 + if (!$post && !postInNotifications) { 98 + delete states.statuses[key]; 99 + delete states.statusQuotes[key]; 100 + for (const link in unfurledLinks) { 101 + const unfurled = unfurledLinks[link]; 102 + const sKey = statusKey(unfurled.id, unfurled.instance); 103 + if (sKey === key) { 104 + delete states.unfurledLinks[link]; 105 + break; 106 + } 107 + } 108 + keysCount++; 109 + } 110 + } catch (e) {} 111 + } 112 + if (keysCount) { 113 + console.info(`GC: Removed ${keysCount} keys`); 114 + } 115 + }, 15 * 60 * 1000); 58 116 59 117 // Preload icons 60 118 // There's probably a better way to do this ··· 70 128 }, 5000); 71 129 72 130 (() => { 73 - window.__IDLE__ = false; 131 + window.__IDLE__ = true; 74 132 const nonIdleEvents = [ 75 133 'mousemove', 76 134 'mousedown', ··· 81 139 'pointermove', 82 140 'wheel', 83 141 ]; 84 - const IDLE_TIME = 5_000; // 5 seconds 85 - const setIdle = debounce(() => { 142 + const setIdle = () => { 86 143 window.__IDLE__ = true; 87 - }, IDLE_TIME); 144 + }; 145 + const IDLE_TIME = 3_000; // 3 seconds 146 + const debouncedSetIdle = debounce(setIdle, IDLE_TIME); 88 147 const onNonIdle = () => { 89 148 window.__IDLE__ = false; 90 - setIdle(); 149 + debouncedSetIdle(); 91 150 }; 92 151 nonIdleEvents.forEach((event) => { 93 152 window.addEventListener(event, onNonIdle, { ··· 95 154 capture: true, 96 155 }); 97 156 }); 157 + window.addEventListener('blur', setIdle, { 158 + passive: true, 159 + }); 160 + // When cursor leaves the window, set idle 161 + document.documentElement.addEventListener( 162 + 'mouseleave', 163 + (e) => { 164 + if (!e.relatedTarget && !e.toElement) { 165 + setIdle(); 166 + } 167 + }, 168 + { 169 + passive: true, 170 + }, 171 + ); 98 172 // document.addEventListener( 99 173 // 'visibilitychange', 100 174 // () => {
+2 -1
src/cloak-mode.css
··· 11 11 .status .content-compact, 12 12 .account-container :is(header, main > *:not(.actions)), 13 13 .account-container :is(header, main > *:not(.actions)) *, 14 - .header-account, 14 + .header-double-lines, 15 15 .account-block { 16 16 text-decoration-thickness: 1.1em; 17 17 text-decoration-line: line-through; ··· 25 25 } 26 26 27 27 .status :is(img, video, audio), 28 + .media-post .media, 28 29 .avatar, 29 30 .emoji, 30 31 .header-banner {
+1 -1
src/components/account-block.jsx
··· 82 82 }} 83 83 > 84 84 <Avatar url={avatar} size={avatarSize} squircle={bot} /> 85 - <span> 85 + <span class="account-block-content"> 86 86 {!hideDisplayName && ( 87 87 <> 88 88 {displayName ? (
+67 -40
src/components/account-info.css
··· 18 18 position: absolute; 19 19 top: 8px; 20 20 inset-inline: 8px; 21 - z-index: 2; 21 + z-index: 3; 22 22 border: 1px solid var(--outline-color); 23 23 box-shadow: 0 8px 16px var(--drop-shadow-color); 24 24 border-radius: calc(16px - 8px); ··· 47 47 48 48 ~ * { 49 49 /* pointer-events: none; */ 50 - filter: grayscale(0.75) brightness(0.75); 50 + filter: grayscale(0.75) opacity(0.75); 51 51 } 52 52 } 53 53 ··· 254 254 .account-container .note { 255 255 font-size: 95%; 256 256 line-height: 1.4; 257 + text-wrap: pretty; 257 258 } 258 259 .account-container .note:not(:has(p)):not(:empty) { 259 260 /* Some notes don't have <p> tags, so we need to add some padding */ ··· 408 409 409 410 .timeline-start .account-container { 410 411 border-bottom: 1px solid var(--outline-color); 412 + position: relative; 411 413 } 412 414 .timeline-start .account-container header { 413 415 padding: 16px 16px 1px; ··· 424 426 display: none; 425 427 } 426 428 429 + @keyframes bye-banner { 430 + 20% { 431 + filter: blur(0) opacity(1); 432 + } 433 + 100% { 434 + filter: blur(16px) opacity(0.2); 435 + } 436 + } 437 + @keyframes surface-header { 438 + 0% { 439 + border-bottom-color: transparent; 440 + box-shadow: none; 441 + } 442 + 100% { 443 + border-bottom-color: var(--outline-color); 444 + box-shadow: 0 8px 16px -8px var(--drop-shadow-color); 445 + } 446 + } 447 + @keyframes shrink-avatar { 448 + 0% { 449 + width: 64px; 450 + height: 64px; 451 + } 452 + 100% { 453 + width: 2.5em; 454 + height: 2.5em; 455 + } 456 + } 427 457 .sheet .account-container { 428 458 border-radius: 16px 16px 0 0; 429 459 overflow-x: hidden; 430 460 max-height: 75vh; 431 461 overscroll-behavior: none; 462 + scroll-timeline: --account-scroll; 432 463 433 464 header { 434 465 padding-bottom: 16px; 435 466 position: sticky; 436 467 top: 0; 437 468 z-index: 2; 438 - /* --bg-color: red; */ 439 469 background-image: linear-gradient( 440 470 to bottom, 441 471 transparent 30%, ··· 443 473 var(--bg-color) calc(100% - 8px), 444 474 transparent 445 475 ); 476 + 477 + .account-block-content { 478 + display: -webkit-box; 479 + -webkit-box-orient: vertical; 480 + overflow: hidden; 481 + line-clamp: 3; 482 + -webkit-line-clamp: 3; 483 + } 446 484 } 447 485 448 486 .faux-header-bg { ··· 455 493 margin-top: calc(-1 * var(--banner-overlap)); 456 494 } 457 495 496 + @supports (animation-timeline: scroll()) { 497 + .header-banner:not(.header-is-avatar):not(:hover):not(:active) { 498 + animation: bye-banner 1s linear both; 499 + animation-timeline: view(); 500 + animation-range: contain 100% cover 100%; 501 + } 502 + 503 + header { 504 + background-image: linear-gradient( 505 + to bottom, 506 + transparent 30%, 507 + var(--bg-color) var(--banner-overlap) 508 + ); 509 + border-bottom: 1px solid transparent; 510 + animation: surface-header 1s linear both; 511 + animation-timeline: --account-scroll; 512 + animation-range: 0 150px; 513 + } 514 + 515 + header .avatar { 516 + animation: shrink-avatar 1s linear both; 517 + animation-timeline: --account-scroll; 518 + animation-range: 0 150px; 519 + } 520 + } 521 + 458 522 main { 459 523 margin-top: -8px; 460 524 padding-top: 1px; ··· 610 674 background-color: var(--reblog-color); 611 675 } 612 676 } 613 - } 614 - 615 - @keyframes shine { 616 - 0% { 617 - left: -100%; 618 - } 619 - 100% { 620 - left: 100%; 621 - } 622 - } 623 - .timeline-start .account-container { 624 - position: relative; 625 - overflow: hidden; 626 - } 627 - .timeline-start .account-container:before { 628 - content: ''; 629 - position: absolute; 630 - z-index: 2; 631 - width: 100%; 632 - height: 100%; 633 - background-image: linear-gradient( 634 - 100deg, 635 - rgba(255, 255, 255, 0) 30%, 636 - rgba(255, 255, 255, 0.25), 637 - rgba(255, 255, 255, 0) 70% 638 - ); 639 - top: 0; 640 - left: -100%; 641 - pointer-events: none; 642 - } 643 - @media (prefers-color-scheme: dark) { 644 - .timeline-start .account-container:before { 645 - opacity: 0.25; 646 - } 647 - } 648 - .timeline-start .account-container:hover:before { 649 - animation: shine 1s ease-in-out 1s; 650 677 } 651 678 652 679 #list-add-remove-container .list-add-remove {
+38 -11
src/components/account-info.jsx
··· 126 126 const { masto } = api({ 127 127 instance, 128 128 }); 129 - const { masto: currentMasto } = api(); 129 + const { masto: currentMasto, instance: currentInstance } = api(); 130 130 const [uiState, setUIState] = useState('default'); 131 131 const isString = typeof account === 'string'; 132 132 const [info, setInfo] = useState(isString ? null : account); 133 133 134 - const isSelf = useMemo( 135 - () => account.id === store.session.get('currentAccount'), 136 - [account?.id], 137 - ); 138 - 139 134 const sameCurrentInstance = useMemo( 140 - () => instance === api().instance, 141 - [instance], 135 + () => instance === currentInstance, 136 + [instance, currentInstance], 142 137 ); 143 138 144 139 useEffect(() => { ··· 197 192 } 198 193 } 199 194 } 195 + 196 + const isSelf = useMemo( 197 + () => id === store.session.get('currentAccount'), 198 + [id], 199 + ); 200 + 201 + useEffect(() => { 202 + const infoHasEssentials = !!( 203 + info?.id && 204 + info?.username && 205 + info?.acct && 206 + info?.avatar && 207 + info?.avatarStatic && 208 + info?.displayName && 209 + info?.url 210 + ); 211 + if (isSelf && instance && infoHasEssentials) { 212 + const accounts = store.local.getJSON('accounts'); 213 + let updated = false; 214 + accounts.forEach((account) => { 215 + if (account.info.id === info.id && account.instanceURL === instance) { 216 + account.info = info; 217 + updated = true; 218 + } 219 + }); 220 + if (updated) { 221 + console.log('Updated account info', info); 222 + store.local.setJSON('accounts', accounts); 223 + } 224 + } 225 + }, [isSelf, info, instance]); 200 226 201 227 const accountInstance = useMemo(() => { 202 228 if (!url) return null; ··· 304 330 ({ relationship, currentID }) => { 305 331 if (!relationship.following) { 306 332 renderFamiliarFollowers(currentID); 307 - if (!standalone) { 333 + if (!standalone && statusesCount > 0) { 334 + // Only render posting stats if not standalone and has posts 308 335 renderPostingStats(); 309 336 } 310 337 } 311 338 }, 312 - [standalone, id], 339 + [standalone, id, statusesCount], 313 340 ); 314 341 315 342 return ( ··· 534 561 class="note" 535 562 dir="auto" 536 563 onClick={handleContentLinks({ 537 - instance, 564 + instance: currentInstance, 538 565 })} 539 566 dangerouslySetInnerHTML={{ 540 567 __html: enhanceContent(note, { emojis }),
+25 -18
src/components/avatar.css
··· 8 8 box-shadow: 0 0 0 1px var(--bg-blur-color); 9 9 flex-shrink: 0; 10 10 vertical-align: middle; 11 - } 12 - .avatar.has-alpha { 13 - border-radius: 0; 14 - } 15 - .avatar:not(.has-alpha).squircle { 16 - border-radius: 25%; 17 - } 18 11 19 - .avatar img { 20 - width: 100%; 21 - height: 100%; 22 - object-fit: cover; 23 - background-color: var(--img-bg-color); 24 - contain: none; 25 - } 12 + &.has-alpha { 13 + border-radius: 0; 14 + background-color: transparent; 15 + box-shadow: none; 16 + 17 + img { 18 + background-color: transparent; 19 + } 20 + } 21 + &:not(.has-alpha).squircle { 22 + border-radius: 25%; 23 + } 24 + 25 + img { 26 + width: 100%; 27 + height: 100%; 28 + object-fit: cover; 29 + background-color: var(--img-bg-color); 30 + contain: none; 31 + } 26 32 27 - .avatar[data-loaded], 28 - .avatar[data-loaded] img { 29 - box-shadow: none; 30 - background-color: transparent; 33 + &[data-loaded], 34 + &[data-loaded] img { 35 + box-shadow: none; 36 + background-color: transparent; 37 + } 31 38 }
+74 -38
src/components/background-service.jsx
··· 1 1 import { memo } from 'preact/compat'; 2 2 import { useEffect, useRef, useState } from 'preact/hooks'; 3 - import { useDebouncedCallback } from 'use-debounce'; 3 + import { useHotkeys } from 'react-hotkeys-hook'; 4 4 5 5 import { api } from '../utils/api'; 6 + import showToast from '../utils/show-toast'; 6 7 import states, { saveStatus } from '../utils/states'; 7 8 import useInterval from '../utils/useInterval'; 8 9 import usePageVisibility from '../utils/usePageVisibility'; 9 10 11 + const STREAMING_TIMEOUT = 1000 * 3; // 3 seconds 12 + const POLL_INTERVAL = 15_000; // 15 seconds 13 + 10 14 export default memo(function BackgroundService({ isLoggedIn }) { 11 15 // Notifications service 12 16 // - WebSocket to receive notifications when page is visible 13 17 const [visible, setVisible] = useState(true); 14 18 usePageVisibility(setVisible); 15 - const subRef = useRef(); 16 - const debouncedStartNotifications = useDebouncedCallback(() => { 17 - const { masto, streaming, instance } = api(); 18 - (async () => { 19 - // 1. Get the latest notification 20 - if (states.notificationsLast) { 21 - const notificationsIterator = masto.v1.notifications.list({ 22 - limit: 1, 23 - since_id: states.notificationsLast.id, 24 - }); 25 - const { value: notifications } = await notificationsIterator.next(); 26 - if (notifications?.length) { 19 + const checkLatestNotification = async (masto, instance, skipCheckMarkers) => { 20 + if (states.notificationsLast) { 21 + const notificationsIterator = masto.v1.notifications.list({ 22 + limit: 1, 23 + sinceId: states.notificationsLast.id, 24 + }); 25 + const { value: notifications } = await notificationsIterator.next(); 26 + if (notifications?.length) { 27 + if (skipCheckMarkers) { 28 + states.notificationsShowNew = true; 29 + } else { 27 30 let lastReadId; 28 31 try { 29 32 const markers = await masto.v1.markers.fetch({ ··· 38 41 } 39 42 } 40 43 } 44 + } 45 + }; 41 46 42 - // 2. Start streaming 43 - if (streaming) { 44 - let sub = (subRef.current = streaming.user.notification.subscribe()); 45 - console.log('🎏 Streaming notification', sub); 46 - for await (const entry of sub) { 47 - if (!sub) break; 48 - console.log('🔔🔔 Notification entry', entry); 49 - if (entry.event === 'notification') { 50 - console.log('🔔🔔 Notification', entry); 51 - saveStatus(entry.payload, instance, { 52 - skipThreading: true, 53 - }); 54 - } 55 - states.notificationsShowNew = true; 56 - } 57 - } 58 - })(); 59 - }, 3000); 60 47 useEffect(() => { 61 - // let sub; 48 + let sub; 49 + let pollNotifications; 62 50 if (isLoggedIn && visible) { 63 - debouncedStartNotifications(); 51 + const { masto, streaming, instance } = api(); 52 + (async () => { 53 + // 1. Get the latest notification 54 + await checkLatestNotification(masto, instance); 55 + 56 + let hasStreaming = false; 57 + // 2. Start streaming 58 + if (streaming) { 59 + pollNotifications = setTimeout(() => { 60 + (async () => { 61 + try { 62 + hasStreaming = true; 63 + sub = streaming.user.notification.subscribe(); 64 + console.log('🎏 Streaming notification', sub); 65 + for await (const entry of sub) { 66 + if (!sub) break; 67 + if (!visible) break; 68 + console.log('🔔🔔 Notification entry', entry); 69 + if (entry.event === 'notification') { 70 + console.log('🔔🔔 Notification', entry); 71 + saveStatus(entry.payload, instance, { 72 + skipThreading: true, 73 + }); 74 + } 75 + states.notificationsShowNew = true; 76 + } 77 + } catch (e) { 78 + hasStreaming = false; 79 + console.error(e); 80 + } 81 + 82 + if (!hasStreaming) { 83 + console.log('🎏 Streaming failed, fallback to polling'); 84 + pollNotifications = setInterval(() => { 85 + checkLatestNotification(masto, instance, true); 86 + }, POLL_INTERVAL); 87 + } 88 + })(); 89 + }, STREAMING_TIMEOUT); 90 + } 91 + })(); 64 92 } 65 93 return () => { 66 - // sub?.unsubscribe?.(); 67 - // sub = null; 68 - debouncedStartNotifications?.cancel?.(); 69 - subRef.current?.unsubscribe?.(); 70 - subRef.current = null; 94 + sub?.unsubscribe?.(); 95 + sub = null; 96 + clearTimeout(pollNotifications); 97 + clearInterval(pollNotifications); 71 98 }; 72 99 }, [visible, isLoggedIn]); 73 100 ··· 98 125 } 99 126 } 100 127 } 128 + }); 129 + 130 + // Global keyboard shortcuts "service" 131 + useHotkeys('shift+alt+k', () => { 132 + const currentCloakMode = states.settings.cloakMode; 133 + states.settings.cloakMode = !currentCloakMode; 134 + showToast({ 135 + text: `Cloak mode ${currentCloakMode ? 'disabled' : 'enabled'}`, 136 + }); 101 137 }); 102 138 103 139 return null;
+18 -1
src/components/columns.jsx
··· 49 49 } 50 50 }); 51 51 52 - return <div id="columns">{components}</div>; 52 + return ( 53 + <div 54 + id="columns" 55 + onContextMenu={(e) => { 56 + // If right-click on header, but not links or buttons 57 + if ( 58 + e.target.closest('.deck > header') && 59 + !e.target.closest('a') && 60 + !e.target.closest('button') 61 + ) { 62 + e.preventDefault(); 63 + states.showShortcutsSettings = true; 64 + } 65 + }} 66 + > 67 + {components} 68 + </div> 69 + ); 53 70 } 54 71 55 72 export default Columns;
-1
src/components/compose-button.jsx
··· 11 11 const newWin = openCompose(); 12 12 13 13 if (!newWin) { 14 - alert('Looks like your browser is blocking popups.'); 15 14 states.showCompose = true; 16 15 } 17 16 } else {
+45 -24
src/components/compose.css
··· 40 40 border-radius: 9999px; 41 41 } 42 42 43 - #compose-container textarea { 44 - width: 100%; 45 - max-width: 100%; 46 - height: 5em; 47 - min-height: 5em; 48 - max-height: 50vh; 49 - resize: vertical; 50 - line-height: 1.4; 51 - border-color: transparent; 52 - } 53 - #compose-container textarea:hover { 54 - border-color: var(--divider-color); 55 - } 56 - 57 - @media (min-width: 40em) { 58 - #compose-container textarea { 59 - /* font-size: 150%; 60 - font-size: calc(100% + 50% / var(--text-weight)); */ 61 - max-height: 65vh; 62 - } 63 - } 64 - 65 43 @keyframes appear-up { 66 44 0% { 67 45 opacity: 0; ··· 129 107 } 130 108 131 109 #compose-container form { 132 - border-radius: 16px; 133 - padding: 4px 12px; 110 + --form-padding-inline: 12px; 111 + --form-padding-block: 8px; 112 + /* border-radius: 16px; */ 113 + padding: var(--form-padding-block) var(--form-padding-inline); 134 114 background-color: var(--bg-blur-color); 135 115 /* background-image: linear-gradient(var(--bg-color) 85%, transparent); */ 136 116 position: relative; 137 117 z-index: 2; 138 118 --drop-shadow: 0 3px 6px -3px var(--drop-shadow-color); 139 119 box-shadow: var(--drop-shadow); 120 + 121 + @media (min-width: 40em) { 122 + border-radius: 16px; 123 + } 140 124 } 141 125 #compose-container .status-preview ~ form { 142 126 box-shadow: var(--drop-shadow), 0 -3px 6px -3px var(--drop-shadow-color); 127 + } 128 + 129 + #compose-container textarea { 130 + width: 100%; 131 + max-width: 100%; 132 + height: 5em; 133 + min-height: 5em; 134 + max-height: 50vh; 135 + resize: vertical; 136 + line-height: 1.4; 137 + border-color: transparent; 138 + 139 + &.compose-field { 140 + @media (width < 30em) { 141 + margin-inline: calc(-1 * var(--form-padding-inline)); 142 + width: 100vw !important; 143 + max-width: 100vw; 144 + border-radius: 0; 145 + border: 0; 146 + } 147 + 148 + @media (min-width: 40em) { 149 + max-height: 65vh; 150 + } 151 + } 152 + } 153 + #compose-container textarea:hover { 154 + border-color: var(--divider-color); 143 155 } 144 156 145 157 #compose-container .toolbar { ··· 269 281 gap: 8px; 270 282 align-items: center; 271 283 font-size: 90%; 284 + 285 + .grow { 286 + flex-grow: 1; 287 + } 288 + 289 + .count { 290 + font-size: 80%; 291 + opacity: 0.5; 292 + } 272 293 } 273 294 #compose-container .text-expander-menu li b img { 274 295 /* The shortcode emojis */
+52 -28
src/components/compose.jsx
··· 17 17 import emojifyText from '../utils/emojify-text'; 18 18 import localeMatch from '../utils/locale-match'; 19 19 import openCompose from '../utils/open-compose'; 20 + import shortenNumber from '../utils/shorten-number'; 20 21 import states, { saveStatus } from '../utils/states'; 21 22 import store from '../utils/store'; 22 23 import { ··· 521 522 522 523 const [showEmoji2Picker, setShowEmoji2Picker] = useState(false); 523 524 525 + const [topSupportedLanguages, restSupportedLanguages] = useMemo(() => { 526 + const topLanguages = []; 527 + const restLanguages = []; 528 + const { contentTranslationHideLanguages = [] } = states.settings; 529 + supportedLanguages.forEach((l) => { 530 + const [code] = l; 531 + if ( 532 + code === language || 533 + code === prevLanguage.current || 534 + code === DEFAULT_LANG || 535 + contentTranslationHideLanguages.includes(code) 536 + ) { 537 + topLanguages.push(l); 538 + } else { 539 + restLanguages.push(l); 540 + } 541 + }); 542 + topLanguages.sort(([codeA, commonA], [codeB, commonB]) => { 543 + if (codeA === language) return -1; 544 + if (codeB === language) return 1; 545 + return commonA.localeCompare(commonB); 546 + }); 547 + restLanguages.sort(([codeA, commonA], [codeB, commonB]) => 548 + commonA.localeCompare(commonB), 549 + ); 550 + return [topLanguages, restLanguages]; 551 + }, [language]); 552 + 524 553 return ( 525 554 <div id="compose-container-outer"> 526 555 <div id="compose-container" class={standalone ? 'standalone' : ''}> ··· 578 607 }); 579 608 580 609 if (!newWin) { 581 - alert('Looks like your browser is blocking popups.'); 582 610 return; 583 611 } 584 612 ··· 1125 1153 }} 1126 1154 disabled={uiState === 'loading'} 1127 1155 > 1128 - {supportedLanguages 1129 - .sort(([codeA, commonA], [codeB, commonB]) => { 1130 - const { contentTranslationHideLanguages = [] } = 1131 - states.settings; 1132 - // Sort codes that same as language, prevLanguage, DEFAULT_LANGUAGE and all the ones in states.settings.contentTranslationHideLanguages, to the top 1133 - if ( 1134 - codeA === language || 1135 - codeA === prevLanguage || 1136 - codeA === DEFAULT_LANG || 1137 - contentTranslationHideLanguages?.includes(codeA) 1138 - ) 1139 - return -1; 1140 - if ( 1141 - codeB === language || 1142 - codeB === prevLanguage || 1143 - codeB === DEFAULT_LANG || 1144 - contentTranslationHideLanguages?.includes(codeB) 1145 - ) 1146 - return 1; 1147 - return commonA.localeCompare(commonB); 1148 - }) 1149 - .map(([code, common, native]) => ( 1150 - <option value={code}> 1151 - {common} ({native}) 1152 - </option> 1153 - ))} 1156 + {topSupportedLanguages.map(([code, common, native]) => ( 1157 + <option value={code} key={code}> 1158 + {common} ({native}) 1159 + </option> 1160 + ))} 1161 + <hr /> 1162 + {restSupportedLanguages.map(([code, common, native]) => ( 1163 + <option value={code} key={code}> 1164 + {common} ({native}) 1165 + </option> 1166 + ))} 1154 1167 </select> 1155 1168 </label>{' '} 1156 1169 <button ··· 1306 1319 username, 1307 1320 acct, 1308 1321 emojis, 1322 + history, 1309 1323 } = result; 1310 1324 const displayNameWithEmoji = emojifyText(displayName, emojis); 1311 1325 // const item = menuItem.cloneNode(); ··· 1324 1338 </li> 1325 1339 `; 1326 1340 } else { 1341 + const total = history?.reduce?.( 1342 + (acc, cur) => acc + +cur.uses, 1343 + 0, 1344 + ); 1327 1345 html += ` 1328 1346 <li role="option" data-value="${encodeHTML(name)}"> 1329 - <span>#<b>${encodeHTML(name)}</b></span> 1347 + <span class="grow">#<b>${encodeHTML(name)}</b></span> 1348 + ${ 1349 + total 1350 + ? `<span class="count">${shortenNumber(total)}</span>` 1351 + : '' 1352 + } 1330 1353 </li> 1331 1354 `; 1332 1355 } ··· 1393 1416 return ( 1394 1417 <text-expander ref={textExpanderRef} keys="@ # :"> 1395 1418 <textarea 1419 + class="compose-field" 1396 1420 autoCapitalize="sentences" 1397 1421 autoComplete="on" 1398 1422 autoCorrect="on"
+4 -1
src/components/generic-accounts.jsx
··· 1 1 import './generic-accounts.css'; 2 2 3 - import { useEffect, useState } from 'preact/hooks'; 3 + import { useEffect, useRef, useState } from 'preact/hooks'; 4 4 import { InView } from 'react-intersection-observer'; 5 5 import { useSnapshot } from 'valtio'; 6 6 ··· 56 56 })(); 57 57 }; 58 58 59 + const firstLoad = useRef(true); 59 60 useEffect(() => { 60 61 if (staticAccounts?.length > 0) { 61 62 setAccounts(staticAccounts); 62 63 } else { 63 64 loadAccounts(true); 65 + firstLoad.current = false; 64 66 } 65 67 }, [staticAccounts, fetchAccounts]); 66 68 67 69 useEffect(() => { 70 + if (firstLoad.current) return; 68 71 // reloadGenericAccounts contains value like {id: 'mute', counter: 1} 69 72 // We only need to reload if the id matches 70 73 if (snapStates.reloadGenericAccounts?.id === id) {
+1
src/components/icon.jsx
··· 102 102 keyboard: () => import('@iconify-icons/mingcute/keyboard-line'), 103 103 cloud: () => import('@iconify-icons/mingcute/cloud-line'), 104 104 month: () => import('@iconify-icons/mingcute/calendar-month-line'), 105 + media: () => import('@iconify-icons/mingcute/photo-album-line'), 105 106 }; 106 107 107 108 function Icon({
+28 -2
src/components/keyboard-shortcuts-help.jsx
··· 118 118 keys: <kbd>c</kbd>, 119 119 }, 120 120 { 121 + action: 'Compose new post (new window)', 122 + className: 'insignificant', 123 + keys: ( 124 + <> 125 + <kbd>Shift</kbd> + <kbd>c</kbd> 126 + </> 127 + ), 128 + }, 129 + { 121 130 action: 'Send post', 122 131 keys: ( 123 132 <> ··· 135 144 keys: <kbd>r</kbd>, 136 145 }, 137 146 { 147 + action: 'Reply (new window)', 148 + className: 'insignificant', 149 + keys: ( 150 + <> 151 + <kbd>Shift</kbd> + <kbd>r</kbd> 152 + </> 153 + ), 154 + }, 155 + { 138 156 action: 'Like (favourite)', 139 157 keys: ( 140 158 <> ··· 154 172 action: 'Bookmark', 155 173 keys: <kbd>d</kbd>, 156 174 }, 157 - ].map(({ action, keys }) => ( 175 + { 176 + action: 'Toggle Cloak mode', 177 + keys: ( 178 + <> 179 + <kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>k</kbd> 180 + </> 181 + ), 182 + }, 183 + ].map(({ action, className, keys }) => ( 158 184 <tr key={action}> 159 - <th>{action}</th> 185 + <th class={className}>{action}</th> 160 186 <td>{keys}</td> 161 187 </tr> 162 188 ))}
+1
src/components/media-alt-modal.jsx
··· 57 57 <p 58 58 style={{ 59 59 whiteSpace: 'pre-wrap', 60 + textWrap: 'pretty', 60 61 }} 61 62 > 62 63 {alt}
+9 -3
src/components/media-modal.jsx
··· 103 103 }, []); 104 104 105 105 return ( 106 - <div class="media-modal-container"> 106 + <div 107 + class={`media-modal-container media-modal-count-${mediaAttachments?.length}`} 108 + > 107 109 <div 108 110 ref={carouselRef} 109 111 tabIndex="0" ··· 142 144 key={media.id} 143 145 ref={i === currentIndex ? carouselFocusItem : null} 144 146 onClick={(e) => { 145 - if (e.target !== e.currentTarget) { 147 + // console.log(e); 148 + // if (e.target !== e.currentTarget) { 149 + // setShowControls(!showControls); 150 + // } 151 + if (!e.target.classList.contains('media')) { 146 152 setShowControls(!showControls); 147 153 } 148 154 }} ··· 248 254 // } 249 255 // }} 250 256 > 251 - <span class="button-label">See post </span>&raquo; 257 + <span class="button-label">View post </span>&raquo; 252 258 </Link> 253 259 </span> 254 260 </div>
+107
src/components/media-post.css
··· 1 + .media-post { 2 + --item-radius: 16px; 3 + position: relative; 4 + animation: appear-smooth 1s ease-out; 5 + 6 + &:is(.filtered, .has-spoiler) :is(img, video) { 7 + filter: blur(32px); 8 + image-rendering: crisp-edges; 9 + image-rendering: pixelated; 10 + animation: none !important; 11 + } 12 + 13 + &.filtered[data-filtered-text]:before { 14 + content: attr(data-filtered-text); 15 + } 16 + &.has-spoiler[data-spoiler-text]:before { 17 + content: attr(data-spoiler-text); 18 + } 19 + 20 + &.filtered[data-filtered-text]:before, 21 + &.has-spoiler[data-spoiler-text]:before { 22 + pointer-events: none; 23 + position: absolute; 24 + top: 0; 25 + left: 0; 26 + z-index: 1; 27 + background-color: var(--bg-blur-color); 28 + margin: 8px; 29 + padding: 4px 6px; 30 + border-radius: calc(var(--item-radius) / 2); 31 + font-size: 90%; 32 + border: var(--hairline-width) dashed var(--bg-color); 33 + word-break: break-word; 34 + word-wrap: break-word; 35 + overflow-wrap: break-word; 36 + mix-blend-mode: luminosity; 37 + -webkit-line-clamp: 3; 38 + line-clamp: 3; 39 + -webkit-box-orient: vertical; 40 + box-orient: vertical; 41 + display: -webkit-box; 42 + display: box; 43 + overflow: hidden; 44 + z-index: 2; 45 + 46 + > * { 47 + pointer-events: none; 48 + } 49 + } 50 + 51 + .media { 52 + border-radius: var(--item-radius); 53 + overflow: hidden; 54 + position: relative; 55 + display: block; 56 + aspect-ratio: 1 !important; 57 + 58 + &:before { 59 + position: absolute; 60 + inset: 0; 61 + content: ''; 62 + border: 1px solid var(--outline-color); 63 + border-radius: inherit; 64 + } 65 + 66 + &:not(.media-audio) { 67 + background-color: var(--average-color, var(--media-bg-color)); 68 + } 69 + 70 + @media (hover: hover) { 71 + &:hover { 72 + --drop-shadow: var(--drop-shadow-color); 73 + position: relative; 74 + z-index: 1; 75 + box-shadow: 0 8px 16px -4px var(--drop-shadow), 76 + 0 4px 8px var(--drop-shadow); 77 + 78 + @media (prefers-color-scheme: dark) { 79 + --drop-shadow: var(--link-color); 80 + } 81 + } 82 + } 83 + 84 + &:active:not(:has(button:active)) { 85 + box-shadow: none; 86 + filter: brightness(0.8); 87 + transform: scale(0.99); 88 + } 89 + 90 + video, 91 + img, 92 + audio { 93 + border-radius: 16px; 94 + /* object-fit: scale-down; */ 95 + object-fit: cover; 96 + width: 100%; 97 + height: 100%; 98 + vertical-align: top; 99 + } 100 + 101 + :not(.filtered, .has-spoiler) &:is(:hover, :focus) img { 102 + /* Less delay here to make it feel more responsive */ 103 + animation: position-object 5s ease-in-out 0.1s 5; 104 + animation-duration: var(--anim-duration, 5s); 105 + } 106 + } 107 + }
+150
src/components/media-post.jsx
··· 1 + import './media-post.css'; 2 + 3 + import { memo } from 'preact/compat'; 4 + import { useContext, useMemo } from 'preact/hooks'; 5 + import { useSnapshot } from 'valtio'; 6 + 7 + import FilterContext from '../utils/filter-context'; 8 + import { isFiltered } from '../utils/filters'; 9 + import states, { statusKey } from '../utils/states'; 10 + import store from '../utils/store'; 11 + 12 + import Media from './media'; 13 + 14 + function MediaPost({ 15 + class: className, 16 + statusID, 17 + status, 18 + instance, 19 + parent, 20 + // allowFilters, 21 + onMediaClick, 22 + }) { 23 + let sKey = statusKey(statusID, instance); 24 + const snapStates = useSnapshot(states); 25 + if (!status) { 26 + status = snapStates.statuses[sKey] || snapStates.statuses[statusID]; 27 + sKey = statusKey(status?.id, instance); 28 + } 29 + if (!status) { 30 + return null; 31 + } 32 + 33 + const { 34 + account: { 35 + acct, 36 + avatar, 37 + avatarStatic, 38 + id: accountId, 39 + url: accountURL, 40 + displayName, 41 + username, 42 + emojis: accountEmojis, 43 + bot, 44 + group, 45 + }, 46 + id, 47 + repliesCount, 48 + reblogged, 49 + reblogsCount, 50 + favourited, 51 + favouritesCount, 52 + bookmarked, 53 + poll, 54 + muted, 55 + sensitive, 56 + spoilerText, 57 + visibility, // public, unlisted, private, direct 58 + language, 59 + editedAt, 60 + filtered, 61 + card, 62 + createdAt, 63 + inReplyToId, 64 + inReplyToAccountId, 65 + content, 66 + mentions, 67 + mediaAttachments, 68 + reblog, 69 + uri, 70 + url, 71 + emojis, 72 + // Non-API props 73 + _deleted, 74 + _pinned, 75 + // _filtered, 76 + } = status; 77 + 78 + if (!mediaAttachments?.length) { 79 + return null; 80 + } 81 + 82 + const debugHover = (e) => { 83 + if (e.shiftKey) { 84 + console.log({ 85 + ...status, 86 + }); 87 + } 88 + }; 89 + 90 + const currentAccount = useMemo(() => { 91 + return store.session.get('currentAccount'); 92 + }, []); 93 + const isSelf = useMemo(() => { 94 + return currentAccount && currentAccount === accountId; 95 + }, [accountId, currentAccount]); 96 + 97 + const filterContext = useContext(FilterContext); 98 + const filterInfo = !isSelf && isFiltered(filtered, filterContext); 99 + 100 + if (filterInfo?.action === 'hide') { 101 + return null; 102 + } 103 + 104 + console.debug('RENDER Media post', id, status?.account.displayName); 105 + 106 + // const readingExpandSpoilers = useMemo(() => { 107 + // const prefs = store.account.get('preferences') || {}; 108 + // return !!prefs['reading:expand:spoilers']; 109 + // }, []); 110 + const hasSpoiler = spoilerText || sensitive; 111 + 112 + const Parent = parent || 'div'; 113 + 114 + return mediaAttachments.map((media, i) => { 115 + const mediaKey = `${sKey}-${media.id}`; 116 + const filterTitleStr = filterInfo?.titlesStr; 117 + return ( 118 + <Parent 119 + data-state-post-id={sKey} 120 + onMouseEnter={debugHover} 121 + key={mediaKey} 122 + data-spoiler-text={ 123 + spoilerText || (sensitive ? 'Sensitive media' : undefined) 124 + } 125 + data-filtered-text={ 126 + filterInfo 127 + ? `Filtered${filterTitleStr ? `: ${filterTitleStr}` : ''}` 128 + : undefined 129 + } 130 + class={` 131 + media-post 132 + ${filterInfo ? 'filtered' : ''} 133 + ${hasSpoiler ? 'has-spoiler' : ''} 134 + `} 135 + > 136 + <Media 137 + class={className} 138 + media={media} 139 + lang={language} 140 + to={`/${instance}/s/${id}?media-only=${i + 1}`} 141 + onClick={ 142 + onMediaClick ? (e) => onMediaClick(e, i, media, status) : undefined 143 + } 144 + /> 145 + </Parent> 146 + ); 147 + }); 148 + } 149 + 150 + export default memo(MediaPost);
+18 -6
src/components/media.jsx
··· 62 62 ); 63 63 64 64 function Media({ 65 + class: className = '', 65 66 media, 66 67 to, 67 68 lang, ··· 170 171 const maxAspectHeight = 171 172 window.innerHeight * (orientation === 'portrait' ? 0.45 : 0.33); 172 173 const maxHeight = orientation === 'portrait' ? 0 : 160; 174 + const averageColorStyle = { 175 + '--average-color': rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`, 176 + }; 173 177 const mediaStyles = 174 178 width && height 175 179 ? { ··· 180 184 (width / height) * Math.max(maxHeight, maxAspectHeight) 181 185 }px`, 182 186 aspectRatio: `${width} / ${height}`, 187 + ...averageColorStyle, 183 188 } 184 - : {}; 189 + : { 190 + ...averageColorStyle, 191 + }; 185 192 186 193 const longDesc = isMediaCaptionLong(description); 187 194 const showInlineDesc = ··· 233 240 <Figure> 234 241 <Parent 235 242 ref={parentRef} 236 - class={`media media-image`} 243 + class={`media media-image ${className}`} 237 244 onClick={onClick} 238 245 data-orientation={orientation} 239 246 data-has-alt={!showInlineDesc} ··· 244 251 backgroundSize: imageSmallerThanParent 245 252 ? `${width}px ${height}px` 246 253 : undefined, 254 + ...averageColorStyle, 247 255 } 248 256 : mediaStyles 249 257 } ··· 341 349 return ( 342 350 <Figure> 343 351 <Parent 344 - class={`media media-${isGIF ? 'gif' : 'video'} ${ 352 + class={`media ${className} media-${isGIF ? 'gif' : 'video'} ${ 345 353 autoGIFAnimate ? 'media-contain' : '' 346 354 }`} 347 355 data-orientation={orientation} 348 - data-formatted-duration={formattedDuration} 356 + data-formatted-duration={ 357 + !showOriginal ? formattedDuration : undefined 358 + } 349 359 data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''} 350 360 data-has-alt={!showInlineDesc} 351 361 // style={{ ··· 448 458 return ( 449 459 <Figure> 450 460 <Parent 451 - class="media media-audio" 452 - data-formatted-duration={formattedDuration} 461 + class={`media media-audio ${className}`} 462 + data-formatted-duration={ 463 + !showOriginal ? formattedDuration : undefined 464 + } 453 465 data-has-alt={!showInlineDesc} 454 466 onClick={onClick} 455 467 style={!showOriginal && mediaStyles}
+3 -2
src/components/nav-menu.jsx
··· 35 35 // User may choose pin or not to pin Following 36 36 // If user doesn't pin Following, we show it in the menu 37 37 const showFollowing = 38 - (snapStates.settings.shortcutsColumnsMode || 39 - snapStates.settings.shortcutsViewMode === 'multi-column') && 38 + (snapStates.settings.shortcutsViewMode === 'multi-column' || 39 + (!snapStates.settings.shortcutsViewMode && 40 + snapStates.settings.shortcutsColumnsMode)) && 40 41 !snapStates.shortcuts.find((pin) => pin.type === 'following'); 41 42 42 43 const bindLongPress = useLongPress(
+5 -3
src/components/notification.jsx
··· 1 + import { Fragment } from 'preact'; 1 2 import { memo } from 'preact/compat'; 2 3 3 4 import shortenNumber from '../utils/shorten-number'; ··· 221 222 )} 222 223 {_accounts?.length > 1 && ( 223 224 <p class="avatars-stack"> 224 - {_accounts.slice(0, AVATARS_LIMIT).map((account, i) => ( 225 - <> 225 + {_accounts.slice(0, AVATARS_LIMIT).map((account) => ( 226 + <Fragment key={account.id}> 226 227 <a 228 + key={account.id} 227 229 href={account.url} 228 230 rel="noopener noreferrer" 229 231 class="account-avatar-stack" ··· 261 263 </div> 262 264 )} 263 265 </a>{' '} 264 - </> 266 + </Fragment> 265 267 ))} 266 268 <button 267 269 type="button"
+2 -1
src/components/shortcuts-settings.css
··· 86 86 transition: all 0.2s ease-out; 87 87 } 88 88 #shortcuts-settings-container .shortcuts-view-mode label.checked { 89 - box-shadow: inset 0 0 0 3px var(--link-color); 89 + box-shadow: inset 0 0 0 3px var(--link-color), 90 + inset 0 0 32px var(--link-faded-color); 90 91 } 91 92 #shortcuts-settings-container 92 93 .shortcuts-view-mode
+82 -75
src/components/shortcuts-settings.jsx
··· 22 22 import MenuConfirm from './menu-confirm'; 23 23 import Modal from './modal'; 24 24 25 - const SHORTCUTS_LIMIT = 9; 25 + export const SHORTCUTS_LIMIT = 9; 26 26 27 27 const TYPES = [ 28 28 'following', ··· 105 105 pattern: '[^#]+', 106 106 }, 107 107 { 108 + text: 'Media only', 109 + name: 'media', 110 + type: 'checkbox', 111 + }, 112 + { 108 113 text: 'Instance', 109 114 name: 'instance', 110 115 type: 'text', ··· 113 118 }, 114 119 ], 115 120 }; 121 + const fetchListTitle = pmem(async ({ id }) => { 122 + const list = await api().masto.v1.lists.$select(id).fetch(); 123 + return list.title; 124 + }); 125 + const fetchAccountTitle = pmem(async ({ id }) => { 126 + const account = await api().masto.v1.accounts.$select(id).fetch(); 127 + return account.username || account.acct || account.displayName; 128 + }); 116 129 export const SHORTCUTS_META = { 117 130 following: { 118 131 id: 'home', ··· 134 147 }, 135 148 list: { 136 149 id: 'list', 137 - title: pmem(async ({ id }) => { 138 - const list = await api().masto.v1.lists.$select(id).fetch(); 139 - return list.title; 140 - }), 150 + title: fetchListTitle, 141 151 path: ({ id }) => `/l/${id}`, 142 152 icon: 'list', 143 153 }, ··· 163 173 }, 164 174 'account-statuses': { 165 175 id: 'account-statuses', 166 - title: pmem(async ({ id }) => { 167 - const account = await api().masto.v1.accounts.$select(id).fetch(); 168 - return account.username || account.acct || account.displayName; 169 - }), 176 + title: fetchAccountTitle, 170 177 path: ({ id }) => `/a/${id}`, 171 178 icon: 'user', 172 179 }, ··· 186 193 id: 'hashtag', 187 194 title: ({ hashtag }) => hashtag, 188 195 subtitle: ({ instance }) => instance || api().instance, 189 - path: ({ hashtag, instance }) => 190 - `${instance ? `/${instance}` : ''}/t/${hashtag.split(/\s+/).join('+')}`, 196 + path: ({ hashtag, instance, media }) => 197 + `${instance ? `/${instance}` : ''}/t/${hashtag.split(/\s+/).join('+')}${ 198 + media ? '?media=1' : '' 199 + }`, 191 200 icon: 'hashtag', 192 201 }, 193 202 }; 194 203 195 204 function ShortcutsSettings({ onClose }) { 196 205 const snapStates = useSnapshot(states); 197 - const { masto } = api(); 198 206 const { shortcuts } = snapStates; 199 - 200 - const [lists, setLists] = useState([]); 201 - const [followedHashtags, setFollowedHashtags] = useState([]); 202 207 const [showForm, setShowForm] = useState(false); 203 208 const [showImportExport, setShowImportExport] = useState(false); 204 209 205 210 const [shortcutsListParent] = useAutoAnimate(); 206 211 207 - useEffect(() => { 208 - (async () => { 209 - try { 210 - const lists = await masto.v1.lists.list(); 211 - lists.sort((a, b) => a.title.localeCompare(b.title)); 212 - setLists(lists); 213 - } catch (e) { 214 - console.error(e); 215 - } 216 - })(); 217 - 218 - (async () => { 219 - try { 220 - const iterator = masto.v1.followedTags.list(); 221 - const tags = []; 222 - do { 223 - const { value, done } = await iterator.next(); 224 - if (done || value?.length === 0) break; 225 - tags.push(...value); 226 - } while (true); 227 - setFollowedHashtags(tags); 228 - } catch (e) { 229 - console.error(e); 230 - } 231 - })(); 232 - }, []); 233 - 234 212 return ( 235 213 <div id="shortcuts-settings-container" class="sheet" tabindex="-1"> 236 214 {!!onClose && ( ··· 293 271 ); 294 272 })} 295 273 </div> 296 - {/* <select 297 - value={snapStates.settings.shortcutsViewMode || 'float-button'} 298 - onChange={(e) => { 299 - states.settings.shortcutsViewMode = e.target.value; 300 - }} 301 - > 302 - <option value="float-button">Floating button</option> 303 - <option value="multi-column">Multi-column</option> 304 - <option value="tab-menu-bar">Tab/Menu bar </option> 305 - </select> */} 306 - {/* <p> 307 - <details> 308 - <summary class="insignificant"> 309 - Experimental Multi-column mode 310 - </summary> 311 - <label> 312 - <input 313 - type="checkbox" 314 - checked={snapStates.settings.shortcutsColumnsMode} 315 - onChange={(e) => { 316 - states.settings.shortcutsColumnsMode = e.target.checked; 317 - }} 318 - />{' '} 319 - Show shortcuts in multiple columns instead of the floating button. 320 - </label> 321 - </details> 322 - </p> */} 323 274 {shortcuts.length > 0 ? ( 324 275 <ol class="shortcuts-list" ref={shortcutsListParent}> 325 276 {shortcuts.filter(Boolean).map((shortcut, i) => { ··· 474 425 <ShortcutForm 475 426 shortcut={showForm.shortcut} 476 427 shortcutIndex={showForm.shortcutIndex} 477 - lists={lists} 478 - followedHashtags={followedHashtags} 479 428 onSubmit={({ result, mode }) => { 480 429 console.log('onSubmit', result); 481 430 if (mode === 'edit') { ··· 507 456 ); 508 457 } 509 458 459 + const FETCH_MAX_AGE = 1000 * 60; // 1 minute 460 + const fetchLists = pmem( 461 + () => { 462 + const { masto } = api(); 463 + return masto.v1.lists.list(); 464 + }, 465 + { 466 + maxAge: FETCH_MAX_AGE, 467 + }, 468 + ); 469 + const fetchFollowedHashtags = pmem( 470 + () => { 471 + const { masto } = api(); 472 + return masto.v1.followedTags.list(); 473 + }, 474 + { 475 + maxAge: FETCH_MAX_AGE, 476 + }, 477 + ); 478 + 510 479 function ShortcutForm({ 511 - lists, 512 - followedHashtags, 513 480 onSubmit, 514 481 disabled, 515 482 shortcut, ··· 520 487 const editMode = !!shortcut; 521 488 const [currentType, setCurrentType] = useState(shortcut?.type || null); 522 489 490 + const [uiState, setUIState] = useState('default'); 491 + const [lists, setLists] = useState([]); 492 + const [followedHashtags, setFollowedHashtags] = useState([]); 493 + useEffect(() => { 494 + (async () => { 495 + if (currentType !== 'list') return; 496 + try { 497 + setUIState('loading'); 498 + const lists = await fetchLists(); 499 + lists.sort((a, b) => a.title.localeCompare(b.title)); 500 + setLists(lists); 501 + setUIState('default'); 502 + } catch (e) { 503 + console.error(e); 504 + setUIState('error'); 505 + } 506 + })(); 507 + 508 + (async () => { 509 + if (currentType !== 'hashtag') return; 510 + try { 511 + const iterator = fetchFollowedHashtags(); 512 + const tags = []; 513 + do { 514 + const { value, done } = await iterator.next(); 515 + if (done || value?.length === 0) break; 516 + tags.push(...value); 517 + } while (true); 518 + setFollowedHashtags(tags); 519 + } catch (e) { 520 + console.error(e); 521 + } 522 + })(); 523 + }, [currentType]); 524 + 523 525 const formRef = useRef(); 524 526 useEffect(() => { 525 527 if (editMode && currentType && TYPE_PARAMS[currentType]) { ··· 608 610 <select 609 611 name="id" 610 612 required={!notRequired} 611 - disabled={disabled} 613 + disabled={disabled || uiState === 'loading'} 614 + defaultValue={editMode ? shortcut.id : undefined} 612 615 > 613 616 {lists.map((list) => ( 614 617 <option value={list.id}>{list.title}</option> ··· 653 656 }, 654 657 )} 655 658 <footer> 656 - <button type="submit" class="block" disabled={disabled}> 659 + <button 660 + type="submit" 661 + class="block" 662 + disabled={disabled || uiState === 'loading'} 663 + > 657 664 {editMode ? 'Save' : 'Add'} 658 665 </button> 659 666 {editMode && (
+1 -1
src/components/shortcuts.css
··· 152 152 } 153 153 } 154 154 155 - @media (min-width: 40em) { 155 + @media (min-width: 40em) and (hover: hover) { 156 156 #app[data-shortcuts-view-mode='tab-menu-bar'] .timeline-deck { 157 157 margin-top: 44px; 158 158 }
+13 -3
src/components/shortcuts.jsx
··· 25 25 return null; 26 26 } 27 27 if ( 28 - settings.shortcutsColumnsMode || 29 - settings.shortcutsViewMode === 'multi-column' 28 + settings.shortcutsViewMode === 'multi-column' || 29 + (!settings.shortcutsViewMode && settings.shortcutsColumnsMode) 30 30 ) { 31 31 return null; 32 32 } ··· 90 90 return ( 91 91 <div id="shortcuts"> 92 92 {snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? ( 93 - <nav class="tab-bar"> 93 + <nav 94 + class="tab-bar" 95 + onContextMenu={(e) => { 96 + e.preventDefault(); 97 + states.showShortcutsSettings = true; 98 + }} 99 + > 94 100 <ul> 95 101 {formattedShortcuts.map( 96 102 ({ id, path, title, subtitle, icon }, i) => { ··· 146 152 type="button" 147 153 id="shortcuts-button" 148 154 class="plain" 155 + onContextMenu={(e) => { 156 + e.preventDefault(); 157 + states.showShortcutsSettings = true; 158 + }} 149 159 onTransitionStart={(e) => { 150 160 // Close menu if the button disappears 151 161 try {
+15 -9
src/components/status.css
··· 603 603 margin-block: min(0.75em, 12px); 604 604 white-space: pre-wrap; 605 605 tab-size: 2; 606 + text-wrap: pretty; 606 607 } 607 608 .status .content p:first-child { 608 609 margin-block-start: 0; ··· 726 727 white-space: pre-line; 727 728 flex-basis: 15em; 728 729 flex-grow: 1; 730 + text-wrap: pretty; 729 731 } 730 732 } 731 733 ··· 846 848 object-fit: cover; 847 849 vertical-align: middle; 848 850 } 849 - .status .media { 851 + :is(.status, .media-post) .media { 850 852 cursor: pointer; 851 853 852 854 &[data-has-alt] { ··· 885 887 position: relative; 886 888 background-clip: padding-box; 887 889 } 888 - .status :is(.media-video, .media-audio) .media-play { 890 + :is(.status, .media-post) :is(.media-video, .media-audio) .media-play { 889 891 pointer-events: none; 890 892 width: 44px; 891 893 height: 44px; ··· 902 904 border-radius: 70px; 903 905 transition: transform 0.2s ease-in-out; 904 906 } 905 - .status :is(.media-video, .media-audio):hover:not(:active) .media-play { 907 + :is(.status, .media-post) 908 + :is(.media-video, .media-audio):hover:not(:active) 909 + .media-play { 906 910 transform: translate(-50%, -50%) scale(1.1); 907 911 } 908 - .status :is(.media-video, .media-audio)[data-formatted-duration]:after { 912 + :is(.status, .media-post) 913 + :is(.media-video, .media-audio)[data-formatted-duration]:after { 909 914 font-size: 12px; 910 915 pointer-events: none; 911 916 content: attr(data-formatted-duration); ··· 918 923 border-radius: 4px; 919 924 padding: 0 4px; 920 925 } 921 - .status .media-audio[data-formatted-duration]:after { 926 + :is(.status, .media-post) .media-audio[data-formatted-duration]:after { 922 927 content: '♬ ' attr(data-formatted-duration); 923 928 } 924 - .status .media-gif[data-label]:not(:hover):after { 929 + :is(.status, .media-post) .media-gif[data-label]:not(:hover):after { 925 930 font-size: 12px; 926 931 font-weight: bold; 927 932 pointer-events: none; ··· 953 958 .status .media-audio audio { 954 959 width: 100%; 955 960 } */ 956 - .status .media-audio { 961 + :is(.status, .media-post) .media-audio { 957 962 width: 100%; 958 963 height: 100%; 964 + min-height: 88px; 959 965 background-image: radial-gradient( 960 966 circle at center center, 961 - var(--bg-color), 967 + transparent, 962 968 var(--bg-faded-color) 963 969 ), 964 970 repeating-radial-gradient( ··· 1078 1084 font-size: 90%; 1079 1085 z-index: 1; 1080 1086 text-shadow: 0 var(--hairline-width) var(--bg-color); 1081 - mix-blend-mode: luminosity; 1082 1087 white-space: pre-line; 1083 1088 1084 1089 &:is(:hover, :focus) { ··· 1202 1207 box-orient: vertical; 1203 1208 -webkit-line-clamp: 2; 1204 1209 line-clamp: 2; 1210 + text-wrap: balance; 1205 1211 } 1206 1212 .card .meta { 1207 1213 font-size: smaller;
+101 -29
src/components/status.jsx
··· 13 13 import { memo } from 'preact/compat'; 14 14 import { 15 15 useCallback, 16 + useContext, 16 17 useEffect, 17 18 useMemo, 18 19 useRef, ··· 34 35 import { api } from '../utils/api'; 35 36 import emojifyText from '../utils/emojify-text'; 36 37 import enhanceContent from '../utils/enhance-content'; 38 + import FilterContext from '../utils/filter-context'; 39 + import { isFiltered } from '../utils/filters'; 37 40 import getTranslateTargetLanguage from '../utils/get-translate-target-language'; 38 41 import getHTMLText from '../utils/getHTMLText'; 39 42 import handleContentLinks from '../utils/handle-content-links'; ··· 41 44 import isMastodonLinkMaybe from '../utils/isMastodonLinkMaybe'; 42 45 import localeMatch from '../utils/locale-match'; 43 46 import niceDateTime from '../utils/nice-date-time'; 47 + import openCompose from '../utils/open-compose'; 44 48 import pmem from '../utils/pmem'; 45 49 import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding'; 46 50 import shortenNumber from '../utils/shorten-number'; ··· 77 81 private: 'Followers only', 78 82 direct: 'Private mention', 79 83 }; 84 + 85 + const isIOS = 86 + window.ontouchstart !== undefined && 87 + /iPad|iPhone|iPod/.test(navigator.userAgent); 80 88 81 89 function Status({ 82 90 statusID, ··· 90 98 enableTranslate, 91 99 forceTranslate: _forceTranslate, 92 100 previewMode, 93 - allowFilters, 101 + // allowFilters, 94 102 onMediaClick, 95 103 quoted, 96 104 onStatusLinkClick = () => {}, ··· 166 174 // Non-API props 167 175 _deleted, 168 176 _pinned, 169 - _filtered, 177 + // _filtered, 170 178 } = status; 171 179 180 + const currentAccount = useMemo(() => { 181 + return store.session.get('currentAccount'); 182 + }, []); 183 + const isSelf = useMemo(() => { 184 + return currentAccount && currentAccount === accountId; 185 + }, [accountId, currentAccount]); 186 + 187 + const filterContext = useContext(FilterContext); 188 + const filterInfo = 189 + !isSelf && !readOnly && !previewMode && isFiltered(filtered, filterContext); 190 + 191 + if (filterInfo?.action === 'hide') { 192 + return null; 193 + } 194 + 172 195 console.debug('RENDER Status', id, status?.account.displayName, quoted); 173 196 174 197 const debugHover = (e) => { ··· 179 202 } 180 203 }; 181 204 182 - if (allowFilters && size !== 'l' && _filtered) { 205 + if (/*allowFilters && */ size !== 'l' && filterInfo) { 183 206 return ( 184 207 <FilteredStatus 185 208 status={status} 186 - filterInfo={_filtered} 209 + filterInfo={filterInfo} 187 210 instance={instance} 188 211 containerProps={{ 189 212 onMouseEnter: debugHover, ··· 195 218 const createdAtDate = new Date(createdAt); 196 219 const editedAtDate = new Date(editedAt); 197 220 198 - const currentAccount = useMemo(() => { 199 - return store.session.get('currentAccount'); 200 - }, []); 201 - const isSelf = useMemo(() => { 202 - return currentAccount && currentAccount === accountId; 203 - }, [accountId, currentAccount]); 204 - 205 221 let inReplyToAccountRef = mentions?.find( 206 222 (mention) => mention.id === inReplyToAccountId, 207 223 ); ··· 238 254 239 255 if (group) { 240 256 return ( 241 - <div class="status-group" onMouseEnter={debugHover}> 257 + <div 258 + data-state-post-id={sKey} 259 + class="status-group" 260 + onMouseEnter={debugHover} 261 + > 242 262 <div class="status-pre-meta"> 243 263 <Icon icon="group" size="l" alt="Group" />{' '} 244 264 <NameText account={status.account} instance={instance} showAvatar /> ··· 249 269 instance={instance} 250 270 size={size} 251 271 contentTextWeight={contentTextWeight} 272 + readOnly={readOnly} 252 273 /> 253 274 </div> 254 275 ); 255 276 } 256 277 257 278 return ( 258 - <div class="status-reblog" onMouseEnter={debugHover}> 279 + <div 280 + data-state-post-id={sKey} 281 + class="status-reblog" 282 + onMouseEnter={debugHover} 283 + > 259 284 <div class="status-pre-meta"> 260 285 <Icon icon="rocket" size="l" />{' '} 261 286 <NameText account={status.account} instance={instance} showAvatar />{' '} ··· 267 292 instance={instance} 268 293 size={size} 269 294 contentTextWeight={contentTextWeight} 295 + readOnly={readOnly} 270 296 /> 271 297 </div> 272 298 ); ··· 348 374 canBoost = true; 349 375 } 350 376 351 - const replyStatus = () => { 377 + const replyStatus = (e) => { 352 378 if (!sameInstance || !authenticated) { 353 379 return alert(unauthInteractionErrorMessage); 380 + } 381 + // syntheticEvent comes from MenuItem 382 + if (e?.shiftKey || e?.syntheticEvent?.shiftKey) { 383 + const newWin = openCompose({ 384 + replyToStatus: status, 385 + }); 386 + if (newWin) return; 354 387 } 355 388 states.showCompose = { 356 389 replyToStatus: status, ··· 795 828 const contextMenuRef = useRef(); 796 829 const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); 797 830 const [contextMenuProps, setContextMenuProps] = useState({}); 798 - const isIOS = 799 - window.ontouchstart !== undefined && 800 - /iPad|iPhone|iPod/.test(navigator.userAgent); 831 + 832 + const showContextMenu = !isSizeLarge && !previewMode && !_deleted && !quoted; 833 + 801 834 // Only iOS/iPadOS browsers don't support contextmenu 802 835 // Some comments report iPadOS might support contextmenu if a mouse is connected 803 836 const bindLongPressContext = useLongPress( 804 - isIOS 837 + isIOS && showContextMenu 805 838 ? (e) => { 806 839 if (e.pointerType === 'mouse') return; 807 840 // There's 'pen' too, but not sure if contextmenu event would trigger from a pen ··· 829 862 }, 830 863 ); 831 864 832 - const showContextMenu = size !== 'l' && !previewMode && !_deleted && !quoted; 833 - 834 865 const hotkeysEnabled = !readOnly && !previewMode; 835 - const rRef = useHotkeys('r', replyStatus, { 866 + const rRef = useHotkeys('r, shift+r', replyStatus, { 836 867 enabled: hotkeysEnabled, 837 868 }); 838 869 const fRef = useHotkeys( ··· 960 991 961 992 return ( 962 993 <article 994 + data-state-post-id={sKey} 963 995 ref={(node) => { 964 996 statusRef.current = node; 965 997 // Use parent node if it's in focus ··· 1091 1123 <Link 1092 1124 to={instance ? `/${instance}/s/${id}` : `/s/${id}`} 1093 1125 onClick={(e) => { 1126 + if ( 1127 + e.metaKey || 1128 + e.ctrlKey || 1129 + e.shiftKey || 1130 + e.altKey || 1131 + e.which === 2 1132 + ) { 1133 + return; 1134 + } 1094 1135 e.preventDefault(); 1095 1136 e.stopPropagation(); 1096 1137 onStatusLinkClick?.(e, status); ··· 1457 1498 {' '} 1458 1499 &bull; <Icon icon="pencil" alt="Edited" />{' '} 1459 1500 <time 1501 + tabIndex="0" 1460 1502 class="edited" 1461 1503 datetime={editedAtDate.toISOString()} 1462 1504 onClick={() => { ··· 1576 1618 </div> 1577 1619 {!!showEdited && ( 1578 1620 <Modal 1621 + class="light" 1579 1622 onClick={(e) => { 1580 1623 if (e.target === e.currentTarget) { 1581 1624 setShowEdited(false); 1582 - statusRef.current?.focus(); 1625 + // statusRef.current?.focus(); 1583 1626 } 1584 1627 }} 1585 1628 > ··· 1691 1734 if (snapStates.unfurledLinks[url]) return null; 1692 1735 1693 1736 if (hasText && (image || (type === 'photo' && blurhash))) { 1694 - const domain = new URL(url).hostname.replace(/^www\./, ''); 1737 + const domain = new URL(url).hostname 1738 + .replace(/^www\./, '') 1739 + .replace(/\/$/, ''); 1695 1740 let blurhashImage; 1696 1741 if (!image) { 1697 1742 const w = 44; ··· 1737 1782 {title} 1738 1783 </p> 1739 1784 <p class="meta" dir="auto"> 1740 - {description || providerName || authorName} 1785 + {description || 1786 + (!!publishedAt && ( 1787 + <RelativeTime datetime={publishedAt} format="micro" /> 1788 + ))} 1741 1789 </p> 1742 1790 </div> 1743 1791 </a> ··· 2120 2168 2121 2169 let remoteInstanceFetch; 2122 2170 let theURL = url; 2123 - if (/\/\/elk\.[^\/]+\/[^.]+\.[^.]+/i.test(theURL)) { 2124 - // E.g. https://elk.zone/domain.com/@stest/123 -> https://domain.com/@stest/123 2171 + 2172 + // https://elk.zone/domain.com/@stest/123 -> https://domain.com/@stest/123 2173 + if (/\/\/elk\.[^\/]+\/[^\/]+\.[^\/]+/i.test(theURL)) { 2125 2174 theURL = theURL.replace(/elk\.[^\/]+\//i, ''); 2126 2175 } 2176 + 2177 + // https://trunks.social/status/domain.com/@stest/123 -> https://domain.com/@stest/123 2178 + if (/\/\/trunks\.[^\/]+\/status\/[^\/]+\.[^\/]+/i.test(theURL)) { 2179 + theURL = theURL.replace(/trunks\.[^\/]+\/status\//i, ''); 2180 + } 2181 + 2182 + // https://phanpy.social/#/domain.com/s/123 -> https://domain.com/statuses/123 2183 + if (/\/#\/[^\/]+\.[^\/]+\/s\/.+/i.test(theURL)) { 2184 + const urlAfterHash = theURL.split('/#/')[1]; 2185 + const finalURL = urlAfterHash.replace(/\/s\//i, '/@fakeUsername/'); 2186 + theURL = `https://${finalURL}`; 2187 + } 2188 + 2127 2189 const urlObj = new URL(theURL); 2128 2190 const domain = urlObj.hostname; 2129 2191 const path = urlObj.pathname; ··· 2151 2213 const { masto } = api({ instance }); 2152 2214 const mastoSearchFetch = masto.v2.search 2153 2215 .fetch({ 2154 - q: url, 2216 + q: theURL, 2155 2217 type: 'statuses', 2156 2218 resolve: true, 2157 2219 limit: 1, ··· 2224 2286 2225 2287 function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) { 2226 2288 const { 2289 + id: statusID, 2227 2290 account: { avatar, avatarStatic, bot, group }, 2228 2291 createdAt, 2229 2292 visibility, ··· 2248 2311 ); 2249 2312 2250 2313 const statusPeekRef = useTruncated(); 2314 + const sKey = 2315 + statusKey(status.id, instance) + 2316 + ' ' + 2317 + (statusKey(reblog?.id, instance) || ''); 2318 + 2319 + const actualStatusID = reblog?.id || statusID; 2320 + const url = instance 2321 + ? `/${instance}/s/${actualStatusID}` 2322 + : `/s/${actualStatusID}`; 2251 2323 2252 2324 return ( 2253 2325 <div ··· 2260 2332 }} 2261 2333 {...bindLongPressPeek()} 2262 2334 > 2263 - <article class="status filtered" tabindex="-1"> 2335 + <article data-state-post-id={sKey} class="status filtered" tabindex="-1"> 2264 2336 <b 2265 2337 class="status-filtered-badge clickable badge-meta" 2266 2338 title={filterTitleStr} ··· 2324 2396 <Link 2325 2397 ref={statusPeekRef} 2326 2398 class="status-link" 2327 - to={`/${instance}/s/${status.id}`} 2399 + to={url} 2328 2400 onClick={() => { 2329 2401 setShowPeek(false); 2330 2402 }}
+342 -286
src/components/timeline.jsx
··· 4 4 import { useDebouncedCallback } from 'use-debounce'; 5 5 import { useSnapshot } from 'valtio'; 6 6 7 + import FilterContext from '../utils/filter-context'; 8 + import { isFiltered } from '../utils/filters'; 7 9 import states, { statusKey } from '../utils/states'; 8 10 import statusPeek from '../utils/status-peek'; 9 11 import { groupBoosts, groupContext } from '../utils/timeline-utils'; ··· 13 15 14 16 import Icon from './icon'; 15 17 import Link from './link'; 18 + import MediaPost from './media-post'; 16 19 import NavMenu from './nav-menu'; 17 20 import Status from './status'; 18 21 ··· 33 36 boostsCarousel, 34 37 fetchItems = () => {}, 35 38 checkForUpdates = () => {}, 36 - checkForUpdatesInterval = 60_000, // 1 minute 39 + checkForUpdatesInterval = 15_000, // 15 seconds 37 40 headerStart, 38 41 headerEnd, 39 42 timelineStart, 40 - allowFilters, 43 + // allowFilters, 41 44 refresh, 45 + view, 46 + filterContext, 42 47 }) { 43 48 const snapStates = useSnapshot(states); 44 49 const [items, setItems] = useState([]); ··· 50 55 51 56 console.debug('RENDER Timeline', id, refresh); 52 57 58 + const allowGrouping = view !== 'media'; 53 59 const loadItems = useDebouncedCallback( 54 60 (firstLoad) => { 55 61 setShowNew(false); ··· 59 65 try { 60 66 let { done, value } = await fetchItems(firstLoad); 61 67 if (Array.isArray(value)) { 62 - if (boostsCarousel) { 63 - value = groupBoosts(value); 68 + if (allowGrouping) { 69 + if (boostsCarousel) { 70 + value = groupBoosts(value); 71 + } 72 + value = groupContext(value); 64 73 } 65 - value = groupContext(value); 66 74 console.log(value); 67 75 if (firstLoad) { 68 76 setItems(value); ··· 210 218 } 211 219 }, [nearReachEnd, showMore]); 212 220 221 + const prevView = useRef(view); 222 + useEffect(() => { 223 + if (prevView.current !== view) { 224 + prevView.current = view; 225 + setItems([]); 226 + } 227 + }, [view]); 228 + 213 229 const loadOrCheckUpdates = useCallback( 214 230 async ({ disableIdleCheck = false } = {}) => { 215 - console.log('✨ Load or check updates', { 231 + const noPointers = scrollableRef.current 232 + ? getComputedStyle(scrollableRef.current).pointerEvents === 'none' 233 + : false; 234 + console.log('✨ Load or check updates', id, { 216 235 autoRefresh: snapStates.settings.autoRefresh, 217 236 scrollTop: scrollableRef.current.scrollTop, 218 237 disableIdleCheck, 219 238 idle: window.__IDLE__, 220 239 inBackground: inBackground(), 240 + noPointers, 221 241 }); 222 242 if ( 223 243 snapStates.settings.autoRefresh && 224 - scrollableRef.current.scrollTop === 0 && 244 + scrollableRef.current.scrollTop < 16 && 225 245 (disableIdleCheck || window.__IDLE__) && 226 - !inBackground() 246 + !inBackground() && 247 + !noPointers 227 248 ) { 228 - console.log('✨ Load updates', snapStates.settings.autoRefresh); 249 + console.log('✨ Load updates', id, snapStates.settings.autoRefresh); 229 250 loadItems(true); 230 251 } else { 231 - console.log('✨ Check updates', snapStates.settings.autoRefresh); 252 + console.log('✨ Check updates', id, snapStates.settings.autoRefresh); 232 253 const hasUpdate = await checkForUpdates(); 233 254 if (hasUpdate) { 234 255 console.log('✨ Has new updates', id); ··· 237 258 } 238 259 }, 239 260 [id, loadItems, checkForUpdates, snapStates.settings.autoRefresh], 240 - ); 241 - const debouncedLoadOrCheckUpdates = useDebouncedCallback( 242 - loadOrCheckUpdates, 243 - 3000, 244 261 ); 245 262 246 263 const lastHiddenTime = useRef(); ··· 249 266 if (visible) { 250 267 const timeDiff = Date.now() - lastHiddenTime.current; 251 268 if (!lastHiddenTime.current || timeDiff > 1000 * 60) { 252 - // 1 minute 253 - debouncedLoadOrCheckUpdates({ 269 + loadOrCheckUpdates({ 254 270 disableIdleCheck: true, 255 271 }); 256 272 } 257 273 } else { 258 274 lastHiddenTime.current = Date.now(); 259 - debouncedLoadOrCheckUpdates.cancel(); 260 275 } 261 276 setVisible(visible); 262 277 }, ··· 272 287 const hiddenUI = scrollDirection === 'end' && !nearReachStart; 273 288 274 289 return ( 275 - <div 276 - id={`${id}-page`} 277 - class="deck-container" 278 - ref={(node) => { 279 - scrollableRef.current = node; 280 - jRef.current = node; 281 - kRef.current = node; 282 - oRef.current = node; 283 - }} 284 - tabIndex="-1" 285 - > 286 - <div class="timeline-deck deck"> 287 - <header 288 - hidden={hiddenUI} 289 - onClick={(e) => { 290 - if (!e.target.closest('a, button')) { 291 - scrollableRef.current?.scrollTo({ 292 - top: 0, 293 - behavior: 'smooth', 294 - }); 295 - } 296 - }} 297 - onDblClick={(e) => { 298 - if (!e.target.closest('a, button')) { 299 - loadItems(true); 300 - } 301 - }} 302 - class={uiState === 'loading' ? 'loading' : ''} 303 - > 304 - <div class="header-grid"> 305 - <div class="header-side"> 306 - <NavMenu /> 307 - {headerStart !== null && headerStart !== undefined ? ( 308 - headerStart 309 - ) : ( 310 - <Link to="/" class="button plain home-button"> 311 - <Icon icon="home" size="l" /> 312 - </Link> 313 - )} 314 - </div> 315 - {title && (titleComponent ? titleComponent : <h1>{title}</h1>)} 316 - <div class="header-side"> 317 - {/* <Loader hidden={uiState !== 'loading'} /> */} 318 - {!!headerEnd && headerEnd} 319 - </div> 320 - </div> 321 - {items.length > 0 && 322 - uiState !== 'loading' && 323 - !hiddenUI && 324 - showNew && ( 325 - <button 326 - class="updates-button shiny-pill" 327 - type="button" 328 - onClick={() => { 329 - loadItems(true); 330 - scrollableRef.current?.scrollTo({ 331 - top: 0, 332 - behavior: 'smooth', 333 - }); 334 - }} 335 - > 336 - <Icon icon="arrow-up" /> New posts 337 - </button> 338 - )} 339 - </header> 340 - {!!timelineStart && ( 341 - <div 342 - class={`timeline-start ${uiState === 'loading' ? 'loading' : ''}`} 290 + <FilterContext.Provider value={filterContext}> 291 + <div 292 + id={`${id}-page`} 293 + class="deck-container" 294 + ref={(node) => { 295 + scrollableRef.current = node; 296 + jRef.current = node; 297 + kRef.current = node; 298 + oRef.current = node; 299 + }} 300 + tabIndex="-1" 301 + > 302 + <div class="timeline-deck deck"> 303 + <header 304 + hidden={hiddenUI} 305 + onClick={(e) => { 306 + if (!e.target.closest('a, button')) { 307 + scrollableRef.current?.scrollTo({ 308 + top: 0, 309 + behavior: 'smooth', 310 + }); 311 + } 312 + }} 313 + onDblClick={(e) => { 314 + if (!e.target.closest('a, button')) { 315 + loadItems(true); 316 + } 317 + }} 318 + class={uiState === 'loading' ? 'loading' : ''} 343 319 > 344 - {timelineStart} 345 - </div> 346 - )} 347 - {!!items.length ? ( 348 - <> 349 - <ul class="timeline"> 350 - {items.map((status) => { 351 - const { id: statusID, reblog, items, type, _pinned } = status; 352 - const actualStatusID = reblog?.id || statusID; 353 - const url = instance 354 - ? `/${instance}/s/${actualStatusID}` 355 - : `/s/${actualStatusID}`; 356 - let title = ''; 357 - if (type === 'boosts') { 358 - title = `${items.length} Boosts`; 359 - } else if (type === 'pinned') { 360 - title = 'Pinned posts'; 361 - } 362 - const isCarousel = type === 'boosts' || type === 'pinned'; 363 - if (items) { 364 - if (isCarousel) { 365 - // Here, we don't hide filtered posts, but we sort them last 366 - items.sort((a, b) => { 367 - if (a._filtered && !b._filtered) { 368 - return 1; 369 - } 370 - if (!a._filtered && b._filtered) { 371 - return -1; 372 - } 373 - return 0; 320 + <div class="header-grid"> 321 + <div class="header-side"> 322 + <NavMenu /> 323 + {headerStart !== null && headerStart !== undefined ? ( 324 + headerStart 325 + ) : ( 326 + <Link to="/" class="button plain home-button"> 327 + <Icon icon="home" size="l" /> 328 + </Link> 329 + )} 330 + </div> 331 + {title && (titleComponent ? titleComponent : <h1>{title}</h1>)} 332 + <div class="header-side"> 333 + {/* <Loader hidden={uiState !== 'loading'} /> */} 334 + {!!headerEnd && headerEnd} 335 + </div> 336 + </div> 337 + {items.length > 0 && 338 + uiState !== 'loading' && 339 + !hiddenUI && 340 + showNew && ( 341 + <button 342 + class="updates-button shiny-pill" 343 + type="button" 344 + onClick={() => { 345 + loadItems(true); 346 + scrollableRef.current?.scrollTo({ 347 + top: 0, 348 + behavior: 'smooth', 374 349 }); 375 - return ( 350 + }} 351 + > 352 + <Icon icon="arrow-up" /> New posts 353 + </button> 354 + )} 355 + </header> 356 + {!!timelineStart && ( 357 + <div 358 + class={`timeline-start ${uiState === 'loading' ? 'loading' : ''}`} 359 + > 360 + {timelineStart} 361 + </div> 362 + )} 363 + {!!items.length ? ( 364 + <> 365 + <ul class={`timeline ${view ? `timeline-${view}` : ''}`}> 366 + {items.map((status) => ( 367 + <TimelineItem 368 + status={status} 369 + instance={instance} 370 + useItemID={useItemID} 371 + // allowFilters={allowFilters} 372 + filterContext={filterContext} 373 + key={status.id + status?._pinned} 374 + view={view} 375 + /> 376 + ))} 377 + {showMore && 378 + uiState === 'loading' && 379 + (view === 'media' ? null : ( 380 + <> 376 381 <li 377 - key={`timeline-${statusID}`} 378 - class="timeline-item-carousel" 382 + style={{ 383 + height: '20vh', 384 + }} 379 385 > 380 - <StatusCarousel 381 - title={title} 382 - class={`${type}-carousel`} 383 - > 384 - {items.map((item) => { 385 - const { id: statusID, reblog } = item; 386 - const actualStatusID = reblog?.id || statusID; 387 - const url = instance 388 - ? `/${instance}/s/${actualStatusID}` 389 - : `/s/${actualStatusID}`; 390 - return ( 391 - <li key={statusID}> 392 - <Link 393 - class="status-carousel-link timeline-item-alt" 394 - to={url} 395 - > 396 - {useItemID ? ( 397 - <Status 398 - statusID={statusID} 399 - instance={instance} 400 - size="s" 401 - contentTextWeight 402 - allowFilters={allowFilters} 403 - /> 404 - ) : ( 405 - <Status 406 - status={item} 407 - instance={instance} 408 - size="s" 409 - contentTextWeight 410 - allowFilters={allowFilters} 411 - /> 412 - )} 413 - </Link> 414 - </li> 415 - ); 416 - })} 417 - </StatusCarousel> 386 + <Status skeleton /> 418 387 </li> 419 - ); 420 - } 421 - const manyItems = items.length > 3; 422 - return items.map((item, i) => { 423 - const { id: statusID, _differentAuthor } = item; 424 - const url = instance 425 - ? `/${instance}/s/${statusID}` 426 - : `/s/${statusID}`; 427 - const isMiddle = i > 0 && i < items.length - 1; 428 - const isSpoiler = item.sensitive && !!item.spoilerText; 429 - const showCompact = 430 - (!_differentAuthor && isSpoiler && i > 0) || 431 - (manyItems && 432 - isMiddle && 433 - (type === 'thread' || 434 - (type === 'conversation' && 435 - !_differentAuthor && 436 - !items[i - 1]._differentAuthor && 437 - !items[i + 1]._differentAuthor))); 438 - return ( 439 388 <li 440 - key={`timeline-${statusID}`} 441 - class={`timeline-item-container timeline-item-container-type-${type} timeline-item-container-${ 442 - i === 0 443 - ? 'start' 444 - : i === items.length - 1 445 - ? 'end' 446 - : 'middle' 447 - } ${ 448 - _differentAuthor ? 'timeline-item-diff-author' : '' 449 - }`} 389 + style={{ 390 + height: '25vh', 391 + }} 450 392 > 451 - <Link class="status-link timeline-item" to={url}> 452 - {showCompact ? ( 453 - <TimelineStatusCompact 454 - status={item} 455 - instance={instance} 456 - /> 457 - ) : useItemID ? ( 458 - <Status 459 - statusID={statusID} 460 - instance={instance} 461 - allowFilters={allowFilters} 462 - /> 463 - ) : ( 464 - <Status 465 - status={item} 466 - instance={instance} 467 - allowFilters={allowFilters} 468 - /> 469 - )} 470 - </Link> 393 + <Status skeleton /> 471 394 </li> 472 - ); 473 - }); 474 - } 475 - return ( 476 - <li key={`timeline-${statusID + _pinned}`}> 477 - <Link class="status-link timeline-item" to={url}> 478 - {useItemID ? ( 479 - <Status 480 - statusID={statusID} 481 - instance={instance} 482 - allowFilters={allowFilters} 483 - /> 484 - ) : ( 485 - <Status 486 - status={status} 487 - instance={instance} 488 - allowFilters={allowFilters} 489 - /> 490 - )} 491 - </Link> 492 - </li> 493 - ); 494 - })} 495 - {showMore && uiState === 'loading' && ( 496 - <> 497 - <li 498 - style={{ 499 - height: '20vh', 395 + </> 396 + ))} 397 + </ul> 398 + {uiState === 'default' && 399 + (showMore ? ( 400 + <InView 401 + onChange={(inView) => { 402 + if (inView) { 403 + loadItems(); 404 + } 500 405 }} 501 406 > 502 - <Status skeleton /> 503 - </li> 504 - <li 407 + <button 408 + type="button" 409 + class="plain block" 410 + onClick={() => loadItems()} 411 + style={{ marginBlockEnd: '6em' }} 412 + > 413 + Show more&hellip; 414 + </button> 415 + </InView> 416 + ) : ( 417 + <p class="ui-state insignificant">The end.</p> 418 + ))} 419 + </> 420 + ) : uiState === 'loading' ? ( 421 + <ul class="timeline"> 422 + {Array.from({ length: 5 }).map((_, i) => 423 + view === 'media' ? ( 424 + <div 505 425 style={{ 506 - height: '25vh', 426 + height: '50vh', 507 427 }} 508 - > 428 + /> 429 + ) : ( 430 + <li key={i}> 509 431 <Status skeleton /> 510 432 </li> 511 - </> 433 + ), 512 434 )} 513 435 </ul> 514 - {uiState === 'default' && 515 - (showMore ? ( 516 - <InView 517 - onChange={(inView) => { 518 - if (inView) { 519 - loadItems(); 520 - } 521 - }} 522 - > 523 - <button 524 - type="button" 525 - class="plain block" 526 - onClick={() => loadItems()} 527 - style={{ marginBlockEnd: '6em' }} 528 - > 529 - Show more&hellip; 530 - </button> 531 - </InView> 532 - ) : ( 533 - <p class="ui-state insignificant">The end.</p> 534 - ))} 535 - </> 536 - ) : uiState === 'loading' ? ( 537 - <ul class="timeline"> 538 - {Array.from({ length: 5 }).map((_, i) => ( 539 - <li key={i}> 540 - <Status skeleton /> 541 - </li> 542 - ))} 543 - </ul> 436 + ) : ( 437 + uiState !== 'error' && <p class="ui-state">{emptyText}</p> 438 + )} 439 + {uiState === 'error' && ( 440 + <p class="ui-state"> 441 + {errorText} 442 + <br /> 443 + <br /> 444 + <button type="button" onClick={() => loadItems(!items.length)}> 445 + Try again 446 + </button> 447 + </p> 448 + )} 449 + </div> 450 + </div> 451 + </FilterContext.Provider> 452 + ); 453 + } 454 + 455 + function TimelineItem({ 456 + status, 457 + instance, 458 + useItemID, 459 + // allowFilters, 460 + filterContext, 461 + view, 462 + }) { 463 + const { id: statusID, reblog, items, type, _pinned } = status; 464 + const actualStatusID = reblog?.id || statusID; 465 + const url = instance 466 + ? `/${instance}/s/${actualStatusID}` 467 + : `/s/${actualStatusID}`; 468 + let title = ''; 469 + if (type === 'boosts') { 470 + title = `${items.length} Boosts`; 471 + } else if (type === 'pinned') { 472 + title = 'Pinned posts'; 473 + } 474 + const isCarousel = type === 'boosts' || type === 'pinned'; 475 + if (items) { 476 + if (isCarousel) { 477 + // Here, we don't hide filtered posts, but we sort them last 478 + items.sort((a, b) => { 479 + // if (a._filtered && !b._filtered) { 480 + // return 1; 481 + // } 482 + // if (!a._filtered && b._filtered) { 483 + // return -1; 484 + // } 485 + const aFiltered = isFiltered(a.filtered, filterContext); 486 + const bFiltered = isFiltered(b.filtered, filterContext); 487 + if (aFiltered && !bFiltered) { 488 + return 1; 489 + } 490 + if (!aFiltered && bFiltered) { 491 + return -1; 492 + } 493 + return 0; 494 + }); 495 + return ( 496 + <li key={`timeline-${statusID}`} class="timeline-item-carousel"> 497 + <StatusCarousel title={title} class={`${type}-carousel`}> 498 + {items.map((item) => { 499 + const { id: statusID, reblog } = item; 500 + const actualStatusID = reblog?.id || statusID; 501 + const url = instance 502 + ? `/${instance}/s/${actualStatusID}` 503 + : `/s/${actualStatusID}`; 504 + return ( 505 + <li key={statusID}> 506 + <Link class="status-carousel-link timeline-item-alt" to={url}> 507 + {useItemID ? ( 508 + <Status 509 + statusID={statusID} 510 + instance={instance} 511 + size="s" 512 + contentTextWeight 513 + // allowFilters={allowFilters} 514 + /> 515 + ) : ( 516 + <Status 517 + status={item} 518 + instance={instance} 519 + size="s" 520 + contentTextWeight 521 + // allowFilters={allowFilters} 522 + /> 523 + )} 524 + </Link> 525 + </li> 526 + ); 527 + })} 528 + </StatusCarousel> 529 + </li> 530 + ); 531 + } 532 + const manyItems = items.length > 3; 533 + return items.map((item, i) => { 534 + const { id: statusID, _differentAuthor } = item; 535 + const url = instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`; 536 + const isMiddle = i > 0 && i < items.length - 1; 537 + const isSpoiler = item.sensitive && !!item.spoilerText; 538 + const showCompact = 539 + (!_differentAuthor && isSpoiler && i > 0) || 540 + (manyItems && 541 + isMiddle && 542 + (type === 'thread' || 543 + (type === 'conversation' && 544 + !_differentAuthor && 545 + !items[i - 1]._differentAuthor && 546 + !items[i + 1]._differentAuthor))); 547 + return ( 548 + <li 549 + key={`timeline-${statusID}`} 550 + class={`timeline-item-container timeline-item-container-type-${type} timeline-item-container-${ 551 + i === 0 ? 'start' : i === items.length - 1 ? 'end' : 'middle' 552 + } ${_differentAuthor ? 'timeline-item-diff-author' : ''}`} 553 + > 554 + <Link class="status-link timeline-item" to={url}> 555 + {showCompact ? ( 556 + <TimelineStatusCompact status={item} instance={instance} /> 557 + ) : useItemID ? ( 558 + <Status 559 + statusID={statusID} 560 + instance={instance} 561 + // allowFilters={allowFilters} 562 + /> 563 + ) : ( 564 + <Status 565 + status={item} 566 + instance={instance} 567 + // allowFilters={allowFilters} 568 + /> 569 + )} 570 + </Link> 571 + </li> 572 + ); 573 + }); 574 + } 575 + 576 + const itemKey = `timeline-${statusID + _pinned}`; 577 + 578 + if (view === 'media') { 579 + return useItemID ? ( 580 + <MediaPost 581 + class="timeline-item" 582 + parent="li" 583 + key={itemKey} 584 + statusID={statusID} 585 + instance={instance} 586 + // allowFilters={allowFilters} 587 + /> 588 + ) : ( 589 + <MediaPost 590 + class="timeline-item" 591 + parent="li" 592 + key={itemKey} 593 + status={status} 594 + instance={instance} 595 + // allowFilters={allowFilters} 596 + /> 597 + ); 598 + } 599 + 600 + return ( 601 + <li key={itemKey}> 602 + <Link class="status-link timeline-item" to={url}> 603 + {useItemID ? ( 604 + <Status 605 + statusID={statusID} 606 + instance={instance} 607 + // allowFilters={allowFilters} 608 + /> 544 609 ) : ( 545 - uiState !== 'error' && <p class="ui-state">{emptyText}</p> 610 + <Status 611 + status={status} 612 + instance={instance} 613 + // allowFilters={allowFilters} 614 + /> 546 615 )} 547 - {uiState === 'error' && ( 548 - <p class="ui-state"> 549 - {errorText} 550 - <br /> 551 - <br /> 552 - <button 553 - class="button plain" 554 - onClick={() => loadItems(!items.length)} 555 - > 556 - Try again 557 - </button> 558 - </p> 559 - )} 560 - </div> 561 - </div> 616 + </Link> 617 + </li> 562 618 ); 563 619 } 564 620
+1
src/components/translation-block.css
··· 83 83 .status-translation-block .translated-block output { 84 84 display: block; 85 85 margin-top: 0.75em; 86 + text-wrap: pretty; 86 87 } 87 88 .status-translation-block 88 89 .translated-block
+97 -18
src/pages/account-statuses.jsx
··· 1 1 import { MenuItem } from '@szhsin/react-menu'; 2 - import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; 2 + import { 3 + useCallback, 4 + useEffect, 5 + useMemo, 6 + useRef, 7 + useState, 8 + } from 'preact/hooks'; 3 9 import { useParams, useSearchParams } from 'react-router-dom'; 4 10 import { useSnapshot } from 'valtio'; 5 11 ··· 51 57 const tagged = searchParams.get('tagged'); 52 58 const media = !!searchParams.get('media'); 53 59 const { masto, instance, authenticated } = api({ instance: params.instance }); 60 + const { masto: currentMasto, instance: currentInstance } = api(); 54 61 const accountStatusesIterator = useRef(); 55 62 56 63 const allSearchParams = [month, excludeReplies, excludeBoosts, tagged, media]; ··· 61 68 }, allSearchParams); 62 69 63 70 const sameCurrentInstance = useMemo( 64 - () => instance === api().instance, 65 - [instance], 71 + () => instance === currentInstance, 72 + [instance, currentInstance], 66 73 ); 67 74 const [searchEnabled, setSearchEnabled] = useState(false); 68 75 useEffect(() => { ··· 153 160 .next(); 154 161 if (pinnedStatuses?.length && !tagged && !media) { 155 162 pinnedStatuses.forEach((status) => { 156 - status._pinned = true; 157 163 saveStatus(status, instance); 164 + status._pinned = true; 158 165 }); 159 166 if (pinnedStatuses.length >= 3) { 160 167 const pinnedStatusesIds = pinnedStatuses.map((status) => status.id); ··· 195 202 196 203 const [featuredTags, setFeaturedTags] = useState([]); 197 204 useTitle( 198 - `${account?.displayName ? account.displayName + ' ' : ''}@${ 199 - account?.acct ? account.acct : 'Account posts' 200 - }`, 205 + account?.acct 206 + ? `${account?.displayName ? account.displayName + ' ' : ''}@${ 207 + account.acct 208 + }${ 209 + !excludeReplies 210 + ? ' (+ Replies)' 211 + : excludeBoosts 212 + ? ' (- Boosts)' 213 + : tagged 214 + ? ` (#${tagged})` 215 + : media 216 + ? ' (Media)' 217 + : month 218 + ? ` (${new Date(month).toLocaleString('default', { 219 + month: 'long', 220 + year: 'numeric', 221 + })})` 222 + : '' 223 + }` 224 + : 'Account posts', 201 225 '/:instance?/a/:id', 202 226 ); 227 + 228 + const fetchAccountPromiseRef = useRef(); 229 + const fetchAccount = useCallback(() => { 230 + const fetchPromise = 231 + fetchAccountPromiseRef.current || masto.v1.accounts.$select(id).fetch(); 232 + fetchAccountPromiseRef.current = fetchPromise; 233 + return fetchPromise; 234 + }, [id, masto]); 235 + 203 236 useEffect(() => { 204 237 (async () => { 205 238 try { 206 - const acc = await masto.v1.accounts.$select(id).fetch(); 239 + const acc = await fetchAccount(); 207 240 console.log(acc); 208 241 setAccount(acc); 209 242 } catch (e) { ··· 223 256 224 257 const { displayName, acct, emojis } = account || {}; 225 258 259 + const accountInfoMemo = useMemo(() => { 260 + const cachedAccount = snapStates.accounts[`${id}@${instance}`]; 261 + return ( 262 + <AccountInfo 263 + instance={instance} 264 + account={cachedAccount || id} 265 + fetchAccount={fetchAccount} 266 + authenticated={authenticated} 267 + standalone 268 + /> 269 + ); 270 + }, [id, instance, authenticated, fetchAccount]); 271 + 226 272 const filterBarRef = useRef(); 227 273 const TimelineStart = useMemo(() => { 228 - const cachedAccount = snapStates.accounts[`${id}@${instance}`]; 229 274 const filtered = 230 275 !excludeReplies || excludeBoosts || tagged || media || !!month; 276 + 231 277 return ( 232 278 <> 233 - <AccountInfo 234 - instance={instance} 235 - account={cachedAccount || id} 236 - fetchAccount={() => masto.v1.accounts.$select(id).fetch()} 237 - authenticated={authenticated} 238 - standalone 239 - /> 240 - <div class="filter-bar" ref={filterBarRef}> 279 + {accountInfoMemo} 280 + <div 281 + class="filter-bar" 282 + ref={filterBarRef} 283 + style={{ 284 + position: 'relative', 285 + }} 286 + > 241 287 {filtered ? ( 242 288 <Link 243 289 to={`/${instance}/a/${id}`} ··· 328 374 } 329 375 : {}, 330 376 ); 377 + showToast( 378 + `Showing posts in ${new Date(value).toLocaleString( 379 + 'default', 380 + { 381 + month: 'long', 382 + year: 'numeric', 383 + }, 384 + )}`, 385 + ); 331 386 }} 332 387 /> 333 388 </label> ··· 392 447 title={`${account?.acct ? '@' + account.acct : 'Posts'}`} 393 448 titleComponent={ 394 449 <h1 395 - class="header-account" 450 + class="header-double-lines header-account" 396 451 // onClick={() => { 397 452 // states.showAccount = { 398 453 // account, ··· 414 469 errorText="Unable to load posts" 415 470 fetchItems={fetchAccountStatuses} 416 471 useItemID 472 + view={media ? 'media' : undefined} 417 473 boostsCarousel={snapStates.settings.boostsCarousel} 418 474 timelineStart={TimelineStart} 419 475 refresh={[ ··· 461 517 Switch to account's instance (<b>{accountInstance}</b>) 462 518 </small> 463 519 </MenuItem> 520 + {!sameCurrentInstance && ( 521 + <MenuItem 522 + onClick={() => { 523 + (async () => { 524 + try { 525 + const acc = await currentMasto.v1.accounts.lookup({ 526 + acct: account.acct + '@' + instance, 527 + }); 528 + const { id } = acc; 529 + location.hash = `/${currentInstance}/a/${id}`; 530 + } catch (e) { 531 + console.error(e); 532 + alert('Unable to fetch account info'); 533 + } 534 + })(); 535 + }} 536 + > 537 + <Icon icon="transfer" />{' '} 538 + <small class="menu-double-lines"> 539 + Switch to my instance (<b>{currentInstance}</b>) 540 + </small> 541 + </MenuItem> 542 + )} 464 543 </Menu2> 465 544 } 466 545 />
+3 -2
src/pages/following.jsx
··· 32 32 console.log('First load', latestItem.current); 33 33 } 34 34 35 - value = filteredItems(value, 'home'); 35 + // value = filteredItems(value, 'home'); 36 36 value.forEach((item) => { 37 37 saveStatus(item, instance); 38 38 }); ··· 115 115 useItemID 116 116 boostsCarousel={snapStates.settings.boostsCarousel} 117 117 {...props} 118 - allowFilters 118 + // allowFilters 119 + filterContext="home" 119 120 /> 120 121 ); 121 122 }
+77 -13
src/pages/hashtag.jsx
··· 2 2 FocusableItem, 3 3 MenuDivider, 4 4 MenuGroup, 5 + MenuHeader, 5 6 MenuItem, 6 7 } from '@szhsin/react-menu'; 7 8 import { useEffect, useRef, useState } from 'preact/hooks'; 8 - import { useNavigate, useParams } from 'react-router-dom'; 9 + import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; 9 10 10 11 import Icon from '../components/icon'; 11 12 import Menu2 from '../components/menu2'; 12 13 import MenuConfirm from '../components/menu-confirm'; 14 + import { SHORTCUTS_LIMIT } from '../components/shortcuts-settings'; 13 15 import Timeline from '../components/timeline'; 14 16 import { api } from '../utils/api'; 17 + import { filteredItems } from '../utils/filters'; 15 18 import showToast from '../utils/show-toast'; 16 19 import states from '../utils/states'; 17 20 import { saveStatus } from '../utils/states'; ··· 25 28 const TAGS_LIMIT_PER_MODE = 4; 26 29 const TOTAL_TAGS_LIMIT = TAGS_LIMIT_PER_MODE + 1; 27 30 28 - function Hashtags({ columnMode, ...props }) { 31 + function Hashtags({ media: mediaView, columnMode, ...props }) { 29 32 // const navigate = useNavigate(); 30 33 let { hashtag, ...params } = columnMode ? {} : useParams(); 31 34 if (props.hashtag) hashtag = props.hashtag; 32 35 let hashtags = hashtag.trim().split(/[\s+]+/); 33 36 hashtags.sort(); 34 37 hashtag = hashtags[0]; 38 + const [searchParams, setSearchParams] = useSearchParams(); 39 + const media = mediaView || !!searchParams.get('media'); 40 + const linkParams = media ? '?media=1' : ''; 35 41 36 42 const { masto, instance, authenticated } = api({ 37 43 instance: props?.instance || params.instance, 38 44 }); 39 - const { authenticated: currentAuthenticated } = api(); 45 + const { 46 + masto: currentMasto, 47 + instance: currentInstance, 48 + authenticated: currentAuthenticated, 49 + } = api(); 40 50 const hashtagTitle = hashtags.map((t) => `#${t}`).join(' '); 41 - const title = instance ? `${hashtagTitle} on ${instance}` : hashtagTitle; 51 + const hashtagPostTitle = media ? ` (Media only)` : ''; 52 + const title = instance 53 + ? `${hashtagTitle}${hashtagPostTitle} on ${instance}` 54 + : `${hashtagTitle}${hashtagPostTitle}`; 42 55 useTitle(title, `/:instance?/t/:hashtag`); 43 56 const latestItem = useRef(); 44 57 ··· 60 73 limit: LIMIT, 61 74 any: hashtags.slice(1), 62 75 maxId: firstLoad ? undefined : maxID.current, 76 + onlyMedia: media, 63 77 }) 64 78 .next(); 65 - const { value } = results; 79 + let { value } = results; 66 80 if (value?.length) { 67 81 if (firstLoad) { 68 82 latestItem.current = value[0].id; 69 83 } 70 84 85 + // value = filteredItems(value, 'public'); 71 86 value.forEach((item) => { 72 - saveStatus(item, instance); 87 + saveStatus(item, instance, { 88 + skipThreading: media, // If media view, no need to form threads 89 + }); 73 90 }); 74 91 75 92 maxID.current = value[value.length - 1].id; ··· 88 105 limit: 1, 89 106 any: hashtags.slice(1), 90 107 since_id: latestItem.current, 108 + onlyMedia: media, 91 109 }) 92 110 .next(); 93 - const { value } = results; 111 + let { value } = results; 112 + value = filteredItems(value, 'public'); 94 113 if (value?.length) { 95 114 return true; 96 115 } ··· 123 142 title={title} 124 143 titleComponent={ 125 144 !!instance && ( 126 - <h1 class="header-account"> 145 + <h1 class="header-double-lines"> 127 146 <b>{hashtagTitle}</b> 128 147 <div>{instance}</div> 129 148 </h1> ··· 136 155 fetchItems={fetchHashtags} 137 156 checkForUpdates={checkForUpdates} 138 157 useItemID 158 + view={media ? 'media' : undefined} 159 + refresh={media} 160 + // allowFilters 161 + filterContext="public" 139 162 headerEnd={ 140 163 <Menu2 141 164 portal ··· 209 232 <MenuDivider /> 210 233 </> 211 234 )} 235 + <MenuHeader className="plain">Filters</MenuHeader> 236 + <MenuItem 237 + type="checkbox" 238 + checked={!!media} 239 + onClick={() => { 240 + if (media) { 241 + searchParams.delete('media'); 242 + } else { 243 + searchParams.set('media', '1'); 244 + } 245 + setSearchParams(searchParams); 246 + }} 247 + > 248 + <Icon icon="check-circle" />{' '} 249 + <span class="menu-grow">Media only</span> 250 + </MenuItem> 251 + <MenuDivider /> 212 252 <FocusableItem className="menu-field" disabled={reachLimit}> 213 253 {({ ref }) => ( 214 254 <form ··· 231 271 // ); 232 272 location.hash = instance 233 273 ? `/${instance}/t/${hashtags.join('+')}` 234 - : `/t/${hashtags.join('+')}`; 274 + : `/t/${hashtags.join('+')}${linkParams}`; 235 275 } 236 276 }} 237 277 > ··· 267 307 // : `/t/${hashtags.join('+')}`, 268 308 // ); 269 309 location.hash = instance 270 - ? `/${instance}/t/${hashtags.join('+')}` 271 - : `/t/${hashtags.join('+')}`; 310 + ? `/${instance}/t/${hashtags.join('+')}${linkParams}` 311 + : `/t/${hashtags.join('+')}${linkParams}`; 272 312 }} 273 313 > 274 314 <Icon icon="x" alt="Remove hashtag" class="danger-icon" /> ··· 283 323 <MenuItem 284 324 disabled={!currentAuthenticated} 285 325 onClick={() => { 326 + if (states.shortcuts.length >= SHORTCUTS_LIMIT) { 327 + alert( 328 + `Max ${SHORTCUTS_LIMIT} shortcuts reached. Unable to add shortcut.`, 329 + ); 330 + return; 331 + } 286 332 const shortcut = { 287 333 type: 'hashtag', 288 334 hashtag: hashtags.join(' '), 289 335 instance, 336 + media: media ? 'on' : undefined, 290 337 }; 291 338 // Check if already exists 292 339 const exists = states.shortcuts.some( ··· 300 347 .split(/[\s+]+/) 301 348 .sort() 302 349 .join(' ') && 303 - (s.instance ? s.instance === shortcut.instance : true), 350 + (s.instance ? s.instance === shortcut.instance : true) && 351 + (s.media ? !!s.media === !!shortcut.media : true), 304 352 ); 305 353 if (exists) { 306 354 alert('This shortcut already exists'); ··· 324 372 if (newInstance) { 325 373 newInstance = newInstance.toLowerCase().trim(); 326 374 // navigate(`/${newInstance}/t/${hashtags.join('+')}`); 327 - location.hash = `/${newInstance}/t/${hashtags.join('+')}`; 375 + location.hash = `/${newInstance}/t/${hashtags.join( 376 + '+', 377 + )}${linkParams}`; 328 378 } 329 379 }} 330 380 > 331 381 <Icon icon="bus" /> <span>Go to another instance…</span> 332 382 </MenuItem> 383 + {currentInstance !== instance && ( 384 + <MenuItem 385 + onClick={() => { 386 + location.hash = `/${currentInstance}/t/${hashtags.join( 387 + '+', 388 + )}${linkParams}`; 389 + }} 390 + > 391 + <Icon icon="bus" />{' '} 392 + <small class="menu-double-lines"> 393 + Go to my instance (<b>{currentInstance}</b>) 394 + </small> 395 + </MenuItem> 396 + )} 333 397 </Menu2> 334 398 } 335 399 />
+3 -2
src/pages/home.jsx
··· 35 35 36 36 return ( 37 37 <> 38 - {(snapStates.settings.shortcutsColumnsMode || 39 - snapStates.settings.shortcutsViewMode === 'multi-column') && 38 + {(snapStates.settings.shortcutsViewMode === 'multi-column' || 39 + (!snapStates.settings.shortcutsViewMode && 40 + snapStates.settings.shortcutsColumnsMode)) && 40 41 !!snapStates.shortcuts?.length ? ( 41 42 <Columns /> 42 43 ) : (
+3 -2
src/pages/list.jsx
··· 43 43 latestItem.current = value[0].id; 44 44 } 45 45 46 - value = filteredItems(value, 'home'); 46 + // value = filteredItems(value, 'home'); 47 47 value.forEach((item) => { 48 48 saveStatus(item, instance); 49 49 }); ··· 102 102 checkForUpdates={checkForUpdates} 103 103 useItemID 104 104 boostsCarousel={snapStates.settings.boostsCarousel} 105 - allowFilters 105 + // allowFilters 106 + filterContext="home" 106 107 // refresh={reloadCount} 107 108 headerStart={ 108 109 <Link to="/l" class="button plain">
+1 -1
src/pages/mentions.jsx
··· 11 11 const emptySearchParams = new URLSearchParams(); 12 12 13 13 function Mentions({ columnMode, ...props }) { 14 - useTitle('Mentions', '/mentions'); 15 14 const { masto, instance } = api(); 16 15 const [searchParams] = columnMode ? [emptySearchParams] : useSearchParams(); 17 16 const [stateType, setStateType] = useState(null); 18 17 const type = props?.type || searchParams.get('type') || stateType; 18 + useTitle(`Mentions${type === 'private' ? ' (Private)' : ''}`, '/mentions'); 19 19 20 20 const mentionsIterator = useRef(); 21 21 const latestItem = useRef();
+3 -1
src/pages/notifications.css
··· 162 162 flex-grow: 1; 163 163 min-width: 0; 164 164 } 165 - .notification-content p:first-child { 165 + .notification-content > p:first-child { 166 166 margin-top: 0; 167 167 margin-bottom: 8px; 168 + text-wrap: pretty; 168 169 } 169 170 170 171 .notification-group-statuses { ··· 407 408 margin-block: min(0.75em, 12px); 408 409 white-space: pre-wrap; 409 410 tab-size: 2; 411 + text-wrap: pretty; 410 412 } 411 413 .announcements .announcement-reactions:not(:hidden) { 412 414 display: flex;
+72 -36
src/pages/notifications.jsx
··· 6 6 import { InView } from 'react-intersection-observer'; 7 7 import { useSearchParams } from 'react-router-dom'; 8 8 import { useSnapshot } from 'valtio'; 9 + import { subscribeKey } from 'valtio/utils'; 9 10 10 11 import AccountBlock from '../components/account-block'; 11 12 import FollowRequestButtons from '../components/follow-request-buttons'; ··· 23 24 import shortenNumber from '../utils/shorten-number'; 24 25 import states, { saveStatus } from '../utils/states'; 25 26 import { getCurrentInstance } from '../utils/store-utils'; 27 + import usePageVisibility from '../utils/usePageVisibility'; 26 28 import useScroll from '../utils/useScroll'; 27 29 import useTitle from '../utils/useTitle'; 28 30 ··· 132 134 (async () => { 133 135 try { 134 136 const fetchNotificationsPromise = fetchNotifications(firstLoad); 135 - const fetchFollowRequestsPromise = fetchFollowRequests(); 136 - const fetchAnnouncementsPromise = fetchAnnouncements(); 137 137 138 138 if (firstLoad) { 139 - const announcements = await fetchAnnouncementsPromise; 140 - announcements.sort((a, b) => { 141 - // Sort by updatedAt first, then createdAt 142 - const aDate = new Date(a.updatedAt || a.createdAt); 143 - const bDate = new Date(b.updatedAt || b.createdAt); 144 - return bDate - aDate; 145 - }); 146 - setAnnouncements(announcements); 147 - const requests = await fetchFollowRequestsPromise; 148 - setFollowRequests(requests); 139 + fetchAnnouncements() 140 + .then((announcements) => { 141 + announcements.sort((a, b) => { 142 + // Sort by updatedAt first, then createdAt 143 + const aDate = new Date(a.updatedAt || a.createdAt); 144 + const bDate = new Date(b.updatedAt || b.createdAt); 145 + return bDate - aDate; 146 + }); 147 + setAnnouncements(announcements); 148 + }) 149 + .catch(() => {}); 150 + 151 + fetchFollowRequests() 152 + .then((requests) => { 153 + setFollowRequests(requests); 154 + }) 155 + .catch(() => {}); 149 156 } 150 157 151 158 const { done } = await fetchNotificationsPromise; ··· 173 180 // } 174 181 // }, [nearReachEnd, showMore]); 175 182 176 - const loadUpdates = useCallback(() => { 177 - console.log('✨ Load updates', { 178 - autoRefresh: snapStates.settings.autoRefresh, 179 - scrollTop: scrollableRef.current?.scrollTop === 0, 180 - inBackground: inBackground(), 181 - notificationsShowNew: snapStates.notificationsShowNew, 182 - uiState, 183 - }); 184 - if ( 185 - snapStates.settings.autoRefresh && 186 - scrollableRef.current?.scrollTop === 0 && 187 - window.__IDLE__ && 188 - !inBackground() && 189 - snapStates.notificationsShowNew && 190 - uiState !== 'loading' 191 - ) { 192 - loadNotifications(true); 183 + const [showNew, setShowNew] = useState(false); 184 + 185 + const loadUpdates = useCallback( 186 + ({ disableIdleCheck = false } = {}) => { 187 + if (uiState === 'loading') { 188 + return; 189 + } 190 + console.log('✨ Load updates', { 191 + autoRefresh: snapStates.settings.autoRefresh, 192 + scrollTop: scrollableRef.current?.scrollTop, 193 + inBackground: inBackground(), 194 + disableIdleCheck, 195 + notificationsShowNew: snapStates.notificationsShowNew, 196 + }); 197 + if ( 198 + snapStates.settings.autoRefresh && 199 + scrollableRef.current?.scrollTop < 16 && 200 + (disableIdleCheck || window.__IDLE__) && 201 + !inBackground() && 202 + snapStates.notificationsShowNew 203 + ) { 204 + setShowNew(false); 205 + loadNotifications(true); 206 + } else { 207 + setShowNew(snapStates.notificationsShowNew); 208 + } 209 + }, 210 + [snapStates.notificationsShowNew, snapStates.settings.autoRefresh, uiState], 211 + ); 212 + // useEffect(loadUpdates, [snapStates.notificationsShowNew]); 213 + 214 + const lastHiddenTime = useRef(); 215 + usePageVisibility((visible) => { 216 + let unsub; 217 + if (visible) { 218 + const timeDiff = Date.now() - lastHiddenTime.current; 219 + if (!lastHiddenTime.current || timeDiff > 1000 * 60) { 220 + loadUpdates({ 221 + disableIdleCheck: true, 222 + }); 223 + } else { 224 + lastHiddenTime.current = Date.now(); 225 + } 226 + unsub = subscribeKey(states, 'notificationsShowNew', (v) => { 227 + if (v) { 228 + loadUpdates(); 229 + } 230 + }); 193 231 } 194 - }, [ 195 - snapStates.notificationsShowNew, 196 - snapStates.settings.autoRefresh, 197 - uiState, 198 - ]); 199 - useEffect(loadUpdates, [snapStates.notificationsShowNew]); 232 + return () => { 233 + unsub?.(); 234 + }; 235 + }); 200 236 201 237 const todayDate = new Date(); 202 238 const yesterdayDate = new Date(todayDate - 24 * 60 * 60 * 1000); ··· 265 301 {/* <Loader hidden={uiState !== 'loading'} /> */} 266 302 </div> 267 303 </div> 268 - {snapStates.notificationsShowNew && uiState !== 'loading' && ( 304 + {showNew && uiState !== 'loading' && ( 269 305 <button 270 306 class="updates-button shiny-pill" 271 307 type="button"
+19 -3
src/pages/public.jsx
··· 21 21 const { masto, instance } = api({ 22 22 instance: props?.instance || params.instance, 23 23 }); 24 + const { masto: currentMasto, instance: currentInstance } = api(); 24 25 const title = `${isLocal ? 'Local' : 'Federated'} timeline (${instance})`; 25 26 useTitle(title, isLocal ? `/:instance?/p/l` : `/:instance?/p`); 26 27 // const navigate = useNavigate(); ··· 41 42 latestItem.current = value[0].id; 42 43 } 43 44 44 - value = filteredItems(value, 'public'); 45 + // value = filteredItems(value, 'public'); 45 46 value.forEach((item) => { 46 47 saveStatus(item, instance); 47 48 }); ··· 77 78 key={instance + isLocal} 78 79 title={title} 79 80 titleComponent={ 80 - <h1 class="header-account"> 81 + <h1 class="header-double-lines"> 81 82 <b>{isLocal ? 'Local timeline' : 'Federated timeline'}</b> 82 83 <div>{instance}</div> 83 84 </h1> ··· 91 92 useItemID 92 93 headerStart={<></>} 93 94 boostsCarousel={snapStates.settings.boostsCarousel} 94 - allowFilters 95 + // allowFilters 96 + filterContext="public" 95 97 headerEnd={ 96 98 <Menu2 97 99 portal ··· 137 139 > 138 140 <Icon icon="bus" /> <span>Go to another instance…</span> 139 141 </MenuItem> 142 + {currentInstance !== instance && ( 143 + <MenuItem 144 + onClick={() => { 145 + location.hash = isLocal 146 + ? `/${currentInstance}/p/l` 147 + : `/${currentInstance}/p`; 148 + }} 149 + > 150 + <Icon icon="bus" />{' '} 151 + <small class="menu-double-lines"> 152 + Go to my instance (<b>{currentInstance}</b>) 153 + </small> 154 + </MenuItem> 155 + )} 140 156 </Menu2> 141 157 } 142 158 />
+34 -15
src/pages/search.jsx
··· 1 1 import './search.css'; 2 2 3 + import { useAutoAnimate } from '@formkit/auto-animate/preact'; 3 4 import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'; 4 5 import { useHotkeys } from 'react-hotkeys-hook'; 5 6 import { InView } from 'react-intersection-observer'; ··· 13 14 import SearchForm from '../components/search-form'; 14 15 import Status from '../components/status'; 15 16 import { api } from '../utils/api'; 17 + import shortenNumber from '../utils/shorten-number'; 16 18 import useTitle from '../utils/useTitle'; 17 19 18 20 const SHORT_LIMIT = 5; ··· 144 146 }, 145 147 ); 146 148 149 + const [filterBarParent] = useAutoAnimate(); 150 + 147 151 return ( 148 152 <div id="search-page" class="deck-container" ref={scrollableRef}> 149 153 <div class="timeline-deck deck"> ··· 158 162 </header> 159 163 <main> 160 164 {!!q && ( 161 - <div class={`filter-bar ${uiState === 'loading' ? 'loading' : ''}`}> 165 + <div 166 + ref={filterBarParent} 167 + class={`filter-bar ${uiState === 'loading' ? 'loading' : ''}`} 168 + > 162 169 {!!type && ( 163 170 <Link to={`/search${q ? `?q=${encodeURIComponent(q)}` : ''}`}> 164 171 ‹ All ··· 244 251 {hashtagResults.length > 0 ? ( 245 252 <> 246 253 <ul class="link-list hashtag-list"> 247 - {hashtagResults.map((hashtag) => ( 248 - <li key={hashtag.name}> 249 - <Link 250 - to={ 251 - instance 252 - ? `/${instance}/t/${hashtag.name}` 253 - : `/t/${hashtag.name}` 254 - } 255 - > 256 - <Icon icon="hashtag" /> 257 - <span>{hashtag.name}</span> 258 - </Link> 259 - </li> 260 - ))} 254 + {hashtagResults.map((hashtag) => { 255 + const { name, history } = hashtag; 256 + const total = history.reduce( 257 + (acc, cur) => acc + +cur.uses, 258 + 0, 259 + ); 260 + return ( 261 + <li key={hashtag.name}> 262 + <Link 263 + to={ 264 + instance 265 + ? `/${instance}/t/${hashtag.name}` 266 + : `/t/${hashtag.name}` 267 + } 268 + > 269 + <Icon icon="hashtag" /> 270 + <span>{hashtag.name}</span> 271 + {!!total && ( 272 + <span class="count"> 273 + {shortenNumber(total)} 274 + </span> 275 + )} 276 + </Link> 277 + </li> 278 + ); 279 + })} 261 280 </ul> 262 281 {type !== 'hashtags' && ( 263 282 <div class="ui-state">
+228 -179
src/pages/status.jsx
··· 60 60 behavior: 'smooth', 61 61 }; 62 62 63 + // Select all statuses except those inside collapsed details/summary 64 + // Hat-tip to @AmeliaBR@front-end.social 65 + // https://front-end.social/@AmeliaBR/109784776146144471 66 + const STATUSES_SELECTOR = 67 + '.status-link:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *), .status-focus:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *)'; 68 + 69 + const STATUS_URL_REGEX = /\/s\//i; 70 + 63 71 function StatusPage(params) { 64 72 const { id } = params; 65 73 const { masto, instance } = api({ instance: params.instance }); ··· 167 175 function StatusThread({ id, closeLink = '/', instance: propInstance }) { 168 176 const [searchParams, setSearchParams] = useSearchParams(); 169 177 const mediaParam = searchParams.get('media'); 178 + const mediaStatusID = searchParams.get('mediaStatusID'); 170 179 const showMedia = parseInt(mediaParam, 10) > 0; 171 - const firstLoad = useRef(!states.prevLocation && history.length === 1); 180 + const firstLoad = useRef( 181 + !states.prevLocation && 182 + (history.length === 1 || 183 + ('navigation' in window && navigation?.entries?.()?.length === 1)), 184 + ); 172 185 const [viewMode, setViewMode] = useState( 173 186 searchParams.get('view') || firstLoad.current ? 'full' : null, 174 187 ); ··· 554 567 ); 555 568 const activeStatusRect = activeStatus?.getBoundingClientRect(); 556 569 const allStatusLinks = Array.from( 557 - // Select all statuses except those inside collapsed details/summary 558 - // Hat-tip to @AmeliaBR@front-end.social 559 - // https://front-end.social/@AmeliaBR/109784776146144471 560 - scrollableRef.current.querySelectorAll( 561 - '.status-link:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *), .status-focus:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *)', 562 - ), 570 + scrollableRef.current.querySelectorAll(STATUSES_SELECTOR), 563 571 ); 564 572 console.log({ allStatusLinks }); 565 573 if ( ··· 592 600 ); 593 601 const activeStatusRect = activeStatus?.getBoundingClientRect(); 594 602 const allStatusLinks = Array.from( 595 - scrollableRef.current.querySelectorAll( 596 - '.status-link:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *), .status-focus:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *)', 597 - ), 603 + scrollableRef.current.querySelectorAll(STATUSES_SELECTOR), 598 604 ); 599 605 if ( 600 606 activeStatus && ··· 657 663 resetScrollPosition(status.id); 658 664 }, []); 659 665 660 - const renderStatus = (status) => { 661 - const { 662 - id: statusID, 663 - ancestor, 664 - isThread, 665 - descendant, 666 - thread, 667 - replies, 668 - repliesCount, 669 - weight, 670 - } = status; 671 - const isHero = statusID === id; 672 - // const StatusParent = useCallback( 673 - // (props) => 674 - // isThread || thread || ancestor ? ( 675 - // <Link 676 - // class="status-link" 677 - // to={ 678 - // instance ? `/${instance}/s/${statusID}` : `/s/${statusID}` 679 - // } 680 - // onClick={() => { 681 - // resetScrollPosition(statusID); 682 - // }} 683 - // {...props} 684 - // /> 685 - // ) : ( 686 - // <div class="status-focus" tabIndex={0} {...props} /> 687 - // ), 688 - // [isThread, thread], 689 - // ); 690 - return ( 691 - <li 692 - key={statusID} 693 - ref={isHero ? heroStatusRef : null} 694 - class={`${ancestor ? 'ancestor' : ''} ${ 695 - descendant ? 'descendant' : '' 696 - } ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`} 697 - > 698 - {isHero ? ( 699 - <> 700 - <InView 701 - threshold={0.1} 702 - onChange={onView} 703 - class="status-focus" 704 - tabIndex={0} 666 + useEffect(() => { 667 + let timer; 668 + if (mediaStatusID && showMedia) { 669 + timer = setTimeout(() => { 670 + const status = scrollableRef.current?.querySelector( 671 + `.status-link[href*="/${mediaStatusID}"]`, 672 + ); 673 + if (status) { 674 + status.scrollIntoView(scrollIntoViewOptions); 675 + } 676 + }, 400); // After CSS transition 677 + } 678 + return () => { 679 + clearTimeout(timer); 680 + }; 681 + }, [mediaStatusID, showMedia]); 682 + 683 + const renderStatus = useCallback( 684 + (status) => { 685 + const { 686 + id: statusID, 687 + ancestor, 688 + isThread, 689 + descendant, 690 + thread, 691 + replies, 692 + repliesCount, 693 + weight, 694 + } = status; 695 + const isHero = statusID === id; 696 + // const StatusParent = useCallback( 697 + // (props) => 698 + // isThread || thread || ancestor ? ( 699 + // <Link 700 + // class="status-link" 701 + // to={ 702 + // instance ? `/${instance}/s/${statusID}` : `/s/${statusID}` 703 + // } 704 + // onClick={() => { 705 + // resetScrollPosition(statusID); 706 + // }} 707 + // {...props} 708 + // /> 709 + // ) : ( 710 + // <div class="status-focus" tabIndex={0} {...props} /> 711 + // ), 712 + // [isThread, thread], 713 + // ); 714 + return ( 715 + <li 716 + key={statusID} 717 + ref={isHero ? heroStatusRef : null} 718 + class={`${ancestor ? 'ancestor' : ''} ${ 719 + descendant ? 'descendant' : '' 720 + } ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`} 721 + > 722 + {isHero ? ( 723 + <> 724 + <InView 725 + threshold={0.1} 726 + onChange={onView} 727 + class="status-focus" 728 + tabIndex={0} 729 + > 730 + <Status 731 + statusID={statusID} 732 + instance={instance} 733 + withinContext 734 + size="l" 735 + enableTranslate 736 + forceTranslate={translate} 737 + /> 738 + </InView> 739 + {uiState !== 'loading' && !authenticated ? ( 740 + <div class="post-status-banner"> 741 + <p> 742 + You're not logged in. Interactions (reply, boost, etc) are 743 + not possible. 744 + </p> 745 + <Link to="/login" class="button"> 746 + Log in 747 + </Link> 748 + </div> 749 + ) : ( 750 + !sameInstance && ( 751 + <div class="post-status-banner"> 752 + <p> 753 + This post is from another instance (<b>{instance}</b>). 754 + Interactions (reply, boost, etc) are not possible. 755 + </p> 756 + <button 757 + type="button" 758 + disabled={uiState === 'loading'} 759 + onClick={() => { 760 + setUIState('loading'); 761 + (async () => { 762 + try { 763 + const results = await currentMasto.v2.search.fetch({ 764 + q: heroStatus.url, 765 + type: 'statuses', 766 + resolve: true, 767 + limit: 1, 768 + }); 769 + if (results.statuses.length) { 770 + const status = results.statuses[0]; 771 + location.hash = currentInstance 772 + ? `/${currentInstance}/s/${status.id}` 773 + : `/s/${status.id}`; 774 + } else { 775 + throw new Error('No results'); 776 + } 777 + } catch (e) { 778 + setUIState('default'); 779 + alert('Error: ' + e); 780 + console.error(e); 781 + } 782 + })(); 783 + }} 784 + > 785 + <Icon icon="transfer" /> Switch to my instance to enable 786 + interactions 787 + </button> 788 + </div> 789 + ) 790 + )} 791 + </> 792 + ) : ( 793 + // <StatusParent> 794 + <Link 795 + class="status-link" 796 + to={instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`} 797 + onClick={() => { 798 + resetScrollPosition(statusID); 799 + }} 705 800 > 706 801 <Status 707 802 statusID={statusID} 708 803 instance={instance} 709 804 withinContext 710 - size="l" 805 + size={thread || ancestor ? 'm' : 's'} 711 806 enableTranslate 712 - forceTranslate={translate} 807 + onMediaClick={handleMediaClick} 808 + onStatusLinkClick={handleStatusLinkClick} 713 809 /> 714 - </InView> 715 - {uiState !== 'loading' && !authenticated ? ( 716 - <div class="post-status-banner"> 717 - <p> 718 - You're not logged in. Interactions (reply, boost, etc) are not 719 - possible. 720 - </p> 721 - <Link to="/login" class="button"> 722 - Log in 723 - </Link> 724 - </div> 725 - ) : ( 726 - !sameInstance && ( 727 - <div class="post-status-banner"> 728 - <p> 729 - This post is from another instance (<b>{instance}</b>). 730 - Interactions (reply, boost, etc) are not possible. 731 - </p> 732 - <button 733 - type="button" 734 - disabled={uiState === 'loading'} 735 - onClick={() => { 736 - setUIState('loading'); 737 - (async () => { 738 - try { 739 - const results = await currentMasto.v2.search.fetch({ 740 - q: heroStatus.url, 741 - type: 'statuses', 742 - resolve: true, 743 - limit: 1, 744 - }); 745 - if (results.statuses.length) { 746 - const status = results.statuses[0]; 747 - location.hash = currentInstance 748 - ? `/${currentInstance}/s/${status.id}` 749 - : `/s/${status.id}`; 750 - } else { 751 - throw new Error('No results'); 752 - } 753 - } catch (e) { 754 - setUIState('default'); 755 - alert('Error: ' + e); 756 - console.error(e); 757 - } 758 - })(); 759 - }} 760 - > 761 - <Icon icon="transfer" /> Switch to my instance to enable 762 - interactions 763 - </button> 810 + {ancestor && isThread && repliesCount > 1 && ( 811 + <div class="replies-link"> 812 + <Icon icon="comment" />{' '} 813 + <span title={repliesCount}> 814 + {shortenNumber(repliesCount)} 815 + </span> 764 816 </div> 765 - ) 766 - )} 767 - </> 768 - ) : ( 769 - // <StatusParent> 770 - <Link 771 - class="status-link" 772 - to={instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`} 773 - onClick={() => { 774 - resetScrollPosition(statusID); 775 - }} 776 - > 777 - <Status 778 - statusID={statusID} 779 - instance={instance} 780 - withinContext 781 - size={thread || ancestor ? 'm' : 's'} 782 - enableTranslate 783 - onMediaClick={handleMediaClick} 784 - onStatusLinkClick={handleStatusLinkClick} 785 - /> 786 - {ancestor && isThread && repliesCount > 1 && ( 787 - <div class="replies-link"> 788 - <Icon icon="comment" />{' '} 789 - <span title={repliesCount}>{shortenNumber(repliesCount)}</span> 790 - </div> 791 - )}{' '} 792 - {/* {replies?.length > LIMIT && ( 817 + )}{' '} 818 + {/* {replies?.length > LIMIT && ( 793 819 <div class="replies-link"> 794 820 <Icon icon="comment" />{' '} 795 821 <span title={replies.length}> ··· 797 823 </span> 798 824 </div> 799 825 )} */} 800 - {/* </StatusParent> */} 801 - </Link> 802 - )} 803 - {descendant && replies?.length > 0 && ( 804 - <SubComments 805 - instance={instance} 806 - replies={replies} 807 - hasParentThread={thread} 808 - level={1} 809 - accWeight={weight} 810 - openAll={totalDescendants.current < SUBCOMMENTS_OPEN_ALL_LIMIT} 811 - /> 812 - )} 813 - {uiState === 'loading' && 814 - isHero && 815 - !!heroStatus?.repliesCount && 816 - !hasDescendants && ( 817 - <div class="status-loading"> 818 - <Loader /> 819 - </div> 826 + {/* </StatusParent> */} 827 + </Link> 820 828 )} 821 - {uiState === 'error' && 822 - isHero && 823 - !!heroStatus?.repliesCount && 824 - !hasDescendants && ( 825 - <div class="status-error"> 826 - Unable to load replies. 827 - <br /> 828 - <button 829 - type="button" 830 - class="plain" 831 - onClick={() => { 832 - states.reloadStatusPage++; 833 - }} 834 - > 835 - Try again 836 - </button> 837 - </div> 829 + {descendant && replies?.length > 0 && ( 830 + <SubComments 831 + instance={instance} 832 + replies={replies} 833 + hasParentThread={thread} 834 + level={1} 835 + accWeight={weight} 836 + openAll={totalDescendants.current < SUBCOMMENTS_OPEN_ALL_LIMIT} 837 + /> 838 838 )} 839 - </li> 840 - ); 841 - }; 839 + {uiState === 'loading' && 840 + isHero && 841 + !!heroStatus?.repliesCount && 842 + !hasDescendants && ( 843 + <div class="status-loading"> 844 + <Loader /> 845 + </div> 846 + )} 847 + {uiState === 'error' && 848 + isHero && 849 + !!heroStatus?.repliesCount && 850 + !hasDescendants && ( 851 + <div class="status-error"> 852 + Unable to load replies. 853 + <br /> 854 + <button 855 + type="button" 856 + class="plain" 857 + onClick={() => { 858 + states.reloadStatusPage++; 859 + }} 860 + > 861 + Try again 862 + </button> 863 + </div> 864 + )} 865 + </li> 866 + ); 867 + }, 868 + [ 869 + id, 870 + instance, 871 + uiState, 872 + authenticated, 873 + sameInstance, 874 + translate, 875 + handleMediaClick, 876 + handleStatusLinkClick, 877 + hasDescendants, 878 + ], 879 + ); 880 + 881 + const prevLocationIsStatusPage = useMemo(() => { 882 + // Navigation API 883 + if ('navigation' in window && navigation?.entries) { 884 + const prevEntry = navigation.entries()[navigation.currentEntry.index - 1]; 885 + if (prevEntry?.url) { 886 + return STATUS_URL_REGEX.test(prevEntry.url); 887 + } 888 + } 889 + return STATUS_URL_REGEX.test(states.prevLocation?.pathname); 890 + }, [sKey]); 842 891 843 892 return ( 844 893 <div ··· 876 925 </div> */} 877 926 <div class="header-grid header-grid-2"> 878 927 <h1> 879 - {!!/\/s\//i.test(snapStates.prevLocation?.pathname) && ( 928 + {prevLocationIsStatusPage && ( 880 929 <button 881 930 type="button" 882 931 class="plain deck-back" ··· 970 1019 <div class="header-side"> 971 1020 <button 972 1021 type="button" 973 - class="plain4" 1022 + class="plain4 button-switch-view" 974 1023 style={{ 975 1024 display: viewMode === 'full' ? '' : 'none', 976 1025 }} ··· 1231 1280 /> 1232 1281 ))} 1233 1282 </span> 1234 - <span> 1283 + <b> 1235 1284 <span title={replies.length}>{shortenNumber(replies.length)}</span>{' '} 1236 1285 repl 1237 1286 {replies.length === 1 ? 'y' : 'ies'} 1238 - </span> 1287 + </b> 1239 1288 {!sameCount && totalComments > 1 && ( 1240 1289 <> 1241 1290 {' '}
+19 -4
src/pages/trending.jsx
··· 15 15 import { oklab2rgb, rgb2oklab } from '../utils/color-utils'; 16 16 import { filteredItems } from '../utils/filters'; 17 17 import pmem from '../utils/pmem'; 18 + import shortenNumber from '../utils/shorten-number'; 18 19 import states from '../utils/states'; 19 20 import { saveStatus } from '../utils/states'; 20 21 import useTitle from '../utils/useTitle'; ··· 37 38 const { masto, instance } = api({ 38 39 instance: props?.instance || params.instance, 39 40 }); 41 + const { masto: currentMasto, instance: currentInstance } = api(); 40 42 const title = `Trending (${instance})`; 41 43 useTitle(title, `/:instance?/trending`); 42 44 // const navigate = useNavigate(); ··· 84 86 latestItem.current = value[0].id; 85 87 } 86 88 87 - value = filteredItems(value, 'public'); // Might not work here 89 + // value = filteredItems(value, 'public'); // Might not work here 88 90 value.forEach((item) => { 89 91 saveStatus(item, instance); 90 92 }); ··· 131 133 <span class="more-insignificant">#</span> 132 134 {name} 133 135 </span> 134 - <span class="filter-count">{total.toLocaleString()}</span> 136 + <span class="filter-count">{shortenNumber(total)}</span> 135 137 </Link> 136 138 ); 137 139 })} ··· 241 243 key={instance} 242 244 title={title} 243 245 titleComponent={ 244 - <h1 class="header-account"> 246 + <h1 class="header-double-lines"> 245 247 <b>Trending</b> 246 248 <div>{instance}</div> 247 249 </h1> ··· 256 258 useItemID 257 259 headerStart={<></>} 258 260 boostsCarousel={snapStates.settings.boostsCarousel} 259 - allowFilters 261 + // allowFilters 262 + filterContext="public" 260 263 timelineStart={TimelineStart} 261 264 headerEnd={ 262 265 <Menu2 ··· 289 292 > 290 293 <Icon icon="bus" /> <span>Go to another instance…</span> 291 294 </MenuItem> 295 + {currentInstance !== instance && ( 296 + <MenuItem 297 + onClick={() => { 298 + location.hash = `/${currentInstance}/trending`; 299 + }} 300 + > 301 + <Icon icon="bus" />{' '} 302 + <small class="menu-double-lines"> 303 + Go to my instance (<b>{currentInstance}</b>) 304 + </small> 305 + </MenuItem> 306 + )} 292 307 </Menu2> 293 308 } 294 309 />
+160 -113
src/pages/welcome.css
··· 1 + @keyframes shine2 { 2 + 0% { 3 + left: -100%; 4 + } 5 + 20% { 6 + left: 100%; 7 + } 8 + 100% { 9 + left: 100%; 10 + } 11 + } 12 + 1 13 #welcome { 2 14 text-align: center; 3 15 background-image: radial-gradient( ··· 8 20 radial-gradient(circle at center, var(--bg-color), transparent 8em); 9 21 background-repeat: no-repeat; 10 22 background-attachment: fixed; 11 - padding: 16px; 12 23 cursor: default; 13 - } 24 + 25 + @media (prefers-color-scheme: dark) { 26 + background-image: none; 27 + } 28 + 29 + .hero-container { 30 + padding: 16px; 31 + height: 100vh; 32 + height: 100svh; 33 + max-height: 800px; 34 + display: flex; 35 + flex-direction: column; 14 36 15 - #welcome .hero-container { 16 - padding-block: 60px; 17 - height: 100vh; 18 - height: 100svh; 19 - max-height: 1024px; 20 - display: flex; 21 - flex-direction: column; 22 - } 37 + a { 38 + color: inherit; 23 39 24 - #welcome h1 { 25 - margin: 0; 26 - padding: 0; 27 - font-size: 5em; 28 - line-height: 1; 29 - letter-spacing: -1px; 30 - flex-grow: 1; 31 - display: flex; 32 - flex-direction: column; 33 - justify-content: center; 34 - align-items: center; 35 - position: relative; 36 - mix-blend-mode: multiply; 37 - } 38 - @keyframes shine2 { 39 - 0% { 40 - left: -100%; 40 + &:hover { 41 + color: var(--link-text-color); 42 + } 43 + } 41 44 } 42 - 20% { 43 - left: 100%; 45 + 46 + h1 { 47 + margin: 0; 48 + padding: 0; 49 + font-size: 5em; 50 + line-height: 1; 51 + letter-spacing: -1px; 52 + flex-grow: 1; 53 + display: flex; 54 + flex-direction: column; 55 + justify-content: center; 56 + align-items: center; 57 + position: relative; 58 + mix-blend-mode: multiply; 59 + 60 + @media (prefers-color-scheme: dark) { 61 + mix-blend-mode: normal; 62 + } 63 + 64 + &:before { 65 + content: ''; 66 + position: absolute; 67 + z-index: 2; 68 + width: 100%; 69 + height: 100%; 70 + background-image: linear-gradient( 71 + 100deg, 72 + rgba(255, 255, 255, 0) 30%, 73 + rgba(255, 255, 255, 0.4), 74 + rgba(255, 255, 255, 0) 70% 75 + ); 76 + top: 0; 77 + left: -100%; 78 + pointer-events: none; 79 + animation: shine2 5s ease-in-out 1s infinite; 80 + 81 + @media (prefers-color-scheme: dark) { 82 + content: none; 83 + } 84 + } 85 + 86 + img { 87 + filter: drop-shadow(-1px -1px var(--bg-blur-color)) 88 + drop-shadow(0 -1px 1px #fff) 89 + drop-shadow(0 16px 32px var(--drop-shadow-color)); 90 + 91 + @media (prefers-color-scheme: dark) { 92 + filter: none; 93 + } 94 + } 95 + 96 + &:hover img { 97 + transform: scale(1.05); 98 + } 44 99 } 45 - 100% { 46 - left: 100%; 100 + 101 + img { 102 + vertical-align: top; 103 + transition: transform 0.3s ease-out; 47 104 } 48 - } 49 - #welcome h1:before { 50 - content: ''; 51 - position: absolute; 52 - z-index: 2; 53 - width: 100%; 54 - height: 100%; 55 - background-image: linear-gradient( 56 - 100deg, 57 - rgba(255, 255, 255, 0) 30%, 58 - rgba(255, 255, 255, 0.4), 59 - rgba(255, 255, 255, 0) 70% 60 - ); 61 - top: 0; 62 - left: -100%; 63 - pointer-events: none; 64 - animation: shine2 5s ease-in-out 1s infinite; 65 - } 66 - @media (prefers-color-scheme: dark) { 67 - #welcome { 68 - background-image: none; 105 + 106 + .desc { 107 + font-size: 1.4em; 108 + text-wrap: balance; 109 + opacity: 0.7; 69 110 } 70 - #welcome h1 { 71 - mix-blend-mode: normal; 111 + 112 + .hero-container > p { 113 + margin-top: 0; 72 114 } 73 - #welcome h1:before { 74 - content: none; 115 + 116 + #why-container { 117 + padding: 0 16px; 75 118 } 76 - } 77 - #welcome img { 78 - vertical-align: top; 79 - transition: transform 0.3s ease-out; 80 - } 81 - #welcome h1 img { 82 - filter: drop-shadow(-1px -1px var(--bg-blur-color)) 83 - drop-shadow(0 -1px 1px #fff) 84 - drop-shadow(0 16px 32px var(--drop-shadow-color)); 85 - } 86 - @media (prefers-color-scheme: dark) { 87 - #welcome h1 img { 88 - filter: none; 119 + 120 + .sections { 121 + padding-inline: 16px; 122 + 123 + section { 124 + text-align: start; 125 + max-width: 480px; 126 + background-color: var(--bg-color); 127 + border-radius: 16px; 128 + overflow: hidden; 129 + box-shadow: 17px 20px 40px var(--drop-shadow-color); 130 + margin-bottom: 48px; 131 + 132 + h4 { 133 + margin: 0; 134 + padding: 30px 30px 0; 135 + font-size: 1.4em; 136 + font-weight: 600; 137 + } 138 + 139 + p { 140 + margin-inline: 30px; 141 + margin-bottom: 30px; 142 + opacity: 0.7; 143 + text-wrap: balance; 144 + } 145 + 146 + img { 147 + width: 100%; 148 + height: auto; 149 + border-bottom: 1px solid var(--outline-color); 150 + 151 + @media (prefers-color-scheme: dark) { 152 + filter: invert(0.85) hue-rotate(180deg); 153 + } 154 + } 155 + } 89 156 } 90 - } 91 157 92 - #welcome h1:hover img { 93 - transform: scale(1.05); 94 - } 95 - #welcome .desc { 96 - font-size: 1.4em; 97 - text-wrap: balance; 98 - opacity: 0.7; 99 - } 100 - #welcome .hero-container > p { 101 - margin-top: 0; 102 - } 158 + @media (width > 40em) { 159 + display: grid; 160 + grid-template-columns: 1fr 1fr; 161 + grid-template-rows: 1fr auto; 162 + height: 100vh; 163 + height: 100svh; 103 164 104 - #why-container .sections { 105 - padding-inline: 16px; 106 - } 107 - #why-container .sections section { 108 - text-align: start; 109 - max-width: 480px; 110 - background-color: var(--bg-color); 111 - border-radius: 16px; 112 - overflow: hidden; 113 - box-shadow: 17px 20px 40px var(--drop-shadow-color); 114 - margin-bottom: 48px; 115 - } 116 - #why-container .sections section h4 { 117 - margin: 0; 118 - padding: 30px 30px 0; 119 - font-size: 1.4em; 120 - font-weight: 600; 121 - } 122 - #why-container .sections section p { 123 - margin-inline: 30px; 124 - margin-bottom: 30px; 125 - opacity: 0.7; 126 - text-wrap: balance; 127 - } 128 - #why-container .sections section img { 129 - width: 100%; 130 - height: auto; 131 - border-bottom: 1px solid var(--outline-color); 132 - } 133 - @media (prefers-color-scheme: dark) { 134 - #why-container .sections section img { 135 - filter: invert(0.85) hue-rotate(180deg); 165 + .hero-container { 166 + height: auto; 167 + } 168 + 169 + #why-container { 170 + padding: 32px; 171 + overflow: auto; 172 + mask-image: linear-gradient(to top, transparent 16px, black 64px); 173 + } 174 + 175 + footer { 176 + grid-row: 2; 177 + grid-column: 1 / span 2; 178 + } 179 + } 180 + 181 + & ~ :is(#compose-button, #shortcuts) { 182 + display: none; 136 183 } 137 184 }
+27 -25
src/pages/welcome.jsx
··· 98 98 </section> 99 99 </div> 100 100 </div> 101 - <hr /> 102 - <p> 103 - <a href="https://github.com/cheeaun/phanpy" target="_blank"> 104 - Built 105 - </a>{' '} 106 - by{' '} 107 - <a 108 - href="https://mastodon.social/@cheeaun" 109 - target="_blank" 110 - onClick={(e) => { 111 - e.preventDefault(); 112 - states.showAccount = 'cheeaun@mastodon.social'; 113 - }} 114 - > 115 - @cheeaun 116 - </a> 117 - .{' '} 118 - <a 119 - href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD" 120 - target="_blank" 121 - > 122 - Privacy Policy 123 - </a> 124 - . 125 - </p> 101 + <footer> 102 + <hr /> 103 + <p> 104 + <a href="https://github.com/cheeaun/phanpy" target="_blank"> 105 + Built 106 + </a>{' '} 107 + by{' '} 108 + <a 109 + href="https://mastodon.social/@cheeaun" 110 + target="_blank" 111 + onClick={(e) => { 112 + e.preventDefault(); 113 + states.showAccount = 'cheeaun@mastodon.social'; 114 + }} 115 + > 116 + @cheeaun 117 + </a> 118 + .{' '} 119 + <a 120 + href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD" 121 + target="_blank" 122 + > 123 + Privacy Policy 124 + </a> 125 + . 126 + </p> 127 + </footer> 126 128 </main> 127 129 ); 128 130 }
+26 -1
src/utils/api.js
··· 221 221 } 222 222 } 223 223 224 + const currentAccount = getCurrentAccount(); 225 + 224 226 // If only instance is provided, get the masto instance for that instance 225 227 if (instance) { 228 + if (currentAccountApi?.instance === instance) { 229 + return { 230 + masto: currentAccountApi.masto, 231 + streaming: currentAccountApi.streaming, 232 + client: currentAccountApi, 233 + authenticated: true, 234 + instance, 235 + }; 236 + } 237 + 238 + if (currentAccount?.instanceURL === instance) { 239 + const { accessToken } = currentAccount; 240 + currentAccountApi = 241 + accountApis[instance]?.[accessToken] || 242 + initClient({ instance, accessToken }); 243 + return { 244 + masto: currentAccountApi.masto, 245 + streaming: currentAccountApi.streaming, 246 + client: currentAccountApi, 247 + authenticated: true, 248 + instance, 249 + }; 250 + } 251 + 226 252 const client = apis[instance] || initClient({ instance }); 227 253 const { masto, streaming, accessToken } = client; 228 254 return { ··· 244 270 instance: currentAccountApi.instance, 245 271 }; 246 272 } 247 - const currentAccount = getCurrentAccount(); 248 273 if (currentAccount) { 249 274 const { accessToken, instanceURL: instance } = currentAccount; 250 275 currentAccountApi =
+4
src/utils/filter-context.js
··· 1 + import { createContext } from 'preact'; 2 + 3 + const FilterContext = createContext(); 4 + export default FilterContext;
+24 -10
src/utils/filters.jsx
··· 1 + import mem from './mem'; 1 2 import store from './store'; 2 3 3 - export function filteredItem(item, filterContext, currentAccountID) { 4 - const { filtered } = item; 5 - if (!filtered?.length) return true; 6 - const isSelf = currentAccountID && item.account?.id === currentAccountID; 7 - if (isSelf) return true; 4 + function _isFiltered(filtered, filterContext) { 5 + if (!filtered?.length) return false; 8 6 const appliedFilters = filtered.filter((f) => { 9 7 const { filter } = f; 10 8 const hasContext = filter.context.includes(filterContext); ··· 12 10 if (!filter.expiresAt) return hasContext; 13 11 return new Date(filter.expiresAt) > new Date(); 14 12 }); 15 - if (!appliedFilters.length) return true; 13 + if (!appliedFilters.length) return false; 16 14 const isHidden = appliedFilters.some((f) => f.filter.filterAction === 'hide'); 17 - console.log({ isHidden, filtered, appliedFilters, item }); 18 - if (isHidden) return false; 15 + if (isHidden) 16 + return { 17 + action: 'hide', 18 + }; 19 19 const isWarn = appliedFilters.some((f) => f.filter.filterAction === 'warn'); 20 20 if (isWarn) { 21 21 const filterTitles = appliedFilters.map((f) => f.filter.title); 22 - item._filtered = { 22 + return { 23 + action: 'warn', 23 24 titles: filterTitles, 24 25 titlesStr: filterTitles.join(' • '), 25 26 }; 26 27 } 27 - return isWarn; 28 + return false; 29 + } 30 + export const isFiltered = mem(_isFiltered); 31 + 32 + export function filteredItem(item, filterContext, currentAccountID) { 33 + const { filtered } = item; 34 + if (!filtered?.length) return true; 35 + const isSelf = currentAccountID && item.account?.id === currentAccountID; 36 + if (isSelf) return true; 37 + const filterState = isFiltered(filtered, filterContext); 38 + if (!filterState) return true; 39 + if (filterState.action === 'hide') return false; 40 + // item._filtered = filterState; 41 + return true; 28 42 } 29 43 export function filteredItems(items, filterContext) { 30 44 if (!items?.length) return [];
+35 -2
src/utils/group-notifications.jsx
··· 1 + // This is like very lame "type-checking" lol 2 + const notificationTypeKeys = { 3 + mention: ['account', 'status'], 4 + status: ['account', 'status'], 5 + reblog: ['account', 'status'], 6 + follow: ['account'], 7 + follow_request: ['account'], 8 + favourite: ['account', 'status'], 9 + poll: ['status'], 10 + update: ['status'], 11 + }; 12 + function fixNotifications(notifications) { 13 + return notifications.filter((notification) => { 14 + const { type, id, createdAt } = notification; 15 + if (!type) { 16 + console.warn('Notification missing type', notification); 17 + return false; 18 + } 19 + if (!id || !createdAt) { 20 + console.warn('Notification missing id or createdAt', notification); 21 + // Continue processing this despite missing id or createdAt 22 + } 23 + const keys = notificationTypeKeys[type]; 24 + if (keys?.length) { 25 + return keys.every((key) => !!notification[key]); 26 + } 27 + return true; // skip other types 28 + }); 29 + } 30 + 1 31 function groupNotifications(notifications) { 32 + // Filter out invalid notifications 33 + notifications = fixNotifications(notifications); 34 + 2 35 // Create new flat list of notifications 3 36 // Combine sibling notifications based on type and status id 4 37 // Concat all notification.account into an array of _accounts ··· 7 40 for (let i = 0, j = 0; i < notifications.length; i++) { 8 41 const notification = notifications[i]; 9 42 const { id, status, account, type, createdAt } = notification; 10 - const date = new Date(createdAt).toLocaleDateString(); 43 + const date = createdAt ? new Date(createdAt).toLocaleDateString() : ''; 11 44 let virtualType = type; 12 45 if (type === 'favourite' || type === 'reblog') { 13 46 virtualType = 'favourite+reblog'; ··· 50 83 for (let i = 0, j = 0; i < cleanNotifications.length; i++) { 51 84 const notification = cleanNotifications[i]; 52 85 const { id, account, _accounts, type, createdAt } = notification; 53 - const date = new Date(createdAt).toLocaleDateString(); 86 + const date = createdAt ? new Date(createdAt).toLocaleDateString() : ''; 54 87 if (type === 'favourite+reblog' && account && _accounts.length === 1) { 55 88 const key = `${account?.id}-${type}-${date}`; 56 89 const mappedNotification = notificationsMap2[key];
+4 -2
src/utils/isMastodonLinkMaybe.jsx
··· 1 1 export default function isMastodonLinkMaybe(url) { 2 - const { pathname } = new URL(url); 2 + const { pathname, hash } = new URL(url); 3 3 return ( 4 4 /^\/.*\/\d+$/i.test(pathname) || 5 5 /^\/@[^/]+\/(statuses|posts)\/\w+\/?$/i.test(pathname) || // GoToSocial, Takahe 6 6 /^\/notes\/[a-z0-9]+$/i.test(pathname) || // Misskey, Calckey 7 - /^\/(notice|objects)\/[a-z0-9-]+$/i.test(pathname) // Pleroma 7 + /^\/notes\/[a-z0-9]+$/i.test(pathname) || // Misskey, Calckey 8 + /^\/(notice|objects)\/[a-z0-9-]+$/i.test(pathname) || // Pleroma 9 + /#\/[^\/]+\.[^\/]+\/s\/.+/i.test(hash) // Phanpy 🫣 8 10 ); 9 11 }
+3 -1
src/utils/mem.js
··· 1 1 import moize from 'moize'; 2 2 3 + window._moize = moize; 4 + 3 5 export default function mem(fn, opts = {}) { 4 - return moize(fn, { ...opts, maxSize: 100 }); 6 + return moize(fn, { ...opts, maxSize: 50, isDeepEqual: true }); 5 7 }
+2
src/utils/open-compose.js
··· 18 18 // } 19 19 20 20 newWin.__COMPOSE__ = opts; 21 + } else { 22 + alert('Looks like your browser is blocking popups.'); 21 23 } 22 24 23 25 return newWin;
+4 -7
src/utils/states.js
··· 22 22 notificationsNew: [], 23 23 notificationsShowNew: false, 24 24 notificationsLastFetchTime: null, 25 - accounts: {}, 26 25 reloadStatusPage: 0, 27 26 reloadGenericAccounts: { 28 27 id: null, ··· 72 71 store.account.get('settings-autoRefresh') ?? false; 73 72 states.settings.shortcutsViewMode = 74 73 store.account.get('settings-shortcutsViewMode') ?? null; 75 - states.settings.shortcutsColumnsMode = 76 - store.account.get('settings-shortcutsColumnsMode') ?? false; 74 + if (store.account.get('settings-shortcutsColumnsMode')) { 75 + states.settings.shortcutsColumnsMode = true; 76 + } 77 77 states.settings.boostsCarousel = 78 78 store.account.get('settings-boostsCarousel') ?? true; 79 79 states.settings.contentTranslation = ··· 100 100 if (path.join('.') === 'settings.boostsCarousel') { 101 101 store.account.set('settings-boostsCarousel', !!value); 102 102 } 103 - if (path.join('.') === 'settings.shortcutsColumnsMode') { 104 - store.account.set('settings-shortcutsColumnsMode', !!value); 105 - } 106 103 if (path.join('.') === 'settings.shortcutsViewMode') { 107 104 store.account.set('settings-shortcutsViewMode', value); 108 105 } ··· 171 168 if (!override && oldStatus) return; 172 169 const key = statusKey(status.id, instance); 173 170 if (oldStatus?._pinned) status._pinned = oldStatus._pinned; 174 - if (oldStatus?._filtered) status._filtered = oldStatus._filtered; 171 + // if (oldStatus?._filtered) status._filtered = oldStatus._filtered; 175 172 states.statuses[key] = status; 176 173 if (status.reblog) { 177 174 const key = statusKey(status.reblog.id, instance);
+1 -1
src/utils/supports.js
··· 1 - import { satisfies } from 'semver'; 1 + import { satisfies } from 'compare-versions'; 2 2 3 3 import features from '../data/features.json'; 4 4