diff --git a/package-lock.json b/package-lock.json index cedc7f81..92f2498b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 40c6aa0d..4a16dc92 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app.css b/src/app.css index e0d41fa0..a196c571 100644 --- a/src/app.css +++ b/src/app.css @@ -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 { diff --git a/src/app.jsx b/src/app.jsx index a673a34e..44d937ad 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -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', // () => { diff --git a/src/cloak-mode.css b/src/cloak-mode.css index d703b7a9..1c6d780b 100644 --- a/src/cloak-mode.css +++ b/src/cloak-mode.css @@ -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 { diff --git a/src/components/account-block.jsx b/src/components/account-block.jsx index 0b7e9cea..13c680be 100644 --- a/src/components/account-block.jsx +++ b/src/components/account-block.jsx @@ -82,7 +82,7 @@ function AccountBlock({ }} > <Avatar url={avatar} size={avatarSize} squircle={bot} /> - <span> + <span class="account-block-content"> {!hideDisplayName && ( <> {displayName ? ( diff --git a/src/components/account-info.css b/src/components/account-info.css index cfc49e91..e5c00324 100644 --- a/src/components/account-info.css +++ b/src/components/account-info.css @@ -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; diff --git a/src/components/account-info.jsx b/src/components/account-info.jsx index 3e56205b..f3b2ecaa 100644 --- a/src/components/account-info.jsx +++ b/src/components/account-info.jsx @@ -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 }), diff --git a/src/components/avatar.css b/src/components/avatar.css index 743793b0..41325817 100644 --- a/src/components/avatar.css +++ b/src/components/avatar.css @@ -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; + } } diff --git a/src/components/background-service.jsx b/src/components/background-service.jsx index e7d7dcfc..6c4b63d1 100644 --- a/src/components/background-service.jsx +++ b/src/components/background-service.jsx @@ -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; }); diff --git a/src/components/columns.jsx b/src/components/columns.jsx index 0f1f0237..1c8c64a3 100644 --- a/src/components/columns.jsx +++ b/src/components/columns.jsx @@ -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; diff --git a/src/components/compose-button.jsx b/src/components/compose-button.jsx index ceb452c4..3587c2f6 100644 --- a/src/components/compose-button.jsx +++ b/src/components/compose-button.jsx @@ -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 { diff --git a/src/components/compose.css b/src/components/compose.css index 5531f85e..4870063c 100644 --- a/src/components/compose.css +++ b/src/components/compose.css @@ -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 */ diff --git a/src/components/compose.jsx b/src/components/compose.jsx index b3c213d5..18480db7 100644 --- a/src/components/compose.jsx +++ b/src/components/compose.jsx @@ -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" diff --git a/src/components/generic-accounts.jsx b/src/components/generic-accounts.jsx index 11070c0c..0f38e472 100644 --- a/src/components/generic-accounts.jsx +++ b/src/components/generic-accounts.jsx @@ -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) { diff --git a/src/components/icon.jsx b/src/components/icon.jsx index 42e33f2a..cd2b5357 100644 --- a/src/components/icon.jsx +++ b/src/components/icon.jsx @@ -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({ diff --git a/src/components/keyboard-shortcuts-help.jsx b/src/components/keyboard-shortcuts-help.jsx index 08683cb4..df4e437b 100644 --- a/src/components/keyboard-shortcuts-help.jsx +++ b/src/components/keyboard-shortcuts-help.jsx @@ -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> ))} diff --git a/src/components/media-alt-modal.jsx b/src/components/media-alt-modal.jsx index a45a9c83..58908013 100644 --- a/src/components/media-alt-modal.jsx +++ b/src/components/media-alt-modal.jsx @@ -57,6 +57,7 @@ export default function MediaAltModal({ alt, lang, onClose }) { <p style={{ whiteSpace: 'pre-wrap', + textWrap: 'pretty', }} > {alt} diff --git a/src/components/media-modal.jsx b/src/components/media-modal.jsx index 3c920cc2..74cf21cf 100644 --- a/src/components/media-modal.jsx +++ b/src/components/media-modal.jsx @@ -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>» + <span class="button-label">View post </span>» </Link> </span> </div> diff --git a/src/components/media-post.css b/src/components/media-post.css new file mode 100644 index 00000000..d8c12673 --- /dev/null +++ b/src/components/media-post.css @@ -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); + } + } +} diff --git a/src/components/media-post.jsx b/src/components/media-post.jsx new file mode 100644 index 00000000..fdc1040c --- /dev/null +++ b/src/components/media-post.jsx @@ -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); diff --git a/src/components/media.jsx b/src/components/media.jsx index 590caa40..f0390458 100644 --- a/src/components/media.jsx +++ b/src/components/media.jsx @@ -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} diff --git a/src/components/nav-menu.jsx b/src/components/nav-menu.jsx index 2e22d532..96b9ce46 100644 --- a/src/components/nav-menu.jsx +++ b/src/components/nav-menu.jsx @@ -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( diff --git a/src/components/notification.jsx b/src/components/notification.jsx index 198c542e..823d9f43 100644 --- a/src/components/notification.jsx +++ b/src/components/notification.jsx @@ -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" diff --git a/src/components/shortcuts-settings.css b/src/components/shortcuts-settings.css index 086c7911..1c9aa7b1 100644 --- a/src/components/shortcuts-settings.css +++ b/src/components/shortcuts-settings.css @@ -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 diff --git a/src/components/shortcuts-settings.jsx b/src/components/shortcuts-settings.jsx index 11b0b895..9d5bb516 100644 --- a/src/components/shortcuts-settings.jsx +++ b/src/components/shortcuts-settings.jsx @@ -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 && ( diff --git a/src/components/shortcuts.css b/src/components/shortcuts.css index a0e88160..59bee8f7 100644 --- a/src/components/shortcuts.css +++ b/src/components/shortcuts.css @@ -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; } diff --git a/src/components/shortcuts.jsx b/src/components/shortcuts.jsx index b45a2354..e21f9bef 100644 --- a/src/components/shortcuts.jsx +++ b/src/components/shortcuts.jsx @@ -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 { diff --git a/src/components/status.css b/src/components/status.css index 77f55d9a..0fd1890b 100644 --- a/src/components/status.css +++ b/src/components/status.css @@ -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; diff --git a/src/components/status.jsx b/src/components/status.jsx index 565901f6..a5ef587c 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -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({ {' '} • <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); }} diff --git a/src/components/timeline.jsx b/src/components/timeline.jsx index a687ab43..6aab086b 100644 --- a/src/components/timeline.jsx +++ b/src/components/timeline.jsx @@ -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… - </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… + </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> ); } diff --git a/src/components/translation-block.css b/src/components/translation-block.css index 375a3afe..a1c83b9c 100644 --- a/src/components/translation-block.css +++ b/src/components/translation-block.css @@ -83,6 +83,7 @@ .status-translation-block .translated-block output { display: block; margin-top: 0.75em; + text-wrap: pretty; } .status-translation-block .translated-block diff --git a/src/pages/account-statuses.jsx b/src/pages/account-statuses.jsx index 3cd1c0a6..f7c61465 100644 --- a/src/pages/account-statuses.jsx +++ b/src/pages/account-statuses.jsx @@ -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> } /> diff --git a/src/pages/following.jsx b/src/pages/following.jsx index 3d081f32..c0505fec 100644 --- a/src/pages/following.jsx +++ b/src/pages/following.jsx @@ -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" /> ); } diff --git a/src/pages/hashtag.jsx b/src/pages/hashtag.jsx index 1efb6aa2..4f5bfbdc 100644 --- a/src/pages/hashtag.jsx +++ b/src/pages/hashtag.jsx @@ -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> } /> diff --git a/src/pages/home.jsx b/src/pages/home.jsx index 35176112..fe4503cd 100644 --- a/src/pages/home.jsx +++ b/src/pages/home.jsx @@ -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 /> ) : ( diff --git a/src/pages/list.jsx b/src/pages/list.jsx index d6f9a93c..1bc6b845 100644 --- a/src/pages/list.jsx +++ b/src/pages/list.jsx @@ -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"> diff --git a/src/pages/mentions.jsx b/src/pages/mentions.jsx index 6403d188..da6e5ae0 100644 --- a/src/pages/mentions.jsx +++ b/src/pages/mentions.jsx @@ -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(); diff --git a/src/pages/notifications.css b/src/pages/notifications.css index 337812bc..a12becd3 100644 --- a/src/pages/notifications.css +++ b/src/pages/notifications.css @@ -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; diff --git a/src/pages/notifications.jsx b/src/pages/notifications.jsx index 824e7b7d..6c48ec2a 100644 --- a/src/pages/notifications.jsx +++ b/src/pages/notifications.jsx @@ -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" diff --git a/src/pages/public.jsx b/src/pages/public.jsx index 2fac5b3f..089d084a 100644 --- a/src/pages/public.jsx +++ b/src/pages/public.jsx @@ -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> } /> diff --git a/src/pages/search.jsx b/src/pages/search.jsx index 25a9739a..7c4587cb 100644 --- a/src/pages/search.jsx +++ b/src/pages/search.jsx @@ -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"> diff --git a/src/pages/status.jsx b/src/pages/status.jsx index 5c074126..e8de6597 100644 --- a/src/pages/status.jsx +++ b/src/pages/status.jsx @@ -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 && ( <> {' '} diff --git a/src/pages/trending.jsx b/src/pages/trending.jsx index dffd8310..c1126aa1 100644 --- a/src/pages/trending.jsx +++ b/src/pages/trending.jsx @@ -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> } /> diff --git a/src/pages/welcome.css b/src/pages/welcome.css index adb8fd45..1060d4b0 100644 --- a/src/pages/welcome.css +++ b/src/pages/welcome.css @@ -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; } } diff --git a/src/pages/welcome.jsx b/src/pages/welcome.jsx index e0abe16e..58882dcb 100644 --- a/src/pages/welcome.jsx +++ b/src/pages/welcome.jsx @@ -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> ); } diff --git a/src/utils/api.js b/src/utils/api.js index debc4422..2d101915 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -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 = diff --git a/src/utils/filter-context.js b/src/utils/filter-context.js new file mode 100644 index 00000000..f896690a --- /dev/null +++ b/src/utils/filter-context.js @@ -0,0 +1,4 @@ +import { createContext } from 'preact'; + +const FilterContext = createContext(); +export default FilterContext; diff --git a/src/utils/filters.jsx b/src/utils/filters.jsx index 18f6877f..5d51bc0b 100644 --- a/src/utils/filters.jsx +++ b/src/utils/filters.jsx @@ -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 []; diff --git a/src/utils/group-notifications.jsx b/src/utils/group-notifications.jsx index 83326ccb..132fd32a 100644 --- a/src/utils/group-notifications.jsx +++ b/src/utils/group-notifications.jsx @@ -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]; diff --git a/src/utils/isMastodonLinkMaybe.jsx b/src/utils/isMastodonLinkMaybe.jsx index 7e029d2a..8fcfb306 100644 --- a/src/utils/isMastodonLinkMaybe.jsx +++ b/src/utils/isMastodonLinkMaybe.jsx @@ -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 🫣 ); } diff --git a/src/utils/mem.js b/src/utils/mem.js index 773a4929..620ab6df 100644 --- a/src/utils/mem.js +++ b/src/utils/mem.js @@ -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 }); } diff --git a/src/utils/open-compose.js b/src/utils/open-compose.js index da677112..b8192876 100644 --- a/src/utils/open-compose.js +++ b/src/utils/open-compose.js @@ -18,6 +18,8 @@ export default function openCompose(opts) { // } newWin.__COMPOSE__ = opts; + } else { + alert('Looks like your browser is blocking popups.'); } return newWin; diff --git a/src/utils/states.js b/src/utils/states.js index 580d02aa..314af7fb 100644 --- a/src/utils/states.js +++ b/src/utils/states.js @@ -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); diff --git a/src/utils/supports.js b/src/utils/supports.js index 5f7e8163..02b1a496 100644 --- a/src/utils/supports.js +++ b/src/utils/supports.js @@ -1,4 +1,4 @@ -import { satisfies } from 'semver'; +import { satisfies } from 'compare-versions'; import features from '../data/features.json';