commit
7d806301f2
|
@ -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
252
package-lock.json
generated
|
@ -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",
|
||||
|
|
10
package.json
10
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",
|
||||
|
|
|
@ -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({
|
||||
|
|
120
src/app.css
120
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);
|
||||
}
|
||||
|
|
79
src/app.jsx
79
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) {
|
||||
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,15 +340,11 @@ function App() {
|
|||
window.__COMPOSE__ = null;
|
||||
if (newStatus) {
|
||||
states.reloadStatusPage++;
|
||||
setTimeout(() => {
|
||||
const toast = Toastify({
|
||||
className: 'shiny-pill',
|
||||
showToast({
|
||||
text: 'Status posted. Check it out.',
|
||||
delay: 1000,
|
||||
duration: 10_000, // 10 seconds
|
||||
gravity: 'bottom',
|
||||
position: 'center',
|
||||
// destination: `/#/s/${newStatus.id}`,
|
||||
onClick: () => {
|
||||
onClick: (toast) => {
|
||||
toast.hideToast();
|
||||
states.prevLocation = location;
|
||||
navigate(
|
||||
|
@ -341,8 +354,6 @@ function App() {
|
|||
);
|
||||
},
|
||||
});
|
||||
toast.showToast();
|
||||
}, 1000);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'}
|
||||
>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 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,7 +310,35 @@ function ShortcutsSettings() {
|
|||
No shortcuts yet. Add one from the form below.
|
||||
</p>
|
||||
)}
|
||||
<hr />
|
||||
<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}
|
||||
|
@ -296,17 +347,29 @@ function ShortcutsSettings() {
|
|||
console.log('onSubmit', data);
|
||||
states.shortcuts.push(data);
|
||||
}}
|
||||
onClose={() => setShowForm(false)}
|
||||
/>
|
||||
</main>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShortcutsSettings;
|
||||
function ShortcutForm({ type, lists, followedHashtags, onSubmit, disabled }) {
|
||||
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
|
||||
|
@ -314,21 +377,16 @@ function ShortcutForm({ type, lists, followedHashtags, onSubmit, disabled }) {
|
|||
const data = new FormData(e.target);
|
||||
const result = {};
|
||||
data.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
result[key] = value?.trim();
|
||||
});
|
||||
if (!result.type) return;
|
||||
onSubmit(result);
|
||||
// Reset
|
||||
e.target.reset();
|
||||
setCurrentType(null);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<header>
|
||||
<h3>Add a shortcut</h3>
|
||||
<button type="submit" disabled={disabled}>
|
||||
Add
|
||||
</button>
|
||||
</header>
|
||||
<p>
|
||||
<label>
|
||||
<span>Timeline</span>
|
||||
|
@ -348,7 +406,7 @@ function ShortcutForm({ type, lists, followedHashtags, onSubmit, disabled }) {
|
|||
</label>
|
||||
</p>
|
||||
{TYPE_PARAMS[currentType]?.map?.(
|
||||
({ text, name, type, placeholder }) => {
|
||||
({ text, name, type, placeholder, pattern }) => {
|
||||
if (currentType === 'list') {
|
||||
return (
|
||||
<p>
|
||||
|
@ -382,8 +440,10 @@ function ShortcutForm({ type, lists, followedHashtags, onSubmit, disabled }) {
|
|||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck={false}
|
||||
pattern={pattern}
|
||||
/>
|
||||
{currentType === 'hashtag' && followedHashtags.length > 0 && (
|
||||
{currentType === 'hashtag' &&
|
||||
followedHashtags.length > 0 && (
|
||||
<datalist id="followed-hashtags-datalist">
|
||||
{followedHashtags.map((tag) => (
|
||||
<option value={tag.name} />
|
||||
|
@ -395,7 +455,13 @@ function ShortcutForm({ type, lists, followedHashtags, onSubmit, disabled }) {
|
|||
);
|
||||
},
|
||||
)}
|
||||
<button type="submit" class="block" disabled={disabled}>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShortcutsSettings;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,6 +70,41 @@ function Shortcuts() {
|
|||
|
||||
return (
|
||||
<div id="shortcuts">
|
||||
{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"
|
||||
|
@ -106,6 +146,7 @@ function Shortcuts() {
|
|||
);
|
||||
})}
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 ? (
|
||||
<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}`}
|
||||
class="time"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
class={`time ${open ? 'is-open' : ''}`}
|
||||
>
|
||||
<Icon
|
||||
icon={visibilityIconsMap[visibility]}
|
||||
alt={visibility}
|
||||
alt={visibilityText[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,36 +887,19 @@ 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
|
||||
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
|
||||
|
@ -679,19 +912,8 @@ function Status({
|
|||
</div>
|
||||
}
|
||||
>
|
||||
{isSelf && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showCompose = {
|
||||
editStatus: status,
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Icon icon="pencil" /> <span>Edit…</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{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);
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>}
|
||||
|
|
|
@ -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,10 +77,36 @@ function Public({ local, ...props }) {
|
|||
fetchItems={fetchPublic}
|
||||
checkForUpdates={checkForUpdates}
|
||||
headerStart={<></>}
|
||||
boostsCarousel={snapStates.settings.boostsCarousel}
|
||||
headerEnd={
|
||||
<button
|
||||
type="button"
|
||||
class="plain"
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<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"',
|
||||
|
@ -92,8 +121,9 @@ function Public({ local, ...props }) {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<Icon icon="transfer" alt="Switch instance" />
|
||||
</button>
|
||||
<Icon icon="bus" /> <span>Go to another instance…</span>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,7 +158,8 @@ function Settings({ onClose }) {
|
|||
</p>
|
||||
</section>
|
||||
<h2>Settings</h2>
|
||||
<ul class="section">
|
||||
<section>
|
||||
<ul>
|
||||
<li>
|
||||
<div>
|
||||
<label>Appearance</label>
|
||||
|
@ -238,6 +241,7 @@ function Settings({ onClose }) {
|
|||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<h2>Hidden features</h2>
|
||||
<section>
|
||||
<div>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
19
src/utils/nice-date-time.js
Normal file
19
src/utils/nice-date-time.js
Normal 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
26
src/utils/show-toast.js
Normal 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;
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,12 +36,16 @@ export default function useScroll({
|
|||
const scrollDimension = isVertical ? scrollHeight : scrollWidth;
|
||||
const clientDimension = isVertical ? clientHeight : clientWidth;
|
||||
const scrollDistance = Math.abs(scrollStart - previousScrollStart);
|
||||
const distanceFromStartPx = Math.min(
|
||||
const distanceFromStartPx =
|
||||
_distanceFromStartPx ||
|
||||
Math.min(
|
||||
clientDimension * distanceFromStart,
|
||||
scrollDimension,
|
||||
scrollStart,
|
||||
);
|
||||
const distanceFromEndPx = Math.min(
|
||||
const distanceFromEndPx =
|
||||
_distanceFromEndPx ||
|
||||
Math.min(
|
||||
clientDimension * distanceFromEnd,
|
||||
scrollDimension,
|
||||
scrollDimension - scrollStart - clientDimension,
|
||||
|
@ -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'));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue