diff --git a/README.md b/README.md index 62500f20..bcf52dc4 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ Everything is designed and engineered for my own use case, following my taste an - 🌗 Light/dark/auto theme - 🔔 Grouped notifications - 🪺 Nested replies view +- 📬 Unsent draft recovery +- 🎠 Boosts Carousel™️ +- ⚡ Shortcuts™️ with view modes like multi-column or tab bar +- #️⃣ Multi-hashtag timeline ## Design decisions diff --git a/package-lock.json b/package-lock.json index b3adf415..22d02159 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,10 @@ "name": "phanpy", "version": "0.1.0", "dependencies": { + "@formatjs/intl-localematcher": "~0.2.32", "@github/text-expander-element": "~2.3.0", "@iconify-icons/mingcute": "~1.2.4", - "@szhsin/react-menu": "~3.4.1", + "@szhsin/react-menu": "~3.5.1", "dayjs": "~1.11.7", "dayjs-twitter": "~0.5.0", "fast-blurhash": "~1.1.2", @@ -21,9 +22,9 @@ "mem": "~9.0.2", "p-retry": "~5.1.2", "p-throttle": "~5.0.0", - "preact": "~10.12.1", + "preact": "~10.13.0", "react-hotkeys-hook": "~4.3.7", - "react-intersection-observer": "~9.4.2", + "react-intersection-observer": "~9.4.3", "react-router-dom": "6.6.2", "string-length": "~5.0.1", "swiped-events": "~1.1.7", @@ -35,12 +36,13 @@ }, "devDependencies": { "@preact/preset-vite": "~2.5.0", - "@trivago/prettier-plugin-sort-imports": "~4.1.0", + "@trivago/prettier-plugin-sort-imports": "~4.1.1", "postcss": "~8.4.21", "postcss-dark-theme-class": "~0.7.3", "postcss-preset-env": "~8.0.1", "twitter-text": "~3.1.0", "vite": "~4.1.4", + "vite-plugin-generate-file": "~0.0.4", "vite-plugin-html-config": "~1.0.11", "vite-plugin-html-env": "~1.2.7", "vite-plugin-pwa": "~0.14.4", @@ -2507,6 +2509,14 @@ "node": ">=12" } }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.2.32", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.32.tgz", + "integrity": "sha512-k/MEBstff4sttohyEpXxCmC3MqbUn9VvHGlZ8fauLzkbwXmVrEeyzS+4uhrvAk9DWU9/7otYWxyDox4nT/KVLQ==", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@github/combobox-nav": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.1.5.tgz", @@ -2811,9 +2821,9 @@ } }, "node_modules/@szhsin/react-menu": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.4.1.tgz", - "integrity": "sha512-Pxt7Kyp3yuX7zkT5tjdLRJGNFMa5Tx4BP+01gJ/dnMmHQpI1H2or9gEC0X+t3cLldO3LGmm4ViGypNCmQLv/4A==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.5.1.tgz", + "integrity": "sha512-bTCfVNBSReG4+mnbN8n2OQWZ3DRPlJgMIBJFepPfDLiRzNSe5lbZ8Z5Kjiv9nuPLHOu3jSaybxgYJj/Dn8n75Q==", "dependencies": { "prop-types": "^15.7.2", "react-transition-state": "^1.1.5" @@ -2824,9 +2834,9 @@ } }, "node_modules/@trivago/prettier-plugin-sort-imports": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.1.0.tgz", - "integrity": "sha512-aTr6QPFaPAAzPRFn9yWB/9yKi3ZAFqfGpxIGLPWuQfYJFGUed+W3KKwxntsoCiNvNE2iuKOg6haMo5KG8WXltg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.1.1.tgz", + "integrity": "sha512-dQ2r2uzNr1x6pJsuh/8x0IRA3CBUB+pWEW3J/7N98axqt7SQSm+2fy0FLNXvXGg77xEDC7KHxJlHfLYyi7PDcw==", "dev": true, "dependencies": { "@babel/generator": "7.17.7", @@ -3099,6 +3109,12 @@ "node": ">=4" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, "node_modules/async": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", @@ -4541,6 +4557,18 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -5630,9 +5658,9 @@ "dev": true }, "node_modules/preact": { - "version": "10.12.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", - "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "version": "10.13.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.13.0.tgz", + "integrity": "sha512-ERdIdUpR6doqdaSIh80hvzebHB7O6JxycOhyzAeLEchqOq/4yueslQbfnPwXaNhAYacFTyCclhwkEbOumT0tHw==", "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -5768,9 +5796,9 @@ } }, "node_modules/react-intersection-observer": { - "version": "9.4.2", - "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.4.2.tgz", - "integrity": "sha512-AdK+ryzZ7U9ZJYttDUZ8q2Am3nqE0exg5Ryl5Y124KeVsix/1hGZPbdu58EqA98TwnzwDNWHxg/kwNawmIiUig==", + "version": "9.4.3", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.4.3.tgz", + "integrity": "sha512-WNRqMQvKpupr6MzecAQI0Pj0+JQong307knLP4g/nBex7kYfIaZsPpXaIhKHR+oV8z+goUbH9e10j6lGRnTzlQ==", "peerDependencies": { "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" } @@ -6631,6 +6659,88 @@ } } }, + "node_modules/vite-plugin-generate-file": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/vite-plugin-generate-file/-/vite-plugin-generate-file-0.0.4.tgz", + "integrity": "sha512-5cdsdSRGdtUxbAGdaXlW3Wiy46lK7LYm2FaTy42KCFT9fS6kiR+Ynjsjt7UEuE4nfStvCS9bVk9+YtsEIJ+Vhw==", + "dev": true, + "dependencies": { + "chalk": "^4.1.2", + "ejs": "^3.1.6", + "js-yaml": "^4.1.0", + "mime-types": "^2.1.35" + } + }, + "node_modules/vite-plugin-generate-file/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/vite-plugin-generate-file/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/vite-plugin-generate-file/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/vite-plugin-generate-file/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/vite-plugin-generate-file/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/vite-plugin-generate-file/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/vite-plugin-html-config": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/vite-plugin-html-config/-/vite-plugin-html-config-1.0.11.tgz", @@ -8593,6 +8703,14 @@ "dev": true, "optional": true }, + "@formatjs/intl-localematcher": { + "version": "0.2.32", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.32.tgz", + "integrity": "sha512-k/MEBstff4sttohyEpXxCmC3MqbUn9VvHGlZ8fauLzkbwXmVrEeyzS+4uhrvAk9DWU9/7otYWxyDox4nT/KVLQ==", + "requires": { + "tslib": "^2.4.0" + } + }, "@github/combobox-nav": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.1.5.tgz", @@ -8836,18 +8954,18 @@ } }, "@szhsin/react-menu": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.4.1.tgz", - "integrity": "sha512-Pxt7Kyp3yuX7zkT5tjdLRJGNFMa5Tx4BP+01gJ/dnMmHQpI1H2or9gEC0X+t3cLldO3LGmm4ViGypNCmQLv/4A==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.5.1.tgz", + "integrity": "sha512-bTCfVNBSReG4+mnbN8n2OQWZ3DRPlJgMIBJFepPfDLiRzNSe5lbZ8Z5Kjiv9nuPLHOu3jSaybxgYJj/Dn8n75Q==", "requires": { "prop-types": "^15.7.2", "react-transition-state": "^1.1.5" } }, "@trivago/prettier-plugin-sort-imports": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.1.0.tgz", - "integrity": "sha512-aTr6QPFaPAAzPRFn9yWB/9yKi3ZAFqfGpxIGLPWuQfYJFGUed+W3KKwxntsoCiNvNE2iuKOg6haMo5KG8WXltg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.1.1.tgz", + "integrity": "sha512-dQ2r2uzNr1x6pJsuh/8x0IRA3CBUB+pWEW3J/7N98axqt7SQSm+2fy0FLNXvXGg77xEDC7KHxJlHfLYyi7PDcw==", "dev": true, "requires": { "@babel/generator": "7.17.7", @@ -9078,6 +9196,12 @@ "color-convert": "^1.9.0" } }, + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, "async": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", @@ -10127,6 +10251,15 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, "jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -10831,9 +10964,9 @@ "dev": true }, "preact": { - "version": "10.12.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", - "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==" + "version": "10.13.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.13.0.tgz", + "integrity": "sha512-ERdIdUpR6doqdaSIh80hvzebHB7O6JxycOhyzAeLEchqOq/4yueslQbfnPwXaNhAYacFTyCclhwkEbOumT0tHw==" }, "prettier": { "version": "2.8.0", @@ -10918,9 +11051,9 @@ "requires": {} }, "react-intersection-observer": { - "version": "9.4.2", - "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.4.2.tgz", - "integrity": "sha512-AdK+ryzZ7U9ZJYttDUZ8q2Am3nqE0exg5Ryl5Y124KeVsix/1hGZPbdu58EqA98TwnzwDNWHxg/kwNawmIiUig==", + "version": "9.4.3", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.4.3.tgz", + "integrity": "sha512-WNRqMQvKpupr6MzecAQI0Pj0+JQong307knLP4g/nBex7kYfIaZsPpXaIhKHR+oV8z+goUbH9e10j6lGRnTzlQ==", "requires": {} }, "react-is": { @@ -11518,6 +11651,69 @@ "rollup": "^3.10.0" } }, + "vite-plugin-generate-file": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/vite-plugin-generate-file/-/vite-plugin-generate-file-0.0.4.tgz", + "integrity": "sha512-5cdsdSRGdtUxbAGdaXlW3Wiy46lK7LYm2FaTy42KCFT9fS6kiR+Ynjsjt7UEuE4nfStvCS9bVk9+YtsEIJ+Vhw==", + "dev": true, + "requires": { + "chalk": "^4.1.2", + "ejs": "^3.1.6", + "js-yaml": "^4.1.0", + "mime-types": "^2.1.35" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "vite-plugin-html-config": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/vite-plugin-html-config/-/vite-plugin-html-config-1.0.11.tgz", diff --git a/package.json b/package.json index e457306e..2ee389b7 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,10 @@ "sourcemap": "npx source-map-explorer dist/assets/*.js" }, "dependencies": { + "@formatjs/intl-localematcher": "~0.2.32", "@github/text-expander-element": "~2.3.0", "@iconify-icons/mingcute": "~1.2.4", - "@szhsin/react-menu": "~3.4.1", + "@szhsin/react-menu": "~3.5.1", "dayjs": "~1.11.7", "dayjs-twitter": "~0.5.0", "fast-blurhash": "~1.1.2", @@ -23,9 +24,9 @@ "mem": "~9.0.2", "p-retry": "~5.1.2", "p-throttle": "~5.0.0", - "preact": "~10.12.1", + "preact": "~10.13.0", "react-hotkeys-hook": "~4.3.7", - "react-intersection-observer": "~9.4.2", + "react-intersection-observer": "~9.4.3", "react-router-dom": "6.6.2", "string-length": "~5.0.1", "swiped-events": "~1.1.7", @@ -37,12 +38,13 @@ }, "devDependencies": { "@preact/preset-vite": "~2.5.0", - "@trivago/prettier-plugin-sort-imports": "~4.1.0", + "@trivago/prettier-plugin-sort-imports": "~4.1.1", "postcss": "~8.4.21", "postcss-dark-theme-class": "~0.7.3", "postcss-preset-env": "~8.0.1", "twitter-text": "~3.1.0", "vite": "~4.1.4", + "vite-plugin-generate-file": "~0.0.4", "vite-plugin-html-config": "~1.0.11", "vite-plugin-html-env": "~1.2.7", "vite-plugin-pwa": "~0.14.4", diff --git a/public/sw.js b/public/sw.js index ddf9f42d..14514039 100644 --- a/public/sw.js +++ b/public/sw.js @@ -21,8 +21,8 @@ const imageRoute = new Route( cacheName: 'remote-images', plugins: [ new ExpirationPlugin({ - maxEntries: 100, - maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days + maxEntries: 50, + maxAgeSeconds: 3 * 24 * 60 * 60, // 3 days purgeOnQuotaError: true, }), new CacheableResponsePlugin({ diff --git a/src/app.css b/src/app.css index 7a53f6c9..5a5e10b9 100644 --- a/src/app.css +++ b/src/app.css @@ -109,9 +109,8 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { border-bottom: var(--hairline-width) solid var(--divider-color); min-height: 3em; display: grid; - grid-template-columns: 1fr max-content 1fr; + grid-template-columns: 1fr minmax(0, max-content) 1fr; align-items: center; - text-overflow: ellipsis; white-space: nowrap; } .deck > header .header-grid > .header-side:last-of-type { @@ -126,6 +125,9 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { padding: 0; font-size: 1.2em; text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .deck > header .header-grid.header-grid-2 { grid-template-columns: 1fr max-content; @@ -749,6 +751,13 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { .updates-button .icon { vertical-align: top; } +@media (pointer: coarse) { + .updates-button:after { + content: ''; + position: absolute; + inset: -16px; + } +} /* BOX */ @@ -842,7 +851,6 @@ button.carousel-dot { backdrop-filter: blur(12px) invert(0.25); } button.carousel-dot { - color: var(--text-insignificant-color) !important; font-weight: bold; backdrop-filter: none !important; transition: all 0.2s; @@ -850,8 +858,11 @@ button.carousel-dot { button.carousel-dot[disabled] { pointer-events: none; } -button.carousel-dot:is(:hover, :focus, .active, [disabled].active) { - color: var(--button-text-color) !important; +button.carousel-dot:not(.active) { + opacity: 0.5; +} +button.carousel-dot:not(.active):is(:hover, :focus) { + opacity: 1; } button.carousel-dot:is(.active, [disabled].active) { opacity: 1; @@ -936,6 +947,7 @@ body:has(.status-deck) .media-post-link { transform: translateY(200%); pointer-events: none; user-select: none; + opacity: 0; } #compose-button .icon { transition: transform 0.3s ease-in-out; @@ -954,6 +966,12 @@ body:has(.status-deck) .media-post-link { #compose-button .icon { filter: drop-shadow(0 1px 2px var(--button-bg-color)); } +@media (max-width: calc(40em - 1px)) { + #app:has(#shortcuts .tab-bar) #compose-button { + bottom: calc(16px + 52px); + bottom: calc(16px + env(safe-area-inset-bottom) + 52px); + } +} /* SHEET */ @@ -1026,6 +1044,11 @@ body:has(.status-deck) .media-post-link { /* MENU POPUP */ +.szh-menu-container:has(.szh-menu--state-open) { + inset: 0; + inset: env(safe-area-inset-top) env(safe-area-inset-right) + env(safe-area-inset-bottom) env(safe-area-inset-left); +} .szh-menu { padding: 8px 0; margin: 0; @@ -1033,7 +1056,7 @@ body:has(.status-deck) .media-post-link { background-color: var(--bg-color); border: 1px solid var(--outline-color); border-radius: 8px; - box-shadow: 0 3px 6px var(--drop-shadow-color); + box-shadow: 0 3px 16px -3px var(--drop-shadow-color); text-align: left; animation: appear-smooth 0.15s ease-in-out; width: 16em; @@ -1043,25 +1066,45 @@ body:has(.status-deck) .media-post-link { .szh-menu__item--focusable { background-color: transparent; } +.szh-menu__header { + margin: -8px 0 8px; + padding: 8px 16px; + color: var(--text-insignificant-color); + font-size: 90%; + background-color: var(--bg-faded-color); + /* background-image: linear-gradient(to top, var(--bg-faded-color), transparent); */ + text-shadow: 0 1px 0 var(--bg-color); + line-height: 1.2; + /* border-bottom: 1px solid var(--outline-color); */ +} +.szh-menu__header * { + vertical-align: middle; +} .szh-menu .szh-menu__item { + display: flex; + gap: 8px; + align-items: center; + line-height: 1; padding: 8px 16px !important; transition: all 0.1s ease-in-out; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + text-decoration: none; } .szh-menu .szh-menu__item * { vertical-align: middle; } .szh-menu .szh-menu__item a { + flex: 1; overflow: hidden; text-overflow: ellipsis; display: flex; + gap: 8px; color: inherit; text-decoration: none; padding: 8px 16px !important; margin: -8px -16px !important; - gap: 8px; } .szh-menu .szh-menu__item a.is-active { font-weight: bold; @@ -1073,7 +1116,7 @@ body:has(.status-deck) .media-post-link { .szh-menu__item:not(.szh-menu__item--disabled, .szh-menu__item--hover) { color: var(--text-color); } -.szh-menu .szh-menu__item--hover { +.szh-menu .szh-menu__item--hover:not(.menu-field) { color: var(--button-text-color); background-color: var(--button-bg-color); } @@ -1082,11 +1125,27 @@ body:has(.status-deck) .media-post-link { } .szh-menu .szh-menu__item .menu-grow { flex-grow: 1; + text-overflow: ellipsis; + overflow: hidden; } .szh-menu .szh-menu__item .menu-shortcut { opacity: 0.5; font-weight: normal; } +.szh-menu .szh-menu__item form { + display: flex; + flex: 1; + gap: 8px; + align-items: center; +} +.szh-menu .szh-menu__item form > input[type='text'] { + flex-grow: 1; + width: 100%; +} +.szh-menu .szh-menu__item--hover .danger-icon { + color: var(--red-color); + opacity: 1; +} /* GLASS MENU */ @@ -1161,7 +1220,7 @@ meter.donut:is(.danger, .explode):after { .shiny-pill { color: var(--button-text-color); - text-shadow: 0 -1px var(--drop-shadow-color); + text-shadow: 0 calc(var(--hairline-width) * -1) var(--drop-shadow-color); background-color: var(--button-bg-color); background-image: linear-gradient( 160deg, @@ -1228,43 +1287,6 @@ meter.donut:is(.danger, .explode):after { /* content-visibility: hidden; */ } -/* TAB BAR */ - -#tab-bar:not([hidden]) { - position: fixed; - bottom: 16px; - bottom: max(16px, env(safe-area-inset-bottom)); - width: calc(100% - 32px); - max-width: calc(var(--main-width) - 32px); - z-index: 100; - display: flex; - background-color: var(--bg-blur-color); - backdrop-filter: blur(16px) saturate(3); - border: var(--hairline-width) solid var(--outline-color); - border-radius: 16px; - box-shadow: 0 8px 32px var(--outline-color); -} -#tab-bar li { - flex-grow: 1; - margin: 0; - padding: 0; - list-style: none; -} -#tab-bar li a { - text-align: center; - padding: 16px 0; - display: block; - color: var(--text-insignificant-color); -} -#tab-bar li a.is-active { - color: var(--link-color); - background-image: radial-gradient( - closest-side at 50% 50%, - var(--bg-blur-color), - transparent 75% - ); -} - /* 404 */ #not-found-page { @@ -1483,15 +1505,19 @@ ul.link-list li a .icon { margin-top: 24px; } .timeline:not(.flat) > li { + --item-radius: 16px; border: 1px solid var(--divider-color); margin: 16px 0; background-color: var(--bg-color); - border-radius: 16px; + border-radius: var(--item-radius); overflow: hidden; box-shadow: 0px 1px var(--bg-blur-color); transition: transform 0.4s var(--timing-function); --back-transition: transform 0.4s ease-out; } + .timeline:not(.flat) > li > a { + border-radius: var(--item-radius); + } .timeline:not(.flat) > li:not(:has(.status-carousel)) { transform: translate3d(0, 0, 0); } diff --git a/src/app.jsx b/src/app.jsx index 113b74ae..ebbce5dd 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -14,14 +14,11 @@ import { useLocation, useNavigate, } from 'react-router-dom'; -import Toastify from 'toastify-js'; import { useSnapshot } from 'valtio'; import Account from './components/account'; import Compose from './components/compose'; import Drafts from './components/drafts'; -import Icon from './components/icon'; -import Link from './components/link'; import Loader from './components/loader'; import MediaModal from './components/media-modal'; import Modal from './components/modal'; @@ -53,9 +50,11 @@ import { initPreferences, } from './utils/api'; import { getAccessToken } from './utils/auth'; +import showToast from './utils/show-toast'; import states, { getStatus, saveStatus } from './utils/states'; import store from './utils/store'; import { getCurrentAccount } from './utils/store-utils'; +import useInterval from './utils/useInterval'; import usePageVisibility from './utils/usePageVisibility'; window.__STATES__ = states; @@ -139,11 +138,24 @@ function App() { }; const focusDeck = () => { let timer = setTimeout(() => { - const page = document.getElementById(locationDeckMap[location.pathname]); - console.debug('FOCUS', location.pathname, page); - if (page) { - page.focus(); + const columns = document.getElementById('columns'); + if (columns) { + // Focus first column + columns.querySelector('.deck-container')?.focus?.(); + } else { + // Focus last deck + const pages = document.querySelectorAll('.deck-container'); + const page = pages[pages.length - 1]; // last one + if (page && page.tabIndex === -1) { + console.log('FOCUS', page); + page.focus(); + } } + // const page = document.getElementById(locationDeckMap[location.pathname]); + // console.debug('FOCUS', location.pathname, page); + // if (page) { + // page.focus(); + // } }, 100); return () => clearTimeout(timer); }; @@ -175,7 +187,7 @@ function App() { const notificationStream = useRef(); useEffect(() => { if (isLoggedIn && visible) { - const { masto } = api(); + const { masto, instance } = api(); (async () => { // 1. Get the latest notification if (states.notificationsLast) { @@ -200,6 +212,11 @@ function App() { notificationStream.current.on('notification', (notification) => { console.log('🔔🔔 Notification', notification); + if (notification.status) { + saveStatus(notification.status, instance, { + skipThreading: true, + }); + } states.notificationsShowNew = true; }); @@ -236,6 +253,18 @@ function App() { return !/^\/(login|welcome)/.test(pathname); }, [location]); + useInterval(() => { + console.log('✨ Check app update'); + fetch('./version.json') + .then((r) => r.json()) + .then((info) => { + if (info) states.appVersion = info; + }) + .catch((e) => { + console.error(e); + }); + }, visible && 1000 * 60 * 60); // 1 hour + return ( <> @@ -281,24 +310,12 @@ function App() { } /> - - {!snapStates.settings.shortcutsColumnsMode && } +
+ {!snapStates.settings.shortcutsColumnsMode && + snapStates.settings.shortcutsViewMode !== 'multi-column' && ( + + )} +
{!!snapStates.showCompose && ( { - const toast = Toastify({ - className: 'shiny-pill', - text: 'Status posted. Check it out.', - duration: 10_000, // 10 seconds - gravity: 'bottom', - position: 'center', - // destination: `/#/s/${newStatus.id}`, - onClick: () => { - toast.hideToast(); - states.prevLocation = location; - navigate( - instance - ? `/${instance}/s/${newStatus.id}` - : `/s/${newStatus.id}`, - ); - }, - }); - toast.showToast(); - }, 1000); + showToast({ + text: 'Status posted. Check it out.', + delay: 1000, + duration: 10_000, // 10 seconds + onClick: (toast) => { + toast.hideToast(); + states.prevLocation = location; + navigate( + instance + ? `/${instance}/s/${newStatus.id}` + : `/s/${newStatus.id}`, + ); + }, + }); } }} /> diff --git a/src/components/account.jsx b/src/components/account.jsx index 73ed46be..76dc18c7 100644 --- a/src/components/account.jsx +++ b/src/components/account.jsx @@ -7,6 +7,7 @@ import { api } from '../utils/api'; import emojifyText from '../utils/emojify-text'; import enhanceContent from '../utils/enhance-content'; import handleContentLinks from '../utils/handle-content-links'; +import niceDateTime from '../utils/nice-date-time'; import shortenNumber from '../utils/shorten-number'; import states, { hideAllModals } from '../utils/states'; import store from '../utils/store'; @@ -205,11 +206,9 @@ function Account({ account, instance: propInstance, onClose }) {
diff --git a/src/components/compose.css b/src/components/compose.css index 8c0d0956..a56bc469 100644 --- a/src/components/compose.css +++ b/src/components/compose.css @@ -60,7 +60,7 @@ animation: appear-up 1s ease-in-out; overflow: auto; } -#compose-container .status-preview .hashtag { +#compose-container .status-preview :is(.hashtag, .time) { /* Prevent hashtags from being clickable */ /* TODO: maybe use a different solution? */ pointer-events: none; diff --git a/src/components/compose.jsx b/src/components/compose.jsx index 848e8c8f..0eafecf0 100644 --- a/src/components/compose.jsx +++ b/src/components/compose.jsx @@ -1,5 +1,6 @@ import './compose.css'; +import { match } from '@formatjs/intl-localematcher'; import '@github/text-expander-element'; import equal from 'fast-deep-equal'; import { forwardRef } from 'preact/compat'; @@ -84,7 +85,11 @@ const observer = new IntersectionObserver((entries) => { }); observer.observe(menu); -const DEFAULT_LANG = 'en'; +const DEFAULT_LANG = match( + [new Intl.DateTimeFormat().resolvedOptions().locale, ...navigator.languages], + supportedLanguages.map((l) => l[0]), + 'en', +); // https://github.com/mastodon/mastodon/blob/c4a429ed47e85a6bbf0d470a41cc2f64cf120c19/app/javascript/mastodon/features/compose/util/counter.js const urlRegexObj = new RegExp(urlRegex.source, urlRegex.flags); @@ -772,7 +777,7 @@ function Compose({ editStatus.id, params, ); - saveStatus(newStatus, { + saveStatus(newStatus, instance, { skipThreading: true, }); } else { @@ -1014,7 +1019,7 @@ function Compose({ onChange={(e) => { const { value } = e.target; setLanguage(value || DEFAULT_LANG); - store.session.set('language', value); + store.session.set('currentLanguage', value || DEFAULT_LANG); }} disabled={uiState === 'loading'} > diff --git a/src/components/drafts.jsx b/src/components/drafts.jsx index 28c7c35d..445add1b 100644 --- a/src/components/drafts.jsx +++ b/src/components/drafts.jsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useReducer, useState } from 'react'; import { api } from '../utils/api'; import db from '../utils/db'; +import niceDateTime from '../utils/nice-date-time'; import states from '../utils/states'; import { getCurrentAccountNS } from '../utils/store-utils'; @@ -67,7 +68,6 @@ function Drafts() {
    {drafts.map((draft) => { const { updatedAt, key, draftStatus, replyTo } = draft; - const currentYear = new Date().getFullYear(); const updatedAtDate = new Date(updatedAt); return (
  • @@ -81,19 +81,7 @@ function Drafts() {
    )} - {Intl.DateTimeFormat('en', { - // Show year if not current year - year: - updatedAtDate.getFullYear() === currentYear - ? undefined - : 'numeric', - month: 'short', - day: 'numeric', - weekday: 'short', - hour: 'numeric', - minute: '2-digit', - second: '2-digit', - }).format(updatedAtDate)} + {niceDateTime(updatedAtDate)} } > + {!!snapStates.appVersion?.commitHash && + __COMMIT_HASH__ !== snapStates.appVersion.commitHash && ( + <> + { + const yes = confirm('Reload page now to update?'); + if (yes) { + (async () => { + try { + location.reload(); + } catch (e) {} + })(); + } + }} + > + {' '} + New update available… + + + + )} Home diff --git a/src/components/shortcuts-settings.css b/src/components/shortcuts-settings.css index bec8246a..0758f967 100644 --- a/src/components/shortcuts-settings.css +++ b/src/components/shortcuts-settings.css @@ -26,44 +26,34 @@ #shortcuts-settings-container .shortcuts-list li .shortcut-text { flex-grow: 1; } +#shortcuts-settings-container .shortcuts-list li .shortcut-actions { + flex-shrink: 0; +} + +#shortcuts-settings-container .shortcuts-view-mode { + display: flex; + align-items: center; + gap: 8px; +} #shortcuts-settings-container summary { cursor: pointer; } -#shortcuts-settings-container form { - display: flex; - gap: 8px; - flex-wrap: wrap; - align-items: center; - padding: 16px; - background-color: var(--bg-faded-color); - border-radius: 16px; +#shortcut-settings-form form > * { } -#shortcuts-settings-container form header { - display: flex; - align-items: center; - justify-content: space-between; -} - -#shortcuts-settings-container form > * { - flex-basis: max(320px, 100%); - margin: 0; - padding: 0; -} - -#shortcuts-settings-container form label { +#shortcut-settings-form label { display: flex; flex-direction: row; gap: 8px; align-items: center; } -#shortcuts-settings-container form label > span:first-child { +#shortcut-settings-form label > span:first-child { flex-basis: 5em; text-align: right; } -#shortcuts-settings-container form :is(input[type='text'], select) { +#shortcut-settings-form :is(input[type='text'], select) { flex-grow: 1; flex-basis: 70%; flex-shrink: 1; diff --git a/src/components/shortcuts-settings.jsx b/src/components/shortcuts-settings.jsx index d933ce27..ecf39c3d 100644 --- a/src/components/shortcuts-settings.jsx +++ b/src/components/shortcuts-settings.jsx @@ -9,6 +9,7 @@ import states from '../utils/states'; import AsyncText from './AsyncText'; import Icon from './icon'; +import Modal from './modal'; const SHORTCUTS_LIMIT = 9; @@ -75,22 +76,26 @@ const TYPE_PARAMS = { text: '#', name: 'hashtag', type: 'text', - placeholder: 'e.g PixelArt', + placeholder: 'e.g. PixelArt (Max 5, space-separated)', + pattern: '[^#]+', }, ], }; export const SHORTCUTS_META = { following: { - title: 'Home / Following', - path: (_, index) => (index === 0 ? '/' : '/following'), + id: 'home', + title: (_, index) => (index === 0 ? 'Home' : 'Following'), + path: '/', icon: 'home', }, notifications: { + id: 'notifications', title: 'Notifications', path: '/notifications', icon: 'notification', }, list: { + id: 'list', title: mem( async ({ id }) => { const list = await api().masto.v1.lists.fetch(id); @@ -104,17 +109,20 @@ export const SHORTCUTS_META = { icon: 'list', }, public: { + id: 'public', title: ({ local, instance }) => `${local ? 'Local' : 'Federated'} (${instance})`, path: ({ local, instance }) => `/${instance}/p${local ? '/l' : ''}`, icon: ({ local }) => (local ? 'group' : 'earth'), }, search: { + id: 'search', title: ({ query }) => query, path: ({ query }) => `/search?q=${query}`, icon: 'search', }, 'account-statuses': { + id: 'account-statuses', title: mem( async ({ id }) => { const account = await api().masto.v1.accounts.fetch(id); @@ -128,18 +136,21 @@ export const SHORTCUTS_META = { icon: 'user', }, bookmarks: { + id: 'bookmarks', title: 'Bookmarks', path: '/b', icon: 'bookmark', }, favourites: { + id: 'favourites', title: 'Favourites', path: '/f', icon: 'heart', }, hashtag: { + id: 'hashtag', title: ({ hashtag }) => hashtag, - path: ({ hashtag }) => `/t/${hashtag}`, + path: ({ hashtag }) => `/t/${hashtag.split(/\s+/).join('+')}`, icon: 'hashtag', }, }; @@ -151,6 +162,7 @@ function ShortcutsSettings() { const [lists, setLists] = useState([]); const [followedHashtags, setFollowedHashtags] = useState([]); + const [showForm, setShowForm] = useState(false); useEffect(() => { (async () => { @@ -196,10 +208,21 @@ function ShortcutsSettings() {

    - Specify a list of shortcuts that'll appear in the floating Shortcuts - button. +

    -

    + {/*

    Experimental Multi-column mode @@ -215,7 +238,7 @@ function ShortcutsSettings() { Show shortcuts in multiple columns instead of the floating button.
    -

    +

    */} {shortcuts.length > 0 ? (
      {shortcuts.map((shortcut, i) => { @@ -224,10 +247,10 @@ function ShortcutsSettings() { if (!SHORTCUTS_META[type]) return null; let { icon, title } = SHORTCUTS_META[type]; if (typeof title === 'function') { - title = title(shortcut); + title = title(shortcut, i); } if (typeof icon === 'function') { - icon = icon(shortcut); + icon = icon(shortcut, i); } return (
    1. @@ -235,7 +258,7 @@ function ShortcutsSettings() { {title} - + +

      +
    + {showForm && ( + { + if (e.target === e.currentTarget) { + setShowForm(false); + } + }} + > + = SHORTCUTS_LIMIT} + lists={lists} + followedHashtags={followedHashtags} + onSubmit={(data) => { + console.log('onSubmit', data); + states.shortcuts.push(data); + }} + onClose={() => setShowForm(false)} + /> + + )} + + ); +} + +function ShortcutForm({ + type, + lists, + followedHashtags, + onSubmit, + disabled, + onClose = () => {}, +}) { + const [currentType, setCurrentType] = useState(type); + return ( +
    +
    +

    Add shortcut

    +
    +
    +
    { + // Construct a nice object from form + e.preventDefault(); + const data = new FormData(e.target); + const result = {}; + data.forEach((value, key) => { + result[key] = value?.trim(); + }); + if (!result.type) return; + onSubmit(result); + // Reset + e.target.reset(); + setCurrentType(null); + onClose(); + }} + > +

    + +

    + {TYPE_PARAMS[currentType]?.map?.( + ({ text, name, type, placeholder, pattern }) => { + if (currentType === 'list') { + return ( +

    + +

    + ); + } + + return ( +

    + +

    + ); + }, + )} + +
    ); } export default ShortcutsSettings; -function ShortcutForm({ type, lists, followedHashtags, onSubmit, disabled }) { - const [currentType, setCurrentType] = useState(type); - return ( - <> -
    { - // Construct a nice object from form - e.preventDefault(); - const data = new FormData(e.target); - const result = {}; - data.forEach((value, key) => { - result[key] = value; - }); - if (!result.type) return; - onSubmit(result); - // Reset - e.target.reset(); - setCurrentType(null); - }} - > -
    -

    Add a shortcut

    - -
    -

    - -

    - {TYPE_PARAMS[currentType]?.map?.( - ({ text, name, type, placeholder }) => { - if (currentType === 'list') { - return ( -

    - -

    - ); - } - - return ( -

    - -

    - ); - }, - )} -
    - - ); -} diff --git a/src/components/shortcuts.css b/src/components/shortcuts.css index 851a9d05..5c57a920 100644 --- a/src/components/shortcuts.css +++ b/src/components/shortcuts.css @@ -44,3 +44,129 @@ transform: translateY(-200%); } } + +/* TAB BAR */ + +#shortcuts .tab-bar:not([hidden]) { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 100; + background-color: var(--bg-blur-color); + backdrop-filter: blur(16px) saturate(3); + border-top: var(--hairline-width) solid var(--outline-color); + box-shadow: 0 -8px 16px -8px var(--drop-shadow-color); + overflow: auto; + transition: all 0.3s ease-in-out; + padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) + env(safe-area-inset-left); + user-select: none; + -webkit-touch-callout: none; +} +#shortcuts .tab-bar ul { + display: flex; + margin: 0; + padding: 0; +} +#shortcuts .tab-bar li { + flex-grow: 1; + margin: 0; + padding: 0; + list-style: none; + display: flex; + justify-content: center; +} +#shortcuts .tab-bar li a { + -webkit-tap-highlight-color: transparent; + display: block; + color: var(--text-insignificant-color); + font-size: 10px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 8px; + text-decoration: none; + text-shadow: 0 var(--hairline-width) var(--bg-color); + width: 20vw; +} +#shortcuts .tab-bar li a:active { + transform: scale(0.95); + transition: none; +} +#shortcuts .tab-bar li a * { + pointer-events: none; +} +#shortcuts .tab-bar li a span { + width: 100%; + text-align: center; + display: block; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} +#shortcuts .tab-bar li a.is-active { + color: var(--link-color); + background-image: radial-gradient( + closest-side at 50% 50%, + var(--bg-color), + transparent + ); +} +#app:has(header[hidden]) #shortcuts .tab-bar, +shortcuts .tab-bar[hidden] { + transform: translateY(200%); + pointer-events: none; + user-select: none; +} + +@media (max-width: calc(40em - 1px)) { + #app:has(#shortcuts .tab-bar) .deck-container { + padding-bottom: 52px; + } +} + +@media (min-width: 40em) { + #app:has(#shortcuts .tab-bar) .timeline-deck { + margin-top: 44px; + } + #app:has(#shortcuts .tab-bar) .timeline-deck > header { + --margin-top: calc(44px + 8px); + } + #shortcuts .tab-bar:not([hidden]) { + top: 0; + bottom: auto; + padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 + env(safe-area-inset-left); + background-color: var(--bg-faded-blur-color); + border: 0; + box-shadow: none; + border-bottom: var(--hairline-width) solid var(--bg-faded-color); + } + #shortcuts .tab-bar ul:before { + content: ''; + margin: auto; + } + #shortcuts .tab-bar ul:after { + content: ''; + margin: auto; + } + #shortcuts .tab-bar li { + flex-grow: 0; + } + #shortcuts .tab-bar li a { + padding: 0 16px; + width: auto; + flex-direction: row; + font-size: 14px; + height: 44px; + gap: 4px; + } + #app:has(header[hidden]) #shortcuts .tab-bar, + shortcuts .tab-bar[hidden] { + transform: translateY(-150%); + pointer-events: none; + user-select: none; + } +} diff --git a/src/components/shortcuts.jsx b/src/components/shortcuts.jsx index 19c8d338..c0832415 100644 --- a/src/components/shortcuts.jsx +++ b/src/components/shortcuts.jsx @@ -11,6 +11,7 @@ import states from '../utils/states'; import AsyncText from './AsyncText'; import Icon from './icon'; +import Link from './link'; import MenuLink from './MenuLink'; function Shortcuts() { @@ -29,19 +30,23 @@ function Shortcuts() { .map((pin, i) => { const { type, ...data } = pin; if (!SHORTCUTS_META[type]) return null; - let { path, title, icon } = SHORTCUTS_META[type]; + let { id, path, title, icon } = SHORTCUTS_META[type]; + if (typeof id === 'function') { + id = id(data, i); + } if (typeof path === 'function') { path = path(data, i); } if (typeof title === 'function') { - title = title(data); + title = title(data, i); } if (typeof icon === 'function') { - icon = icon(data); + icon = icon(data, i); } return { + id, path, title, icon, @@ -65,47 +70,83 @@ function Shortcuts() { return (
    - { - // Close menu if the button disappears - try { - const { target } = e; - if (getComputedStyle(target).pointerEvents === 'none') { - menuRef.current?.closeMenu?.(); - } - } catch (e) {} - }} - > - - - } - > - {formattedShortcuts.map(({ path, title, icon }, i) => { - return ( - - {' '} - - {title} - - - {i + 1} - - - ); - })} - + {snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? ( + + ) : ( + { + // Close menu if the button disappears + try { + const { target } = e; + if (getComputedStyle(target).pointerEvents === 'none') { + menuRef.current?.closeMenu?.(); + } + } catch (e) {} + }} + > + + + } + > + {formattedShortcuts.map(({ path, title, icon }, i) => { + return ( + + {' '} + + {title} + + + {i + 1} + + + ); + })} + + )}
    ); } diff --git a/src/components/status.css b/src/components/status.css index 3b9c6ccb..424c0f87 100644 --- a/src/components/status.css +++ b/src/components/status.css @@ -141,9 +141,23 @@ margin-left: 4px; white-space: nowrap; } +.status > .container > .meta a.time { + position: relative; + overflow: visible; +} .status > .container > .meta a.time:is(:hover, :focus) { text-decoration: underline; } +.status > .container > .meta a.time:active, +.status > .container > .meta a.time.is-open { + text-decoration: none; + opacity: 1; +} +.status > .container > .meta a.time:after { + content: ''; + position: absolute; + inset: -16px; +} .status > .container > .meta .reply-to { opacity: 0.5; font-size: smaller; @@ -321,6 +335,11 @@ gap: 2px; height: 160px; } +.status .media-container.media-eq1 { + min-height: 44px; + height: auto; + max-height: 160px; +} .status .media-container.media-gt2 { height: 200px; } @@ -644,7 +663,11 @@ a:focus-visible .card img { flex-grow: 1; align-self: center; } -.status.large .card.large .meta-container { +.status.large .card.large .meta-container, +.status-carousel + .content-container[data-content-text-weight='1'] + .card.large + .meta-container { align-self: flex-start; flex-grow: 0; } diff --git a/src/components/status.jsx b/src/components/status.jsx index 5c21528a..652bd656 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -1,6 +1,12 @@ import './status.css'; -import { Menu, MenuItem } from '@szhsin/react-menu'; +import { + ControlledMenu, + Menu, + MenuDivider, + MenuHeader, + MenuItem, +} from '@szhsin/react-menu'; import mem from 'mem'; import pThrottle from 'p-throttle'; import { memo } from 'preact/compat'; @@ -16,7 +22,9 @@ import { api } from '../utils/api'; import enhanceContent from '../utils/enhance-content'; import handleContentLinks from '../utils/handle-content-links'; import htmlContentLength from '../utils/html-content-length'; +import niceDateTime from '../utils/nice-date-time'; import shortenNumber from '../utils/shorten-number'; +import showToast from '../utils/show-toast'; import states, { saveStatus, statusKey } from '../utils/states'; import store from '../utils/store'; import visibilityIconsMap from '../utils/visibility-icons-map'; @@ -25,6 +33,7 @@ import Avatar from './avatar'; import Icon from './icon'; import Link from './link'; import Media from './media'; +import MenuLink from './MenuLink'; import RelativeTime from './relative-time'; const throttle = pThrottle({ @@ -41,6 +50,13 @@ function fetchAccount(id, masto) { } const memFetchAccount = mem(fetchAccount); +const visibilityText = { + public: 'Public', + unlisted: 'Unlisted', + private: 'Followers only', + direct: 'Direct', +}; + function Status({ statusID, status, @@ -180,8 +196,6 @@ function Status({ const [showEdited, setShowEdited] = useState(false); - const currentYear = new Date().getFullYear(); - const spoilerContentRef = useRef(null); useResizeObserver({ ref: spoilerContentRef, @@ -217,6 +231,266 @@ function Status({ const textWeight = () => Math.round((spoilerText.length + htmlContentLength(content)) / 140) || 1; + const createdDateText = niceDateTime(createdAtDate); + const editedDateText = editedAt && niceDateTime(editedAtDate); + + const isSizeLarge = size === 'l'; + // TODO: if visibility = private, only can boost own statuses + const canBoost = authenticated && visibility !== 'direct'; + + const replyStatus = () => { + if (!sameInstance || !authenticated) { + return alert(unauthInteractionErrorMessage); + } + states.showCompose = { + replyToStatus: status, + }; + }; + + const boostStatus = async () => { + if (!sameInstance || !authenticated) { + return alert(unauthInteractionErrorMessage); + } + try { + if (!reblogged) { + const yes = confirm('Boost this post?'); + if (!yes) { + return; + } + } + // Optimistic + states.statuses[sKey] = { + ...status, + reblogged: !reblogged, + reblogsCount: reblogsCount + (reblogged ? -1 : 1), + }; + if (reblogged) { + const newStatus = await masto.v1.statuses.unreblog(id); + saveStatus(newStatus, instance); + } else { + const newStatus = await masto.v1.statuses.reblog(id); + saveStatus(newStatus, instance); + } + } catch (e) { + console.error(e); + // Revert optimistism + states.statuses[sKey] = status; + } + }; + + const favouriteStatus = async () => { + if (!sameInstance || !authenticated) { + return alert(unauthInteractionErrorMessage); + } + try { + // Optimistic + states.statuses[sKey] = { + ...status, + favourited: !favourited, + favouritesCount: favouritesCount + (favourited ? -1 : 1), + }; + if (favourited) { + const newStatus = await masto.v1.statuses.unfavourite(id); + saveStatus(newStatus, instance); + } else { + const newStatus = await masto.v1.statuses.favourite(id); + saveStatus(newStatus, instance); + } + } catch (e) { + console.error(e); + // Revert optimistism + states.statuses[sKey] = status; + } + }; + + const bookmarkStatus = async () => { + if (!sameInstance || !authenticated) { + return alert(unauthInteractionErrorMessage); + } + try { + // Optimistic + states.statuses[sKey] = { + ...status, + bookmarked: !bookmarked, + }; + if (bookmarked) { + const newStatus = await masto.v1.statuses.unbookmark(id); + saveStatus(newStatus, instance); + } else { + const newStatus = await masto.v1.statuses.bookmark(id); + saveStatus(newStatus, instance); + } + } catch (e) { + console.error(e); + // Revert optimistism + states.statuses[sKey] = status; + } + }; + + const menuInstanceRef = useRef(); + const StatusMenuItems = ( + <> + {!isSizeLarge && ( + <> + + + {' '} + {visibilityText[visibility]} + {' '} + + {repliesCount > 0 && ( + + {' '} + {shortenNumber(repliesCount)} + + )}{' '} + {reblogsCount > 0 && ( + + {' '} + {shortenNumber(reblogsCount)} + + )}{' '} + {favouritesCount > 0 && ( + + {' '} + {shortenNumber(favouritesCount)} + + )} + +
    + {createdDateText} +
    + + + View post and replies + + + )} + {!!editedAt && ( + { + setShowEdited(id); + }} + > + + + Show Edit History +
    + Edited: {editedDateText} +
    +
    + )} + {(!isSizeLarge || !!editedAt) && } + {!isSizeLarge && ( + <> + + + Reply + + {canBoost && ( + { + try { + await boostStatus(); + if (!isSizeLarge) + showToast(reblogged ? 'Unboosted' : 'Boosted'); + } catch (e) {} + }} + > + + {reblogged ? 'Unboost' : 'Boost…'} + + )} + { + try { + favouriteStatus(); + if (!isSizeLarge) + showToast(favourited ? 'Unfavourited' : 'Favourited'); + } catch (e) {} + }} + > + + {favourited ? 'Unfavourite' : 'Favourite'} + + { + try { + bookmarkStatus(); + if (!isSizeLarge) + showToast(bookmarked ? 'Unbookmarked' : 'Bookmarked'); + } catch (e) {} + }} + > + + {bookmarked ? 'Unbookmark' : 'Bookmark'} + + + + )} + + + Open link to post + + { + // Copy url to clipboard + try { + navigator.clipboard.writeText(url); + showToast('Link copied'); + } catch (e) { + console.error(e); + showToast('Unable to copy link'); + } + }} + > + + Copy link to post + + {navigator?.share && + navigator?.canShare?.({ + url, + }) && ( + { + try { + navigator.share({ + url, + }); + } catch (e) { + console.error(e); + alert("Sharing doesn't seem to work."); + } + }} + > + + Share… + + )} + {isSelf && ( + <> + + { + states.showCompose = { + editStatus: status, + }; + }} + > + + Edit + + + )} + + ); + + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); + const [contextMenuAnchorPoint, setContextMenuAnchorPoint] = useState({ + x: 0, + y: 0, + }); + return (
    { + if (size === 'l') return; + if (e.metaKey) return; + e.preventDefault(); + setContextMenuAnchorPoint({ + x: e.clientX, + y: e.clientY, + }); + setIsContextMenuOpen(true); + }} > + {size !== 'l' && ( + setIsContextMenuOpen(false)} + portal={{ + target: document.body, + }} + containerProps={{ + style: { + // Higher than the backdrop + zIndex: 1001, + }, + }} + overflow="auto" + boundingBoxPadding="8 8 8 8" + unmountOnClose + > + {StatusMenuItems} + + )} {size !== 'l' && (
    {reblogged && } @@ -265,7 +571,7 @@ function Status({ account={status.account} instance={instance} showAvatar={size === 's'} - showAcct={size === 'l'} + showAcct={isSizeLarge} /> {/* {inReplyToAccount && !withinContext && size !== 's' && ( <> @@ -279,22 +585,51 @@ function Status({ {/* */}{' '} {size !== 'l' && (url ? ( - { + menuInstanceRef.current?.closeMenu?.(); + }, + }} + align="end" + offsetY={4} + overflow="auto" + viewScroll="close" + boundingBoxPadding="8 8 8 8" + unmountOnClose + menuButton={({ open }) => ( + { + e.preventDefault(); + e.stopPropagation(); + }} + class={`time ${open ? 'is-open' : ''}`} + > + {' '} + + + )} > - {' '} - - + {StatusMenuItems} + ) : ( {' '} @@ -337,7 +672,7 @@ function Status({ } ${showSpoiler ? 'show-spoiler' : ''}`} data-content-text-weight={contentTextWeight ? textWeight() : null} style={ - (size === 'l' || contentTextWeight) && { + (isSizeLarge || contentTextWeight) && { '--content-text-weight': textWeight(), } } @@ -457,12 +792,12 @@ function Status({ } ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`} > {mediaAttachments - .slice(0, size === 'l' ? undefined : 4) + .slice(0, isSizeLarge ? undefined : 4) .map((media, i) => ( { e.preventDefault(); e.stopPropagation(); @@ -485,23 +820,13 @@ function Status({ )}
    - {size === 'l' && ( + {isSizeLarge && ( <>
    {' '} {editedAt && ( @@ -515,17 +840,7 @@ function Status({ setShowEdited(id); }} > - {Intl.DateTimeFormat('en', { - // Show year if not this year - year: - editedAtDate.getFullYear() === currentYear - ? undefined - : 'numeric', - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - }).format(editedAtDate)} + {editedDateText} )} @@ -538,18 +853,10 @@ function Status({ class="reply-button" icon="comment" count={repliesCount} - onClick={() => { - if (!sameInstance || !authenticated) { - return alert(unauthInteractionErrorMessage); - } - states.showCompose = { - replyToStatus: status, - }; - }} + onClick={replyStatus} />
    - {/* TODO: if visibility = private, only can reblog own statuses */} - {visibility !== 'direct' && ( + {canBoost && (
    { - if (!sameInstance || !authenticated) { - return alert(unauthInteractionErrorMessage); - } - try { - if (!reblogged) { - const yes = confirm('Boost this post?'); - if (!yes) { - return; - } - } - // Optimistic - states.statuses[sKey] = { - ...status, - reblogged: !reblogged, - reblogsCount: reblogsCount + (reblogged ? -1 : 1), - }; - if (reblogged) { - const newStatus = await masto.v1.statuses.unreblog( - id, - ); - saveStatus(newStatus, instance); - } else { - const newStatus = await masto.v1.statuses.reblog(id); - saveStatus(newStatus, instance); - } - } catch (e) { - console.error(e); - // Revert optimistism - states.statuses[sKey] = status; - } - }} + onClick={boostStatus} />
    )} @@ -601,33 +877,7 @@ function Status({ class="favourite-button" icon="heart" count={favouritesCount} - onClick={async () => { - if (!sameInstance || !authenticated) { - return alert(unauthInteractionErrorMessage); - } - try { - // Optimistic - states.statuses[sKey] = { - ...status, - favourited: !favourited, - favouritesCount: - favouritesCount + (favourited ? -1 : 1), - }; - if (favourited) { - const newStatus = await masto.v1.statuses.unfavourite( - id, - ); - saveStatus(newStatus, instance); - } else { - const newStatus = await masto.v1.statuses.favourite(id); - saveStatus(newStatus, instance); - } - } catch (e) { - console.error(e); - // Revert optimistism - states.statuses[sKey] = status; - } - }} + onClick={favouriteStatus} />
    @@ -637,61 +887,33 @@ function Status({ alt={['Bookmark', 'Bookmarked']} class="bookmark-button" icon="bookmark" - onClick={async () => { - if (!sameInstance || !authenticated) { - return alert(unauthInteractionErrorMessage); - } - try { - // Optimistic - states.statuses[sKey] = { - ...status, - bookmarked: !bookmarked, - }; - if (bookmarked) { - const newStatus = await masto.v1.statuses.unbookmark( - id, - ); - saveStatus(newStatus, instance); - } else { - const newStatus = await masto.v1.statuses.bookmark(id); - saveStatus(newStatus, instance); - } - } catch (e) { - console.error(e); - // Revert optimistism - states.statuses[sKey] = status; - } - }} + onClick={bookmarkStatus} />
    - {isSelf && ( - - - - } - > - {isSelf && ( - { - states.showCompose = { - editStatus: status, - }; - }} + + - )} + + + + } + > + {StatusMenuItems} + )} @@ -951,6 +1173,7 @@ function Poll({ choices.push(value); } }); + if (!choices.length) return; setUIState('loading'); await votePoll(choices); setUIState('default'); @@ -1049,8 +1272,6 @@ function EditedAtModal({ })(); }, []); - const currentYear = new Date().getFullYear(); - return (
    @@ -1074,21 +1295,7 @@ function EditedAtModal({ return (
  • - +

    { + if (status?.id) { + const statusURL = `/${domain}/s/${id}`; + const result = { + id, + url: statusURL, + }; + console.debug('🦦 Unfurled URL', url, id, statusURL); + states.unfurledLinks[url] = result; + return result; + } else { + failedUnfurls[url] = true; + throw new Error('No results'); + } + }) + .catch((e) => { + failedUnfurls[url] = true; + }); + } + const { masto } = api({ instance }); - return masto.v2 + const mastoSearchFetch = masto.v2 .search({ q: url, type: 'statuses', @@ -1224,6 +1464,8 @@ function _unfurlMastodonLink(instance, url) { // console.warn(e); // Silently fail }); + + return Promise.any([remoteInstanceFetch, mastoSearchFetch]); } const unfurlMastodonLink = throttle(_unfurlMastodonLink); diff --git a/src/components/timeline.jsx b/src/components/timeline.jsx index 53b0a60d..491b72be 100644 --- a/src/components/timeline.jsx +++ b/src/components/timeline.jsx @@ -168,7 +168,7 @@ function Timeline({ reachStart, reachEnd, } = useScroll({ - scrollableElement: scrollableRef.current, + scrollableRef, distanceFromEnd: 2, scrollThresholdStart: 44, }); @@ -262,7 +262,7 @@ function Timeline({ {headerStart !== null && headerStart !== undefined ? ( headerStart ) : ( - + )} @@ -477,7 +477,7 @@ function groupBoosts(values) { function StatusCarousel({ title, class: className, children }) { const carouselRef = useRef(); const { reachStart, reachEnd, init } = useScroll({ - scrollableElement: carouselRef.current, + scrollableRef: carouselRef, direction: 'horizontal', }); useEffect(() => { diff --git a/src/index.css b/src/index.css index 479d565e..796ec971 100644 --- a/src/index.css +++ b/src/index.css @@ -84,6 +84,10 @@ box-sizing: border-box; } +html { + text-size-adjust: 100%; +} + body { font-family: ui-rounded, system-ui; font-size: 16px; @@ -297,6 +301,9 @@ code { .insignificant { color: var(--text-insignificant-color); } +.more-insignificant { + opacity: 0.5; +} .hide-until-focus-visible { display: none; diff --git a/src/pages/account-statuses.jsx b/src/pages/account-statuses.jsx index 346d4fe2..5561849f 100644 --- a/src/pages/account-statuses.jsx +++ b/src/pages/account-statuses.jsx @@ -97,7 +97,7 @@ function AccountStatuses() {
  • } - id="account_statuses" + id="account-statuses" instance={instance} emptyText="Nothing to see here yet." errorText="Unable to load statuses" diff --git a/src/pages/followed-hashtags.jsx b/src/pages/followed-hashtags.jsx index 4c627583..21e0f290 100644 --- a/src/pages/followed-hashtags.jsx +++ b/src/pages/followed-hashtags.jsx @@ -39,7 +39,7 @@ function FollowedHashtags() { }, []); return ( -
    +
    diff --git a/src/pages/hashtag.jsx b/src/pages/hashtag.jsx index c51ca326..cb8bce12 100644 --- a/src/pages/hashtag.jsx +++ b/src/pages/hashtag.jsx @@ -1,17 +1,40 @@ -import { useRef } from 'preact/hooks'; -import { useParams } from 'react-router-dom'; +import { + FocusableItem, + Menu, + MenuDivider, + MenuGroup, + MenuItem, +} from '@szhsin/react-menu'; +import { useEffect, useRef, useState } from 'preact/hooks'; +import { useNavigate, useParams } from 'react-router-dom'; +import Icon from '../components/icon'; import Timeline from '../components/timeline'; import { api } from '../utils/api'; +import showToast from '../utils/show-toast'; +import states from '../utils/states'; import useTitle from '../utils/useTitle'; const LIMIT = 20; +// Limit is 4 per "mode" +// https://github.com/mastodon/mastodon/issues/15194 +// Hard-coded https://github.com/mastodon/mastodon/blob/19614ba2477f3d12468f5ec251ce1cc5f8c6210c/app/models/tag_feed.rb#L4 +const TAGS_LIMIT_PER_MODE = 4; +const TOTAL_TAGS_LIMIT = TAGS_LIMIT_PER_MODE + 1; + function Hashtags(props) { + const navigate = useNavigate(); let { hashtag, ...params } = useParams(); if (props.hashtag) hashtag = props.hashtag; + let hashtags = hashtag.trim().split(/[\s+]+/); + hashtags.sort(); + hashtag = hashtags[0]; + const { masto, instance } = api({ instance: params.instance }); - const title = instance ? `#${hashtag} on ${instance}` : `#${hashtag}`; + const { authenticated } = api(); + const hashtagTitle = hashtags.map((t) => `#${t}`).join(' '); + const title = instance ? `${hashtagTitle} on ${instance}` : hashtagTitle; useTitle(title, `/:instance?/t/:hashtag`); const latestItem = useRef(); @@ -20,6 +43,7 @@ function Hashtags(props) { if (firstLoad || !hashtagsIterator.current) { hashtagsIterator.current = masto.v1.timelines.listHashtag(hashtag, { limit: LIMIT, + any: hashtags.slice(1), }); } const results = await hashtagsIterator.current.next(); @@ -37,6 +61,7 @@ function Hashtags(props) { const results = await masto.v1.timelines .listHashtag(hashtag, { limit: 1, + any: hashtags.slice(1), since_id: latestItem.current, }) .next(); @@ -50,24 +75,208 @@ function Hashtags(props) { } } + const [followUIState, setFollowUIState] = useState('default'); + const [info, setInfo] = useState(); + // Get hashtag info + useEffect(() => { + (async () => { + try { + const info = await masto.v1.tags.fetch(hashtag); + console.log(info); + setInfo(info); + } catch (e) { + console.error(e); + } + })(); + }, [hashtag]); + + const reachLimit = hashtags.length >= TOTAL_TAGS_LIMIT; + return (