Merge pull request #74 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2023-03-02 22:29:28 +08:00 committed by GitHub
commit 7d806301f2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 1737 additions and 679 deletions

View file

@ -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

252
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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({

View file

@ -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);
}

View file

@ -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 (
<>
<Routes location={nonRootLocation || location}>
@ -281,24 +310,12 @@ function App() {
<Routes>
<Route path="/:instance?/s/:id" element={<Status />} />
</Routes>
<nav id="tab-bar" hidden>
<li>
<Link to="/">
<Icon icon="home" alt="Home" size="xl" />
</Link>
</li>
<li>
<Link to="/notifications">
<Icon icon="notification" alt="Notifications" size="xl" />
</Link>
</li>
<li>
<Link to="/bookmarks">
<Icon icon="bookmark" alt="Bookmarks" size="xl" />
</Link>
</li>
</nav>
{!snapStates.settings.shortcutsColumnsMode && <Shortcuts />}
<div>
{!snapStates.settings.shortcutsColumnsMode &&
snapStates.settings.shortcutsViewMode !== 'multi-column' && (
<Shortcuts />
)}
</div>
{!!snapStates.showCompose && (
<Modal>
<Compose
@ -323,26 +340,20 @@ function App() {
window.__COMPOSE__ = null;
if (newStatus) {
states.reloadStatusPage++;
setTimeout(() => {
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}`,
);
},
});
}
}}
/>

View file

@ -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 }) {
<br />
<b>
<time datetime={createdAt}>
{Intl.DateTimeFormat('en', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(new Date(createdAt))}
{niceDateTime(createdAt, {
hideTime: true,
})}
</time>
</b>
</span>

View file

@ -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;

View file

@ -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'}
>

View file

@ -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() {
<ul class="drafts-list">
{drafts.map((draft) => {
const { updatedAt, key, draftStatus, replyTo } = draft;
const currentYear = new Date().getFullYear();
const updatedAtDate = new Date(updatedAt);
return (
<li key={updatedAt}>
@ -81,19 +81,7 @@ function Drafts() {
<br />
</>
)}
{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)}
</time>
</b>
<button

View file

@ -37,7 +37,7 @@ const ICONS = {
attachment: 'mingcute:attachment-line',
upload: 'mingcute:upload-3-line',
gear: 'mingcute:settings-3-line',
more: 'mingcute:more-1-line',
more: 'mingcute:more-3-line',
external: 'mingcute:external-link-line',
popout: 'mingcute:external-link-line',
popin: ['mingcute:external-link-line', '180deg'],
@ -57,6 +57,12 @@ const ICONS = {
user: 'mingcute:user-4-line',
following: 'mingcute:walk-line',
pin: 'mingcute:pin-line',
bus: 'mingcute:bus-2-line',
link: 'mingcute:link-2-line',
history: 'mingcute:history-line',
share: 'mingcute:share-2-line',
sparkles: 'mingcute:sparkles-line',
exit: 'mingcute:exit-line',
};
const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js');

View file

@ -144,7 +144,7 @@ function MediaModal({
key={media.id}
type="button"
disabled={i === currentIndex}
class={`plain carousel-dot ${
class={`plain3 carousel-dot ${
i === currentIndex ? 'active' : ''
}`}
onClick={(e) => {

View file

@ -34,6 +34,27 @@ function NavMenu(props) {
</button>
}
>
{!!snapStates.appVersion?.commitHash &&
__COMMIT_HASH__ !== snapStates.appVersion.commitHash && (
<>
<MenuItem
onClick={() => {
const yes = confirm('Reload page now to update?');
if (yes) {
(async () => {
try {
location.reload();
} catch (e) {}
})();
}
}}
>
<Icon icon="sparkles" size="l" />{' '}
<span>New update available</span>
</MenuItem>
<MenuDivider />
</>
)}
<MenuLink to="/">
<Icon icon="home" size="l" /> <span>Home</span>
</MenuLink>

View file

@ -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;

View file

@ -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() {
</header>
<main>
<p>
Specify a list of shortcuts that'll appear in the floating Shortcuts
button.
<label class="shortcuts-view-mode">
Specify a list of shortcuts that'll appear&nbsp;as:
<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>
</label>
</p>
<p>
{/* <p>
<details>
<summary class="insignificant">
Experimental Multi-column mode
@ -215,7 +238,7 @@ function ShortcutsSettings() {
Show shortcuts in multiple columns instead of the floating button.
</label>
</details>
</p>
</p> */}
{shortcuts.length > 0 ? (
<ol class="shortcuts-list">
{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 (
<li key={key}>
@ -235,7 +258,7 @@ function ShortcutsSettings() {
<span class="shortcut-text">
<AsyncText>{title}</AsyncText>
</span>
<span>
<span class="shortcut-actions">
<button
type="button"
class="plain small"
@ -287,115 +310,158 @@ function ShortcutsSettings() {
No shortcuts yet. Add one from the form below.
</p>
)}
<hr />
<ShortcutForm
disabled={shortcuts.length >= SHORTCUTS_LIMIT}
lists={lists}
followedHashtags={followedHashtags}
onSubmit={(data) => {
console.log('onSubmit', data);
states.shortcuts.push(data);
<p
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
/>
>
<span class="insignificant">
{shortcuts.length >= SHORTCUTS_LIMIT &&
`Max ${SHORTCUTS_LIMIT} shortcuts`}
</span>
<button
type="button"
disabled={shortcuts.length >= SHORTCUTS_LIMIT}
onClick={() => setShowForm(true)}
>
<Icon icon="plus" /> <span>Add shortcut</span>
</button>
</p>
</main>
{showForm && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowForm(false);
}
}}
>
<ShortcutForm
disabled={shortcuts.length >= SHORTCUTS_LIMIT}
lists={lists}
followedHashtags={followedHashtags}
onSubmit={(data) => {
console.log('onSubmit', data);
states.shortcuts.push(data);
}}
onClose={() => setShowForm(false)}
/>
</Modal>
)}
</div>
);
}
function ShortcutForm({
type,
lists,
followedHashtags,
onSubmit,
disabled,
onClose = () => {},
}) {
const [currentType, setCurrentType] = useState(type);
return (
<div id="shortcut-settings-form" class="sheet">
<header>
<h2>Add shortcut</h2>
</header>
<main tabindex="-1">
<form
onSubmit={(e) => {
// 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();
}}
>
<p>
<label>
<span>Timeline</span>
<select
required
disabled={disabled}
onChange={(e) => {
setCurrentType(e.target.value);
}}
name="type"
>
<option></option>
{TYPES.map((type) => (
<option value={type}>{TYPE_TEXT[type]}</option>
))}
</select>
</label>
</p>
{TYPE_PARAMS[currentType]?.map?.(
({ text, name, type, placeholder, pattern }) => {
if (currentType === 'list') {
return (
<p>
<label>
<span>List</span>
<select name="id" required disabled={disabled}>
{lists.map((list) => (
<option value={list.id}>{list.title}</option>
))}
</select>
</label>
</p>
);
}
return (
<p>
<label>
<span>{text}</span>{' '}
<input
type={type}
name={name}
placeholder={placeholder}
required={type === 'text'}
disabled={disabled}
list={
currentType === 'hashtag'
? 'followed-hashtags-datalist'
: null
}
autocorrect="off"
autocapitalize="off"
spellcheck={false}
pattern={pattern}
/>
{currentType === 'hashtag' &&
followedHashtags.length > 0 && (
<datalist id="followed-hashtags-datalist">
{followedHashtags.map((tag) => (
<option value={tag.name} />
))}
</datalist>
)}
</label>
</p>
);
},
)}
<button type="submit" class="block" disabled={disabled}>
Add
</button>
</form>
</main>
</div>
);
}
export default ShortcutsSettings;
function ShortcutForm({ type, lists, followedHashtags, onSubmit, disabled }) {
const [currentType, setCurrentType] = useState(type);
return (
<>
<form
onSubmit={(e) => {
// 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);
}}
>
<header>
<h3>Add a shortcut</h3>
<button type="submit" disabled={disabled}>
Add
</button>
</header>
<p>
<label>
<span>Timeline</span>
<select
required
disabled={disabled}
onChange={(e) => {
setCurrentType(e.target.value);
}}
name="type"
>
<option></option>
{TYPES.map((type) => (
<option value={type}>{TYPE_TEXT[type]}</option>
))}
</select>
</label>
</p>
{TYPE_PARAMS[currentType]?.map?.(
({ text, name, type, placeholder }) => {
if (currentType === 'list') {
return (
<p>
<label>
<span>List</span>
<select name="id" required disabled={disabled}>
{lists.map((list) => (
<option value={list.id}>{list.title}</option>
))}
</select>
</label>
</p>
);
}
return (
<p>
<label>
<span>{text}</span>{' '}
<input
type={type}
name={name}
placeholder={placeholder}
required={type === 'text'}
disabled={disabled}
list={
currentType === 'hashtag'
? 'followed-hashtags-datalist'
: null
}
autocorrect="off"
autocapitalize="off"
spellcheck={false}
/>
{currentType === 'hashtag' && followedHashtags.length > 0 && (
<datalist id="followed-hashtags-datalist">
{followedHashtags.map((tag) => (
<option value={tag.name} />
))}
</datalist>
)}
</label>
</p>
);
},
)}
</form>
</>
);
}

View file

@ -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;
}
}

View file

@ -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 (
<div id="shortcuts">
<Menu
instanceRef={menuRef}
overflow="auto"
viewScroll="close"
boundingBoxPadding="8 8 8 8"
menuClassName="glass-menu shortcuts-menu"
offsetY={8}
position="anchor"
menuButton={
<button
type="button"
id="shortcuts-button"
class="plain"
onTransitionStart={(e) => {
// Close menu if the button disappears
try {
const { target } = e;
if (getComputedStyle(target).pointerEvents === 'none') {
menuRef.current?.closeMenu?.();
}
} catch (e) {}
}}
>
<Icon icon="shortcut" size="xl" alt="Shortcuts" />
</button>
}
>
{formattedShortcuts.map(({ path, title, icon }, i) => {
return (
<MenuLink to={path} key={i + title} class="glass-menu-item">
<Icon icon={icon} size="l" />{' '}
<span class="menu-grow">
<AsyncText>{title}</AsyncText>
</span>
<span class="menu-shortcut hide-until-focus-visible">
{i + 1}
</span>
</MenuLink>
);
})}
</Menu>
{snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? (
<nav class="tab-bar">
<ul>
{formattedShortcuts.map(({ id, path, title, icon }, i) => {
return (
<li key={i + title}>
<Link
to={path}
onClick={(e) => {
if (e.target.classList.contains('is-active')) {
e.preventDefault();
const page = document.getElementById(`${id}-page`);
console.log(id, page);
if (page) {
page.scrollTop = 0;
const updatesButton =
page.querySelector('.updates-button');
if (updatesButton) {
updatesButton.click();
}
}
}
}}
>
<Icon icon={icon} size="xl" alt={title} />
<span>
<AsyncText>{title}</AsyncText>
</span>
</Link>
</li>
);
})}
</ul>
</nav>
) : (
<Menu
instanceRef={menuRef}
overflow="auto"
viewScroll="close"
boundingBoxPadding="8 8 8 8"
menuClassName="glass-menu shortcuts-menu"
offsetY={8}
position="anchor"
menuButton={
<button
type="button"
id="shortcuts-button"
class="plain"
onTransitionStart={(e) => {
// Close menu if the button disappears
try {
const { target } = e;
if (getComputedStyle(target).pointerEvents === 'none') {
menuRef.current?.closeMenu?.();
}
} catch (e) {}
}}
>
<Icon icon="shortcut" size="xl" alt="Shortcuts" />
</button>
}
>
{formattedShortcuts.map(({ path, title, icon }, i) => {
return (
<MenuLink to={path} key={i + title} class="glass-menu-item">
<Icon icon={icon} size="l" />{' '}
<span class="menu-grow">
<AsyncText>{title}</AsyncText>
</span>
<span class="menu-shortcut hide-until-focus-visible">
{i + 1}
</span>
</MenuLink>
);
})}
</Menu>
)}
</div>
);
}

View file

@ -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;
}

View file

@ -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 && (
<>
<MenuHeader>
<span class="ib">
<Icon icon={visibilityIconsMap[visibility]} size="s" />{' '}
<span>{visibilityText[visibility]}</span>
</span>{' '}
<span class="ib">
{repliesCount > 0 && (
<span>
<Icon icon="reply" alt="Replies" size="s" />{' '}
<span>{shortenNumber(repliesCount)}</span>
</span>
)}{' '}
{reblogsCount > 0 && (
<span>
<Icon icon="rocket" alt="Boosts" size="s" />{' '}
<span>{shortenNumber(reblogsCount)}</span>
</span>
)}{' '}
{favouritesCount > 0 && (
<span>
<Icon icon="heart" alt="Favourites" size="s" />{' '}
<span>{shortenNumber(favouritesCount)}</span>
</span>
)}
</span>
<br />
{createdDateText}
</MenuHeader>
<MenuLink to={instance ? `/${instance}/s/${id}` : `/s/${id}`}>
<Icon icon="arrow-right" />
View post and replies
</MenuLink>
</>
)}
{!!editedAt && (
<MenuItem
onClick={() => {
setShowEdited(id);
}}
>
<Icon icon="history" />
<span>
Show Edit History
<br />
<small class="more-insignificant">Edited: {editedDateText}</small>
</span>
</MenuItem>
)}
{(!isSizeLarge || !!editedAt) && <MenuDivider />}
{!isSizeLarge && (
<>
<MenuItem onClick={replyStatus}>
<Icon icon="reply" />
<span>Reply</span>
</MenuItem>
{canBoost && (
<MenuItem
onClick={async () => {
try {
await boostStatus();
if (!isSizeLarge)
showToast(reblogged ? 'Unboosted' : 'Boosted');
} catch (e) {}
}}
>
<Icon icon="rocket" />
<span>{reblogged ? 'Unboost' : 'Boost…'}</span>
</MenuItem>
)}
<MenuItem
onClick={() => {
try {
favouriteStatus();
if (!isSizeLarge)
showToast(favourited ? 'Unfavourited' : 'Favourited');
} catch (e) {}
}}
>
<Icon icon="heart" />
<span>{favourited ? 'Unfavourite' : 'Favourite'}</span>
</MenuItem>
<MenuItem
onClick={() => {
try {
bookmarkStatus();
if (!isSizeLarge)
showToast(bookmarked ? 'Unbookmarked' : 'Bookmarked');
} catch (e) {}
}}
>
<Icon icon="bookmark" />
<span>{bookmarked ? 'Unbookmark' : 'Bookmark'}</span>
</MenuItem>
<MenuDivider />
</>
)}
<MenuItem href={url} target="_blank">
<Icon icon="external" />
<span>Open link to post</span>
</MenuItem>
<MenuItem
onClick={() => {
// Copy url to clipboard
try {
navigator.clipboard.writeText(url);
showToast('Link copied');
} catch (e) {
console.error(e);
showToast('Unable to copy link');
}
}}
>
<Icon icon="link" />
<span>Copy link to post</span>
</MenuItem>
{navigator?.share &&
navigator?.canShare?.({
url,
}) && (
<MenuItem
onClick={() => {
try {
navigator.share({
url,
});
} catch (e) {
console.error(e);
alert("Sharing doesn't seem to work.");
}
}}
>
<Icon icon="share" />
<span>Share</span>
</MenuItem>
)}
{isSelf && (
<>
<MenuDivider />
<MenuItem
onClick={() => {
states.showCompose = {
editStatus: status,
};
}}
>
<Icon icon="pencil" />
<span>Edit</span>
</MenuItem>
</>
)}
</>
);
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const [contextMenuAnchorPoint, setContextMenuAnchorPoint] = useState({
x: 0,
y: 0,
});
return (
<article
ref={statusRef}
@ -231,7 +505,39 @@ function Status({
}[size]
}`}
onMouseEnter={debugHover}
onContextMenu={(e) => {
if (size === 'l') return;
if (e.metaKey) return;
e.preventDefault();
setContextMenuAnchorPoint({
x: e.clientX,
y: e.clientY,
});
setIsContextMenuOpen(true);
}}
>
{size !== 'l' && (
<ControlledMenu
state={isContextMenuOpen ? 'open' : undefined}
anchorPoint={contextMenuAnchorPoint}
direction="right"
onClose={() => setIsContextMenuOpen(false)}
portal={{
target: document.body,
}}
containerProps={{
style: {
// Higher than the backdrop
zIndex: 1001,
},
}}
overflow="auto"
boundingBoxPadding="8 8 8 8"
unmountOnClose
>
{StatusMenuItems}
</ControlledMenu>
)}
{size !== 'l' && (
<div class="status-badge">
{reblogged && <Icon class="reblog" icon="rocket" size="s" />}
@ -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({
{/* </span> */}{' '}
{size !== 'l' &&
(url ? (
<Link
to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
class="time"
<Menu
instanceRef={menuInstanceRef}
portal={{
target: document.body,
}}
containerProps={{
style: {
// Higher than the backdrop
zIndex: 1001,
},
onClick: () => {
menuInstanceRef.current?.closeMenu?.();
},
}}
align="end"
offsetY={4}
overflow="auto"
viewScroll="close"
boundingBoxPadding="8 8 8 8"
unmountOnClose
menuButton={({ open }) => (
<Link
to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
class={`time ${open ? 'is-open' : ''}`}
>
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibilityText[visibility]}
size="s"
/>{' '}
<RelativeTime datetime={createdAtDate} format="micro" />
</Link>
)}
>
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibility}
size="s"
/>{' '}
<RelativeTime datetime={createdAtDate} format="micro" />
</Link>
{StatusMenuItems}
</Menu>
) : (
<span class="time">
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibility}
alt={visibilityText[visibility]}
size="s"
/>{' '}
<RelativeTime datetime={createdAtDate} format="micro" />
@ -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) => (
<Media
key={media.id}
media={media}
autoAnimate={size === 'l'}
autoAnimate={isSizeLarge}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -485,23 +820,13 @@ function Status({
<Card card={card} instance={currentInstance} />
)}
</div>
{size === 'l' && (
{isSizeLarge && (
<>
<div class="extra-meta">
<Icon icon={visibilityIconsMap[visibility]} alt={visibility} />{' '}
<a href={url} target="_blank">
<time class="created" datetime={createdAtDate.toISOString()}>
{Intl.DateTimeFormat('en', {
// Show year if not current year
year:
createdAtDate.getFullYear() === currentYear
? undefined
: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(createdAtDate)}
{createdDateText}
</time>
</a>
{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}
</time>
</>
)}
@ -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}
/>
</div>
{/* TODO: if visibility = private, only can reblog own statuses */}
{visibility !== 'direct' && (
{canBoost && (
<div class="action has-count">
<StatusButton
checked={reblogged}
@ -558,38 +865,7 @@ function Status({
class="reblog-button"
icon="rocket"
count={reblogsCount}
onClick={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;
}
}}
onClick={boostStatus}
/>
</div>
)}
@ -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}
/>
</div>
<div class="action">
@ -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}
/>
</div>
{isSelf && (
<Menu
align="end"
menuButton={
<div class="action">
<button
type="button"
title="More"
class="plain more-button"
>
<Icon icon="more" size="l" alt="More" />
</button>
</div>
}
>
{isSelf && (
<MenuItem
onClick={() => {
states.showCompose = {
editStatus: status,
};
}}
<Menu
portal={{
target:
document.querySelector('.status-deck') || document.body,
}}
align="end"
offsetY={4}
overflow="auto"
viewScroll="close"
boundingBoxPadding="8 8 8 8"
menuButton={
<div class="action">
<button
type="button"
title="More"
class="plain more-button"
>
<Icon icon="pencil" /> <span>Edit&hellip;</span>
</MenuItem>
)}
</Menu>
)}
<Icon icon="more" size="l" alt="More" />
</button>
</div>
}
>
{StatusMenuItems}
</Menu>
</div>
</>
)}
@ -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 (
<div id="edit-history" class="sheet">
<header>
@ -1074,21 +1295,7 @@ function EditedAtModal({
return (
<li key={createdAt} class="history-item">
<h3>
<time>
{Intl.DateTimeFormat('en', {
// Show year if not current year
year:
createdAtDate.getFullYear() === currentYear
? undefined
: 'numeric',
month: 'short',
day: 'numeric',
weekday: 'short',
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
}).format(createdAtDate)}
</time>
<time>{niceDate(createdAtDate)}</time>
</h3>
<Status
status={status}
@ -1194,8 +1401,41 @@ function _unfurlMastodonLink(instance, url) {
return Promise.resolve(states.unfurledLinks[url]);
}
console.debug('🦦 Unfurling URL', url);
let remoteInstanceFetch;
const urlObj = new URL(url);
const domain = urlObj.hostname;
const path = urlObj.pathname;
// Regex /:username/:id, where username = @username or @username@domain, id = number
const statusRegex = /\/@([^@\/]+)@?([^\/]+)?\/(\d+)$/i;
const statusMatch = statusRegex.exec(path);
if (statusMatch) {
const id = statusMatch[3];
const { masto } = api({ instance: domain });
remoteInstanceFetch = masto.v1.statuses
.fetch(id)
.then((status) => {
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);

View file

@ -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
) : (
<Link to="/" class="button plain">
<Link to="/" class="button plain home-button">
<Icon icon="home" size="l" />
</Link>
)}
@ -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(() => {

View file

@ -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;

View file

@ -97,7 +97,7 @@ function AccountStatuses() {
</div>
</h1>
}
id="account_statuses"
id="account-statuses"
instance={instance}
emptyText="Nothing to see here yet."
errorText="Unable to load statuses"

View file

@ -39,7 +39,7 @@ function FollowedHashtags() {
}, []);
return (
<div id="followed-hashtags-page" class="deck-container">
<div id="followed-hashtags-page" class="deck-container" tabIndex="-1">
<div class="timeline-deck deck">
<header>
<div class="header-grid">

View file

@ -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 (
<Timeline
key={hashtag}
key={hashtagTitle}
title={title}
titleComponent={
!!instance && (
<h1 class="header-account">
<b>#{hashtag}</b>
<b>{hashtagTitle}</b>
<div>{instance}</div>
</h1>
)
}
id="hashtags"
id="hashtag"
instance={instance}
emptyText="No one has posted anything with this tag yet."
errorText="Unable to load posts with this tag"
fetchItems={fetchHashtags}
checkForUpdates={checkForUpdates}
headerEnd={
<Menu
portal={{
target: document.body,
}}
setDownOverflow
overflow="auto"
viewScroll="close"
position="anchor"
boundingBoxPadding="8 8 8 8"
menuButton={
<button type="button" class="plain">
<Icon icon="more" size="l" />
</button>
}
>
{!!info && hashtags.length === 1 && (
<>
<MenuItem
disabled={followUIState === 'loading' || !authenticated}
onClick={() => {
setFollowUIState('loading');
if (info.following) {
const yes = confirm(`Unfollow #${hashtag}?`);
if (!yes) {
setFollowUIState('default');
return;
}
masto.v1.tags
.unfollow(hashtag)
.then(() => {
setInfo({ ...info, following: false });
showToast(`Unfollowed #${hashtag}`);
})
.catch((e) => {
alert(e);
console.error(e);
})
.finally(() => {
setFollowUIState('default');
});
} else {
masto.v1.tags
.follow(hashtag)
.then(() => {
setInfo({ ...info, following: true });
showToast(`Followed #${hashtag}`);
})
.catch((e) => {
alert(e);
console.error(e);
})
.finally(() => {
setFollowUIState('default');
});
}
}}
>
{info.following ? (
<>
<Icon icon="check-circle" /> <span>Following</span>
</>
) : (
<>
<Icon icon="plus" /> <span>Follow</span>
</>
)}
</MenuItem>
<MenuDivider />
</>
)}
<FocusableItem className="menu-field" disabled={reachLimit}>
{({ ref }) => (
<form
onSubmit={(e) => {
e.preventDefault();
const newHashtag = e.target[0].value?.trim?.();
// Use includes but need to be case insensitive
if (
newHashtag &&
!hashtags.some(
(t) => t.toLowerCase() === newHashtag.toLowerCase(),
)
) {
hashtags.push(newHashtag);
hashtags.sort();
navigate(
instance
? `/${instance}/t/${hashtags.join('+')}`
: `/t/${hashtags.join('+')}`,
);
}
}}
>
<Icon icon="hashtag" />
<input
ref={ref}
type="text"
placeholder={
reachLimit ? `Max ${TOTAL_TAGS_LIMIT} tags` : 'Add hashtag'
}
required
autocorrect="off"
autocapitalize="off"
spellcheck={false}
// no spaces, no hashtags
pattern="[^#][^\s#]+[^#]"
disabled={reachLimit}
/>
</form>
)}
</FocusableItem>
<MenuGroup takeOverflow>
{hashtags.map((t, i) => (
<MenuItem
key={t}
onClick={(e) => {
hashtags.splice(i, 1);
hashtags.sort();
navigate(
instance
? `/${instance}/t/${hashtags.join('+')}`
: `/t/${hashtags.join('+')}`,
);
}}
>
<Icon icon="x" alt="Remove hashtag" class="danger-icon" />
<span>
<span class="more-insignificant">#</span>
{t}
</span>
</MenuItem>
))}
</MenuGroup>
<MenuDivider />
<MenuItem
disabled={!authenticated}
onClick={() => {
const shortcut = {
type: 'hashtag',
hashtag: hashtags.join(' '),
};
// Check if already exists
const exists = states.shortcuts.some(
(s) =>
s.type === shortcut.type &&
s.hashtag
.split(/[\s+]+/)
.sort()
.join(' ') ===
shortcut.hashtag
.split(/[\s+]+/)
.sort()
.join(' '),
);
if (exists) {
alert('This shortcut already exists');
} else {
states.shortcuts.push(shortcut);
showToast(`Hashtag shortcut added`);
}
}}
>
<Icon icon="shortcut" /> <span>Add to Shorcuts</span>
</MenuItem>
</Menu>
}
/>
);
}

View file

@ -28,7 +28,8 @@ function Home() {
return (
<>
{snapStates.settings.shortcutsColumnsMode &&
{(snapStates.settings.shortcutsColumnsMode ||
snapStates.settings.shortcutsViewMode === 'multi-column') &&
!!snapStates.shortcuts?.length ? (
<Columns />
) : (
@ -40,7 +41,7 @@ function Home() {
headerEnd={
<Link
to="/notifications"
class={`button plain ${
class={`button plain notifications-button ${
snapStates.notificationsShowNew ? 'has-badge' : ''
}`}
onClick={(e) => {

View file

@ -29,7 +29,7 @@ function Lists() {
}, []);
return (
<div id="lists-page" class="deck-container">
<div id="lists-page" class="deck-container" tabIndex="-1">
<div class="timeline-deck deck">
<header>
<div class="header-grid">

View file

@ -13,6 +13,7 @@ import NameText from '../components/name-text';
import RelativeTime from '../components/relative-time';
import Status from '../components/status';
import { api } from '../utils/api';
import niceDateTime from '../utils/nice-date-time';
import states, { saveStatus } from '../utils/states';
import store from '../utils/store';
import useScroll from '../utils/useScroll';
@ -58,7 +59,7 @@ function Notifications() {
const scrollableRef = useRef();
const { nearReachEnd, scrollDirection, reachStart, nearReachStart } =
useScroll({
scrollableElement: scrollableRef.current,
scrollableRef,
});
const hiddenUI = scrollDirection === 'end' && !nearReachStart;
@ -77,9 +78,8 @@ function Notifications() {
if (notifications?.length) {
notifications.forEach((notification) => {
saveStatus(notification.status, {
saveStatus(notification.status, instance, {
skipThreading: true,
override: false,
});
});
@ -214,15 +214,9 @@ function Notifications() {
const heading =
notificationDay.toDateString() === yesterdayDate.toDateString()
? 'Yesterday'
: Intl.DateTimeFormat('en', {
// Show year if not current year
year:
currentDay.getFullYear() === todayDate.getFullYear()
? undefined
: 'numeric',
month: 'short',
day: 'numeric',
}).format(currentDay);
: niceDateTime(currentDay, {
hideTime: true,
});
return (
<>
{differentDay && <h2 class="timeline-header">{heading}</h2>}

View file

@ -1,15 +1,18 @@
// EXPERIMENTAL: This is a work in progress and may not work as expected.
import { Menu, MenuDivider, MenuItem } from '@szhsin/react-menu';
import { useRef } from 'preact/hooks';
import { useNavigate, useParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
import Icon from '../components/icon';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import states from '../utils/states';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
function Public({ local, ...props }) {
const snapStates = useSnapshot(states);
const isLocal = !!local;
const params = useParams();
const { masto, instance } = api({
@ -74,26 +77,53 @@ function Public({ local, ...props }) {
fetchItems={fetchPublic}
checkForUpdates={checkForUpdates}
headerStart={<></>}
boostsCarousel={snapStates.settings.boostsCarousel}
headerEnd={
<button
type="button"
class="plain"
onClick={() => {
let newInstance = prompt(
'Enter a new instance e.g. "mastodon.social"',
);
if (!/\./.test(newInstance)) {
if (newInstance) alert('Invalid instance');
return;
}
if (newInstance) {
newInstance = newInstance.toLowerCase().trim();
navigate(isLocal ? `/${newInstance}/p/l` : `/${newInstance}/p`);
}
<Menu
portal={{
target: document.body,
}}
// setDownOverflow
overflow="auto"
viewScroll="close"
position="anchor"
boundingBoxPadding="8 8 8 8"
menuButton={
<button type="button" class="plain">
<Icon icon="more" size="l" />
</button>
}
>
<Icon icon="transfer" alt="Switch instance" />
</button>
<MenuItem href={isLocal ? `/#/${instance}/p` : `/#/${instance}/p/l`}>
{isLocal ? (
<>
<Icon icon="transfer" /> <span>Switch to Federated</span>
</>
) : (
<>
<Icon icon="transfer" /> <span>Switch to Local</span>
</>
)}
</MenuItem>
<MenuDivider />
<MenuItem
onClick={() => {
let newInstance = prompt(
'Enter a new instance e.g. "mastodon.social"',
);
if (!/\./.test(newInstance)) {
if (newInstance) alert('Invalid instance');
return;
}
if (newInstance) {
newInstance = newInstance.toLowerCase().trim();
navigate(isLocal ? `/${newInstance}/p/l` : `/${newInstance}/p`);
}
}}
>
<Icon icon="bus" /> <span>Go to another instance</span>
</MenuItem>
</Menu>
}
/>
);

View file

@ -12,7 +12,7 @@
margin-top: 2em;
}
#settings-container :is(section, .section) {
#settings-container section {
background-color: var(--bg-color);
margin: 0;
padding: 8px 16px;
@ -20,16 +20,16 @@
border-bottom: var(--hairline-width) solid var(--outline-color);
border-radius: 8px;
}
#settings-container :is(section, .section) > li:last-child {
#settings-container section ul > li:last-child {
border-bottom: none;
}
#settings-container ul {
#settings-container section > ul {
margin: 0;
padding: 0;
list-style: none;
}
#settings-container ul:not([role='menu']) > li {
#settings-container section > ul > li {
padding: 8px 0 16px;
display: flex;
justify-content: space-between;
@ -37,26 +37,26 @@
flex-wrap: wrap;
border-bottom: var(--hairline-width) solid var(--outline-color);
}
#settings-container ul:not([role='menu']) > li .current {
#settings-container section > ul > li .current {
margin-right: 8px;
color: var(--green-color);
opacity: 0.1;
}
#settings-container ul:not([role='menu']) > li .current.is-current {
#settings-container section > ul > li .current.is-current {
opacity: 1;
}
#settings-container ul:not([role='menu']) > li .current.is-current + .avatar {
#settings-container section > ul > li .current.is-current + .avatar {
box-shadow: 0 0 0 1.5px var(--green-color), 0 0 8px var(--green-color);
}
#settings-container ul:not([role='menu']) > li > div {
#settings-container section > ul > li > div {
flex-grow: 1;
max-width: 100%;
}
#settings-container ul:not([role='menu']) > li > div.actions {
#settings-container section > ul > li > div.actions {
flex-basis: fit-content;
margin-top: 8px;
}
#settings-container ul:not([role='menu']) > li > div:last-child {
#settings-container section > ul > li > div:last-child {
text-align: right;
}
#settings-container div,

View file

@ -119,7 +119,8 @@ function Settings({ onClose }) {
setCurrentDefault(i);
}}
>
Set as default
<Icon icon="check-circle" />
<span>Set as default</span>
</MenuItem>
)}
<MenuItem
@ -133,7 +134,8 @@ function Settings({ onClose }) {
location.href = '/';
}}
>
Log out
<Icon icon="exit" />
<span>Log out</span>
</MenuItem>
</Menu>
</div>
@ -156,88 +158,90 @@ function Settings({ onClose }) {
</p>
</section>
<h2>Settings</h2>
<ul class="section">
<li>
<div>
<label>Appearance</label>
</div>
<div>
<form
ref={themeFormRef}
onInput={(e) => {
console.log(e);
e.preventDefault();
const formData = new FormData(themeFormRef.current);
const theme = formData.get('theme');
const html = document.documentElement;
<section>
<ul>
<li>
<div>
<label>Appearance</label>
</div>
<div>
<form
ref={themeFormRef}
onInput={(e) => {
console.log(e);
e.preventDefault();
const formData = new FormData(themeFormRef.current);
const theme = formData.get('theme');
const html = document.documentElement;
if (theme === 'auto') {
html.classList.remove('is-light', 'is-dark');
} else {
html.classList.toggle('is-light', theme === 'light');
html.classList.toggle('is-dark', theme === 'dark');
}
document
.querySelector('meta[name="color-scheme"]')
.setAttribute(
'content',
theme === 'auto' ? 'dark light' : theme,
);
if (theme === 'auto') {
html.classList.remove('is-light', 'is-dark');
} else {
html.classList.toggle('is-light', theme === 'light');
html.classList.toggle('is-dark', theme === 'dark');
}
document
.querySelector('meta[name="color-scheme"]')
.setAttribute(
'content',
theme === 'auto' ? 'dark light' : theme,
);
if (theme === 'auto') {
store.local.del('theme');
} else {
store.local.set('theme', theme);
}
}}
>
<div class="radio-group">
<label>
<input
type="radio"
name="theme"
value="light"
defaultChecked={currentTheme === 'light'}
/>
<span>Light</span>
</label>
<label>
<input
type="radio"
name="theme"
value="dark"
defaultChecked={currentTheme === 'dark'}
/>
<span>Dark</span>
</label>
<label>
<input
type="radio"
name="theme"
value="auto"
defaultChecked={
currentTheme !== 'light' && currentTheme !== 'dark'
}
/>
<span>Auto</span>
</label>
</div>
</form>
</div>
</li>
<li>
<label>
<input
type="checkbox"
checked={snapStates.settings.boostsCarousel}
onChange={(e) => {
states.settings.boostsCarousel = e.target.checked;
}}
/>{' '}
Boosts carousel (experimental)
</label>
</li>
</ul>
if (theme === 'auto') {
store.local.del('theme');
} else {
store.local.set('theme', theme);
}
}}
>
<div class="radio-group">
<label>
<input
type="radio"
name="theme"
value="light"
defaultChecked={currentTheme === 'light'}
/>
<span>Light</span>
</label>
<label>
<input
type="radio"
name="theme"
value="dark"
defaultChecked={currentTheme === 'dark'}
/>
<span>Dark</span>
</label>
<label>
<input
type="radio"
name="theme"
value="auto"
defaultChecked={
currentTheme !== 'light' && currentTheme !== 'dark'
}
/>
<span>Auto</span>
</label>
</div>
</form>
</div>
</li>
<li>
<label>
<input
type="checkbox"
checked={snapStates.settings.boostsCarousel}
onChange={(e) => {
states.settings.boostsCarousel = e.target.checked;
}}
/>{' '}
Boosts carousel (experimental)
</label>
</li>
</ul>
</section>
<h2>Hidden features</h2>
<section>
<div>

View file

@ -456,8 +456,8 @@ function StatusPage() {
});
const { nearReachStart } = useScroll({
scrollableElement: scrollableRef.current,
distanceFromStart: 0.2,
scrollableRef,
distanceFromStartPx: 16,
});
return (
@ -646,7 +646,9 @@ function StatusPage() {
</p>
<button
type="button"
disabled={uiState === 'loading'}
onClick={() => {
setUIState('loading');
(async () => {
try {
const results =
@ -667,6 +669,7 @@ function StatusPage() {
throw new Error('No results');
}
} catch (e) {
setUIState('default');
alert('Error: ' + e);
console.error(e);
}

View file

@ -6,7 +6,7 @@ import states from '../utils/states';
import useTitle from '../utils/useTitle';
function Welcome() {
useTitle();
useTitle(null, ['/', '/welcome']);
return (
<main id="welcome">
<h1>

View file

@ -0,0 +1,19 @@
function niceDateTime(date, { hideTime } = {}) {
if (!(date instanceof Date)) {
date = new Date(date);
}
const currentYear = new Date().getFullYear();
const locale = new Intl.DateTimeFormat().resolvedOptions().locale;
const dateText = Intl.DateTimeFormat(locale, {
// Show year if not current year
year: date.getFullYear() === currentYear ? undefined : 'numeric',
month: 'short',
day: 'numeric',
// Hide time if requested
hour: hideTime ? undefined : 'numeric',
minute: hideTime ? undefined : 'numeric',
}).format(date);
return dateText;
}
export default niceDateTime;

26
src/utils/show-toast.js Normal file
View file

@ -0,0 +1,26 @@
import Toastify from 'toastify-js';
function showToast(props) {
if (typeof props === 'string') {
props = { text: props };
}
const { onClick = () => {}, delay, ...rest } = props;
const toast = Toastify({
className: 'shiny-pill',
gravity: 'bottom',
position: 'center',
...rest,
onClick: () => {
onClick(toast); // Pass in the object itself!
},
});
if (delay) {
setTimeout(() => {
toast.showToast();
}, delay);
} else {
toast.showToast();
}
}
export default showToast;

View file

@ -6,6 +6,7 @@ import { api } from './api';
import store from './store';
const states = proxy({
appVersion: {},
// history: [],
prevLocation: null,
currentLocation: null,
@ -37,6 +38,7 @@ const states = proxy({
shortcuts: store.account.get('shortcuts') ?? [],
// Settings
settings: {
shortcutsViewMode: store.account.get('settings-shortcutsViewMode') ?? null,
shortcutsColumnsMode:
store.account.get('settings-shortcutsColumnsMode') ?? false,
boostsCarousel: store.account.get('settings-boostsCarousel') ?? true,
@ -58,6 +60,9 @@ subscribe(states, (v) => {
if (path.join('.') === 'settings.shortcutsColumnsMode') {
store.account.set('settings-shortcutsColumnsMode', !!value);
}
if (path.join('.') === 'settings.shortcutsViewMode') {
store.account.set('settings-shortcutsViewMode', value);
}
if (path?.[0] === 'shortcuts') {
store.account.set('shortcuts', states.shortcuts);
}

View file

@ -1,12 +1,14 @@
import { useEffect, useState } from 'preact/hooks';
export default function useScroll({
scrollableElement,
scrollableRef,
distanceFromStart = 1, // ratio of clientHeight/clientWidth
distanceFromEnd = 1, // ratio of clientHeight/clientWidth
scrollThresholdStart = 10,
scrollThresholdEnd = 10,
direction = 'vertical',
distanceFromStartPx: _distanceFromStartPx,
distanceFromEndPx: _distanceFromEndPx,
} = {}) {
const [scrollDirection, setScrollDirection] = useState(null);
const [reachStart, setReachStart] = useState(false);
@ -15,12 +17,8 @@ export default function useScroll({
const [nearReachEnd, setNearReachEnd] = useState(false);
const isVertical = direction === 'vertical';
if (!scrollableElement) {
// Better be explicit instead of auto-assign to window
return {};
}
useEffect(() => {
const scrollableElement = scrollableRef.current;
let previousScrollStart = isVertical
? scrollableElement.scrollTop
: scrollableElement.scrollLeft;
@ -38,16 +36,20 @@ export default function useScroll({
const scrollDimension = isVertical ? scrollHeight : scrollWidth;
const clientDimension = isVertical ? clientHeight : clientWidth;
const scrollDistance = Math.abs(scrollStart - previousScrollStart);
const distanceFromStartPx = Math.min(
clientDimension * distanceFromStart,
scrollDimension,
scrollStart,
);
const distanceFromEndPx = Math.min(
clientDimension * distanceFromEnd,
scrollDimension,
scrollDimension - scrollStart - clientDimension,
);
const distanceFromStartPx =
_distanceFromStartPx ||
Math.min(
clientDimension * distanceFromStart,
scrollDimension,
scrollStart,
);
const distanceFromEndPx =
_distanceFromEndPx ||
Math.min(
clientDimension * distanceFromEnd,
scrollDimension,
scrollDimension - scrollStart - clientDimension,
);
if (
scrollDistance >=
@ -71,7 +73,6 @@ export default function useScroll({
return () => scrollableElement.removeEventListener('scroll', onScroll);
}, [
scrollableElement,
distanceFromStart,
distanceFromEnd,
scrollThresholdStart,
@ -85,8 +86,8 @@ export default function useScroll({
nearReachStart,
nearReachEnd,
init: () => {
if (scrollableElement) {
scrollableElement.dispatchEvent(new Event('scroll'));
if (scrollableRef.current) {
scrollableRef.current.dispatchEvent(new Event('scroll'));
}
},
};

View file

@ -9,10 +9,11 @@ const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
export default function useTitle(title, path) {
const snapStates = useSnapshot(states);
const { currentLocation } = snapStates;
let paths = [];
const hasPaths = Array.isArray(path);
let paths = hasPaths ? path : [];
// Workaround for matchPath not working for optional path segments
// https://github.com/remix-run/react-router/discussions/9862
if (/:?\w+\?/.test(path)) {
if (!hasPaths && /:?\w+\?/.test(path)) {
paths.push(path.replace(/(:\w+)\?/g, '$1'));
paths.push(path.replace(/\/?:\w+\?/g, ''));
}
@ -24,7 +25,7 @@ export default function useTitle(title, path) {
}
console.debug({ paths, matched, currentLocation });
useEffect(() => {
if (path && !matched) return;
if (!matched) return;
document.title = title ? `${title} / ${CLIENT_NAME}` : CLIENT_NAME;
}, [title, snapStates.currentLocation]);
}, [title, matched]);
}

View file

@ -3,6 +3,7 @@ import { execSync } from 'child_process';
import fs from 'fs';
import { resolve } from 'path';
import { defineConfig, loadEnv, splitVendorChunkPlugin } from 'vite';
import generateFile from 'vite-plugin-generate-file';
import htmlPlugin from 'vite-plugin-html-config';
import VitePluginHtmlEnv from 'vite-plugin-html-env';
import { VitePWA } from 'vite-plugin-pwa';
@ -12,6 +13,7 @@ const { NODE_ENV } = process.env;
const { VITE_CLIENT_NAME: CLIENT_NAME, VITE_APP_ERROR_LOGGING: ERROR_LOGGING } =
loadEnv('production', process.cwd());
const now = new Date();
const commitHash = execSync('git rev-parse --short HEAD').toString().trim();
const rollbarCode = fs.readFileSync(
@ -23,7 +25,7 @@ const rollbarCode = fs.readFileSync(
export default defineConfig({
mode: NODE_ENV,
define: {
__BUILD_TIME__: JSON.stringify(Date.now()),
__BUILD_TIME__: JSON.stringify(now),
__COMMIT_HASH__: JSON.stringify(commitHash),
},
plugins: [
@ -36,6 +38,16 @@ export default defineConfig({
htmlPlugin({
headScripts: ERROR_LOGGING ? [rollbarCode] : [],
}),
generateFile([
{
type: 'json',
output: './version.json',
data: {
buildTime: now,
commitHash,
},
},
]),
VitePWA({
manifest: {
name: CLIENT_NAME,