Merge pull request #286 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2023-11-06 21:18:15 +08:00 committed by GitHub
commit 74991c326d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 2447 additions and 1143 deletions

179
package-lock.json generated
View file

@ -15,6 +15,7 @@
"@justinribeiro/lite-youtube": "~1.5.0",
"@szhsin/react-menu": "~4.1.0",
"@uidotdev/usehooks": "~2.4.1",
"compare-versions": "~6.1.0",
"dayjs": "~1.11.10",
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
@ -22,11 +23,11 @@
"idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0",
"lz-string": "~1.5.0",
"masto": "~6.3.3",
"masto": "~6.4.2",
"moize": "~6.1.6",
"p-retry": "~6.1.0",
"p-throttle": "~5.1.0",
"preact": "~10.18.1",
"preact": "~10.18.2",
"react-hotkeys-hook": "~4.4.1",
"react-intersection-observer": "~9.5.2",
"react-quick-pinch-zoom": "~5.0.0",
@ -45,12 +46,12 @@
"@trivago/prettier-plugin-sort-imports": "~4.2.1",
"postcss": "~8.4.31",
"postcss-dark-theme-class": "~1.0.0",
"postcss-preset-env": "~9.2.0",
"postcss-preset-env": "~9.3.0",
"twitter-text": "~3.1.0",
"vite": "~4.5.0",
"vite-plugin-generate-file": "~0.0.4",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.16.5",
"vite-plugin-pwa": "~0.16.6",
"vite-plugin-remove-console": "~2.1.1",
"workbox-cacheable-response": "~7.0.0",
"workbox-expiration": "~7.0.0",
@ -1988,9 +1989,9 @@
}
},
"node_modules/@csstools/postcss-cascade-layers": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-4.0.0.tgz",
"integrity": "sha512-dVPVVqQG0FixjM9CG/+8eHTsCAxRKqmNh6H69IpruolPlnEF1611f2AoLK8TijTSAsqBSclKd4WHs1KUb/LdJw==",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-4.0.1.tgz",
"integrity": "sha512-UYFuFL9GgVnftg9v7tBvVEBRLaBeAD66euD+yYy5fYCUld9ZIWTJNCE30hm6STMEdt6FL5xzeVw1lAZ1tpvUEg==",
"dev": true,
"funding": [
{
@ -2299,6 +2300,50 @@
"postcss": "^8.4"
}
},
"node_modules/@csstools/postcss-logical-overflow": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@csstools/postcss-logical-overflow/-/postcss-logical-overflow-1.0.0.tgz",
"integrity": "sha512-cIrZ8f7bGGvr+W53nEuMspcwaeaI2YTmz6LZ4yiAO5z14/PQgOOv+Pn+qjvPOPoadeY2BmpaoTzZKvdAQuM17w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"engines": {
"node": "^14 || ^16 || >=18"
},
"peerDependencies": {
"postcss": "^8.4"
}
},
"node_modules/@csstools/postcss-logical-overscroll-behavior": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@csstools/postcss-logical-overscroll-behavior/-/postcss-logical-overscroll-behavior-1.0.0.tgz",
"integrity": "sha512-e89S2LWjnxf0SB2wNUAbqDyFb/Fow/tlOe1XqOLbNx4rf3LrQokM9qldVx7sarnddml3ORE5LDUmlKpPOOeJTA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"engines": {
"node": "^14 || ^16 || >=18"
},
"peerDependencies": {
"postcss": "^8.4"
}
},
"node_modules/@csstools/postcss-logical-resize": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@csstools/postcss-logical-resize/-/postcss-logical-resize-2.0.0.tgz",
@ -3870,6 +3915,11 @@
"node": ">=4.0.0"
}
},
"node_modules/compare-versions": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.0.tgz",
"integrity": "sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg=="
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -3997,9 +4047,9 @@
}
},
"node_modules/cssdb": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.8.0.tgz",
"integrity": "sha512-SkeezZOQr5AHt9MgJgSFNyiuJwg1p8AwoVln6JwaQJsyxduRW9QJ+HP/gAQzbsz8SIqINtYvpJKjxTRI67zxLg==",
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.9.0.tgz",
"integrity": "sha512-WPMT9seTQq6fPAa1yN4zjgZZeoTriSN2LqW9C+otjar12DQIWA4LuSfFrvFJiKp4oD0xIk1vumDLw8K9ur4NBw==",
"dev": true,
"funding": [
{
@ -4264,9 +4314,9 @@
}
},
"node_modules/events-to-async": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/events-to-async/-/events-to-async-2.0.0.tgz",
"integrity": "sha512-NiZEr4g51nI4/lz/6NdwMqK/TLIctlnp9TQ3wCJjlRp47VgrthUZE4nrk2UhfZ8VzoQ/Xyth+G6MKioLCt0FVA=="
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/events-to-async/-/events-to-async-2.0.1.tgz",
"integrity": "sha512-RtnLYrMbXp4JkZIoZu+3VTqV21bNVBlJBZ4NmtwvMNqSE3qouhxv2gvLE4JJDaQc54ioPkrX74V6x+hp/hqjkQ=="
},
"node_modules/fast-blurhash": {
"version": "1.1.2",
@ -5257,12 +5307,12 @@
}
},
"node_modules/masto": {
"version": "6.3.3",
"resolved": "https://registry.npmjs.org/masto/-/masto-6.3.3.tgz",
"integrity": "sha512-hmDsiscImeZfpkS+5oEWk3w5mkbxERFKN/UpuaoKZpVWWoGWCNnO7iPfQHygs/phP7PQqS6pVHlE5ylqSylf6A==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/masto/-/masto-6.4.2.tgz",
"integrity": "sha512-aIxhsTkl0pc755Sf9NMuglY0VM9HuU/tec60e4oLsyyzAkE7ELwufh6anErO84n8g3eWu/u0LGucTujIiDaAhg==",
"dependencies": {
"change-case": "^4.1.2",
"events-to-async": "^2.0.0",
"events-to-async": "^2.0.1",
"isomorphic-ws": "^5.0.0",
"ts-custom-error": "^3.3.1",
"ws": "^8.13.0"
@ -6087,9 +6137,9 @@
}
},
"node_modules/postcss-preset-env": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-9.2.0.tgz",
"integrity": "sha512-Lnr4C5gb7t5Cc8akQMJzNdJkqw7s7s7BHUaQSgsuf+CTY9Lsz5lqQTft5yNZr59JyCLz0aFNSAqSLm/xRtcTpg==",
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-9.3.0.tgz",
"integrity": "sha512-ycw6doPrqV6QxDCtgiyGDef61bEfiSc59HGM4gOw/wxQxmKnhuEery61oOC/5ViENz/ycpRsuhTexs1kUBTvVw==",
"dev": true,
"funding": [
{
@ -6102,7 +6152,7 @@
}
],
"dependencies": {
"@csstools/postcss-cascade-layers": "^4.0.0",
"@csstools/postcss-cascade-layers": "^4.0.1",
"@csstools/postcss-color-function": "^3.0.7",
"@csstools/postcss-color-mix-function": "^2.0.7",
"@csstools/postcss-exponential-functions": "^1.0.1",
@ -6114,6 +6164,8 @@
"@csstools/postcss-initial": "^1.0.0",
"@csstools/postcss-is-pseudo-class": "^4.0.3",
"@csstools/postcss-logical-float-and-clear": "^2.0.0",
"@csstools/postcss-logical-overflow": "^1.0.0",
"@csstools/postcss-logical-overscroll-behavior": "^1.0.0",
"@csstools/postcss-logical-resize": "^2.0.0",
"@csstools/postcss-logical-viewport-units": "^2.0.3",
"@csstools/postcss-media-minmax": "^1.1.0",
@ -6133,7 +6185,7 @@
"css-blank-pseudo": "^6.0.0",
"css-has-pseudo": "^6.0.0",
"css-prefers-color-scheme": "^9.0.0",
"cssdb": "^7.8.0",
"cssdb": "^7.9.0",
"postcss-attribute-case-insensitive": "^6.0.2",
"postcss-clamp": "^4.1.0",
"postcss-color-functional-notation": "^6.0.2",
@ -6241,9 +6293,9 @@
"dev": true
},
"node_modules/preact": {
"version": "10.18.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.18.1.tgz",
"integrity": "sha512-mKUD7RRkQQM6s7Rkmi7IFkoEHjuFqRQUaXamO61E6Nn7vqF/bo7EZCmSyrUnp2UWHw0O7XjZ2eeXis+m7tf4lg==",
"version": "10.18.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.18.2.tgz",
"integrity": "sha512-X/K43vocUHDg0XhWVmTTMbec4LT/iBMh+csCEqJk+pJqegaXsvjdqN80ZZ3L+93azWCnWCZ+WGwYb8SplxeNjA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@ -7394,9 +7446,9 @@
}
},
"node_modules/vite-plugin-pwa": {
"version": "0.16.5",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.16.5.tgz",
"integrity": "sha512-Ahol4dwhMP2UHPQXkllSlXbihOaDFnvBIDPmAxoSZ1EObBUJGP4CMRyCyAVkIHjd6/H+//vH0DM2ON+XxHr81g==",
"version": "0.16.6",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.16.6.tgz",
"integrity": "sha512-bQPDOWvhPMwydMoWqohXvIzvrq4X8iuCF+q95qEiaM4yC0ybViGKWMnWcpWp0vcnoLk7QvxHDlK65KUZvqB3Sg==",
"dev": true,
"dependencies": {
"debug": "^4.3.4",
@ -7412,7 +7464,7 @@
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vite": "^3.1.0 || ^4.0.0",
"vite": "^3.1.0 || ^4.0.0 || ^5.0.0-0",
"workbox-build": "^7.0.0",
"workbox-window": "^7.0.0"
}
@ -9113,9 +9165,9 @@
"requires": {}
},
"@csstools/postcss-cascade-layers": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-4.0.0.tgz",
"integrity": "sha512-dVPVVqQG0FixjM9CG/+8eHTsCAxRKqmNh6H69IpruolPlnEF1611f2AoLK8TijTSAsqBSclKd4WHs1KUb/LdJw==",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-4.0.1.tgz",
"integrity": "sha512-UYFuFL9GgVnftg9v7tBvVEBRLaBeAD66euD+yYy5fYCUld9ZIWTJNCE30hm6STMEdt6FL5xzeVw1lAZ1tpvUEg==",
"dev": true,
"requires": {
"@csstools/selector-specificity": "^3.0.0",
@ -9234,6 +9286,20 @@
"dev": true,
"requires": {}
},
"@csstools/postcss-logical-overflow": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@csstools/postcss-logical-overflow/-/postcss-logical-overflow-1.0.0.tgz",
"integrity": "sha512-cIrZ8f7bGGvr+W53nEuMspcwaeaI2YTmz6LZ4yiAO5z14/PQgOOv+Pn+qjvPOPoadeY2BmpaoTzZKvdAQuM17w==",
"dev": true,
"requires": {}
},
"@csstools/postcss-logical-overscroll-behavior": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@csstools/postcss-logical-overscroll-behavior/-/postcss-logical-overscroll-behavior-1.0.0.tgz",
"integrity": "sha512-e89S2LWjnxf0SB2wNUAbqDyFb/Fow/tlOe1XqOLbNx4rf3LrQokM9qldVx7sarnddml3ORE5LDUmlKpPOOeJTA==",
"dev": true,
"requires": {}
},
"@csstools/postcss-logical-resize": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@csstools/postcss-logical-resize/-/postcss-logical-resize-2.0.0.tgz",
@ -10185,6 +10251,11 @@
"integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==",
"dev": true
},
"compare-versions": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.0.tgz",
"integrity": "sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg=="
},
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -10256,9 +10327,9 @@
"requires": {}
},
"cssdb": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.8.0.tgz",
"integrity": "sha512-SkeezZOQr5AHt9MgJgSFNyiuJwg1p8AwoVln6JwaQJsyxduRW9QJ+HP/gAQzbsz8SIqINtYvpJKjxTRI67zxLg==",
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.9.0.tgz",
"integrity": "sha512-WPMT9seTQq6fPAa1yN4zjgZZeoTriSN2LqW9C+otjar12DQIWA4LuSfFrvFJiKp4oD0xIk1vumDLw8K9ur4NBw==",
"dev": true
},
"cssesc": {
@ -10453,9 +10524,9 @@
"dev": true
},
"events-to-async": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/events-to-async/-/events-to-async-2.0.0.tgz",
"integrity": "sha512-NiZEr4g51nI4/lz/6NdwMqK/TLIctlnp9TQ3wCJjlRp47VgrthUZE4nrk2UhfZ8VzoQ/Xyth+G6MKioLCt0FVA=="
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/events-to-async/-/events-to-async-2.0.1.tgz",
"integrity": "sha512-RtnLYrMbXp4JkZIoZu+3VTqV21bNVBlJBZ4NmtwvMNqSE3qouhxv2gvLE4JJDaQc54ioPkrX74V6x+hp/hqjkQ=="
},
"fast-blurhash": {
"version": "1.1.2",
@ -11188,12 +11259,12 @@
}
},
"masto": {
"version": "6.3.3",
"resolved": "https://registry.npmjs.org/masto/-/masto-6.3.3.tgz",
"integrity": "sha512-hmDsiscImeZfpkS+5oEWk3w5mkbxERFKN/UpuaoKZpVWWoGWCNnO7iPfQHygs/phP7PQqS6pVHlE5ylqSylf6A==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/masto/-/masto-6.4.2.tgz",
"integrity": "sha512-aIxhsTkl0pc755Sf9NMuglY0VM9HuU/tec60e4oLsyyzAkE7ELwufh6anErO84n8g3eWu/u0LGucTujIiDaAhg==",
"requires": {
"change-case": "^4.1.2",
"events-to-async": "^2.0.0",
"events-to-async": "^2.0.1",
"isomorphic-ws": "^5.0.0",
"ts-custom-error": "^3.3.1",
"ws": "^8.13.0"
@ -11620,12 +11691,12 @@
}
},
"postcss-preset-env": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-9.2.0.tgz",
"integrity": "sha512-Lnr4C5gb7t5Cc8akQMJzNdJkqw7s7s7BHUaQSgsuf+CTY9Lsz5lqQTft5yNZr59JyCLz0aFNSAqSLm/xRtcTpg==",
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-9.3.0.tgz",
"integrity": "sha512-ycw6doPrqV6QxDCtgiyGDef61bEfiSc59HGM4gOw/wxQxmKnhuEery61oOC/5ViENz/ycpRsuhTexs1kUBTvVw==",
"dev": true,
"requires": {
"@csstools/postcss-cascade-layers": "^4.0.0",
"@csstools/postcss-cascade-layers": "^4.0.1",
"@csstools/postcss-color-function": "^3.0.7",
"@csstools/postcss-color-mix-function": "^2.0.7",
"@csstools/postcss-exponential-functions": "^1.0.1",
@ -11637,6 +11708,8 @@
"@csstools/postcss-initial": "^1.0.0",
"@csstools/postcss-is-pseudo-class": "^4.0.3",
"@csstools/postcss-logical-float-and-clear": "^2.0.0",
"@csstools/postcss-logical-overflow": "^1.0.0",
"@csstools/postcss-logical-overscroll-behavior": "^1.0.0",
"@csstools/postcss-logical-resize": "^2.0.0",
"@csstools/postcss-logical-viewport-units": "^2.0.3",
"@csstools/postcss-media-minmax": "^1.1.0",
@ -11656,7 +11729,7 @@
"css-blank-pseudo": "^6.0.0",
"css-has-pseudo": "^6.0.0",
"css-prefers-color-scheme": "^9.0.0",
"cssdb": "^7.8.0",
"cssdb": "^7.9.0",
"postcss-attribute-case-insensitive": "^6.0.2",
"postcss-clamp": "^4.1.0",
"postcss-color-functional-notation": "^6.0.2",
@ -11727,9 +11800,9 @@
"dev": true
},
"preact": {
"version": "10.18.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.18.1.tgz",
"integrity": "sha512-mKUD7RRkQQM6s7Rkmi7IFkoEHjuFqRQUaXamO61E6Nn7vqF/bo7EZCmSyrUnp2UWHw0O7XjZ2eeXis+m7tf4lg=="
"version": "10.18.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.18.2.tgz",
"integrity": "sha512-X/K43vocUHDg0XhWVmTTMbec4LT/iBMh+csCEqJk+pJqegaXsvjdqN80ZZ3L+93azWCnWCZ+WGwYb8SplxeNjA=="
},
"prettier": {
"version": "2.8.0",
@ -12507,9 +12580,9 @@
"requires": {}
},
"vite-plugin-pwa": {
"version": "0.16.5",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.16.5.tgz",
"integrity": "sha512-Ahol4dwhMP2UHPQXkllSlXbihOaDFnvBIDPmAxoSZ1EObBUJGP4CMRyCyAVkIHjd6/H+//vH0DM2ON+XxHr81g==",
"version": "0.16.6",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.16.6.tgz",
"integrity": "sha512-bQPDOWvhPMwydMoWqohXvIzvrq4X8iuCF+q95qEiaM4yC0ybViGKWMnWcpWp0vcnoLk7QvxHDlK65KUZvqB3Sg==",
"dev": true,
"requires": {
"debug": "^4.3.4",

View file

@ -17,6 +17,7 @@
"@justinribeiro/lite-youtube": "~1.5.0",
"@szhsin/react-menu": "~4.1.0",
"@uidotdev/usehooks": "~2.4.1",
"compare-versions": "~6.1.0",
"dayjs": "~1.11.10",
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
@ -24,11 +25,11 @@
"idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0",
"lz-string": "~1.5.0",
"masto": "~6.3.3",
"masto": "~6.4.2",
"moize": "~6.1.6",
"p-retry": "~6.1.0",
"p-throttle": "~5.1.0",
"preact": "~10.18.1",
"preact": "~10.18.2",
"react-hotkeys-hook": "~4.4.1",
"react-intersection-observer": "~9.5.2",
"react-quick-pinch-zoom": "~5.0.0",
@ -47,12 +48,12 @@
"@trivago/prettier-plugin-sort-imports": "~4.2.1",
"postcss": "~8.4.31",
"postcss-dark-theme-class": "~1.0.0",
"postcss-preset-env": "~9.2.0",
"postcss-preset-env": "~9.3.0",
"twitter-text": "~3.1.0",
"vite": "~4.5.0",
"vite-plugin-generate-file": "~0.0.4",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.16.5",
"vite-plugin-pwa": "~0.16.6",
"vite-plugin-remove-console": "~2.1.1",
"workbox-cacheable-response": "~7.0.0",
"workbox-expiration": "~7.0.0",

View file

@ -56,6 +56,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
overscroll-behavior: contain;
scroll-behavior: smooth;
background-color: var(--bg-color);
flex-grow: 1;
/* This `transform` fixes carousel blocking vertical scrolling for pointer devices on iPad */
transform: translateZ(0);
}
@ -79,6 +80,21 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
scroll-padding-top: 3em;
}
:is(#home-page, #welcome, #columns, #loader-root) ~ .deck-container {
z-index: 10;
position: fixed;
inset: 0;
}
:is(#home-page, #welcome, #columns, #loader-root):has(~ .deck-container) {
display: block;
position: absolute;
user-select: none;
pointer-events: none;
opacity: 0;
/* This causes scrollTop to be reset to 0 when the page is hidden */
/* content-visibility: hidden; */
}
.deck {
min-height: 100vh;
min-height: 100dvh;
@ -110,6 +126,16 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
user-select: none;
transition: transform 0.5s ease-in-out;
user-select: none;
.header-double-lines {
font-size: 90% !important;
cursor: pointer;
div {
font-weight: normal;
color: var(--text-insignificant-color);
}
}
}
.deck > header[hidden] {
display: block;
@ -209,6 +235,64 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
.timeline {
margin: 0 auto;
padding: 0;
&.timeline-media {
--grid-gap: 8px;
display: grid;
grid-template-columns: 1fr;
grid-auto-rows: fit-content;
gap: var(--grid-gap);
padding: var(--grid-gap);
&:not(#columns &) {
background-color: var(--bg-faded-color);
}
@media (min-width: 320px) {
grid-template-columns: 1fr 1fr;
}
@media (min-width: 40em) {
&:not(#columns &) {
--grid-gap: 16px;
grid-template-columns: 1fr 1fr 1fr;
@media (min-width: 40em) {
width: 95vw;
max-width: calc(320px * 3.3);
transform: translateX(calc(-50% + var(--main-width) / 2));
}
}
#columns & {
padding-inline: 0;
}
}
li {
padding: 0 !important;
margin: 0 !important;
border: 0 !important;
overflow: visible !important;
background-color: transparent !important;
box-shadow: none !important;
}
@supports (grid-template-rows: masonry) {
grid-template-rows: masonry;
masonry-auto-flow: pack;
.media-post a {
aspect-ratio: revert !important;
video,
img,
audio {
min-height: 88px; /* for extreme dimensions */
}
}
}
}
}
.timeline.grow {
/* min-height: 100vh;
@ -275,10 +359,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
box-shadow: inset 0 -3px var(--comment-line-color),
inset 0 3px var(--comment-line-color);
overscroll-behavior-x: contain;
.status-link {
width: fit-content;
}
touch-action: pan-x;
}
.timeline.contextual .replies[data-comments-level='4'] {
overflow-x: auto;
@ -471,7 +552,6 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
border-radius: 8px;
cursor: pointer;
text-transform: uppercase;
font-weight: 500;
font-size: 12px;
color: var(--text-insignificant-color);
user-select: none;
@ -479,6 +559,11 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
position: relative;
list-style: none;
white-space: nowrap;
b {
font-weight: 500;
color: var(--text-color);
}
}
.timeline.contextual > li .replies > .replies-summary::-webkit-details-marker {
display: none;
@ -488,6 +573,10 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
}
.timeline.contextual > li .replies > .replies-summary .avatars {
margin-right: 8px;
> *:not(:first-child) {
margin: 0 0 0 -4px;
}
}
.timeline.contextual > li .replies > .replies-summary:active,
.timeline.contextual > li .replies[open] > .replies-summary {
@ -928,10 +1017,13 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
.deck-backdrop .deck .status {
max-width: var(--main-width);
}
.deck-backdrop .deck .menu-switch-view {
.deck-backdrop .deck :is(.button-switch-view, .menu-switch-view) {
display: none;
}
@media (min-width: 40em) {
.deck-backdrop .deck .button-switch-view {
display: inline-block;
}
.deck-backdrop .deck .menu-switch-view {
display: flex;
}
@ -1218,6 +1310,9 @@ button.carousel-dot:is(.active, [disabled].active) .icon {
body:has(.status-deck) .media-post-link {
display: none;
}
.media-modal-count-1 .button-label {
display: inline;
}
/* ✨ New */
body:has(.media-modal-container + .status-deck) .media-post-link {
@ -1675,6 +1770,24 @@ body > .szh-menu-container {
opacity: 1;
}
.szh-menu
.szh-menu__item--type-checkbox:not(.szh-menu__item--disabled):not(
.szh-menu__item--hover
) {
.icon {
opacity: 0.15;
}
&.szh-menu__item--checked {
color: var(--link-color);
.icon {
opacity: 1;
color: inherit;
}
}
}
.szh-menu .menu-wrap {
display: flex;
flex-wrap: wrap;
@ -1843,25 +1956,6 @@ meter.donut[hidden] {
}
}
.deck-container {
width: 100%;
flex-grow: 1;
}
:is(#home-page, #welcome, #columns, #loader-root) ~ .deck-container {
z-index: 10;
position: fixed;
inset: 0;
}
:is(#home-page, #welcome, #columns, #loader-root):has(~ .deck-container) {
display: block;
position: absolute;
user-select: none;
pointer-events: none;
opacity: 0;
/* This causes scrollTop to be reset to 0 when the page is hidden */
/* content-visibility: hidden; */
}
/* 404 */
#not-found-page {
@ -1882,13 +1976,23 @@ meter.donut[hidden] {
/* ACCOUNT STATUSES */
.header-account {
font-size: 90% !important;
cursor: pointer;
@keyframes peekaboo-header {
from {
opacity: 0;
transform: translateY(10%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.header-account div {
font-weight: normal;
color: var(--text-insignificant-color);
@supports (animation-timeline: scroll()) {
.header-account {
animation: peekaboo-header 1s linear both;
animation-timeline: scroll();
animation-range: 0 150px;
}
}
/* LINK LISTS? */
@ -1917,6 +2021,21 @@ ul.link-list li a {
display: flex;
align-items: center;
gap: 8px;
.count {
font-size: 80%;
display: inline-block;
color: var(--text-insignificant-color);
min-width: 16px;
min-height: 16px;
padding: 4px;
background-color: var(--bg-color);
border-radius: 4px;
@media (min-width: 40em) {
background-color: var(--bg-faded-color);
}
}
}
ul.link-list li:first-child a {
border-top-left-radius: var(--radius);
@ -2009,13 +2128,18 @@ ul.link-list li a .icon {
#columns .header-grid input {
pointer-events: none;
}
#columns
.header-grid
.header-side:first-of-type
:is(button, .button)
~ :is(button, .button),
#columns .deck-container:not(:first-of-type) .header-grid .header-side > * {
display: none;
#columns {
/* Any buttons except nav menu button on first header-side, on 1st column */
.deck-container:first-of-type
.header-grid
.header-side:first-of-type
> *:not(.nav-menu-button),
/* Any buttons on last header-side, on 1st column */
.deck-container:first-of-type .header-grid .header-side:last-of-type > *,
/* Any buttons on any header-side, on columns after 1st */
.deck-container:not(:first-of-type) .header-grid .header-side > * {
display: none;
}
}
@media (min-width: 40em) {
#columns {

View file

@ -10,7 +10,7 @@ import {
} from 'preact/hooks';
import { matchPath, Route, Routes, useLocation } from 'react-router-dom';
import 'swiped-events';
import { subscribe, useSnapshot } from 'valtio';
import { subscribe } from 'valtio';
import BackgroundService from './components/background-service';
import ComposeButton from './components/compose-button';
@ -49,12 +49,70 @@ import {
} from './utils/api';
import { getAccessToken } from './utils/auth';
import focusDeck from './utils/focus-deck';
import states, { initStates } from './utils/states';
import states, { initStates, statusKey } from './utils/states';
import store from './utils/store';
import { getCurrentAccount } from './utils/store-utils';
import './utils/toast-alert';
window.__STATES__ = states;
window.__STATES_STATS__ = () => {
const keys = [
'statuses',
'accounts',
'spoilers',
'unfurledLinks',
'statusQuotes',
];
const counts = {};
keys.forEach((key) => {
counts[key] = Object.keys(states[key]).length;
});
console.warn('STATE stats', counts);
const { statuses } = states;
const unmountedPosts = [];
for (const key in statuses) {
const $post = document.querySelector(`[data-state-post-id="${key}"]`);
if (!$post) {
unmountedPosts.push(key);
}
}
console.warn('Unmounted posts', unmountedPosts.length, unmountedPosts);
};
// Experimental "garbage collection" for states
// Every 15 minutes
// Only posts for now
setInterval(() => {
if (!window.__IDLE__) return;
const { statuses, unfurledLinks, notifications } = states;
let keysCount = 0;
const { instance } = api();
for (const key in statuses) {
try {
const $post = document.querySelector(`[data-state-post-id~="${key}"]`);
const postInNotifications = notifications.some(
(n) => key === statusKey(n.status?.id, instance),
);
if (!$post && !postInNotifications) {
delete states.statuses[key];
delete states.statusQuotes[key];
for (const link in unfurledLinks) {
const unfurled = unfurledLinks[link];
const sKey = statusKey(unfurled.id, unfurled.instance);
if (sKey === key) {
delete states.unfurledLinks[link];
break;
}
}
keysCount++;
}
} catch (e) {}
}
if (keysCount) {
console.info(`GC: Removed ${keysCount} keys`);
}
}, 15 * 60 * 1000);
// Preload icons
// There's probably a better way to do this
@ -70,7 +128,7 @@ setTimeout(() => {
}, 5000);
(() => {
window.__IDLE__ = false;
window.__IDLE__ = true;
const nonIdleEvents = [
'mousemove',
'mousedown',
@ -81,13 +139,14 @@ setTimeout(() => {
'pointermove',
'wheel',
];
const IDLE_TIME = 5_000; // 5 seconds
const setIdle = debounce(() => {
const setIdle = () => {
window.__IDLE__ = true;
}, IDLE_TIME);
};
const IDLE_TIME = 3_000; // 3 seconds
const debouncedSetIdle = debounce(setIdle, IDLE_TIME);
const onNonIdle = () => {
window.__IDLE__ = false;
setIdle();
debouncedSetIdle();
};
nonIdleEvents.forEach((event) => {
window.addEventListener(event, onNonIdle, {
@ -95,6 +154,21 @@ setTimeout(() => {
capture: true,
});
});
window.addEventListener('blur', setIdle, {
passive: true,
});
// When cursor leaves the window, set idle
document.documentElement.addEventListener(
'mouseleave',
(e) => {
if (!e.relatedTarget && !e.toElement) {
setIdle();
}
},
{
passive: true,
},
);
// document.addEventListener(
// 'visibilitychange',
// () => {

View file

@ -11,7 +11,7 @@ body.cloak,
.status .content-compact,
.account-container :is(header, main > *:not(.actions)),
.account-container :is(header, main > *:not(.actions)) *,
.header-account,
.header-double-lines,
.account-block {
text-decoration-thickness: 1.1em;
text-decoration-line: line-through;
@ -25,6 +25,7 @@ body.cloak,
}
.status :is(img, video, audio),
.media-post .media,
.avatar,
.emoji,
.header-banner {

View file

@ -82,7 +82,7 @@ function AccountBlock({
}}
>
<Avatar url={avatar} size={avatarSize} squircle={bot} />
<span>
<span class="account-block-content">
{!hideDisplayName && (
<>
{displayName ? (

View file

@ -18,7 +18,7 @@
position: absolute;
top: 8px;
inset-inline: 8px;
z-index: 2;
z-index: 3;
border: 1px solid var(--outline-color);
box-shadow: 0 8px 16px var(--drop-shadow-color);
border-radius: calc(16px - 8px);
@ -47,7 +47,7 @@
~ * {
/* pointer-events: none; */
filter: grayscale(0.75) brightness(0.75);
filter: grayscale(0.75) opacity(0.75);
}
}
@ -254,6 +254,7 @@
.account-container .note {
font-size: 95%;
line-height: 1.4;
text-wrap: pretty;
}
.account-container .note:not(:has(p)):not(:empty) {
/* Some notes don't have <p> tags, so we need to add some padding */
@ -408,6 +409,7 @@
.timeline-start .account-container {
border-bottom: 1px solid var(--outline-color);
position: relative;
}
.timeline-start .account-container header {
padding: 16px 16px 1px;
@ -424,18 +426,46 @@
display: none;
}
@keyframes bye-banner {
20% {
filter: blur(0) opacity(1);
}
100% {
filter: blur(16px) opacity(0.2);
}
}
@keyframes surface-header {
0% {
border-bottom-color: transparent;
box-shadow: none;
}
100% {
border-bottom-color: var(--outline-color);
box-shadow: 0 8px 16px -8px var(--drop-shadow-color);
}
}
@keyframes shrink-avatar {
0% {
width: 64px;
height: 64px;
}
100% {
width: 2.5em;
height: 2.5em;
}
}
.sheet .account-container {
border-radius: 16px 16px 0 0;
overflow-x: hidden;
max-height: 75vh;
overscroll-behavior: none;
scroll-timeline: --account-scroll;
header {
padding-bottom: 16px;
position: sticky;
top: 0;
z-index: 2;
/* --bg-color: red; */
background-image: linear-gradient(
to bottom,
transparent 30%,
@ -443,6 +473,14 @@
var(--bg-color) calc(100% - 8px),
transparent
);
.account-block-content {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
line-clamp: 3;
-webkit-line-clamp: 3;
}
}
.faux-header-bg {
@ -455,6 +493,32 @@
margin-top: calc(-1 * var(--banner-overlap));
}
@supports (animation-timeline: scroll()) {
.header-banner:not(.header-is-avatar):not(:hover):not(:active) {
animation: bye-banner 1s linear both;
animation-timeline: view();
animation-range: contain 100% cover 100%;
}
header {
background-image: linear-gradient(
to bottom,
transparent 30%,
var(--bg-color) var(--banner-overlap)
);
border-bottom: 1px solid transparent;
animation: surface-header 1s linear both;
animation-timeline: --account-scroll;
animation-range: 0 150px;
}
header .avatar {
animation: shrink-avatar 1s linear both;
animation-timeline: --account-scroll;
animation-range: 0 150px;
}
}
main {
margin-top: -8px;
padding-top: 1px;
@ -612,43 +676,6 @@
}
}
@keyframes shine {
0% {
left: -100%;
}
100% {
left: 100%;
}
}
.timeline-start .account-container {
position: relative;
overflow: hidden;
}
.timeline-start .account-container:before {
content: '';
position: absolute;
z-index: 2;
width: 100%;
height: 100%;
background-image: linear-gradient(
100deg,
rgba(255, 255, 255, 0) 30%,
rgba(255, 255, 255, 0.25),
rgba(255, 255, 255, 0) 70%
);
top: 0;
left: -100%;
pointer-events: none;
}
@media (prefers-color-scheme: dark) {
.timeline-start .account-container:before {
opacity: 0.25;
}
}
.timeline-start .account-container:hover:before {
animation: shine 1s ease-in-out 1s;
}
#list-add-remove-container .list-add-remove {
display: flex;
flex-direction: column;

View file

@ -126,19 +126,14 @@ function AccountInfo({
const { masto } = api({
instance,
});
const { masto: currentMasto } = api();
const { masto: currentMasto, instance: currentInstance } = api();
const [uiState, setUIState] = useState('default');
const isString = typeof account === 'string';
const [info, setInfo] = useState(isString ? null : account);
const isSelf = useMemo(
() => account.id === store.session.get('currentAccount'),
[account?.id],
);
const sameCurrentInstance = useMemo(
() => instance === api().instance,
[instance],
() => instance === currentInstance,
[instance, currentInstance],
);
useEffect(() => {
@ -198,6 +193,37 @@ function AccountInfo({
}
}
const isSelf = useMemo(
() => id === store.session.get('currentAccount'),
[id],
);
useEffect(() => {
const infoHasEssentials = !!(
info?.id &&
info?.username &&
info?.acct &&
info?.avatar &&
info?.avatarStatic &&
info?.displayName &&
info?.url
);
if (isSelf && instance && infoHasEssentials) {
const accounts = store.local.getJSON('accounts');
let updated = false;
accounts.forEach((account) => {
if (account.info.id === info.id && account.instanceURL === instance) {
account.info = info;
updated = true;
}
});
if (updated) {
console.log('Updated account info', info);
store.local.setJSON('accounts', accounts);
}
}
}, [isSelf, info, instance]);
const accountInstance = useMemo(() => {
if (!url) return null;
const domain = new URL(url).hostname;
@ -304,12 +330,13 @@ function AccountInfo({
({ relationship, currentID }) => {
if (!relationship.following) {
renderFamiliarFollowers(currentID);
if (!standalone) {
if (!standalone && statusesCount > 0) {
// Only render posting stats if not standalone and has posts
renderPostingStats();
}
}
},
[standalone, id],
[standalone, id, statusesCount],
);
return (
@ -534,7 +561,7 @@ function AccountInfo({
class="note"
dir="auto"
onClick={handleContentLinks({
instance,
instance: currentInstance,
})}
dangerouslySetInnerHTML={{
__html: enhanceContent(note, { emojis }),

View file

@ -8,24 +8,31 @@
box-shadow: 0 0 0 1px var(--bg-blur-color);
flex-shrink: 0;
vertical-align: middle;
}
.avatar.has-alpha {
border-radius: 0;
}
.avatar:not(.has-alpha).squircle {
border-radius: 25%;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
background-color: var(--img-bg-color);
contain: none;
}
&.has-alpha {
border-radius: 0;
background-color: transparent;
box-shadow: none;
.avatar[data-loaded],
.avatar[data-loaded] img {
box-shadow: none;
background-color: transparent;
img {
background-color: transparent;
}
}
&:not(.has-alpha).squircle {
border-radius: 25%;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
background-color: var(--img-bg-color);
contain: none;
}
&[data-loaded],
&[data-loaded] img {
box-shadow: none;
background-color: transparent;
}
}

View file

@ -1,29 +1,32 @@
import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useDebouncedCallback } from 'use-debounce';
import { useHotkeys } from 'react-hotkeys-hook';
import { api } from '../utils/api';
import showToast from '../utils/show-toast';
import states, { saveStatus } from '../utils/states';
import useInterval from '../utils/useInterval';
import usePageVisibility from '../utils/usePageVisibility';
const STREAMING_TIMEOUT = 1000 * 3; // 3 seconds
const POLL_INTERVAL = 15_000; // 15 seconds
export default memo(function BackgroundService({ isLoggedIn }) {
// Notifications service
// - WebSocket to receive notifications when page is visible
const [visible, setVisible] = useState(true);
usePageVisibility(setVisible);
const subRef = useRef();
const debouncedStartNotifications = useDebouncedCallback(() => {
const { masto, streaming, instance } = api();
(async () => {
// 1. Get the latest notification
if (states.notificationsLast) {
const notificationsIterator = masto.v1.notifications.list({
limit: 1,
since_id: states.notificationsLast.id,
});
const { value: notifications } = await notificationsIterator.next();
if (notifications?.length) {
const checkLatestNotification = async (masto, instance, skipCheckMarkers) => {
if (states.notificationsLast) {
const notificationsIterator = masto.v1.notifications.list({
limit: 1,
sinceId: states.notificationsLast.id,
});
const { value: notifications } = await notificationsIterator.next();
if (notifications?.length) {
if (skipCheckMarkers) {
states.notificationsShowNew = true;
} else {
let lastReadId;
try {
const markers = await masto.v1.markers.fetch({
@ -38,36 +41,60 @@ export default memo(function BackgroundService({ isLoggedIn }) {
}
}
}
}
};
// 2. Start streaming
if (streaming) {
let sub = (subRef.current = streaming.user.notification.subscribe());
console.log('🎏 Streaming notification', sub);
for await (const entry of sub) {
if (!sub) break;
console.log('🔔🔔 Notification entry', entry);
if (entry.event === 'notification') {
console.log('🔔🔔 Notification', entry);
saveStatus(entry.payload, instance, {
skipThreading: true,
});
}
states.notificationsShowNew = true;
}
}
})();
}, 3000);
useEffect(() => {
// let sub;
let sub;
let pollNotifications;
if (isLoggedIn && visible) {
debouncedStartNotifications();
const { masto, streaming, instance } = api();
(async () => {
// 1. Get the latest notification
await checkLatestNotification(masto, instance);
let hasStreaming = false;
// 2. Start streaming
if (streaming) {
pollNotifications = setTimeout(() => {
(async () => {
try {
hasStreaming = true;
sub = streaming.user.notification.subscribe();
console.log('🎏 Streaming notification', sub);
for await (const entry of sub) {
if (!sub) break;
if (!visible) break;
console.log('🔔🔔 Notification entry', entry);
if (entry.event === 'notification') {
console.log('🔔🔔 Notification', entry);
saveStatus(entry.payload, instance, {
skipThreading: true,
});
}
states.notificationsShowNew = true;
}
} catch (e) {
hasStreaming = false;
console.error(e);
}
if (!hasStreaming) {
console.log('🎏 Streaming failed, fallback to polling');
pollNotifications = setInterval(() => {
checkLatestNotification(masto, instance, true);
}, POLL_INTERVAL);
}
})();
}, STREAMING_TIMEOUT);
}
})();
}
return () => {
// sub?.unsubscribe?.();
// sub = null;
debouncedStartNotifications?.cancel?.();
subRef.current?.unsubscribe?.();
subRef.current = null;
sub?.unsubscribe?.();
sub = null;
clearTimeout(pollNotifications);
clearInterval(pollNotifications);
};
}, [visible, isLoggedIn]);
@ -100,5 +127,14 @@ export default memo(function BackgroundService({ isLoggedIn }) {
}
});
// Global keyboard shortcuts "service"
useHotkeys('shift+alt+k', () => {
const currentCloakMode = states.settings.cloakMode;
states.settings.cloakMode = !currentCloakMode;
showToast({
text: `Cloak mode ${currentCloakMode ? 'disabled' : 'enabled'}`,
});
});
return null;
});

View file

@ -49,7 +49,24 @@ function Columns() {
}
});
return <div id="columns">{components}</div>;
return (
<div
id="columns"
onContextMenu={(e) => {
// If right-click on header, but not links or buttons
if (
e.target.closest('.deck > header') &&
!e.target.closest('a') &&
!e.target.closest('button')
) {
e.preventDefault();
states.showShortcutsSettings = true;
}
}}
>
{components}
</div>
);
}
export default Columns;

View file

@ -11,7 +11,6 @@ export default function ComposeButton() {
const newWin = openCompose();
if (!newWin) {
alert('Looks like your browser is blocking popups.');
states.showCompose = true;
}
} else {

View file

@ -40,28 +40,6 @@
border-radius: 9999px;
}
#compose-container textarea {
width: 100%;
max-width: 100%;
height: 5em;
min-height: 5em;
max-height: 50vh;
resize: vertical;
line-height: 1.4;
border-color: transparent;
}
#compose-container textarea:hover {
border-color: var(--divider-color);
}
@media (min-width: 40em) {
#compose-container textarea {
/* font-size: 150%;
font-size: calc(100% + 50% / var(--text-weight)); */
max-height: 65vh;
}
}
@keyframes appear-up {
0% {
opacity: 0;
@ -129,19 +107,53 @@
}
#compose-container form {
border-radius: 16px;
padding: 4px 12px;
--form-padding-inline: 12px;
--form-padding-block: 8px;
/* border-radius: 16px; */
padding: var(--form-padding-block) var(--form-padding-inline);
background-color: var(--bg-blur-color);
/* background-image: linear-gradient(var(--bg-color) 85%, transparent); */
position: relative;
z-index: 2;
--drop-shadow: 0 3px 6px -3px var(--drop-shadow-color);
box-shadow: var(--drop-shadow);
@media (min-width: 40em) {
border-radius: 16px;
}
}
#compose-container .status-preview ~ form {
box-shadow: var(--drop-shadow), 0 -3px 6px -3px var(--drop-shadow-color);
}
#compose-container textarea {
width: 100%;
max-width: 100%;
height: 5em;
min-height: 5em;
max-height: 50vh;
resize: vertical;
line-height: 1.4;
border-color: transparent;
&.compose-field {
@media (width < 30em) {
margin-inline: calc(-1 * var(--form-padding-inline));
width: 100vw !important;
max-width: 100vw;
border-radius: 0;
border: 0;
}
@media (min-width: 40em) {
max-height: 65vh;
}
}
}
#compose-container textarea:hover {
border-color: var(--divider-color);
}
#compose-container .toolbar {
display: flex;
justify-content: space-between;
@ -269,6 +281,15 @@
gap: 8px;
align-items: center;
font-size: 90%;
.grow {
flex-grow: 1;
}
.count {
font-size: 80%;
opacity: 0.5;
}
}
#compose-container .text-expander-menu li b img {
/* The shortcode emojis */

View file

@ -17,6 +17,7 @@ import db from '../utils/db';
import emojifyText from '../utils/emojify-text';
import localeMatch from '../utils/locale-match';
import openCompose from '../utils/open-compose';
import shortenNumber from '../utils/shorten-number';
import states, { saveStatus } from '../utils/states';
import store from '../utils/store';
import {
@ -521,6 +522,34 @@ function Compose({
const [showEmoji2Picker, setShowEmoji2Picker] = useState(false);
const [topSupportedLanguages, restSupportedLanguages] = useMemo(() => {
const topLanguages = [];
const restLanguages = [];
const { contentTranslationHideLanguages = [] } = states.settings;
supportedLanguages.forEach((l) => {
const [code] = l;
if (
code === language ||
code === prevLanguage.current ||
code === DEFAULT_LANG ||
contentTranslationHideLanguages.includes(code)
) {
topLanguages.push(l);
} else {
restLanguages.push(l);
}
});
topLanguages.sort(([codeA, commonA], [codeB, commonB]) => {
if (codeA === language) return -1;
if (codeB === language) return 1;
return commonA.localeCompare(commonB);
});
restLanguages.sort(([codeA, commonA], [codeB, commonB]) =>
commonA.localeCompare(commonB),
);
return [topLanguages, restLanguages];
}, [language]);
return (
<div id="compose-container-outer">
<div id="compose-container" class={standalone ? 'standalone' : ''}>
@ -578,7 +607,6 @@ function Compose({
});
if (!newWin) {
alert('Looks like your browser is blocking popups.');
return;
}
@ -1125,32 +1153,17 @@ function Compose({
}}
disabled={uiState === 'loading'}
>
{supportedLanguages
.sort(([codeA, commonA], [codeB, commonB]) => {
const { contentTranslationHideLanguages = [] } =
states.settings;
// Sort codes that same as language, prevLanguage, DEFAULT_LANGUAGE and all the ones in states.settings.contentTranslationHideLanguages, to the top
if (
codeA === language ||
codeA === prevLanguage ||
codeA === DEFAULT_LANG ||
contentTranslationHideLanguages?.includes(codeA)
)
return -1;
if (
codeB === language ||
codeB === prevLanguage ||
codeB === DEFAULT_LANG ||
contentTranslationHideLanguages?.includes(codeB)
)
return 1;
return commonA.localeCompare(commonB);
})
.map(([code, common, native]) => (
<option value={code}>
{common} ({native})
</option>
))}
{topSupportedLanguages.map(([code, common, native]) => (
<option value={code} key={code}>
{common} ({native})
</option>
))}
<hr />
{restSupportedLanguages.map(([code, common, native]) => (
<option value={code} key={code}>
{common} ({native})
</option>
))}
</select>
</label>{' '}
<button
@ -1306,6 +1319,7 @@ const Textarea = forwardRef((props, ref) => {
username,
acct,
emojis,
history,
} = result;
const displayNameWithEmoji = emojifyText(displayName, emojis);
// const item = menuItem.cloneNode();
@ -1324,9 +1338,18 @@ const Textarea = forwardRef((props, ref) => {
</li>
`;
} else {
const total = history?.reduce?.(
(acc, cur) => acc + +cur.uses,
0,
);
html += `
<li role="option" data-value="${encodeHTML(name)}">
<span>#<b>${encodeHTML(name)}</b></span>
<span class="grow">#<b>${encodeHTML(name)}</b></span>
${
total
? `<span class="count">${shortenNumber(total)}</span>`
: ''
}
</li>
`;
}
@ -1393,6 +1416,7 @@ const Textarea = forwardRef((props, ref) => {
return (
<text-expander ref={textExpanderRef} keys="@ # :">
<textarea
class="compose-field"
autoCapitalize="sentences"
autoComplete="on"
autoCorrect="on"

View file

@ -1,6 +1,6 @@
import './generic-accounts.css';
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useRef, useState } from 'preact/hooks';
import { InView } from 'react-intersection-observer';
import { useSnapshot } from 'valtio';
@ -56,15 +56,18 @@ export default function GenericAccounts({ onClose = () => {} }) {
})();
};
const firstLoad = useRef(true);
useEffect(() => {
if (staticAccounts?.length > 0) {
setAccounts(staticAccounts);
} else {
loadAccounts(true);
firstLoad.current = false;
}
}, [staticAccounts, fetchAccounts]);
useEffect(() => {
if (firstLoad.current) return;
// reloadGenericAccounts contains value like {id: 'mute', counter: 1}
// We only need to reload if the id matches
if (snapStates.reloadGenericAccounts?.id === id) {

View file

@ -102,6 +102,7 @@ export const ICONS = {
keyboard: () => import('@iconify-icons/mingcute/keyboard-line'),
cloud: () => import('@iconify-icons/mingcute/cloud-line'),
month: () => import('@iconify-icons/mingcute/calendar-month-line'),
media: () => import('@iconify-icons/mingcute/photo-album-line'),
};
function Icon({

View file

@ -117,6 +117,15 @@ export default memo(function KeyboardShortcutsHelp() {
action: 'Compose new post',
keys: <kbd>c</kbd>,
},
{
action: 'Compose new post (new window)',
className: 'insignificant',
keys: (
<>
<kbd>Shift</kbd> + <kbd>c</kbd>
</>
),
},
{
action: 'Send post',
keys: (
@ -134,6 +143,15 @@ export default memo(function KeyboardShortcutsHelp() {
action: 'Reply',
keys: <kbd>r</kbd>,
},
{
action: 'Reply (new window)',
className: 'insignificant',
keys: (
<>
<kbd>Shift</kbd> + <kbd>r</kbd>
</>
),
},
{
action: 'Like (favourite)',
keys: (
@ -154,9 +172,17 @@ export default memo(function KeyboardShortcutsHelp() {
action: 'Bookmark',
keys: <kbd>d</kbd>,
},
].map(({ action, keys }) => (
{
action: 'Toggle Cloak mode',
keys: (
<>
<kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>k</kbd>
</>
),
},
].map(({ action, className, keys }) => (
<tr key={action}>
<th>{action}</th>
<th class={className}>{action}</th>
<td>{keys}</td>
</tr>
))}

View file

@ -57,6 +57,7 @@ export default function MediaAltModal({ alt, lang, onClose }) {
<p
style={{
whiteSpace: 'pre-wrap',
textWrap: 'pretty',
}}
>
{alt}

View file

@ -103,7 +103,9 @@ function MediaModal({
}, []);
return (
<div class="media-modal-container">
<div
class={`media-modal-container media-modal-count-${mediaAttachments?.length}`}
>
<div
ref={carouselRef}
tabIndex="0"
@ -142,7 +144,11 @@ function MediaModal({
key={media.id}
ref={i === currentIndex ? carouselFocusItem : null}
onClick={(e) => {
if (e.target !== e.currentTarget) {
// console.log(e);
// if (e.target !== e.currentTarget) {
// setShowControls(!showControls);
// }
if (!e.target.classList.contains('media')) {
setShowControls(!showControls);
}
}}
@ -248,7 +254,7 @@ function MediaModal({
// }
// }}
>
<span class="button-label">See post </span>&raquo;
<span class="button-label">View post </span>&raquo;
</Link>
</span>
</div>

View file

@ -0,0 +1,107 @@
.media-post {
--item-radius: 16px;
position: relative;
animation: appear-smooth 1s ease-out;
&:is(.filtered, .has-spoiler) :is(img, video) {
filter: blur(32px);
image-rendering: crisp-edges;
image-rendering: pixelated;
animation: none !important;
}
&.filtered[data-filtered-text]:before {
content: attr(data-filtered-text);
}
&.has-spoiler[data-spoiler-text]:before {
content: attr(data-spoiler-text);
}
&.filtered[data-filtered-text]:before,
&.has-spoiler[data-spoiler-text]:before {
pointer-events: none;
position: absolute;
top: 0;
left: 0;
z-index: 1;
background-color: var(--bg-blur-color);
margin: 8px;
padding: 4px 6px;
border-radius: calc(var(--item-radius) / 2);
font-size: 90%;
border: var(--hairline-width) dashed var(--bg-color);
word-break: break-word;
word-wrap: break-word;
overflow-wrap: break-word;
mix-blend-mode: luminosity;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
box-orient: vertical;
display: -webkit-box;
display: box;
overflow: hidden;
z-index: 2;
> * {
pointer-events: none;
}
}
.media {
border-radius: var(--item-radius);
overflow: hidden;
position: relative;
display: block;
aspect-ratio: 1 !important;
&:before {
position: absolute;
inset: 0;
content: '';
border: 1px solid var(--outline-color);
border-radius: inherit;
}
&:not(.media-audio) {
background-color: var(--average-color, var(--media-bg-color));
}
@media (hover: hover) {
&:hover {
--drop-shadow: var(--drop-shadow-color);
position: relative;
z-index: 1;
box-shadow: 0 8px 16px -4px var(--drop-shadow),
0 4px 8px var(--drop-shadow);
@media (prefers-color-scheme: dark) {
--drop-shadow: var(--link-color);
}
}
}
&:active:not(:has(button:active)) {
box-shadow: none;
filter: brightness(0.8);
transform: scale(0.99);
}
video,
img,
audio {
border-radius: 16px;
/* object-fit: scale-down; */
object-fit: cover;
width: 100%;
height: 100%;
vertical-align: top;
}
:not(.filtered, .has-spoiler) &:is(:hover, :focus) img {
/* Less delay here to make it feel more responsive */
animation: position-object 5s ease-in-out 0.1s 5;
animation-duration: var(--anim-duration, 5s);
}
}
}

View file

@ -0,0 +1,150 @@
import './media-post.css';
import { memo } from 'preact/compat';
import { useContext, useMemo } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import FilterContext from '../utils/filter-context';
import { isFiltered } from '../utils/filters';
import states, { statusKey } from '../utils/states';
import store from '../utils/store';
import Media from './media';
function MediaPost({
class: className,
statusID,
status,
instance,
parent,
// allowFilters,
onMediaClick,
}) {
let sKey = statusKey(statusID, instance);
const snapStates = useSnapshot(states);
if (!status) {
status = snapStates.statuses[sKey] || snapStates.statuses[statusID];
sKey = statusKey(status?.id, instance);
}
if (!status) {
return null;
}
const {
account: {
acct,
avatar,
avatarStatic,
id: accountId,
url: accountURL,
displayName,
username,
emojis: accountEmojis,
bot,
group,
},
id,
repliesCount,
reblogged,
reblogsCount,
favourited,
favouritesCount,
bookmarked,
poll,
muted,
sensitive,
spoilerText,
visibility, // public, unlisted, private, direct
language,
editedAt,
filtered,
card,
createdAt,
inReplyToId,
inReplyToAccountId,
content,
mentions,
mediaAttachments,
reblog,
uri,
url,
emojis,
// Non-API props
_deleted,
_pinned,
// _filtered,
} = status;
if (!mediaAttachments?.length) {
return null;
}
const debugHover = (e) => {
if (e.shiftKey) {
console.log({
...status,
});
}
};
const currentAccount = useMemo(() => {
return store.session.get('currentAccount');
}, []);
const isSelf = useMemo(() => {
return currentAccount && currentAccount === accountId;
}, [accountId, currentAccount]);
const filterContext = useContext(FilterContext);
const filterInfo = !isSelf && isFiltered(filtered, filterContext);
if (filterInfo?.action === 'hide') {
return null;
}
console.debug('RENDER Media post', id, status?.account.displayName);
// const readingExpandSpoilers = useMemo(() => {
// const prefs = store.account.get('preferences') || {};
// return !!prefs['reading:expand:spoilers'];
// }, []);
const hasSpoiler = spoilerText || sensitive;
const Parent = parent || 'div';
return mediaAttachments.map((media, i) => {
const mediaKey = `${sKey}-${media.id}`;
const filterTitleStr = filterInfo?.titlesStr;
return (
<Parent
data-state-post-id={sKey}
onMouseEnter={debugHover}
key={mediaKey}
data-spoiler-text={
spoilerText || (sensitive ? 'Sensitive media' : undefined)
}
data-filtered-text={
filterInfo
? `Filtered${filterTitleStr ? `: ${filterTitleStr}` : ''}`
: undefined
}
class={`
media-post
${filterInfo ? 'filtered' : ''}
${hasSpoiler ? 'has-spoiler' : ''}
`}
>
<Media
class={className}
media={media}
lang={language}
to={`/${instance}/s/${id}?media-only=${i + 1}`}
onClick={
onMediaClick ? (e) => onMediaClick(e, i, media, status) : undefined
}
/>
</Parent>
);
});
}
export default memo(MediaPost);

View file

@ -62,6 +62,7 @@ export const isMediaCaptionLong = mem((caption) =>
);
function Media({
class: className = '',
media,
to,
lang,
@ -170,6 +171,9 @@ function Media({
const maxAspectHeight =
window.innerHeight * (orientation === 'portrait' ? 0.45 : 0.33);
const maxHeight = orientation === 'portrait' ? 0 : 160;
const averageColorStyle = {
'--average-color': rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
};
const mediaStyles =
width && height
? {
@ -180,8 +184,11 @@ function Media({
(width / height) * Math.max(maxHeight, maxAspectHeight)
}px`,
aspectRatio: `${width} / ${height}`,
...averageColorStyle,
}
: {};
: {
...averageColorStyle,
};
const longDesc = isMediaCaptionLong(description);
const showInlineDesc =
@ -233,7 +240,7 @@ function Media({
<Figure>
<Parent
ref={parentRef}
class={`media media-image`}
class={`media media-image ${className}`}
onClick={onClick}
data-orientation={orientation}
data-has-alt={!showInlineDesc}
@ -244,6 +251,7 @@ function Media({
backgroundSize: imageSmallerThanParent
? `${width}px ${height}px`
: undefined,
...averageColorStyle,
}
: mediaStyles
}
@ -341,11 +349,13 @@ function Media({
return (
<Figure>
<Parent
class={`media media-${isGIF ? 'gif' : 'video'} ${
class={`media ${className} media-${isGIF ? 'gif' : 'video'} ${
autoGIFAnimate ? 'media-contain' : ''
}`}
data-orientation={orientation}
data-formatted-duration={formattedDuration}
data-formatted-duration={
!showOriginal ? formattedDuration : undefined
}
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
data-has-alt={!showInlineDesc}
// style={{
@ -448,8 +458,10 @@ function Media({
return (
<Figure>
<Parent
class="media media-audio"
data-formatted-duration={formattedDuration}
class={`media media-audio ${className}`}
data-formatted-duration={
!showOriginal ? formattedDuration : undefined
}
data-has-alt={!showInlineDesc}
onClick={onClick}
style={!showOriginal && mediaStyles}

View file

@ -35,8 +35,9 @@ function NavMenu(props) {
// User may choose pin or not to pin Following
// If user doesn't pin Following, we show it in the menu
const showFollowing =
(snapStates.settings.shortcutsColumnsMode ||
snapStates.settings.shortcutsViewMode === 'multi-column') &&
(snapStates.settings.shortcutsViewMode === 'multi-column' ||
(!snapStates.settings.shortcutsViewMode &&
snapStates.settings.shortcutsColumnsMode)) &&
!snapStates.shortcuts.find((pin) => pin.type === 'following');
const bindLongPress = useLongPress(

View file

@ -1,3 +1,4 @@
import { Fragment } from 'preact';
import { memo } from 'preact/compat';
import shortenNumber from '../utils/shorten-number';
@ -221,9 +222,10 @@ function Notification({ notification, instance, isStatic }) {
)}
{_accounts?.length > 1 && (
<p class="avatars-stack">
{_accounts.slice(0, AVATARS_LIMIT).map((account, i) => (
<>
{_accounts.slice(0, AVATARS_LIMIT).map((account) => (
<Fragment key={account.id}>
<a
key={account.id}
href={account.url}
rel="noopener noreferrer"
class="account-avatar-stack"
@ -261,7 +263,7 @@ function Notification({ notification, instance, isStatic }) {
</div>
)}
</a>{' '}
</>
</Fragment>
))}
<button
type="button"

View file

@ -86,7 +86,8 @@
transition: all 0.2s ease-out;
}
#shortcuts-settings-container .shortcuts-view-mode label.checked {
box-shadow: inset 0 0 0 3px var(--link-color);
box-shadow: inset 0 0 0 3px var(--link-color),
inset 0 0 32px var(--link-faded-color);
}
#shortcuts-settings-container
.shortcuts-view-mode

View file

@ -22,7 +22,7 @@ import Icon from './icon';
import MenuConfirm from './menu-confirm';
import Modal from './modal';
const SHORTCUTS_LIMIT = 9;
export const SHORTCUTS_LIMIT = 9;
const TYPES = [
'following',
@ -104,6 +104,11 @@ const TYPE_PARAMS = {
placeholder: 'e.g. PixelArt (Max 5, space-separated)',
pattern: '[^#]+',
},
{
text: 'Media only',
name: 'media',
type: 'checkbox',
},
{
text: 'Instance',
name: 'instance',
@ -113,6 +118,14 @@ const TYPE_PARAMS = {
},
],
};
const fetchListTitle = pmem(async ({ id }) => {
const list = await api().masto.v1.lists.$select(id).fetch();
return list.title;
});
const fetchAccountTitle = pmem(async ({ id }) => {
const account = await api().masto.v1.accounts.$select(id).fetch();
return account.username || account.acct || account.displayName;
});
export const SHORTCUTS_META = {
following: {
id: 'home',
@ -134,10 +147,7 @@ export const SHORTCUTS_META = {
},
list: {
id: 'list',
title: pmem(async ({ id }) => {
const list = await api().masto.v1.lists.$select(id).fetch();
return list.title;
}),
title: fetchListTitle,
path: ({ id }) => `/l/${id}`,
icon: 'list',
},
@ -163,10 +173,7 @@ export const SHORTCUTS_META = {
},
'account-statuses': {
id: 'account-statuses',
title: pmem(async ({ id }) => {
const account = await api().masto.v1.accounts.$select(id).fetch();
return account.username || account.acct || account.displayName;
}),
title: fetchAccountTitle,
path: ({ id }) => `/a/${id}`,
icon: 'user',
},
@ -186,51 +193,22 @@ export const SHORTCUTS_META = {
id: 'hashtag',
title: ({ hashtag }) => hashtag,
subtitle: ({ instance }) => instance || api().instance,
path: ({ hashtag, instance }) =>
`${instance ? `/${instance}` : ''}/t/${hashtag.split(/\s+/).join('+')}`,
path: ({ hashtag, instance, media }) =>
`${instance ? `/${instance}` : ''}/t/${hashtag.split(/\s+/).join('+')}${
media ? '?media=1' : ''
}`,
icon: 'hashtag',
},
};
function ShortcutsSettings({ onClose }) {
const snapStates = useSnapshot(states);
const { masto } = api();
const { shortcuts } = snapStates;
const [lists, setLists] = useState([]);
const [followedHashtags, setFollowedHashtags] = useState([]);
const [showForm, setShowForm] = useState(false);
const [showImportExport, setShowImportExport] = useState(false);
const [shortcutsListParent] = useAutoAnimate();
useEffect(() => {
(async () => {
try {
const lists = await masto.v1.lists.list();
lists.sort((a, b) => a.title.localeCompare(b.title));
setLists(lists);
} catch (e) {
console.error(e);
}
})();
(async () => {
try {
const iterator = masto.v1.followedTags.list();
const tags = [];
do {
const { value, done } = await iterator.next();
if (done || value?.length === 0) break;
tags.push(...value);
} while (true);
setFollowedHashtags(tags);
} catch (e) {
console.error(e);
}
})();
}, []);
return (
<div id="shortcuts-settings-container" class="sheet" tabindex="-1">
{!!onClose && (
@ -293,33 +271,6 @@ function ShortcutsSettings({ onClose }) {
);
})}
</div>
{/* <select
value={snapStates.settings.shortcutsViewMode || 'float-button'}
onChange={(e) => {
states.settings.shortcutsViewMode = e.target.value;
}}
>
<option value="float-button">Floating button</option>
<option value="multi-column">Multi-column</option>
<option value="tab-menu-bar">Tab/Menu bar </option>
</select> */}
{/* <p>
<details>
<summary class="insignificant">
Experimental Multi-column mode
</summary>
<label>
<input
type="checkbox"
checked={snapStates.settings.shortcutsColumnsMode}
onChange={(e) => {
states.settings.shortcutsColumnsMode = e.target.checked;
}}
/>{' '}
Show shortcuts in multiple columns instead of the floating button.
</label>
</details>
</p> */}
{shortcuts.length > 0 ? (
<ol class="shortcuts-list" ref={shortcutsListParent}>
{shortcuts.filter(Boolean).map((shortcut, i) => {
@ -474,8 +425,6 @@ function ShortcutsSettings({ onClose }) {
<ShortcutForm
shortcut={showForm.shortcut}
shortcutIndex={showForm.shortcutIndex}
lists={lists}
followedHashtags={followedHashtags}
onSubmit={({ result, mode }) => {
console.log('onSubmit', result);
if (mode === 'edit') {
@ -507,9 +456,27 @@ function ShortcutsSettings({ onClose }) {
);
}
const FETCH_MAX_AGE = 1000 * 60; // 1 minute
const fetchLists = pmem(
() => {
const { masto } = api();
return masto.v1.lists.list();
},
{
maxAge: FETCH_MAX_AGE,
},
);
const fetchFollowedHashtags = pmem(
() => {
const { masto } = api();
return masto.v1.followedTags.list();
},
{
maxAge: FETCH_MAX_AGE,
},
);
function ShortcutForm({
lists,
followedHashtags,
onSubmit,
disabled,
shortcut,
@ -520,6 +487,41 @@ function ShortcutForm({
const editMode = !!shortcut;
const [currentType, setCurrentType] = useState(shortcut?.type || null);
const [uiState, setUIState] = useState('default');
const [lists, setLists] = useState([]);
const [followedHashtags, setFollowedHashtags] = useState([]);
useEffect(() => {
(async () => {
if (currentType !== 'list') return;
try {
setUIState('loading');
const lists = await fetchLists();
lists.sort((a, b) => a.title.localeCompare(b.title));
setLists(lists);
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
(async () => {
if (currentType !== 'hashtag') return;
try {
const iterator = fetchFollowedHashtags();
const tags = [];
do {
const { value, done } = await iterator.next();
if (done || value?.length === 0) break;
tags.push(...value);
} while (true);
setFollowedHashtags(tags);
} catch (e) {
console.error(e);
}
})();
}, [currentType]);
const formRef = useRef();
useEffect(() => {
if (editMode && currentType && TYPE_PARAMS[currentType]) {
@ -608,7 +610,8 @@ function ShortcutForm({
<select
name="id"
required={!notRequired}
disabled={disabled}
disabled={disabled || uiState === 'loading'}
defaultValue={editMode ? shortcut.id : undefined}
>
{lists.map((list) => (
<option value={list.id}>{list.title}</option>
@ -653,7 +656,11 @@ function ShortcutForm({
},
)}
<footer>
<button type="submit" class="block" disabled={disabled}>
<button
type="submit"
class="block"
disabled={disabled || uiState === 'loading'}
>
{editMode ? 'Save' : 'Add'}
</button>
{editMode && (

View file

@ -152,7 +152,7 @@ shortcuts .tab-bar[hidden] {
}
}
@media (min-width: 40em) {
@media (min-width: 40em) and (hover: hover) {
#app[data-shortcuts-view-mode='tab-menu-bar'] .timeline-deck {
margin-top: 44px;
}

View file

@ -25,8 +25,8 @@ function Shortcuts() {
return null;
}
if (
settings.shortcutsColumnsMode ||
settings.shortcutsViewMode === 'multi-column'
settings.shortcutsViewMode === 'multi-column' ||
(!settings.shortcutsViewMode && settings.shortcutsColumnsMode)
) {
return null;
}
@ -90,7 +90,13 @@ function Shortcuts() {
return (
<div id="shortcuts">
{snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? (
<nav class="tab-bar">
<nav
class="tab-bar"
onContextMenu={(e) => {
e.preventDefault();
states.showShortcutsSettings = true;
}}
>
<ul>
{formattedShortcuts.map(
({ id, path, title, subtitle, icon }, i) => {
@ -146,6 +152,10 @@ function Shortcuts() {
type="button"
id="shortcuts-button"
class="plain"
onContextMenu={(e) => {
e.preventDefault();
states.showShortcutsSettings = true;
}}
onTransitionStart={(e) => {
// Close menu if the button disappears
try {

View file

@ -603,6 +603,7 @@
margin-block: min(0.75em, 12px);
white-space: pre-wrap;
tab-size: 2;
text-wrap: pretty;
}
.status .content p:first-child {
margin-block-start: 0;
@ -726,6 +727,7 @@
white-space: pre-line;
flex-basis: 15em;
flex-grow: 1;
text-wrap: pretty;
}
}
@ -846,7 +848,7 @@
object-fit: cover;
vertical-align: middle;
}
.status .media {
:is(.status, .media-post) .media {
cursor: pointer;
&[data-has-alt] {
@ -885,7 +887,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
position: relative;
background-clip: padding-box;
}
.status :is(.media-video, .media-audio) .media-play {
:is(.status, .media-post) :is(.media-video, .media-audio) .media-play {
pointer-events: none;
width: 44px;
height: 44px;
@ -902,10 +904,13 @@ body:has(#modal-container .carousel) .status .media img:hover {
border-radius: 70px;
transition: transform 0.2s ease-in-out;
}
.status :is(.media-video, .media-audio):hover:not(:active) .media-play {
:is(.status, .media-post)
:is(.media-video, .media-audio):hover:not(:active)
.media-play {
transform: translate(-50%, -50%) scale(1.1);
}
.status :is(.media-video, .media-audio)[data-formatted-duration]:after {
:is(.status, .media-post)
:is(.media-video, .media-audio)[data-formatted-duration]:after {
font-size: 12px;
pointer-events: none;
content: attr(data-formatted-duration);
@ -918,10 +923,10 @@ body:has(#modal-container .carousel) .status .media img:hover {
border-radius: 4px;
padding: 0 4px;
}
.status .media-audio[data-formatted-duration]:after {
:is(.status, .media-post) .media-audio[data-formatted-duration]:after {
content: '♬ ' attr(data-formatted-duration);
}
.status .media-gif[data-label]:not(:hover):after {
:is(.status, .media-post) .media-gif[data-label]:not(:hover):after {
font-size: 12px;
font-weight: bold;
pointer-events: none;
@ -953,12 +958,13 @@ body:has(#modal-container .carousel) .status .media img:hover {
.status .media-audio audio {
width: 100%;
} */
.status .media-audio {
:is(.status, .media-post) .media-audio {
width: 100%;
height: 100%;
min-height: 88px;
background-image: radial-gradient(
circle at center center,
var(--bg-color),
transparent,
var(--bg-faded-color)
),
repeating-radial-gradient(
@ -1078,7 +1084,6 @@ body:has(#modal-container .carousel) .status .media img:hover {
font-size: 90%;
z-index: 1;
text-shadow: 0 var(--hairline-width) var(--bg-color);
mix-blend-mode: luminosity;
white-space: pre-line;
&:is(:hover, :focus) {
@ -1202,6 +1207,7 @@ a:focus-visible .card img {
box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
text-wrap: balance;
}
.card .meta {
font-size: smaller;

View file

@ -13,6 +13,7 @@ import pThrottle from 'p-throttle';
import { memo } from 'preact/compat';
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
@ -34,6 +35,8 @@ import Poll from '../components/poll';
import { api } from '../utils/api';
import emojifyText from '../utils/emojify-text';
import enhanceContent from '../utils/enhance-content';
import FilterContext from '../utils/filter-context';
import { isFiltered } from '../utils/filters';
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import getHTMLText from '../utils/getHTMLText';
import handleContentLinks from '../utils/handle-content-links';
@ -41,6 +44,7 @@ import htmlContentLength from '../utils/html-content-length';
import isMastodonLinkMaybe from '../utils/isMastodonLinkMaybe';
import localeMatch from '../utils/locale-match';
import niceDateTime from '../utils/nice-date-time';
import openCompose from '../utils/open-compose';
import pmem from '../utils/pmem';
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
import shortenNumber from '../utils/shorten-number';
@ -78,6 +82,10 @@ const visibilityText = {
direct: 'Private mention',
};
const isIOS =
window.ontouchstart !== undefined &&
/iPad|iPhone|iPod/.test(navigator.userAgent);
function Status({
statusID,
status,
@ -90,7 +98,7 @@ function Status({
enableTranslate,
forceTranslate: _forceTranslate,
previewMode,
allowFilters,
// allowFilters,
onMediaClick,
quoted,
onStatusLinkClick = () => {},
@ -166,9 +174,24 @@ function Status({
// Non-API props
_deleted,
_pinned,
_filtered,
// _filtered,
} = status;
const currentAccount = useMemo(() => {
return store.session.get('currentAccount');
}, []);
const isSelf = useMemo(() => {
return currentAccount && currentAccount === accountId;
}, [accountId, currentAccount]);
const filterContext = useContext(FilterContext);
const filterInfo =
!isSelf && !readOnly && !previewMode && isFiltered(filtered, filterContext);
if (filterInfo?.action === 'hide') {
return null;
}
console.debug('RENDER Status', id, status?.account.displayName, quoted);
const debugHover = (e) => {
@ -179,11 +202,11 @@ function Status({
}
};
if (allowFilters && size !== 'l' && _filtered) {
if (/*allowFilters && */ size !== 'l' && filterInfo) {
return (
<FilteredStatus
status={status}
filterInfo={_filtered}
filterInfo={filterInfo}
instance={instance}
containerProps={{
onMouseEnter: debugHover,
@ -195,13 +218,6 @@ function Status({
const createdAtDate = new Date(createdAt);
const editedAtDate = new Date(editedAt);
const currentAccount = useMemo(() => {
return store.session.get('currentAccount');
}, []);
const isSelf = useMemo(() => {
return currentAccount && currentAccount === accountId;
}, [accountId, currentAccount]);
let inReplyToAccountRef = mentions?.find(
(mention) => mention.id === inReplyToAccountId,
);
@ -238,7 +254,11 @@ function Status({
if (group) {
return (
<div class="status-group" onMouseEnter={debugHover}>
<div
data-state-post-id={sKey}
class="status-group"
onMouseEnter={debugHover}
>
<div class="status-pre-meta">
<Icon icon="group" size="l" alt="Group" />{' '}
<NameText account={status.account} instance={instance} showAvatar />
@ -249,13 +269,18 @@ function Status({
instance={instance}
size={size}
contentTextWeight={contentTextWeight}
readOnly={readOnly}
/>
</div>
);
}
return (
<div class="status-reblog" onMouseEnter={debugHover}>
<div
data-state-post-id={sKey}
class="status-reblog"
onMouseEnter={debugHover}
>
<div class="status-pre-meta">
<Icon icon="rocket" size="l" />{' '}
<NameText account={status.account} instance={instance} showAvatar />{' '}
@ -267,6 +292,7 @@ function Status({
instance={instance}
size={size}
contentTextWeight={contentTextWeight}
readOnly={readOnly}
/>
</div>
);
@ -348,10 +374,17 @@ function Status({
canBoost = true;
}
const replyStatus = () => {
const replyStatus = (e) => {
if (!sameInstance || !authenticated) {
return alert(unauthInteractionErrorMessage);
}
// syntheticEvent comes from MenuItem
if (e?.shiftKey || e?.syntheticEvent?.shiftKey) {
const newWin = openCompose({
replyToStatus: status,
});
if (newWin) return;
}
states.showCompose = {
replyToStatus: status,
};
@ -795,13 +828,13 @@ function Status({
const contextMenuRef = useRef();
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const [contextMenuProps, setContextMenuProps] = useState({});
const isIOS =
window.ontouchstart !== undefined &&
/iPad|iPhone|iPod/.test(navigator.userAgent);
const showContextMenu = !isSizeLarge && !previewMode && !_deleted && !quoted;
// Only iOS/iPadOS browsers don't support contextmenu
// Some comments report iPadOS might support contextmenu if a mouse is connected
const bindLongPressContext = useLongPress(
isIOS
isIOS && showContextMenu
? (e) => {
if (e.pointerType === 'mouse') return;
// There's 'pen' too, but not sure if contextmenu event would trigger from a pen
@ -829,10 +862,8 @@ function Status({
},
);
const showContextMenu = size !== 'l' && !previewMode && !_deleted && !quoted;
const hotkeysEnabled = !readOnly && !previewMode;
const rRef = useHotkeys('r', replyStatus, {
const rRef = useHotkeys('r, shift+r', replyStatus, {
enabled: hotkeysEnabled,
});
const fRef = useHotkeys(
@ -960,6 +991,7 @@ function Status({
return (
<article
data-state-post-id={sKey}
ref={(node) => {
statusRef.current = node;
// Use parent node if it's in focus
@ -1091,6 +1123,15 @@ function Status({
<Link
to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
onClick={(e) => {
if (
e.metaKey ||
e.ctrlKey ||
e.shiftKey ||
e.altKey ||
e.which === 2
) {
return;
}
e.preventDefault();
e.stopPropagation();
onStatusLinkClick?.(e, status);
@ -1457,6 +1498,7 @@ function Status({
{' '}
&bull; <Icon icon="pencil" alt="Edited" />{' '}
<time
tabIndex="0"
class="edited"
datetime={editedAtDate.toISOString()}
onClick={() => {
@ -1576,10 +1618,11 @@ function Status({
</div>
{!!showEdited && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowEdited(false);
statusRef.current?.focus();
// statusRef.current?.focus();
}
}}
>
@ -1691,7 +1734,9 @@ function Card({ card, instance }) {
if (snapStates.unfurledLinks[url]) return null;
if (hasText && (image || (type === 'photo' && blurhash))) {
const domain = new URL(url).hostname.replace(/^www\./, '');
const domain = new URL(url).hostname
.replace(/^www\./, '')
.replace(/\/$/, '');
let blurhashImage;
if (!image) {
const w = 44;
@ -1737,7 +1782,10 @@ function Card({ card, instance }) {
{title}
</p>
<p class="meta" dir="auto">
{description || providerName || authorName}
{description ||
(!!publishedAt && (
<RelativeTime datetime={publishedAt} format="micro" />
))}
</p>
</div>
</a>
@ -2120,10 +2168,24 @@ function _unfurlMastodonLink(instance, url) {
let remoteInstanceFetch;
let theURL = url;
if (/\/\/elk\.[^\/]+\/[^.]+\.[^.]+/i.test(theURL)) {
// E.g. https://elk.zone/domain.com/@stest/123 -> https://domain.com/@stest/123
// https://elk.zone/domain.com/@stest/123 -> https://domain.com/@stest/123
if (/\/\/elk\.[^\/]+\/[^\/]+\.[^\/]+/i.test(theURL)) {
theURL = theURL.replace(/elk\.[^\/]+\//i, '');
}
// https://trunks.social/status/domain.com/@stest/123 -> https://domain.com/@stest/123
if (/\/\/trunks\.[^\/]+\/status\/[^\/]+\.[^\/]+/i.test(theURL)) {
theURL = theURL.replace(/trunks\.[^\/]+\/status\//i, '');
}
// https://phanpy.social/#/domain.com/s/123 -> https://domain.com/statuses/123
if (/\/#\/[^\/]+\.[^\/]+\/s\/.+/i.test(theURL)) {
const urlAfterHash = theURL.split('/#/')[1];
const finalURL = urlAfterHash.replace(/\/s\//i, '/@fakeUsername/');
theURL = `https://${finalURL}`;
}
const urlObj = new URL(theURL);
const domain = urlObj.hostname;
const path = urlObj.pathname;
@ -2151,7 +2213,7 @@ function _unfurlMastodonLink(instance, url) {
const { masto } = api({ instance });
const mastoSearchFetch = masto.v2.search
.fetch({
q: url,
q: theURL,
type: 'statuses',
resolve: true,
limit: 1,
@ -2224,6 +2286,7 @@ const unfurlMastodonLink = throttle(_unfurlMastodonLink);
function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
const {
id: statusID,
account: { avatar, avatarStatic, bot, group },
createdAt,
visibility,
@ -2248,6 +2311,15 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
);
const statusPeekRef = useTruncated();
const sKey =
statusKey(status.id, instance) +
' ' +
(statusKey(reblog?.id, instance) || '');
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
return (
<div
@ -2260,7 +2332,7 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
}}
{...bindLongPressPeek()}
>
<article class="status filtered" tabindex="-1">
<article data-state-post-id={sKey} class="status filtered" tabindex="-1">
<b
class="status-filtered-badge clickable badge-meta"
title={filterTitleStr}
@ -2324,7 +2396,7 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
<Link
ref={statusPeekRef}
class="status-link"
to={`/${instance}/s/${status.id}`}
to={url}
onClick={() => {
setShowPeek(false);
}}

View file

@ -4,6 +4,8 @@ import { InView } from 'react-intersection-observer';
import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio';
import FilterContext from '../utils/filter-context';
import { isFiltered } from '../utils/filters';
import states, { statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek';
import { groupBoosts, groupContext } from '../utils/timeline-utils';
@ -13,6 +15,7 @@ import useScroll from '../utils/useScroll';
import Icon from './icon';
import Link from './link';
import MediaPost from './media-post';
import NavMenu from './nav-menu';
import Status from './status';
@ -33,12 +36,14 @@ function Timeline({
boostsCarousel,
fetchItems = () => {},
checkForUpdates = () => {},
checkForUpdatesInterval = 60_000, // 1 minute
checkForUpdatesInterval = 15_000, // 15 seconds
headerStart,
headerEnd,
timelineStart,
allowFilters,
// allowFilters,
refresh,
view,
filterContext,
}) {
const snapStates = useSnapshot(states);
const [items, setItems] = useState([]);
@ -50,6 +55,7 @@ function Timeline({
console.debug('RENDER Timeline', id, refresh);
const allowGrouping = view !== 'media';
const loadItems = useDebouncedCallback(
(firstLoad) => {
setShowNew(false);
@ -59,10 +65,12 @@ function Timeline({
try {
let { done, value } = await fetchItems(firstLoad);
if (Array.isArray(value)) {
if (boostsCarousel) {
value = groupBoosts(value);
if (allowGrouping) {
if (boostsCarousel) {
value = groupBoosts(value);
}
value = groupContext(value);
}
value = groupContext(value);
console.log(value);
if (firstLoad) {
setItems(value);
@ -210,25 +218,38 @@ function Timeline({
}
}, [nearReachEnd, showMore]);
const prevView = useRef(view);
useEffect(() => {
if (prevView.current !== view) {
prevView.current = view;
setItems([]);
}
}, [view]);
const loadOrCheckUpdates = useCallback(
async ({ disableIdleCheck = false } = {}) => {
console.log('✨ Load or check updates', {
const noPointers = scrollableRef.current
? getComputedStyle(scrollableRef.current).pointerEvents === 'none'
: false;
console.log('✨ Load or check updates', id, {
autoRefresh: snapStates.settings.autoRefresh,
scrollTop: scrollableRef.current.scrollTop,
disableIdleCheck,
idle: window.__IDLE__,
inBackground: inBackground(),
noPointers,
});
if (
snapStates.settings.autoRefresh &&
scrollableRef.current.scrollTop === 0 &&
scrollableRef.current.scrollTop < 16 &&
(disableIdleCheck || window.__IDLE__) &&
!inBackground()
!inBackground() &&
!noPointers
) {
console.log('✨ Load updates', snapStates.settings.autoRefresh);
console.log('✨ Load updates', id, snapStates.settings.autoRefresh);
loadItems(true);
} else {
console.log('✨ Check updates', snapStates.settings.autoRefresh);
console.log('✨ Check updates', id, snapStates.settings.autoRefresh);
const hasUpdate = await checkForUpdates();
if (hasUpdate) {
console.log('✨ Has new updates', id);
@ -238,10 +259,6 @@ function Timeline({
},
[id, loadItems, checkForUpdates, snapStates.settings.autoRefresh],
);
const debouncedLoadOrCheckUpdates = useDebouncedCallback(
loadOrCheckUpdates,
3000,
);
const lastHiddenTime = useRef();
usePageVisibility(
@ -249,14 +266,12 @@ function Timeline({
if (visible) {
const timeDiff = Date.now() - lastHiddenTime.current;
if (!lastHiddenTime.current || timeDiff > 1000 * 60) {
// 1 minute
debouncedLoadOrCheckUpdates({
loadOrCheckUpdates({
disableIdleCheck: true,
});
}
} else {
lastHiddenTime.current = Date.now();
debouncedLoadOrCheckUpdates.cancel();
}
setVisible(visible);
},
@ -272,293 +287,334 @@ function Timeline({
const hiddenUI = scrollDirection === 'end' && !nearReachStart;
return (
<div
id={`${id}-page`}
class="deck-container"
ref={(node) => {
scrollableRef.current = node;
jRef.current = node;
kRef.current = node;
oRef.current = node;
}}
tabIndex="-1"
>
<div class="timeline-deck deck">
<header
hidden={hiddenUI}
onClick={(e) => {
if (!e.target.closest('a, button')) {
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}
}}
onDblClick={(e) => {
if (!e.target.closest('a, button')) {
loadItems(true);
}
}}
class={uiState === 'loading' ? 'loading' : ''}
>
<div class="header-grid">
<div class="header-side">
<NavMenu />
{headerStart !== null && headerStart !== undefined ? (
headerStart
) : (
<Link to="/" class="button plain home-button">
<Icon icon="home" size="l" />
</Link>
)}
</div>
{title && (titleComponent ? titleComponent : <h1>{title}</h1>)}
<div class="header-side">
{/* <Loader hidden={uiState !== 'loading'} /> */}
{!!headerEnd && headerEnd}
</div>
</div>
{items.length > 0 &&
uiState !== 'loading' &&
!hiddenUI &&
showNew && (
<button
class="updates-button shiny-pill"
type="button"
onClick={() => {
loadItems(true);
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}}
>
<Icon icon="arrow-up" /> New posts
</button>
)}
</header>
{!!timelineStart && (
<div
class={`timeline-start ${uiState === 'loading' ? 'loading' : ''}`}
<FilterContext.Provider value={filterContext}>
<div
id={`${id}-page`}
class="deck-container"
ref={(node) => {
scrollableRef.current = node;
jRef.current = node;
kRef.current = node;
oRef.current = node;
}}
tabIndex="-1"
>
<div class="timeline-deck deck">
<header
hidden={hiddenUI}
onClick={(e) => {
if (!e.target.closest('a, button')) {
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}
}}
onDblClick={(e) => {
if (!e.target.closest('a, button')) {
loadItems(true);
}
}}
class={uiState === 'loading' ? 'loading' : ''}
>
{timelineStart}
</div>
)}
{!!items.length ? (
<>
<ul class="timeline">
{items.map((status) => {
const { id: statusID, reblog, items, type, _pinned } = status;
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
let title = '';
if (type === 'boosts') {
title = `${items.length} Boosts`;
} else if (type === 'pinned') {
title = 'Pinned posts';
}
const isCarousel = type === 'boosts' || type === 'pinned';
if (items) {
if (isCarousel) {
// Here, we don't hide filtered posts, but we sort them last
items.sort((a, b) => {
if (a._filtered && !b._filtered) {
return 1;
}
if (!a._filtered && b._filtered) {
return -1;
}
return 0;
<div class="header-grid">
<div class="header-side">
<NavMenu />
{headerStart !== null && headerStart !== undefined ? (
headerStart
) : (
<Link to="/" class="button plain home-button">
<Icon icon="home" size="l" />
</Link>
)}
</div>
{title && (titleComponent ? titleComponent : <h1>{title}</h1>)}
<div class="header-side">
{/* <Loader hidden={uiState !== 'loading'} /> */}
{!!headerEnd && headerEnd}
</div>
</div>
{items.length > 0 &&
uiState !== 'loading' &&
!hiddenUI &&
showNew && (
<button
class="updates-button shiny-pill"
type="button"
onClick={() => {
loadItems(true);
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
return (
<li
key={`timeline-${statusID}`}
class="timeline-item-carousel"
>
<StatusCarousel
title={title}
class={`${type}-carousel`}
>
{items.map((item) => {
const { id: statusID, reblog } = item;
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
return (
<li key={statusID}>
<Link
class="status-carousel-link timeline-item-alt"
to={url}
>
{useItemID ? (
<Status
statusID={statusID}
instance={instance}
size="s"
contentTextWeight
allowFilters={allowFilters}
/>
) : (
<Status
status={item}
instance={instance}
size="s"
contentTextWeight
allowFilters={allowFilters}
/>
)}
</Link>
</li>
);
})}
</StatusCarousel>
</li>
);
}
const manyItems = items.length > 3;
return items.map((item, i) => {
const { id: statusID, _differentAuthor } = item;
const url = instance
? `/${instance}/s/${statusID}`
: `/s/${statusID}`;
const isMiddle = i > 0 && i < items.length - 1;
const isSpoiler = item.sensitive && !!item.spoilerText;
const showCompact =
(!_differentAuthor && isSpoiler && i > 0) ||
(manyItems &&
isMiddle &&
(type === 'thread' ||
(type === 'conversation' &&
!_differentAuthor &&
!items[i - 1]._differentAuthor &&
!items[i + 1]._differentAuthor)));
return (
<li
key={`timeline-${statusID}`}
class={`timeline-item-container timeline-item-container-type-${type} timeline-item-container-${
i === 0
? 'start'
: i === items.length - 1
? 'end'
: 'middle'
} ${
_differentAuthor ? 'timeline-item-diff-author' : ''
}`}
>
<Link class="status-link timeline-item" to={url}>
{showCompact ? (
<TimelineStatusCompact
status={item}
instance={instance}
/>
) : useItemID ? (
<Status
statusID={statusID}
instance={instance}
allowFilters={allowFilters}
/>
) : (
<Status
status={item}
instance={instance}
allowFilters={allowFilters}
/>
)}
</Link>
</li>
);
});
}
return (
<li key={`timeline-${statusID + _pinned}`}>
<Link class="status-link timeline-item" to={url}>
{useItemID ? (
<Status
statusID={statusID}
instance={instance}
allowFilters={allowFilters}
/>
) : (
<Status
status={status}
instance={instance}
allowFilters={allowFilters}
/>
)}
</Link>
</li>
);
})}
{showMore && uiState === 'loading' && (
<>
<li
style={{
height: '20vh',
}}
>
<Status skeleton />
</li>
<li
style={{
height: '25vh',
}}
>
<Status skeleton />
</li>
</>
)}
</ul>
{uiState === 'default' &&
(showMore ? (
<InView
onChange={(inView) => {
if (inView) {
loadItems();
}
}}
>
<button
type="button"
class="plain block"
onClick={() => loadItems()}
style={{ marginBlockEnd: '6em' }}
>
Show more&hellip;
</button>
</InView>
) : (
<p class="ui-state insignificant">The end.</p>
))}
</>
) : uiState === 'loading' ? (
<ul class="timeline">
{Array.from({ length: 5 }).map((_, i) => (
<li key={i}>
<Status skeleton />
</li>
))}
</ul>
) : (
uiState !== 'error' && <p class="ui-state">{emptyText}</p>
)}
{uiState === 'error' && (
<p class="ui-state">
{errorText}
<br />
<br />
<button
class="button plain"
onClick={() => loadItems(!items.length)}
<Icon icon="arrow-up" /> New posts
</button>
)}
</header>
{!!timelineStart && (
<div
class={`timeline-start ${uiState === 'loading' ? 'loading' : ''}`}
>
Try again
</button>
</p>
)}
{timelineStart}
</div>
)}
{!!items.length ? (
<>
<ul class={`timeline ${view ? `timeline-${view}` : ''}`}>
{items.map((status) => (
<TimelineItem
status={status}
instance={instance}
useItemID={useItemID}
// allowFilters={allowFilters}
filterContext={filterContext}
key={status.id + status?._pinned}
view={view}
/>
))}
{showMore &&
uiState === 'loading' &&
(view === 'media' ? null : (
<>
<li
style={{
height: '20vh',
}}
>
<Status skeleton />
</li>
<li
style={{
height: '25vh',
}}
>
<Status skeleton />
</li>
</>
))}
</ul>
{uiState === 'default' &&
(showMore ? (
<InView
onChange={(inView) => {
if (inView) {
loadItems();
}
}}
>
<button
type="button"
class="plain block"
onClick={() => loadItems()}
style={{ marginBlockEnd: '6em' }}
>
Show more&hellip;
</button>
</InView>
) : (
<p class="ui-state insignificant">The end.</p>
))}
</>
) : uiState === 'loading' ? (
<ul class="timeline">
{Array.from({ length: 5 }).map((_, i) =>
view === 'media' ? (
<div
style={{
height: '50vh',
}}
/>
) : (
<li key={i}>
<Status skeleton />
</li>
),
)}
</ul>
) : (
uiState !== 'error' && <p class="ui-state">{emptyText}</p>
)}
{uiState === 'error' && (
<p class="ui-state">
{errorText}
<br />
<br />
<button type="button" onClick={() => loadItems(!items.length)}>
Try again
</button>
</p>
)}
</div>
</div>
</div>
</FilterContext.Provider>
);
}
function TimelineItem({
status,
instance,
useItemID,
// allowFilters,
filterContext,
view,
}) {
const { id: statusID, reblog, items, type, _pinned } = status;
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
let title = '';
if (type === 'boosts') {
title = `${items.length} Boosts`;
} else if (type === 'pinned') {
title = 'Pinned posts';
}
const isCarousel = type === 'boosts' || type === 'pinned';
if (items) {
if (isCarousel) {
// Here, we don't hide filtered posts, but we sort them last
items.sort((a, b) => {
// if (a._filtered && !b._filtered) {
// return 1;
// }
// if (!a._filtered && b._filtered) {
// return -1;
// }
const aFiltered = isFiltered(a.filtered, filterContext);
const bFiltered = isFiltered(b.filtered, filterContext);
if (aFiltered && !bFiltered) {
return 1;
}
if (!aFiltered && bFiltered) {
return -1;
}
return 0;
});
return (
<li key={`timeline-${statusID}`} class="timeline-item-carousel">
<StatusCarousel title={title} class={`${type}-carousel`}>
{items.map((item) => {
const { id: statusID, reblog } = item;
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
return (
<li key={statusID}>
<Link class="status-carousel-link timeline-item-alt" to={url}>
{useItemID ? (
<Status
statusID={statusID}
instance={instance}
size="s"
contentTextWeight
// allowFilters={allowFilters}
/>
) : (
<Status
status={item}
instance={instance}
size="s"
contentTextWeight
// allowFilters={allowFilters}
/>
)}
</Link>
</li>
);
})}
</StatusCarousel>
</li>
);
}
const manyItems = items.length > 3;
return items.map((item, i) => {
const { id: statusID, _differentAuthor } = item;
const url = instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`;
const isMiddle = i > 0 && i < items.length - 1;
const isSpoiler = item.sensitive && !!item.spoilerText;
const showCompact =
(!_differentAuthor && isSpoiler && i > 0) ||
(manyItems &&
isMiddle &&
(type === 'thread' ||
(type === 'conversation' &&
!_differentAuthor &&
!items[i - 1]._differentAuthor &&
!items[i + 1]._differentAuthor)));
return (
<li
key={`timeline-${statusID}`}
class={`timeline-item-container timeline-item-container-type-${type} timeline-item-container-${
i === 0 ? 'start' : i === items.length - 1 ? 'end' : 'middle'
} ${_differentAuthor ? 'timeline-item-diff-author' : ''}`}
>
<Link class="status-link timeline-item" to={url}>
{showCompact ? (
<TimelineStatusCompact status={item} instance={instance} />
) : useItemID ? (
<Status
statusID={statusID}
instance={instance}
// allowFilters={allowFilters}
/>
) : (
<Status
status={item}
instance={instance}
// allowFilters={allowFilters}
/>
)}
</Link>
</li>
);
});
}
const itemKey = `timeline-${statusID + _pinned}`;
if (view === 'media') {
return useItemID ? (
<MediaPost
class="timeline-item"
parent="li"
key={itemKey}
statusID={statusID}
instance={instance}
// allowFilters={allowFilters}
/>
) : (
<MediaPost
class="timeline-item"
parent="li"
key={itemKey}
status={status}
instance={instance}
// allowFilters={allowFilters}
/>
);
}
return (
<li key={itemKey}>
<Link class="status-link timeline-item" to={url}>
{useItemID ? (
<Status
statusID={statusID}
instance={instance}
// allowFilters={allowFilters}
/>
) : (
<Status
status={status}
instance={instance}
// allowFilters={allowFilters}
/>
)}
</Link>
</li>
);
}

View file

@ -83,6 +83,7 @@
.status-translation-block .translated-block output {
display: block;
margin-top: 0.75em;
text-wrap: pretty;
}
.status-translation-block
.translated-block

View file

@ -1,5 +1,11 @@
import { MenuItem } from '@szhsin/react-menu';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { useParams, useSearchParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
@ -51,6 +57,7 @@ function AccountStatuses() {
const tagged = searchParams.get('tagged');
const media = !!searchParams.get('media');
const { masto, instance, authenticated } = api({ instance: params.instance });
const { masto: currentMasto, instance: currentInstance } = api();
const accountStatusesIterator = useRef();
const allSearchParams = [month, excludeReplies, excludeBoosts, tagged, media];
@ -61,8 +68,8 @@ function AccountStatuses() {
}, allSearchParams);
const sameCurrentInstance = useMemo(
() => instance === api().instance,
[instance],
() => instance === currentInstance,
[instance, currentInstance],
);
const [searchEnabled, setSearchEnabled] = useState(false);
useEffect(() => {
@ -153,8 +160,8 @@ function AccountStatuses() {
.next();
if (pinnedStatuses?.length && !tagged && !media) {
pinnedStatuses.forEach((status) => {
status._pinned = true;
saveStatus(status, instance);
status._pinned = true;
});
if (pinnedStatuses.length >= 3) {
const pinnedStatusesIds = pinnedStatuses.map((status) => status.id);
@ -195,15 +202,41 @@ function AccountStatuses() {
const [featuredTags, setFeaturedTags] = useState([]);
useTitle(
`${account?.displayName ? account.displayName + ' ' : ''}@${
account?.acct ? account.acct : 'Account posts'
}`,
account?.acct
? `${account?.displayName ? account.displayName + ' ' : ''}@${
account.acct
}${
!excludeReplies
? ' (+ Replies)'
: excludeBoosts
? ' (- Boosts)'
: tagged
? ` (#${tagged})`
: media
? ' (Media)'
: month
? ` (${new Date(month).toLocaleString('default', {
month: 'long',
year: 'numeric',
})})`
: ''
}`
: 'Account posts',
'/:instance?/a/:id',
);
const fetchAccountPromiseRef = useRef();
const fetchAccount = useCallback(() => {
const fetchPromise =
fetchAccountPromiseRef.current || masto.v1.accounts.$select(id).fetch();
fetchAccountPromiseRef.current = fetchPromise;
return fetchPromise;
}, [id, masto]);
useEffect(() => {
(async () => {
try {
const acc = await masto.v1.accounts.$select(id).fetch();
const acc = await fetchAccount();
console.log(acc);
setAccount(acc);
} catch (e) {
@ -223,21 +256,34 @@ function AccountStatuses() {
const { displayName, acct, emojis } = account || {};
const accountInfoMemo = useMemo(() => {
const cachedAccount = snapStates.accounts[`${id}@${instance}`];
return (
<AccountInfo
instance={instance}
account={cachedAccount || id}
fetchAccount={fetchAccount}
authenticated={authenticated}
standalone
/>
);
}, [id, instance, authenticated, fetchAccount]);
const filterBarRef = useRef();
const TimelineStart = useMemo(() => {
const cachedAccount = snapStates.accounts[`${id}@${instance}`];
const filtered =
!excludeReplies || excludeBoosts || tagged || media || !!month;
return (
<>
<AccountInfo
instance={instance}
account={cachedAccount || id}
fetchAccount={() => masto.v1.accounts.$select(id).fetch()}
authenticated={authenticated}
standalone
/>
<div class="filter-bar" ref={filterBarRef}>
{accountInfoMemo}
<div
class="filter-bar"
ref={filterBarRef}
style={{
position: 'relative',
}}
>
{filtered ? (
<Link
to={`/${instance}/a/${id}`}
@ -328,6 +374,15 @@ function AccountStatuses() {
}
: {},
);
showToast(
`Showing posts in ${new Date(value).toLocaleString(
'default',
{
month: 'long',
year: 'numeric',
},
)}`,
);
}}
/>
</label>
@ -392,7 +447,7 @@ function AccountStatuses() {
title={`${account?.acct ? '@' + account.acct : 'Posts'}`}
titleComponent={
<h1
class="header-account"
class="header-double-lines header-account"
// onClick={() => {
// states.showAccount = {
// account,
@ -414,6 +469,7 @@ function AccountStatuses() {
errorText="Unable to load posts"
fetchItems={fetchAccountStatuses}
useItemID
view={media ? 'media' : undefined}
boostsCarousel={snapStates.settings.boostsCarousel}
timelineStart={TimelineStart}
refresh={[
@ -461,6 +517,29 @@ function AccountStatuses() {
Switch to account's instance (<b>{accountInstance}</b>)
</small>
</MenuItem>
{!sameCurrentInstance && (
<MenuItem
onClick={() => {
(async () => {
try {
const acc = await currentMasto.v1.accounts.lookup({
acct: account.acct + '@' + instance,
});
const { id } = acc;
location.hash = `/${currentInstance}/a/${id}`;
} catch (e) {
console.error(e);
alert('Unable to fetch account info');
}
})();
}}
>
<Icon icon="transfer" />{' '}
<small class="menu-double-lines">
Switch to my instance (<b>{currentInstance}</b>)
</small>
</MenuItem>
)}
</Menu2>
}
/>

View file

@ -32,7 +32,7 @@ function Following({ title, path, id, ...props }) {
console.log('First load', latestItem.current);
}
value = filteredItems(value, 'home');
// value = filteredItems(value, 'home');
value.forEach((item) => {
saveStatus(item, instance);
});
@ -115,7 +115,8 @@ function Following({ title, path, id, ...props }) {
useItemID
boostsCarousel={snapStates.settings.boostsCarousel}
{...props}
allowFilters
// allowFilters
filterContext="home"
/>
);
}

View file

@ -2,16 +2,19 @@ import {
FocusableItem,
MenuDivider,
MenuGroup,
MenuHeader,
MenuItem,
} from '@szhsin/react-menu';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import Icon from '../components/icon';
import Menu2 from '../components/menu2';
import MenuConfirm from '../components/menu-confirm';
import { SHORTCUTS_LIMIT } from '../components/shortcuts-settings';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import { filteredItems } from '../utils/filters';
import showToast from '../utils/show-toast';
import states from '../utils/states';
import { saveStatus } from '../utils/states';
@ -25,20 +28,30 @@ const LIMIT = 20;
const TAGS_LIMIT_PER_MODE = 4;
const TOTAL_TAGS_LIMIT = TAGS_LIMIT_PER_MODE + 1;
function Hashtags({ columnMode, ...props }) {
function Hashtags({ media: mediaView, columnMode, ...props }) {
// const navigate = useNavigate();
let { hashtag, ...params } = columnMode ? {} : useParams();
if (props.hashtag) hashtag = props.hashtag;
let hashtags = hashtag.trim().split(/[\s+]+/);
hashtags.sort();
hashtag = hashtags[0];
const [searchParams, setSearchParams] = useSearchParams();
const media = mediaView || !!searchParams.get('media');
const linkParams = media ? '?media=1' : '';
const { masto, instance, authenticated } = api({
instance: props?.instance || params.instance,
});
const { authenticated: currentAuthenticated } = api();
const {
masto: currentMasto,
instance: currentInstance,
authenticated: currentAuthenticated,
} = api();
const hashtagTitle = hashtags.map((t) => `#${t}`).join(' ');
const title = instance ? `${hashtagTitle} on ${instance}` : hashtagTitle;
const hashtagPostTitle = media ? ` (Media only)` : '';
const title = instance
? `${hashtagTitle}${hashtagPostTitle} on ${instance}`
: `${hashtagTitle}${hashtagPostTitle}`;
useTitle(title, `/:instance?/t/:hashtag`);
const latestItem = useRef();
@ -60,16 +73,20 @@ function Hashtags({ columnMode, ...props }) {
limit: LIMIT,
any: hashtags.slice(1),
maxId: firstLoad ? undefined : maxID.current,
onlyMedia: media,
})
.next();
const { value } = results;
let { value } = results;
if (value?.length) {
if (firstLoad) {
latestItem.current = value[0].id;
}
// value = filteredItems(value, 'public');
value.forEach((item) => {
saveStatus(item, instance);
saveStatus(item, instance, {
skipThreading: media, // If media view, no need to form threads
});
});
maxID.current = value[value.length - 1].id;
@ -88,9 +105,11 @@ function Hashtags({ columnMode, ...props }) {
limit: 1,
any: hashtags.slice(1),
since_id: latestItem.current,
onlyMedia: media,
})
.next();
const { value } = results;
let { value } = results;
value = filteredItems(value, 'public');
if (value?.length) {
return true;
}
@ -123,7 +142,7 @@ function Hashtags({ columnMode, ...props }) {
title={title}
titleComponent={
!!instance && (
<h1 class="header-account">
<h1 class="header-double-lines">
<b>{hashtagTitle}</b>
<div>{instance}</div>
</h1>
@ -136,6 +155,10 @@ function Hashtags({ columnMode, ...props }) {
fetchItems={fetchHashtags}
checkForUpdates={checkForUpdates}
useItemID
view={media ? 'media' : undefined}
refresh={media}
// allowFilters
filterContext="public"
headerEnd={
<Menu2
portal
@ -209,6 +232,23 @@ function Hashtags({ columnMode, ...props }) {
<MenuDivider />
</>
)}
<MenuHeader className="plain">Filters</MenuHeader>
<MenuItem
type="checkbox"
checked={!!media}
onClick={() => {
if (media) {
searchParams.delete('media');
} else {
searchParams.set('media', '1');
}
setSearchParams(searchParams);
}}
>
<Icon icon="check-circle" />{' '}
<span class="menu-grow">Media only</span>
</MenuItem>
<MenuDivider />
<FocusableItem className="menu-field" disabled={reachLimit}>
{({ ref }) => (
<form
@ -231,7 +271,7 @@ function Hashtags({ columnMode, ...props }) {
// );
location.hash = instance
? `/${instance}/t/${hashtags.join('+')}`
: `/t/${hashtags.join('+')}`;
: `/t/${hashtags.join('+')}${linkParams}`;
}
}}
>
@ -267,8 +307,8 @@ function Hashtags({ columnMode, ...props }) {
// : `/t/${hashtags.join('+')}`,
// );
location.hash = instance
? `/${instance}/t/${hashtags.join('+')}`
: `/t/${hashtags.join('+')}`;
? `/${instance}/t/${hashtags.join('+')}${linkParams}`
: `/t/${hashtags.join('+')}${linkParams}`;
}}
>
<Icon icon="x" alt="Remove hashtag" class="danger-icon" />
@ -283,10 +323,17 @@ function Hashtags({ columnMode, ...props }) {
<MenuItem
disabled={!currentAuthenticated}
onClick={() => {
if (states.shortcuts.length >= SHORTCUTS_LIMIT) {
alert(
`Max ${SHORTCUTS_LIMIT} shortcuts reached. Unable to add shortcut.`,
);
return;
}
const shortcut = {
type: 'hashtag',
hashtag: hashtags.join(' '),
instance,
media: media ? 'on' : undefined,
};
// Check if already exists
const exists = states.shortcuts.some(
@ -300,7 +347,8 @@ function Hashtags({ columnMode, ...props }) {
.split(/[\s+]+/)
.sort()
.join(' ') &&
(s.instance ? s.instance === shortcut.instance : true),
(s.instance ? s.instance === shortcut.instance : true) &&
(s.media ? !!s.media === !!shortcut.media : true),
);
if (exists) {
alert('This shortcut already exists');
@ -324,12 +372,28 @@ function Hashtags({ columnMode, ...props }) {
if (newInstance) {
newInstance = newInstance.toLowerCase().trim();
// navigate(`/${newInstance}/t/${hashtags.join('+')}`);
location.hash = `/${newInstance}/t/${hashtags.join('+')}`;
location.hash = `/${newInstance}/t/${hashtags.join(
'+',
)}${linkParams}`;
}
}}
>
<Icon icon="bus" /> <span>Go to another instance</span>
</MenuItem>
{currentInstance !== instance && (
<MenuItem
onClick={() => {
location.hash = `/${currentInstance}/t/${hashtags.join(
'+',
)}${linkParams}`;
}}
>
<Icon icon="bus" />{' '}
<small class="menu-double-lines">
Go to my instance (<b>{currentInstance}</b>)
</small>
</MenuItem>
)}
</Menu2>
}
/>

View file

@ -35,8 +35,9 @@ function Home() {
return (
<>
{(snapStates.settings.shortcutsColumnsMode ||
snapStates.settings.shortcutsViewMode === 'multi-column') &&
{(snapStates.settings.shortcutsViewMode === 'multi-column' ||
(!snapStates.settings.shortcutsViewMode &&
snapStates.settings.shortcutsColumnsMode)) &&
!!snapStates.shortcuts?.length ? (
<Columns />
) : (

View file

@ -43,7 +43,7 @@ function List(props) {
latestItem.current = value[0].id;
}
value = filteredItems(value, 'home');
// value = filteredItems(value, 'home');
value.forEach((item) => {
saveStatus(item, instance);
});
@ -102,7 +102,8 @@ function List(props) {
checkForUpdates={checkForUpdates}
useItemID
boostsCarousel={snapStates.settings.boostsCarousel}
allowFilters
// allowFilters
filterContext="home"
// refresh={reloadCount}
headerStart={
<Link to="/l" class="button plain">

View file

@ -11,11 +11,11 @@ const LIMIT = 20;
const emptySearchParams = new URLSearchParams();
function Mentions({ columnMode, ...props }) {
useTitle('Mentions', '/mentions');
const { masto, instance } = api();
const [searchParams] = columnMode ? [emptySearchParams] : useSearchParams();
const [stateType, setStateType] = useState(null);
const type = props?.type || searchParams.get('type') || stateType;
useTitle(`Mentions${type === 'private' ? ' (Private)' : ''}`, '/mentions');
const mentionsIterator = useRef();
const latestItem = useRef();

View file

@ -162,9 +162,10 @@
flex-grow: 1;
min-width: 0;
}
.notification-content p:first-child {
.notification-content > p:first-child {
margin-top: 0;
margin-bottom: 8px;
text-wrap: pretty;
}
.notification-group-statuses {
@ -407,6 +408,7 @@
margin-block: min(0.75em, 12px);
white-space: pre-wrap;
tab-size: 2;
text-wrap: pretty;
}
.announcements .announcement-reactions:not(:hidden) {
display: flex;

View file

@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { InView } from 'react-intersection-observer';
import { useSearchParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
import { subscribeKey } from 'valtio/utils';
import AccountBlock from '../components/account-block';
import FollowRequestButtons from '../components/follow-request-buttons';
@ -23,6 +24,7 @@ import { getRegistration } from '../utils/push-notifications';
import shortenNumber from '../utils/shorten-number';
import states, { saveStatus } from '../utils/states';
import { getCurrentInstance } from '../utils/store-utils';
import usePageVisibility from '../utils/usePageVisibility';
import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle';
@ -132,20 +134,25 @@ function Notifications({ columnMode }) {
(async () => {
try {
const fetchNotificationsPromise = fetchNotifications(firstLoad);
const fetchFollowRequestsPromise = fetchFollowRequests();
const fetchAnnouncementsPromise = fetchAnnouncements();
if (firstLoad) {
const announcements = await fetchAnnouncementsPromise;
announcements.sort((a, b) => {
// Sort by updatedAt first, then createdAt
const aDate = new Date(a.updatedAt || a.createdAt);
const bDate = new Date(b.updatedAt || b.createdAt);
return bDate - aDate;
});
setAnnouncements(announcements);
const requests = await fetchFollowRequestsPromise;
setFollowRequests(requests);
fetchAnnouncements()
.then((announcements) => {
announcements.sort((a, b) => {
// Sort by updatedAt first, then createdAt
const aDate = new Date(a.updatedAt || a.createdAt);
const bDate = new Date(b.updatedAt || b.createdAt);
return bDate - aDate;
});
setAnnouncements(announcements);
})
.catch(() => {});
fetchFollowRequests()
.then((requests) => {
setFollowRequests(requests);
})
.catch(() => {});
}
const { done } = await fetchNotificationsPromise;
@ -173,30 +180,59 @@ function Notifications({ columnMode }) {
// }
// }, [nearReachEnd, showMore]);
const loadUpdates = useCallback(() => {
console.log('✨ Load updates', {
autoRefresh: snapStates.settings.autoRefresh,
scrollTop: scrollableRef.current?.scrollTop === 0,
inBackground: inBackground(),
notificationsShowNew: snapStates.notificationsShowNew,
uiState,
});
if (
snapStates.settings.autoRefresh &&
scrollableRef.current?.scrollTop === 0 &&
window.__IDLE__ &&
!inBackground() &&
snapStates.notificationsShowNew &&
uiState !== 'loading'
) {
loadNotifications(true);
const [showNew, setShowNew] = useState(false);
const loadUpdates = useCallback(
({ disableIdleCheck = false } = {}) => {
if (uiState === 'loading') {
return;
}
console.log('✨ Load updates', {
autoRefresh: snapStates.settings.autoRefresh,
scrollTop: scrollableRef.current?.scrollTop,
inBackground: inBackground(),
disableIdleCheck,
notificationsShowNew: snapStates.notificationsShowNew,
});
if (
snapStates.settings.autoRefresh &&
scrollableRef.current?.scrollTop < 16 &&
(disableIdleCheck || window.__IDLE__) &&
!inBackground() &&
snapStates.notificationsShowNew
) {
setShowNew(false);
loadNotifications(true);
} else {
setShowNew(snapStates.notificationsShowNew);
}
},
[snapStates.notificationsShowNew, snapStates.settings.autoRefresh, uiState],
);
// useEffect(loadUpdates, [snapStates.notificationsShowNew]);
const lastHiddenTime = useRef();
usePageVisibility((visible) => {
let unsub;
if (visible) {
const timeDiff = Date.now() - lastHiddenTime.current;
if (!lastHiddenTime.current || timeDiff > 1000 * 60) {
loadUpdates({
disableIdleCheck: true,
});
} else {
lastHiddenTime.current = Date.now();
}
unsub = subscribeKey(states, 'notificationsShowNew', (v) => {
if (v) {
loadUpdates();
}
});
}
}, [
snapStates.notificationsShowNew,
snapStates.settings.autoRefresh,
uiState,
]);
useEffect(loadUpdates, [snapStates.notificationsShowNew]);
return () => {
unsub?.();
};
});
const todayDate = new Date();
const yesterdayDate = new Date(todayDate - 24 * 60 * 60 * 1000);
@ -265,7 +301,7 @@ function Notifications({ columnMode }) {
{/* <Loader hidden={uiState !== 'loading'} /> */}
</div>
</div>
{snapStates.notificationsShowNew && uiState !== 'loading' && (
{showNew && uiState !== 'loading' && (
<button
class="updates-button shiny-pill"
type="button"

View file

@ -21,6 +21,7 @@ function Public({ local, columnMode, ...props }) {
const { masto, instance } = api({
instance: props?.instance || params.instance,
});
const { masto: currentMasto, instance: currentInstance } = api();
const title = `${isLocal ? 'Local' : 'Federated'} timeline (${instance})`;
useTitle(title, isLocal ? `/:instance?/p/l` : `/:instance?/p`);
// const navigate = useNavigate();
@ -41,7 +42,7 @@ function Public({ local, columnMode, ...props }) {
latestItem.current = value[0].id;
}
value = filteredItems(value, 'public');
// value = filteredItems(value, 'public');
value.forEach((item) => {
saveStatus(item, instance);
});
@ -77,7 +78,7 @@ function Public({ local, columnMode, ...props }) {
key={instance + isLocal}
title={title}
titleComponent={
<h1 class="header-account">
<h1 class="header-double-lines">
<b>{isLocal ? 'Local timeline' : 'Federated timeline'}</b>
<div>{instance}</div>
</h1>
@ -91,7 +92,8 @@ function Public({ local, columnMode, ...props }) {
useItemID
headerStart={<></>}
boostsCarousel={snapStates.settings.boostsCarousel}
allowFilters
// allowFilters
filterContext="public"
headerEnd={
<Menu2
portal
@ -137,6 +139,20 @@ function Public({ local, columnMode, ...props }) {
>
<Icon icon="bus" /> <span>Go to another instance</span>
</MenuItem>
{currentInstance !== instance && (
<MenuItem
onClick={() => {
location.hash = isLocal
? `/${currentInstance}/p/l`
: `/${currentInstance}/p`;
}}
>
<Icon icon="bus" />{' '}
<small class="menu-double-lines">
Go to my instance (<b>{currentInstance}</b>)
</small>
</MenuItem>
)}
</Menu2>
}
/>

View file

@ -1,5 +1,6 @@
import './search.css';
import { useAutoAnimate } from '@formkit/auto-animate/preact';
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
@ -13,6 +14,7 @@ import NavMenu from '../components/nav-menu';
import SearchForm from '../components/search-form';
import Status from '../components/status';
import { api } from '../utils/api';
import shortenNumber from '../utils/shorten-number';
import useTitle from '../utils/useTitle';
const SHORT_LIMIT = 5;
@ -144,6 +146,8 @@ function Search(props) {
},
);
const [filterBarParent] = useAutoAnimate();
return (
<div id="search-page" class="deck-container" ref={scrollableRef}>
<div class="timeline-deck deck">
@ -158,7 +162,10 @@ function Search(props) {
</header>
<main>
{!!q && (
<div class={`filter-bar ${uiState === 'loading' ? 'loading' : ''}`}>
<div
ref={filterBarParent}
class={`filter-bar ${uiState === 'loading' ? 'loading' : ''}`}
>
{!!type && (
<Link to={`/search${q ? `?q=${encodeURIComponent(q)}` : ''}`}>
All
@ -244,20 +251,32 @@ function Search(props) {
{hashtagResults.length > 0 ? (
<>
<ul class="link-list hashtag-list">
{hashtagResults.map((hashtag) => (
<li key={hashtag.name}>
<Link
to={
instance
? `/${instance}/t/${hashtag.name}`
: `/t/${hashtag.name}`
}
>
<Icon icon="hashtag" />
<span>{hashtag.name}</span>
</Link>
</li>
))}
{hashtagResults.map((hashtag) => {
const { name, history } = hashtag;
const total = history.reduce(
(acc, cur) => acc + +cur.uses,
0,
);
return (
<li key={hashtag.name}>
<Link
to={
instance
? `/${instance}/t/${hashtag.name}`
: `/t/${hashtag.name}`
}
>
<Icon icon="hashtag" />
<span>{hashtag.name}</span>
{!!total && (
<span class="count">
{shortenNumber(total)}
</span>
)}
</Link>
</li>
);
})}
</ul>
{type !== 'hashtags' && (
<div class="ui-state">

View file

@ -60,6 +60,14 @@ const scrollIntoViewOptions = {
behavior: 'smooth',
};
// Select all statuses except those inside collapsed details/summary
// Hat-tip to @AmeliaBR@front-end.social
// https://front-end.social/@AmeliaBR/109784776146144471
const STATUSES_SELECTOR =
'.status-link:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *), .status-focus:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *)';
const STATUS_URL_REGEX = /\/s\//i;
function StatusPage(params) {
const { id } = params;
const { masto, instance } = api({ instance: params.instance });
@ -167,8 +175,13 @@ function StatusPage(params) {
function StatusThread({ id, closeLink = '/', instance: propInstance }) {
const [searchParams, setSearchParams] = useSearchParams();
const mediaParam = searchParams.get('media');
const mediaStatusID = searchParams.get('mediaStatusID');
const showMedia = parseInt(mediaParam, 10) > 0;
const firstLoad = useRef(!states.prevLocation && history.length === 1);
const firstLoad = useRef(
!states.prevLocation &&
(history.length === 1 ||
('navigation' in window && navigation?.entries?.()?.length === 1)),
);
const [viewMode, setViewMode] = useState(
searchParams.get('view') || firstLoad.current ? 'full' : null,
);
@ -554,12 +567,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
);
const activeStatusRect = activeStatus?.getBoundingClientRect();
const allStatusLinks = Array.from(
// Select all statuses except those inside collapsed details/summary
// Hat-tip to @AmeliaBR@front-end.social
// https://front-end.social/@AmeliaBR/109784776146144471
scrollableRef.current.querySelectorAll(
'.status-link:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *), .status-focus:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *)',
),
scrollableRef.current.querySelectorAll(STATUSES_SELECTOR),
);
console.log({ allStatusLinks });
if (
@ -592,9 +600,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
);
const activeStatusRect = activeStatus?.getBoundingClientRect();
const allStatusLinks = Array.from(
scrollableRef.current.querySelectorAll(
'.status-link:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *), .status-focus:not(details:not([open]) > summary ~ *, details:not([open]) > summary ~ * *)',
),
scrollableRef.current.querySelectorAll(STATUSES_SELECTOR),
);
if (
activeStatus &&
@ -657,139 +663,159 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
resetScrollPosition(status.id);
}, []);
const renderStatus = (status) => {
const {
id: statusID,
ancestor,
isThread,
descendant,
thread,
replies,
repliesCount,
weight,
} = status;
const isHero = statusID === id;
// const StatusParent = useCallback(
// (props) =>
// isThread || thread || ancestor ? (
// <Link
// class="status-link"
// to={
// instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`
// }
// onClick={() => {
// resetScrollPosition(statusID);
// }}
// {...props}
// />
// ) : (
// <div class="status-focus" tabIndex={0} {...props} />
// ),
// [isThread, thread],
// );
return (
<li
key={statusID}
ref={isHero ? heroStatusRef : null}
class={`${ancestor ? 'ancestor' : ''} ${
descendant ? 'descendant' : ''
} ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
>
{isHero ? (
<>
<InView
threshold={0.1}
onChange={onView}
class="status-focus"
tabIndex={0}
useEffect(() => {
let timer;
if (mediaStatusID && showMedia) {
timer = setTimeout(() => {
const status = scrollableRef.current?.querySelector(
`.status-link[href*="/${mediaStatusID}"]`,
);
if (status) {
status.scrollIntoView(scrollIntoViewOptions);
}
}, 400); // After CSS transition
}
return () => {
clearTimeout(timer);
};
}, [mediaStatusID, showMedia]);
const renderStatus = useCallback(
(status) => {
const {
id: statusID,
ancestor,
isThread,
descendant,
thread,
replies,
repliesCount,
weight,
} = status;
const isHero = statusID === id;
// const StatusParent = useCallback(
// (props) =>
// isThread || thread || ancestor ? (
// <Link
// class="status-link"
// to={
// instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`
// }
// onClick={() => {
// resetScrollPosition(statusID);
// }}
// {...props}
// />
// ) : (
// <div class="status-focus" tabIndex={0} {...props} />
// ),
// [isThread, thread],
// );
return (
<li
key={statusID}
ref={isHero ? heroStatusRef : null}
class={`${ancestor ? 'ancestor' : ''} ${
descendant ? 'descendant' : ''
} ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
>
{isHero ? (
<>
<InView
threshold={0.1}
onChange={onView}
class="status-focus"
tabIndex={0}
>
<Status
statusID={statusID}
instance={instance}
withinContext
size="l"
enableTranslate
forceTranslate={translate}
/>
</InView>
{uiState !== 'loading' && !authenticated ? (
<div class="post-status-banner">
<p>
You're not logged in. Interactions (reply, boost, etc) are
not possible.
</p>
<Link to="/login" class="button">
Log in
</Link>
</div>
) : (
!sameInstance && (
<div class="post-status-banner">
<p>
This post is from another instance (<b>{instance}</b>).
Interactions (reply, boost, etc) are not possible.
</p>
<button
type="button"
disabled={uiState === 'loading'}
onClick={() => {
setUIState('loading');
(async () => {
try {
const results = await currentMasto.v2.search.fetch({
q: heroStatus.url,
type: 'statuses',
resolve: true,
limit: 1,
});
if (results.statuses.length) {
const status = results.statuses[0];
location.hash = currentInstance
? `/${currentInstance}/s/${status.id}`
: `/s/${status.id}`;
} else {
throw new Error('No results');
}
} catch (e) {
setUIState('default');
alert('Error: ' + e);
console.error(e);
}
})();
}}
>
<Icon icon="transfer" /> Switch to my instance to enable
interactions
</button>
</div>
)
)}
</>
) : (
// <StatusParent>
<Link
class="status-link"
to={instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`}
onClick={() => {
resetScrollPosition(statusID);
}}
>
<Status
statusID={statusID}
instance={instance}
withinContext
size="l"
size={thread || ancestor ? 'm' : 's'}
enableTranslate
forceTranslate={translate}
onMediaClick={handleMediaClick}
onStatusLinkClick={handleStatusLinkClick}
/>
</InView>
{uiState !== 'loading' && !authenticated ? (
<div class="post-status-banner">
<p>
You're not logged in. Interactions (reply, boost, etc) are not
possible.
</p>
<Link to="/login" class="button">
Log in
</Link>
</div>
) : (
!sameInstance && (
<div class="post-status-banner">
<p>
This post is from another instance (<b>{instance}</b>).
Interactions (reply, boost, etc) are not possible.
</p>
<button
type="button"
disabled={uiState === 'loading'}
onClick={() => {
setUIState('loading');
(async () => {
try {
const results = await currentMasto.v2.search.fetch({
q: heroStatus.url,
type: 'statuses',
resolve: true,
limit: 1,
});
if (results.statuses.length) {
const status = results.statuses[0];
location.hash = currentInstance
? `/${currentInstance}/s/${status.id}`
: `/s/${status.id}`;
} else {
throw new Error('No results');
}
} catch (e) {
setUIState('default');
alert('Error: ' + e);
console.error(e);
}
})();
}}
>
<Icon icon="transfer" /> Switch to my instance to enable
interactions
</button>
{ancestor && isThread && repliesCount > 1 && (
<div class="replies-link">
<Icon icon="comment" />{' '}
<span title={repliesCount}>
{shortenNumber(repliesCount)}
</span>
</div>
)
)}
</>
) : (
// <StatusParent>
<Link
class="status-link"
to={instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`}
onClick={() => {
resetScrollPosition(statusID);
}}
>
<Status
statusID={statusID}
instance={instance}
withinContext
size={thread || ancestor ? 'm' : 's'}
enableTranslate
onMediaClick={handleMediaClick}
onStatusLinkClick={handleStatusLinkClick}
/>
{ancestor && isThread && repliesCount > 1 && (
<div class="replies-link">
<Icon icon="comment" />{' '}
<span title={repliesCount}>{shortenNumber(repliesCount)}</span>
</div>
)}{' '}
{/* {replies?.length > LIMIT && (
)}{' '}
{/* {replies?.length > LIMIT && (
<div class="replies-link">
<Icon icon="comment" />{' '}
<span title={replies.length}>
@ -797,48 +823,71 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
</span>
</div>
)} */}
{/* </StatusParent> */}
</Link>
)}
{descendant && replies?.length > 0 && (
<SubComments
instance={instance}
replies={replies}
hasParentThread={thread}
level={1}
accWeight={weight}
openAll={totalDescendants.current < SUBCOMMENTS_OPEN_ALL_LIMIT}
/>
)}
{uiState === 'loading' &&
isHero &&
!!heroStatus?.repliesCount &&
!hasDescendants && (
<div class="status-loading">
<Loader />
</div>
{/* </StatusParent> */}
</Link>
)}
{uiState === 'error' &&
isHero &&
!!heroStatus?.repliesCount &&
!hasDescendants && (
<div class="status-error">
Unable to load replies.
<br />
<button
type="button"
class="plain"
onClick={() => {
states.reloadStatusPage++;
}}
>
Try again
</button>
</div>
{descendant && replies?.length > 0 && (
<SubComments
instance={instance}
replies={replies}
hasParentThread={thread}
level={1}
accWeight={weight}
openAll={totalDescendants.current < SUBCOMMENTS_OPEN_ALL_LIMIT}
/>
)}
</li>
);
};
{uiState === 'loading' &&
isHero &&
!!heroStatus?.repliesCount &&
!hasDescendants && (
<div class="status-loading">
<Loader />
</div>
)}
{uiState === 'error' &&
isHero &&
!!heroStatus?.repliesCount &&
!hasDescendants && (
<div class="status-error">
Unable to load replies.
<br />
<button
type="button"
class="plain"
onClick={() => {
states.reloadStatusPage++;
}}
>
Try again
</button>
</div>
)}
</li>
);
},
[
id,
instance,
uiState,
authenticated,
sameInstance,
translate,
handleMediaClick,
handleStatusLinkClick,
hasDescendants,
],
);
const prevLocationIsStatusPage = useMemo(() => {
// Navigation API
if ('navigation' in window && navigation?.entries) {
const prevEntry = navigation.entries()[navigation.currentEntry.index - 1];
if (prevEntry?.url) {
return STATUS_URL_REGEX.test(prevEntry.url);
}
}
return STATUS_URL_REGEX.test(states.prevLocation?.pathname);
}, [sKey]);
return (
<div
@ -876,7 +925,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
</div> */}
<div class="header-grid header-grid-2">
<h1>
{!!/\/s\//i.test(snapStates.prevLocation?.pathname) && (
{prevLocationIsStatusPage && (
<button
type="button"
class="plain deck-back"
@ -970,7 +1019,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
<div class="header-side">
<button
type="button"
class="plain4"
class="plain4 button-switch-view"
style={{
display: viewMode === 'full' ? '' : 'none',
}}
@ -1231,11 +1280,11 @@ function SubComments({
/>
))}
</span>
<span>
<b>
<span title={replies.length}>{shortenNumber(replies.length)}</span>{' '}
repl
{replies.length === 1 ? 'y' : 'ies'}
</span>
</b>
{!sameCount && totalComments > 1 && (
<>
{' '}

View file

@ -15,6 +15,7 @@ import { api } from '../utils/api';
import { oklab2rgb, rgb2oklab } from '../utils/color-utils';
import { filteredItems } from '../utils/filters';
import pmem from '../utils/pmem';
import shortenNumber from '../utils/shorten-number';
import states from '../utils/states';
import { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle';
@ -37,6 +38,7 @@ function Trending({ columnMode, ...props }) {
const { masto, instance } = api({
instance: props?.instance || params.instance,
});
const { masto: currentMasto, instance: currentInstance } = api();
const title = `Trending (${instance})`;
useTitle(title, `/:instance?/trending`);
// const navigate = useNavigate();
@ -84,7 +86,7 @@ function Trending({ columnMode, ...props }) {
latestItem.current = value[0].id;
}
value = filteredItems(value, 'public'); // Might not work here
// value = filteredItems(value, 'public'); // Might not work here
value.forEach((item) => {
saveStatus(item, instance);
});
@ -131,7 +133,7 @@ function Trending({ columnMode, ...props }) {
<span class="more-insignificant">#</span>
{name}
</span>
<span class="filter-count">{total.toLocaleString()}</span>
<span class="filter-count">{shortenNumber(total)}</span>
</Link>
);
})}
@ -241,7 +243,7 @@ function Trending({ columnMode, ...props }) {
key={instance}
title={title}
titleComponent={
<h1 class="header-account">
<h1 class="header-double-lines">
<b>Trending</b>
<div>{instance}</div>
</h1>
@ -256,7 +258,8 @@ function Trending({ columnMode, ...props }) {
useItemID
headerStart={<></>}
boostsCarousel={snapStates.settings.boostsCarousel}
allowFilters
// allowFilters
filterContext="public"
timelineStart={TimelineStart}
headerEnd={
<Menu2
@ -289,6 +292,18 @@ function Trending({ columnMode, ...props }) {
>
<Icon icon="bus" /> <span>Go to another instance</span>
</MenuItem>
{currentInstance !== instance && (
<MenuItem
onClick={() => {
location.hash = `/${currentInstance}/trending`;
}}
>
<Icon icon="bus" />{' '}
<small class="menu-double-lines">
Go to my instance (<b>{currentInstance}</b>)
</small>
</MenuItem>
)}
</Menu2>
}
/>

View file

@ -1,40 +1,3 @@
#welcome {
text-align: center;
background-image: radial-gradient(
circle at center,
var(--bg-color),
transparent 16em
),
radial-gradient(circle at center, var(--bg-color), transparent 8em);
background-repeat: no-repeat;
background-attachment: fixed;
padding: 16px;
cursor: default;
}
#welcome .hero-container {
padding-block: 60px;
height: 100vh;
height: 100svh;
max-height: 1024px;
display: flex;
flex-direction: column;
}
#welcome h1 {
margin: 0;
padding: 0;
font-size: 5em;
line-height: 1;
letter-spacing: -1px;
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
mix-blend-mode: multiply;
}
@keyframes shine2 {
0% {
left: -100%;
@ -46,92 +9,176 @@
left: 100%;
}
}
#welcome h1:before {
content: '';
position: absolute;
z-index: 2;
width: 100%;
height: 100%;
background-image: linear-gradient(
100deg,
rgba(255, 255, 255, 0) 30%,
rgba(255, 255, 255, 0.4),
rgba(255, 255, 255, 0) 70%
);
top: 0;
left: -100%;
pointer-events: none;
animation: shine2 5s ease-in-out 1s infinite;
}
@media (prefers-color-scheme: dark) {
#welcome {
#welcome {
text-align: center;
background-image: radial-gradient(
circle at center,
var(--bg-color),
transparent 16em
),
radial-gradient(circle at center, var(--bg-color), transparent 8em);
background-repeat: no-repeat;
background-attachment: fixed;
cursor: default;
@media (prefers-color-scheme: dark) {
background-image: none;
}
#welcome h1 {
mix-blend-mode: normal;
}
#welcome h1:before {
content: none;
}
}
#welcome img {
vertical-align: top;
transition: transform 0.3s ease-out;
}
#welcome h1 img {
filter: drop-shadow(-1px -1px var(--bg-blur-color))
drop-shadow(0 -1px 1px #fff)
drop-shadow(0 16px 32px var(--drop-shadow-color));
}
@media (prefers-color-scheme: dark) {
#welcome h1 img {
filter: none;
}
}
#welcome h1:hover img {
transform: scale(1.05);
}
#welcome .desc {
font-size: 1.4em;
text-wrap: balance;
opacity: 0.7;
}
#welcome .hero-container > p {
margin-top: 0;
}
.hero-container {
padding: 16px;
height: 100vh;
height: 100svh;
max-height: 800px;
display: flex;
flex-direction: column;
#why-container .sections {
padding-inline: 16px;
}
#why-container .sections section {
text-align: start;
max-width: 480px;
background-color: var(--bg-color);
border-radius: 16px;
overflow: hidden;
box-shadow: 17px 20px 40px var(--drop-shadow-color);
margin-bottom: 48px;
}
#why-container .sections section h4 {
margin: 0;
padding: 30px 30px 0;
font-size: 1.4em;
font-weight: 600;
}
#why-container .sections section p {
margin-inline: 30px;
margin-bottom: 30px;
opacity: 0.7;
text-wrap: balance;
}
#why-container .sections section img {
width: 100%;
height: auto;
border-bottom: 1px solid var(--outline-color);
}
@media (prefers-color-scheme: dark) {
#why-container .sections section img {
filter: invert(0.85) hue-rotate(180deg);
a {
color: inherit;
&:hover {
color: var(--link-text-color);
}
}
}
h1 {
margin: 0;
padding: 0;
font-size: 5em;
line-height: 1;
letter-spacing: -1px;
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
mix-blend-mode: multiply;
@media (prefers-color-scheme: dark) {
mix-blend-mode: normal;
}
&:before {
content: '';
position: absolute;
z-index: 2;
width: 100%;
height: 100%;
background-image: linear-gradient(
100deg,
rgba(255, 255, 255, 0) 30%,
rgba(255, 255, 255, 0.4),
rgba(255, 255, 255, 0) 70%
);
top: 0;
left: -100%;
pointer-events: none;
animation: shine2 5s ease-in-out 1s infinite;
@media (prefers-color-scheme: dark) {
content: none;
}
}
img {
filter: drop-shadow(-1px -1px var(--bg-blur-color))
drop-shadow(0 -1px 1px #fff)
drop-shadow(0 16px 32px var(--drop-shadow-color));
@media (prefers-color-scheme: dark) {
filter: none;
}
}
&:hover img {
transform: scale(1.05);
}
}
img {
vertical-align: top;
transition: transform 0.3s ease-out;
}
.desc {
font-size: 1.4em;
text-wrap: balance;
opacity: 0.7;
}
.hero-container > p {
margin-top: 0;
}
#why-container {
padding: 0 16px;
}
.sections {
padding-inline: 16px;
section {
text-align: start;
max-width: 480px;
background-color: var(--bg-color);
border-radius: 16px;
overflow: hidden;
box-shadow: 17px 20px 40px var(--drop-shadow-color);
margin-bottom: 48px;
h4 {
margin: 0;
padding: 30px 30px 0;
font-size: 1.4em;
font-weight: 600;
}
p {
margin-inline: 30px;
margin-bottom: 30px;
opacity: 0.7;
text-wrap: balance;
}
img {
width: 100%;
height: auto;
border-bottom: 1px solid var(--outline-color);
@media (prefers-color-scheme: dark) {
filter: invert(0.85) hue-rotate(180deg);
}
}
}
}
@media (width > 40em) {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr auto;
height: 100vh;
height: 100svh;
.hero-container {
height: auto;
}
#why-container {
padding: 32px;
overflow: auto;
mask-image: linear-gradient(to top, transparent 16px, black 64px);
}
footer {
grid-row: 2;
grid-column: 1 / span 2;
}
}
& ~ :is(#compose-button, #shortcuts) {
display: none;
}
}

View file

@ -98,31 +98,33 @@ function Welcome() {
</section>
</div>
</div>
<hr />
<p>
<a href="https://github.com/cheeaun/phanpy" target="_blank">
Built
</a>{' '}
by{' '}
<a
href="https://mastodon.social/@cheeaun"
target="_blank"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'cheeaun@mastodon.social';
}}
>
@cheeaun
</a>
.{' '}
<a
href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"
target="_blank"
>
Privacy Policy
</a>
.
</p>
<footer>
<hr />
<p>
<a href="https://github.com/cheeaun/phanpy" target="_blank">
Built
</a>{' '}
by{' '}
<a
href="https://mastodon.social/@cheeaun"
target="_blank"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'cheeaun@mastodon.social';
}}
>
@cheeaun
</a>
.{' '}
<a
href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"
target="_blank"
>
Privacy Policy
</a>
.
</p>
</footer>
</main>
);
}

View file

@ -221,8 +221,34 @@ export function api({ instance, accessToken, accountID, account } = {}) {
}
}
const currentAccount = getCurrentAccount();
// If only instance is provided, get the masto instance for that instance
if (instance) {
if (currentAccountApi?.instance === instance) {
return {
masto: currentAccountApi.masto,
streaming: currentAccountApi.streaming,
client: currentAccountApi,
authenticated: true,
instance,
};
}
if (currentAccount?.instanceURL === instance) {
const { accessToken } = currentAccount;
currentAccountApi =
accountApis[instance]?.[accessToken] ||
initClient({ instance, accessToken });
return {
masto: currentAccountApi.masto,
streaming: currentAccountApi.streaming,
client: currentAccountApi,
authenticated: true,
instance,
};
}
const client = apis[instance] || initClient({ instance });
const { masto, streaming, accessToken } = client;
return {
@ -244,7 +270,6 @@ export function api({ instance, accessToken, accountID, account } = {}) {
instance: currentAccountApi.instance,
};
}
const currentAccount = getCurrentAccount();
if (currentAccount) {
const { accessToken, instanceURL: instance } = currentAccount;
currentAccountApi =

View file

@ -0,0 +1,4 @@
import { createContext } from 'preact';
const FilterContext = createContext();
export default FilterContext;

View file

@ -1,10 +1,8 @@
import mem from './mem';
import store from './store';
export function filteredItem(item, filterContext, currentAccountID) {
const { filtered } = item;
if (!filtered?.length) return true;
const isSelf = currentAccountID && item.account?.id === currentAccountID;
if (isSelf) return true;
function _isFiltered(filtered, filterContext) {
if (!filtered?.length) return false;
const appliedFilters = filtered.filter((f) => {
const { filter } = f;
const hasContext = filter.context.includes(filterContext);
@ -12,19 +10,35 @@ export function filteredItem(item, filterContext, currentAccountID) {
if (!filter.expiresAt) return hasContext;
return new Date(filter.expiresAt) > new Date();
});
if (!appliedFilters.length) return true;
if (!appliedFilters.length) return false;
const isHidden = appliedFilters.some((f) => f.filter.filterAction === 'hide');
console.log({ isHidden, filtered, appliedFilters, item });
if (isHidden) return false;
if (isHidden)
return {
action: 'hide',
};
const isWarn = appliedFilters.some((f) => f.filter.filterAction === 'warn');
if (isWarn) {
const filterTitles = appliedFilters.map((f) => f.filter.title);
item._filtered = {
return {
action: 'warn',
titles: filterTitles,
titlesStr: filterTitles.join(' • '),
};
}
return isWarn;
return false;
}
export const isFiltered = mem(_isFiltered);
export function filteredItem(item, filterContext, currentAccountID) {
const { filtered } = item;
if (!filtered?.length) return true;
const isSelf = currentAccountID && item.account?.id === currentAccountID;
if (isSelf) return true;
const filterState = isFiltered(filtered, filterContext);
if (!filterState) return true;
if (filterState.action === 'hide') return false;
// item._filtered = filterState;
return true;
}
export function filteredItems(items, filterContext) {
if (!items?.length) return [];

View file

@ -1,4 +1,37 @@
// This is like very lame "type-checking" lol
const notificationTypeKeys = {
mention: ['account', 'status'],
status: ['account', 'status'],
reblog: ['account', 'status'],
follow: ['account'],
follow_request: ['account'],
favourite: ['account', 'status'],
poll: ['status'],
update: ['status'],
};
function fixNotifications(notifications) {
return notifications.filter((notification) => {
const { type, id, createdAt } = notification;
if (!type) {
console.warn('Notification missing type', notification);
return false;
}
if (!id || !createdAt) {
console.warn('Notification missing id or createdAt', notification);
// Continue processing this despite missing id or createdAt
}
const keys = notificationTypeKeys[type];
if (keys?.length) {
return keys.every((key) => !!notification[key]);
}
return true; // skip other types
});
}
function groupNotifications(notifications) {
// Filter out invalid notifications
notifications = fixNotifications(notifications);
// Create new flat list of notifications
// Combine sibling notifications based on type and status id
// Concat all notification.account into an array of _accounts
@ -7,7 +40,7 @@ function groupNotifications(notifications) {
for (let i = 0, j = 0; i < notifications.length; i++) {
const notification = notifications[i];
const { id, status, account, type, createdAt } = notification;
const date = new Date(createdAt).toLocaleDateString();
const date = createdAt ? new Date(createdAt).toLocaleDateString() : '';
let virtualType = type;
if (type === 'favourite' || type === 'reblog') {
virtualType = 'favourite+reblog';
@ -50,7 +83,7 @@ function groupNotifications(notifications) {
for (let i = 0, j = 0; i < cleanNotifications.length; i++) {
const notification = cleanNotifications[i];
const { id, account, _accounts, type, createdAt } = notification;
const date = new Date(createdAt).toLocaleDateString();
const date = createdAt ? new Date(createdAt).toLocaleDateString() : '';
if (type === 'favourite+reblog' && account && _accounts.length === 1) {
const key = `${account?.id}-${type}-${date}`;
const mappedNotification = notificationsMap2[key];

View file

@ -1,9 +1,11 @@
export default function isMastodonLinkMaybe(url) {
const { pathname } = new URL(url);
const { pathname, hash } = new URL(url);
return (
/^\/.*\/\d+$/i.test(pathname) ||
/^\/@[^/]+\/(statuses|posts)\/\w+\/?$/i.test(pathname) || // GoToSocial, Takahe
/^\/notes\/[a-z0-9]+$/i.test(pathname) || // Misskey, Calckey
/^\/(notice|objects)\/[a-z0-9-]+$/i.test(pathname) // Pleroma
/^\/notes\/[a-z0-9]+$/i.test(pathname) || // Misskey, Calckey
/^\/(notice|objects)\/[a-z0-9-]+$/i.test(pathname) || // Pleroma
/#\/[^\/]+\.[^\/]+\/s\/.+/i.test(hash) // Phanpy 🫣
);
}

View file

@ -1,5 +1,7 @@
import moize from 'moize';
window._moize = moize;
export default function mem(fn, opts = {}) {
return moize(fn, { ...opts, maxSize: 100 });
return moize(fn, { ...opts, maxSize: 50, isDeepEqual: true });
}

View file

@ -18,6 +18,8 @@ export default function openCompose(opts) {
// }
newWin.__COMPOSE__ = opts;
} else {
alert('Looks like your browser is blocking popups.');
}
return newWin;

View file

@ -22,7 +22,6 @@ const states = proxy({
notificationsNew: [],
notificationsShowNew: false,
notificationsLastFetchTime: null,
accounts: {},
reloadStatusPage: 0,
reloadGenericAccounts: {
id: null,
@ -72,8 +71,9 @@ export function initStates() {
store.account.get('settings-autoRefresh') ?? false;
states.settings.shortcutsViewMode =
store.account.get('settings-shortcutsViewMode') ?? null;
states.settings.shortcutsColumnsMode =
store.account.get('settings-shortcutsColumnsMode') ?? false;
if (store.account.get('settings-shortcutsColumnsMode')) {
states.settings.shortcutsColumnsMode = true;
}
states.settings.boostsCarousel =
store.account.get('settings-boostsCarousel') ?? true;
states.settings.contentTranslation =
@ -100,9 +100,6 @@ subscribe(states, (changes) => {
if (path.join('.') === 'settings.boostsCarousel') {
store.account.set('settings-boostsCarousel', !!value);
}
if (path.join('.') === 'settings.shortcutsColumnsMode') {
store.account.set('settings-shortcutsColumnsMode', !!value);
}
if (path.join('.') === 'settings.shortcutsViewMode') {
store.account.set('settings-shortcutsViewMode', value);
}
@ -171,7 +168,7 @@ export function saveStatus(status, instance, opts) {
if (!override && oldStatus) return;
const key = statusKey(status.id, instance);
if (oldStatus?._pinned) status._pinned = oldStatus._pinned;
if (oldStatus?._filtered) status._filtered = oldStatus._filtered;
// if (oldStatus?._filtered) status._filtered = oldStatus._filtered;
states.statuses[key] = status;
if (status.reblog) {
const key = statusKey(status.reblog.id, instance);

View file

@ -1,4 +1,4 @@
import { satisfies } from 'semver';
import { satisfies } from 'compare-versions';
import features from '../data/features.json';