this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #272 from cheeaun/main

Update from main

authored by

Chee Aun and committed by
GitHub
87f1d17c 0cf7d683

+1608 -546
+48 -76
package-lock.json
··· 9 9 "version": "0.1.0", 10 10 "dependencies": { 11 11 "@formatjs/intl-localematcher": "~0.4.2", 12 + "@formkit/auto-animate": "~0.8.0", 12 13 "@github/text-expander-element": "~2.5.0", 13 - "@iconify-icons/mingcute": "~1.2.8", 14 + "@iconify-icons/mingcute": "~1.2.9", 14 15 "@justinribeiro/lite-youtube": "~1.5.0", 15 16 "@szhsin/react-menu": "~4.1.0", 16 - "@uidotdev/usehooks": "~2.4.0", 17 + "@uidotdev/usehooks": "~2.4.1", 17 18 "dayjs": "~1.11.10", 18 19 "dayjs-twitter": "~0.5.0", 19 20 "fast-blurhash": "~1.1.2", ··· 21 22 "idb-keyval": "~6.2.1", 22 23 "just-debounce-it": "~3.2.0", 23 24 "lz-string": "~1.5.0", 24 - "masto": "~6.3.1", 25 + "masto": "~6.3.3", 25 26 "moize": "~6.1.6", 26 27 "p-retry": "~6.1.0", 27 28 "p-throttle": "~5.1.0", ··· 41 42 }, 42 43 "devDependencies": { 43 44 "@preact/preset-vite": "~2.6.0", 44 - "@trivago/prettier-plugin-sort-imports": "~4.2.0", 45 + "@trivago/prettier-plugin-sort-imports": "~4.2.1", 45 46 "postcss": "~8.4.31", 46 47 "postcss-dark-theme-class": "~1.0.0", 47 48 "postcss-preset-env": "~9.2.0", 48 49 "twitter-text": "~3.1.0", 49 - "vite": "~4.4.11", 50 + "vite": "~4.5.0", 50 51 "vite-plugin-generate-file": "~0.0.4", 51 52 "vite-plugin-html-config": "~1.0.11", 52 53 "vite-plugin-pwa": "~0.16.5", ··· 3043 3044 "tslib": "^2.4.0" 3044 3045 } 3045 3046 }, 3047 + "node_modules/@formkit/auto-animate": { 3048 + "version": "0.8.0", 3049 + "resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.8.0.tgz", 3050 + "integrity": "sha512-G8f7489ka0mWyi+1IEZT+xgIwcpWtRMmE2x+IrVoQ+KM1cP6VDj/TbujZjwxdb0P8w8b16/qBfViRmydbYHwMw==" 3051 + }, 3046 3052 "node_modules/@github/combobox-nav": { 3047 3053 "version": "2.1.5", 3048 3054 "resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.1.5.tgz", ··· 3057 3063 } 3058 3064 }, 3059 3065 "node_modules/@iconify-icons/mingcute": { 3060 - "version": "1.2.8", 3061 - "resolved": "https://registry.npmjs.org/@iconify-icons/mingcute/-/mingcute-1.2.8.tgz", 3062 - "integrity": "sha512-9mH0dn/rtsKvaR/P57LgTB8IGoN3ePxCiap3EQfmNSu1x+w2ib478HHxUnXdg1WpyRFbX81aFtUDvq7yuSOyeg==", 3066 + "version": "1.2.9", 3067 + "resolved": "https://registry.npmjs.org/@iconify-icons/mingcute/-/mingcute-1.2.9.tgz", 3068 + "integrity": "sha512-u+hX7mh7amKlWFHOTi52tnJ52NWQVAFevjDcQkZvK4Zj2UyVVKZ45yKBsFHo4OTJDzBkIafJh4C4fkPJsvCtOA==", 3063 3069 "dependencies": { 3064 3070 "@iconify/types": "*" 3065 3071 } ··· 3298 3304 } 3299 3305 }, 3300 3306 "node_modules/@trivago/prettier-plugin-sort-imports": { 3301 - "version": "4.2.0", 3302 - "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.2.0.tgz", 3303 - "integrity": "sha512-YBepjbt+ZNBVmN3ev1amQH3lWCmHyt5qTbLCp/syXJRu/Kw2koXh44qayB1gMRxcL/gV8egmjN5xWSrYyfUtyw==", 3307 + "version": "4.2.1", 3308 + "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.2.1.tgz", 3309 + "integrity": "sha512-iuy2MPVURGdxILTchHr15VAioItuYBejKfcTmQFlxIuqA7jeaT6ngr5aUIG6S6U096d6a6lJCgaOwlRrPLlOPg==", 3304 3310 "dev": true, 3305 3311 "dependencies": { 3306 3312 "@babel/generator": "7.17.7", 3307 3313 "@babel/parser": "^7.20.5", 3308 - "@babel/traverse": "7.17.3", 3314 + "@babel/traverse": "7.23.2", 3309 3315 "@babel/types": "7.17.0", 3310 3316 "javascript-natural-sort": "0.7.1", 3311 3317 "lodash": "^4.17.21" ··· 3329 3335 "@babel/types": "^7.17.0", 3330 3336 "jsesc": "^2.5.1", 3331 3337 "source-map": "^0.5.0" 3332 - }, 3333 - "engines": { 3334 - "node": ">=6.9.0" 3335 - } 3336 - }, 3337 - "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/@babel/traverse": { 3338 - "version": "7.17.3", 3339 - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.3.tgz", 3340 - "integrity": "sha512-5irClVky7TxRWIRtxlh2WPUUOLhcPN06AGgaQSB8AEwuyEBgJVuJ5imdHm5zxk8w0QS5T+tDfnDxAlhWjpb7cw==", 3341 - "dev": true, 3342 - "dependencies": { 3343 - "@babel/code-frame": "^7.16.7", 3344 - "@babel/generator": "^7.17.3", 3345 - "@babel/helper-environment-visitor": "^7.16.7", 3346 - "@babel/helper-function-name": "^7.16.7", 3347 - "@babel/helper-hoist-variables": "^7.16.7", 3348 - "@babel/helper-split-export-declaration": "^7.16.7", 3349 - "@babel/parser": "^7.17.3", 3350 - "@babel/types": "^7.17.0", 3351 - "debug": "^4.1.0", 3352 - "globals": "^11.1.0" 3353 3338 }, 3354 3339 "engines": { 3355 3340 "node": ">=6.9.0" ··· 3410 3395 "dev": true 3411 3396 }, 3412 3397 "node_modules/@uidotdev/usehooks": { 3413 - "version": "2.4.0", 3414 - "resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.0.tgz", 3415 - "integrity": "sha512-NrpTsZUGsawYxFbEXrd8+FPpfziC4M01GSQgYWOnGa84UiavqVCzCL5bSRe6rfQc4QsHS2rGAA0h63ya/j+p6A==", 3398 + "version": "2.4.1", 3399 + "resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.1.tgz", 3400 + "integrity": "sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==", 3416 3401 "engines": { 3417 3402 "node": ">=16" 3418 3403 }, ··· 5272 5257 } 5273 5258 }, 5274 5259 "node_modules/masto": { 5275 - "version": "6.3.1", 5276 - "resolved": "https://registry.npmjs.org/masto/-/masto-6.3.1.tgz", 5277 - "integrity": "sha512-Os3MlbGFNL6KHxlKldYY+d/1exO6oBjtF4vx8d6cmXRmeeeW3mKQeunTZz+yY5qWksPg2eVdk+FOhaEnOeclVw==", 5260 + "version": "6.3.3", 5261 + "resolved": "https://registry.npmjs.org/masto/-/masto-6.3.3.tgz", 5262 + "integrity": "sha512-hmDsiscImeZfpkS+5oEWk3w5mkbxERFKN/UpuaoKZpVWWoGWCNnO7iPfQHygs/phP7PQqS6pVHlE5ylqSylf6A==", 5278 5263 "dependencies": { 5279 5264 "change-case": "^4.1.2", 5280 5265 "events-to-async": "^2.0.0", ··· 7260 7245 } 7261 7246 }, 7262 7247 "node_modules/vite": { 7263 - "version": "4.4.11", 7264 - "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.11.tgz", 7265 - "integrity": "sha512-ksNZJlkcU9b0lBwAGZGGaZHCMqHsc8OpgtoYhsQ4/I2v5cnpmmmqe5pM4nv/4Hn6G/2GhTdj0DhZh2e+Er1q5A==", 7248 + "version": "4.5.0", 7249 + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz", 7250 + "integrity": "sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==", 7266 7251 "dev": true, 7267 7252 "dependencies": { 7268 7253 "esbuild": "^0.18.10", ··· 9558 9543 "tslib": "^2.4.0" 9559 9544 } 9560 9545 }, 9546 + "@formkit/auto-animate": { 9547 + "version": "0.8.0", 9548 + "resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.8.0.tgz", 9549 + "integrity": "sha512-G8f7489ka0mWyi+1IEZT+xgIwcpWtRMmE2x+IrVoQ+KM1cP6VDj/TbujZjwxdb0P8w8b16/qBfViRmydbYHwMw==" 9550 + }, 9561 9551 "@github/combobox-nav": { 9562 9552 "version": "2.1.5", 9563 9553 "resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.1.5.tgz", ··· 9572 9562 } 9573 9563 }, 9574 9564 "@iconify-icons/mingcute": { 9575 - "version": "1.2.8", 9576 - "resolved": "https://registry.npmjs.org/@iconify-icons/mingcute/-/mingcute-1.2.8.tgz", 9577 - "integrity": "sha512-9mH0dn/rtsKvaR/P57LgTB8IGoN3ePxCiap3EQfmNSu1x+w2ib478HHxUnXdg1WpyRFbX81aFtUDvq7yuSOyeg==", 9565 + "version": "1.2.9", 9566 + "resolved": "https://registry.npmjs.org/@iconify-icons/mingcute/-/mingcute-1.2.9.tgz", 9567 + "integrity": "sha512-u+hX7mh7amKlWFHOTi52tnJ52NWQVAFevjDcQkZvK4Zj2UyVVKZ45yKBsFHo4OTJDzBkIafJh4C4fkPJsvCtOA==", 9578 9568 "requires": { 9579 9569 "@iconify/types": "*" 9580 9570 } ··· 9771 9761 } 9772 9762 }, 9773 9763 "@trivago/prettier-plugin-sort-imports": { 9774 - "version": "4.2.0", 9775 - "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.2.0.tgz", 9776 - "integrity": "sha512-YBepjbt+ZNBVmN3ev1amQH3lWCmHyt5qTbLCp/syXJRu/Kw2koXh44qayB1gMRxcL/gV8egmjN5xWSrYyfUtyw==", 9764 + "version": "4.2.1", 9765 + "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.2.1.tgz", 9766 + "integrity": "sha512-iuy2MPVURGdxILTchHr15VAioItuYBejKfcTmQFlxIuqA7jeaT6ngr5aUIG6S6U096d6a6lJCgaOwlRrPLlOPg==", 9777 9767 "dev": true, 9778 9768 "requires": { 9779 9769 "@babel/generator": "7.17.7", 9780 9770 "@babel/parser": "^7.20.5", 9781 - "@babel/traverse": "7.17.3", 9771 + "@babel/traverse": "7.23.2", 9782 9772 "@babel/types": "7.17.0", 9783 9773 "javascript-natural-sort": "0.7.1", 9784 9774 "lodash": "^4.17.21" ··· 9795 9785 "source-map": "^0.5.0" 9796 9786 } 9797 9787 }, 9798 - "@babel/traverse": { 9799 - "version": "7.17.3", 9800 - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.3.tgz", 9801 - "integrity": "sha512-5irClVky7TxRWIRtxlh2WPUUOLhcPN06AGgaQSB8AEwuyEBgJVuJ5imdHm5zxk8w0QS5T+tDfnDxAlhWjpb7cw==", 9802 - "dev": true, 9803 - "requires": { 9804 - "@babel/code-frame": "^7.16.7", 9805 - "@babel/generator": "^7.17.3", 9806 - "@babel/helper-environment-visitor": "^7.16.7", 9807 - "@babel/helper-function-name": "^7.16.7", 9808 - "@babel/helper-hoist-variables": "^7.16.7", 9809 - "@babel/helper-split-export-declaration": "^7.16.7", 9810 - "@babel/parser": "^7.17.3", 9811 - "@babel/types": "^7.17.0", 9812 - "debug": "^4.1.0", 9813 - "globals": "^11.1.0" 9814 - } 9815 - }, 9816 9788 "@babel/types": { 9817 9789 "version": "7.17.0", 9818 9790 "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", ··· 9864 9836 "dev": true 9865 9837 }, 9866 9838 "@uidotdev/usehooks": { 9867 - "version": "2.4.0", 9868 - "resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.0.tgz", 9869 - "integrity": "sha512-NrpTsZUGsawYxFbEXrd8+FPpfziC4M01GSQgYWOnGa84UiavqVCzCL5bSRe6rfQc4QsHS2rGAA0h63ya/j+p6A==", 9839 + "version": "2.4.1", 9840 + "resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.1.tgz", 9841 + "integrity": "sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==", 9870 9842 "requires": {} 9871 9843 }, 9872 9844 "@vue/compiler-core": { ··· 11216 11188 } 11217 11189 }, 11218 11190 "masto": { 11219 - "version": "6.3.1", 11220 - "resolved": "https://registry.npmjs.org/masto/-/masto-6.3.1.tgz", 11221 - "integrity": "sha512-Os3MlbGFNL6KHxlKldYY+d/1exO6oBjtF4vx8d6cmXRmeeeW3mKQeunTZz+yY5qWksPg2eVdk+FOhaEnOeclVw==", 11191 + "version": "6.3.3", 11192 + "resolved": "https://registry.npmjs.org/masto/-/masto-6.3.3.tgz", 11193 + "integrity": "sha512-hmDsiscImeZfpkS+5oEWk3w5mkbxERFKN/UpuaoKZpVWWoGWCNnO7iPfQHygs/phP7PQqS6pVHlE5ylqSylf6A==", 11222 11194 "requires": { 11223 11195 "change-case": "^4.1.2", 11224 11196 "events-to-async": "^2.0.0", ··· 12453 12425 } 12454 12426 }, 12455 12427 "vite": { 12456 - "version": "4.4.11", 12457 - "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.11.tgz", 12458 - "integrity": "sha512-ksNZJlkcU9b0lBwAGZGGaZHCMqHsc8OpgtoYhsQ4/I2v5cnpmmmqe5pM4nv/4Hn6G/2GhTdj0DhZh2e+Er1q5A==", 12428 + "version": "4.5.0", 12429 + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz", 12430 + "integrity": "sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==", 12459 12431 "dev": true, 12460 12432 "requires": { 12461 12433 "esbuild": "^0.18.10",
+6 -5
package.json
··· 11 11 }, 12 12 "dependencies": { 13 13 "@formatjs/intl-localematcher": "~0.4.2", 14 + "@formkit/auto-animate": "~0.8.0", 14 15 "@github/text-expander-element": "~2.5.0", 15 - "@iconify-icons/mingcute": "~1.2.8", 16 + "@iconify-icons/mingcute": "~1.2.9", 16 17 "@justinribeiro/lite-youtube": "~1.5.0", 17 18 "@szhsin/react-menu": "~4.1.0", 18 - "@uidotdev/usehooks": "~2.4.0", 19 + "@uidotdev/usehooks": "~2.4.1", 19 20 "dayjs": "~1.11.10", 20 21 "dayjs-twitter": "~0.5.0", 21 22 "fast-blurhash": "~1.1.2", ··· 23 24 "idb-keyval": "~6.2.1", 24 25 "just-debounce-it": "~3.2.0", 25 26 "lz-string": "~1.5.0", 26 - "masto": "~6.3.1", 27 + "masto": "~6.3.3", 27 28 "moize": "~6.1.6", 28 29 "p-retry": "~6.1.0", 29 30 "p-throttle": "~5.1.0", ··· 43 44 }, 44 45 "devDependencies": { 45 46 "@preact/preset-vite": "~2.6.0", 46 - "@trivago/prettier-plugin-sort-imports": "~4.2.0", 47 + "@trivago/prettier-plugin-sort-imports": "~4.2.1", 47 48 "postcss": "~8.4.31", 48 49 "postcss-dark-theme-class": "~1.0.0", 49 50 "postcss-preset-env": "~9.2.0", 50 51 "twitter-text": "~3.1.0", 51 - "vite": "~4.4.11", 52 + "vite": "~4.5.0", 52 53 "vite-plugin-generate-file": "~0.0.4", 53 54 "vite-plugin-html-config": "~1.0.11", 54 55 "vite-plugin-pwa": "~0.16.5",
+84 -6
src/app.css
··· 86 86 width: var(--main-width); 87 87 max-width: 100%; 88 88 background-color: var(--bg-color); 89 + overflow-anchor: auto; 89 90 } 90 91 .deck.contained { 91 92 overflow: auto; ··· 273 274 background-color: var(--bg-color); 274 275 box-shadow: inset 0 -3px var(--comment-line-color), 275 276 inset 0 3px var(--comment-line-color); 277 + overscroll-behavior-x: contain; 278 + 279 + .status-link { 280 + width: fit-content; 281 + } 276 282 } 277 283 .timeline.contextual .replies[data-comments-level='4'] { 278 284 overflow-x: auto; ··· 1051 1057 width: 100%; 1052 1058 height: 100vh; 1053 1059 height: 100dvh; 1054 - background-color: var(--average-color-alpha); 1055 - background-image: radial-gradient( 1060 + background-color: var(--accent-alpha-color); 1061 + /* background-image: radial-gradient( 1056 1062 closest-side, 1057 - var(--average-color) 10%, 1058 - var(--average-color-alpha) 40%, 1063 + var(--accent-color) 10%, 1064 + var(--accent-alpha-color) 40%, 1059 1065 transparent 100% 1060 - ); 1066 + ); */ 1061 1067 } 1062 1068 .carousel .carousel-item :is(img, video) { 1063 1069 width: auto; ··· 1372 1378 margin-top: -44px; 1373 1379 background-image: radial-gradient( 1374 1380 circle, 1375 - var(--bg-faded-color) 0px 14px, 1381 + var(--bg-faded-color) 0px 13px, 1382 + var(--outline-color) 13px 14px, 1376 1383 transparent 14px 1377 1384 ); 1378 1385 } ··· 1792 1799 background-image: none; 1793 1800 box-shadow: 0 3px 8px -1px var(--drop-shadow-color), 1794 1801 0 10px 36px -4px var(--button-bg-blur-color); 1802 + text-align: center; 1795 1803 } 1796 1804 .toastify-bottom { 1797 1805 margin-bottom: env(safe-area-inset-bottom); ··· 2127 2135 pointer-events: none; 2128 2136 opacity: 0.5; 2129 2137 } 2138 + 2139 + .filter-field { 2140 + flex-shrink: 0; 2141 + padding: 8px 16px; 2142 + border-radius: 999px; 2143 + color: var(--text-color); 2144 + background-color: var(--bg-color); 2145 + background-image: none; 2146 + border: 2px solid transparent; 2147 + margin: 0; 2148 + /* appearance: none; */ 2149 + line-height: 1; 2150 + font-size: 90%; 2151 + display: flex; 2152 + gap: 8px; 2153 + 2154 + > .icon { 2155 + color: var(--link-color); 2156 + } 2157 + 2158 + &:placeholder-shown { 2159 + color: var(--text-insignificant-color); 2160 + } 2161 + 2162 + &:is(:hover, :focus-visible) { 2163 + border-color: var(--link-light-color); 2164 + } 2165 + &:focus { 2166 + outline-color: var(--link-light-color); 2167 + } 2168 + &.is-active { 2169 + border-color: var(--link-color); 2170 + box-shadow: inset 0 0 8px var(--link-faded-color); 2171 + } 2172 + 2173 + :is(input, select) { 2174 + background-color: transparent; 2175 + background-image: none; 2176 + border: 0; 2177 + padding: 0; 2178 + margin: 0; 2179 + color: inherit; 2180 + font-size: inherit; 2181 + line-height: inherit; 2182 + appearance: none; 2183 + border-radius: 0; 2184 + box-shadow: none; 2185 + outline: none; 2186 + } 2187 + 2188 + input[type='month'] { 2189 + min-width: 6em; 2190 + 2191 + &::-webkit-calendar-picker-indicator { 2192 + /* replace icon with triangle */ 2193 + opacity: 0.5; 2194 + background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none"><path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>'); 2195 + } 2196 + 2197 + @media (prefers-color-scheme: dark) { 2198 + &::-webkit-calendar-picker-indicator { 2199 + filter: invert(1); 2200 + } 2201 + } 2202 + } 2203 + } 2130 2204 } 2131 2205 .filter-bar.centered { 2132 2206 justify-content: center; ··· 2229 2303 } 2230 2304 .deck > header { 2231 2305 text-shadow: 0 1px var(--bg-color); 2306 + 2307 + form { 2308 + text-shadow: none; 2309 + } 2232 2310 } 2233 2311 .deck > header h1 { 2234 2312 font-size: 1.5em;
+143 -83
src/app.jsx
··· 1 1 import './app.css'; 2 2 3 + import debounce from 'just-debounce-it'; 3 4 import { 4 5 useEffect, 5 6 useLayoutEffect, ··· 9 10 } from 'preact/hooks'; 10 11 import { matchPath, Route, Routes, useLocation } from 'react-router-dom'; 11 12 import 'swiped-events'; 12 - import { useSnapshot } from 'valtio'; 13 + import { subscribe, useSnapshot } from 'valtio'; 13 14 14 15 import BackgroundService from './components/background-service'; 15 16 import ComposeButton from './components/compose-button'; ··· 68 69 } 69 70 }, 5000); 70 71 72 + (() => { 73 + window.__IDLE__ = false; 74 + const nonIdleEvents = [ 75 + 'mousemove', 76 + 'mousedown', 77 + 'resize', 78 + 'keydown', 79 + 'touchstart', 80 + 'pointerdown', 81 + 'pointermove', 82 + 'wheel', 83 + ]; 84 + const IDLE_TIME = 5_000; // 5 seconds 85 + const setIdle = debounce(() => { 86 + window.__IDLE__ = true; 87 + }, IDLE_TIME); 88 + const onNonIdle = () => { 89 + window.__IDLE__ = false; 90 + setIdle(); 91 + }; 92 + nonIdleEvents.forEach((event) => { 93 + window.addEventListener(event, onNonIdle, { 94 + passive: true, 95 + capture: true, 96 + }); 97 + }); 98 + // document.addEventListener( 99 + // 'visibilitychange', 100 + // () => { 101 + // if (document.visibilityState === 'visible') { 102 + // onNonIdle(); 103 + // } 104 + // }, 105 + // { 106 + // passive: true, 107 + // }, 108 + // ); 109 + })(); 110 + 111 + subscribe(states, (changes) => { 112 + for (const [action, path, value, prevValue] of changes) { 113 + // Change #app dataset based on settings.shortcutsViewMode 114 + if (path.join('.') === 'settings.shortcutsViewMode') { 115 + const $app = document.getElementById('app'); 116 + if ($app) { 117 + $app.dataset.shortcutsViewMode = states.shortcuts?.length ? value : ''; 118 + } 119 + } 120 + 121 + // Add/Remove cloak class to body 122 + if (path.join('.') === 'settings.cloakMode') { 123 + const $body = document.body; 124 + $body.classList.toggle('cloak', value); 125 + } 126 + } 127 + }); 128 + 71 129 function App() { 72 - const snapStates = useSnapshot(states); 73 130 const [isLoggedIn, setIsLoggedIn] = useState(false); 74 131 const [uiState, setUIState] = useState('loading'); 75 132 ··· 153 210 154 211 let location = useLocation(); 155 212 states.currentLocation = location.pathname; 213 + // useLayoutEffect(() => { 214 + // states.currentLocation = location.pathname; 215 + // }, [location.pathname]); 156 216 157 217 useEffect(focusDeck, [location, isLoggedIn]); 158 218 159 - const prevLocation = snapStates.prevLocation; 160 - const backgroundLocation = useRef(prevLocation || null); 219 + if (/\/https?:/.test(location.pathname)) { 220 + return <HttpRoute />; 221 + } 222 + 223 + return ( 224 + <> 225 + <PrimaryRoutes isLoggedIn={isLoggedIn} loading={uiState === 'loading'} /> 226 + <SecondaryRoutes isLoggedIn={isLoggedIn} /> 227 + {uiState === 'default' && ( 228 + <Routes> 229 + <Route path="/:instance?/s/:id" element={<StatusRoute />} /> 230 + </Routes> 231 + )} 232 + {isLoggedIn && <ComposeButton />} 233 + {isLoggedIn && <Shortcuts />} 234 + <Modals /> 235 + {isLoggedIn && <NotificationService />} 236 + <BackgroundService isLoggedIn={isLoggedIn} /> 237 + {uiState !== 'loading' && <SearchCommand onClose={focusDeck} />} 238 + <KeyboardShortcutsHelp /> 239 + </> 240 + ); 241 + } 242 + 243 + function PrimaryRoutes({ isLoggedIn, loading }) { 244 + const location = useLocation(); 245 + const nonRootLocation = useMemo(() => { 246 + const { pathname } = location; 247 + return !/^\/(login|welcome)/.test(pathname); 248 + }, [location]); 249 + 250 + return ( 251 + <Routes location={nonRootLocation || location}> 252 + <Route 253 + path="/" 254 + element={ 255 + isLoggedIn ? ( 256 + <Home /> 257 + ) : loading ? ( 258 + <Loader id="loader-root" /> 259 + ) : ( 260 + <Welcome /> 261 + ) 262 + } 263 + /> 264 + <Route path="/login" element={<Login />} /> 265 + <Route path="/welcome" element={<Welcome />} /> 266 + </Routes> 267 + ); 268 + } 269 + 270 + function getPrevLocation() { 271 + return states.prevLocation || null; 272 + } 273 + function SecondaryRoutes({ isLoggedIn }) { 274 + // const snapStates = useSnapshot(states); 275 + const location = useLocation(); 276 + // const prevLocation = snapStates.prevLocation; 277 + const backgroundLocation = useRef(getPrevLocation()); 278 + 161 279 const isModalPage = useMemo(() => { 162 280 return ( 163 281 matchPath('/:instance/s/:id', location.pathname) || ··· 165 283 ); 166 284 }, [location.pathname, matchPath]); 167 285 if (isModalPage) { 168 - if (!backgroundLocation.current) backgroundLocation.current = prevLocation; 286 + if (!backgroundLocation.current) 287 + backgroundLocation.current = getPrevLocation(); 169 288 } else { 170 289 backgroundLocation.current = null; 171 290 } ··· 174 293 location, 175 294 }); 176 295 177 - if (/\/https?:/.test(location.pathname)) { 178 - return <HttpRoute />; 179 - } 180 - 181 - const nonRootLocation = useMemo(() => { 182 - const { pathname } = location; 183 - return !/^\/(login|welcome)/.test(pathname); 184 - }, [location]); 185 - 186 - // Change #app dataset based on snapStates.settings.shortcutsViewMode 187 - useEffect(() => { 188 - const $app = document.getElementById('app'); 189 - if ($app) { 190 - $app.dataset.shortcutsViewMode = snapStates.shortcuts?.length 191 - ? snapStates.settings.shortcutsViewMode 192 - : ''; 193 - } 194 - }, [snapStates.shortcuts, snapStates.settings.shortcutsViewMode]); 195 - 196 - // Add/Remove cloak class to body 197 - useEffect(() => { 198 - const $body = document.body; 199 - $body.classList.toggle('cloak', snapStates.settings.cloakMode); 200 - }, [snapStates.settings.cloakMode]); 201 - 202 296 return ( 203 - <> 204 - <Routes location={nonRootLocation || location}> 205 - <Route 206 - path="/" 207 - element={ 208 - isLoggedIn ? ( 209 - <Home /> 210 - ) : uiState === 'loading' ? ( 211 - <Loader id="loader-root" /> 212 - ) : ( 213 - <Welcome /> 214 - ) 215 - } 216 - /> 217 - <Route path="/login" element={<Login />} /> 218 - <Route path="/welcome" element={<Welcome />} /> 219 - </Routes> 220 - <Routes location={backgroundLocation.current || location}> 221 - {isLoggedIn && ( 297 + <Routes location={backgroundLocation.current || location}> 298 + {isLoggedIn && ( 299 + <> 222 300 <Route path="/notifications" element={<Notifications />} /> 223 - )} 224 - {isLoggedIn && <Route path="/mentions" element={<Mentions />} />} 225 - {isLoggedIn && <Route path="/following" element={<Following />} />} 226 - {isLoggedIn && <Route path="/b" element={<Bookmarks />} />} 227 - {isLoggedIn && <Route path="/f" element={<Favourites />} />} 228 - {isLoggedIn && ( 301 + <Route path="/mentions" element={<Mentions />} /> 302 + <Route path="/following" element={<Following />} /> 303 + <Route path="/b" element={<Bookmarks />} /> 304 + <Route path="/f" element={<Favourites />} /> 229 305 <Route path="/l"> 230 306 <Route index element={<Lists />} /> 231 307 <Route path=":id" element={<List />} /> 232 308 </Route> 233 - )} 234 - {isLoggedIn && <Route path="/ft" element={<FollowedHashtags />} />} 235 - <Route path="/:instance?/t/:hashtag" element={<Hashtag />} /> 236 - <Route path="/:instance?/a/:id" element={<AccountStatuses />} /> 237 - <Route path="/:instance?/p"> 238 - <Route index element={<Public />} /> 239 - <Route path="l" element={<Public local />} /> 240 - </Route> 241 - <Route path="/:instance?/trending" element={<Trending />} /> 242 - <Route path="/:instance?/search" element={<Search />} /> 243 - {/* <Route path="/:anything" element={<NotFound />} /> */} 244 - </Routes> 245 - {uiState === 'default' && ( 246 - <Routes> 247 - <Route path="/:instance?/s/:id" element={<StatusRoute />} /> 248 - </Routes> 309 + <Route path="/ft" element={<FollowedHashtags />} /> 310 + </> 249 311 )} 250 - {isLoggedIn && <ComposeButton />} 251 - {isLoggedIn && 252 - !snapStates.settings.shortcutsColumnsMode && 253 - snapStates.settings.shortcutsViewMode !== 'multi-column' && ( 254 - <Shortcuts /> 255 - )} 256 - <Modals /> 257 - {isLoggedIn && <NotificationService />} 258 - <BackgroundService isLoggedIn={isLoggedIn} /> 259 - {uiState !== 'loading' && <SearchCommand onClose={focusDeck} />} 260 - <KeyboardShortcutsHelp /> 261 - </> 312 + <Route path="/:instance?/t/:hashtag" element={<Hashtag />} /> 313 + <Route path="/:instance?/a/:id" element={<AccountStatuses />} /> 314 + <Route path="/:instance?/p"> 315 + <Route index element={<Public />} /> 316 + <Route path="l" element={<Public local />} /> 317 + </Route> 318 + <Route path="/:instance?/trending" element={<Trending />} /> 319 + <Route path="/:instance?/search" element={<Search />} /> 320 + {/* <Route path="/:anything" element={<NotFound />} /> */} 321 + </Routes> 262 322 ); 263 323 } 264 324
+92 -22
src/components/account-info.css
··· 1 1 .account-container { 2 - display: flex; 3 - flex-direction: column; 4 - overflow: hidden; 2 + /* display: flex; */ 3 + /* flex-direction: column; */ 4 + /* overflow: hidden; */ 5 + overflow-y: auto; 5 6 max-width: 100%; 7 + --banner-overlap: 44px; 6 8 } 7 9 8 10 .account-container.skeleton { ··· 51 53 52 54 .account-container .header-banner { 53 55 /* pointer-events: none; */ 56 + vertical-align: top; 54 57 aspect-ratio: 6 / 1; 55 58 width: 100%; 56 59 height: auto; ··· 75 78 hsla(0, 0%, 0%, 0.013) 95.3%, 76 79 hsla(0, 0%, 0%, 0) 100% 77 80 ); 78 - margin-bottom: -44px; 81 + margin-bottom: calc(-1 * var(--banner-overlap)); 79 82 user-select: none; 80 83 -webkit-user-drag: none; 81 84 opacity: 0; ··· 116 119 } 117 120 .account-container .header-banner:active { 118 121 mask-image: none; 119 - } 120 - .account-container .header-banner:active + header .avatar + * { 121 - transition: opacity 0.3s ease-in-out; 122 - opacity: 0 !important; 123 - } 124 - .account-container .header-banner:active + header .avatar { 125 - transition: filter 0.3s ease-in-out; 126 - filter: none !important; 127 - } 128 - .account-container .header-banner:active + header .avatar img { 129 - transition: border-radius 0.3s ease-in-out; 130 - border-radius: 8px; 122 + 123 + & + header { 124 + background-image: none; 125 + } 126 + 127 + & + header .avatar + * { 128 + transition: opacity 0.3s ease-in-out; 129 + opacity: 0 !important; 130 + } 131 + 132 + &, 133 + & + header .avatar { 134 + transition: filter 0.3s ease-in-out; 135 + filter: none !important; 136 + } 137 + 138 + & + header .avatar img { 139 + transition: border-radius 0.3s ease-in-out; 140 + border-radius: 8px; 141 + } 131 142 } 132 143 133 144 @media (min-height: 480px) { ··· 165 176 animation: fade-in 0.3s both ease-in-out 0.2s; 166 177 } 167 178 179 + .account-container .account-block .account-block-acct { 180 + opacity: 0.7; 181 + } 182 + 168 183 .private-note-tag { 169 184 z-index: 1; 170 185 appearance: none; ··· 290 305 color: inherit; 291 306 } 292 307 308 + .account-container footer { 309 + padding: 0 16px 16px; 310 + } 293 311 .account-container .actions { 294 - margin-block: 8px; 312 + /* margin-block: 8px; */ 295 313 display: flex; 296 314 gap: 8px; 297 315 justify-content: space-between; ··· 396 414 animation: none; 397 415 } 398 416 .timeline-start .account-container main { 399 - padding: 1px 16px 1px; 417 + padding: 1px 16px 16px; 400 418 } 401 419 .timeline-start .account-container main > * { 402 420 animation: none; 403 421 } 404 - .timeline-start .account-container .account-block .account-block-acct { 405 - opacity: 0.5; 422 + 423 + .faux-header-bg { 424 + display: none; 425 + } 426 + 427 + .sheet .account-container { 428 + border-radius: 16px 16px 0 0; 429 + overflow-x: hidden; 430 + max-height: 75vh; 431 + overscroll-behavior: none; 432 + 433 + header { 434 + padding-bottom: 16px; 435 + position: sticky; 436 + top: 0; 437 + z-index: 2; 438 + /* --bg-color: red; */ 439 + background-image: linear-gradient( 440 + to bottom, 441 + transparent 30%, 442 + var(--bg-color) var(--banner-overlap), 443 + var(--bg-color) calc(100% - 8px), 444 + transparent 445 + ); 446 + } 447 + 448 + .faux-header-bg { 449 + display: block; 450 + height: var(--banner-overlap); 451 + position: sticky; 452 + top: 0; 453 + z-index: 1; 454 + background-color: var(--bg-color); 455 + margin-top: calc(-1 * var(--banner-overlap)); 456 + } 457 + 458 + main { 459 + margin-top: -8px; 460 + padding-top: 1px; 461 + } 462 + 463 + footer { 464 + min-height: calc(40px + 16px); 465 + animation: slide-up 0.3s ease-out 0.3s both; 466 + position: sticky; 467 + bottom: 0; 468 + background-color: var(--bg-faded-blur-color); 469 + backdrop-filter: blur(16px) saturate(3); 470 + padding: 8px 16px; 471 + border-top: var(--hairline-width) solid var(--outline-color); 472 + padding-bottom: max(8px, env(safe-area-inset-bottom)); 473 + box-shadow: 0 -8px 16px -8px var(--drop-shadow-color); 474 + } 406 475 } 407 476 408 477 @keyframes swoosh-bg-image { ··· 609 678 610 679 @media (min-width: 40em) { 611 680 .timeline-start .account-container { 681 + --banner-overlap: 77px; 612 682 --item-radius: 16px; 613 683 border: 1px solid var(--divider-color); 614 684 margin: 16px 0; ··· 625 695 var(--shadow-offset) var(--shadow-offset) var(--shadow-blur) 626 696 var(--shadow-spread) var(--header-color-2, var(--drop-shadow-color)); 627 697 } 628 - .timeline-start .account-container .header-banner { 698 + /* .timeline-start .account-container .header-banner { 629 699 margin-bottom: -77px; 630 - } 700 + } */ 631 701 .timeline-start .account-container header .account-block { 632 702 font-size: 175%; 633 703 /* margin-bottom: -8px; */
+38 -14
src/components/account-info.jsx
··· 314 314 315 315 return ( 316 316 <div 317 + tabIndex="-1" 317 318 class={`account-container ${uiState === 'loading' ? 'skeleton' : ''}`} 318 319 style={{ 319 320 '--header-color-1': headerCornerColors[0], ··· 343 344 </header> 344 345 <main> 345 346 <div class="note"> 346 - <p>████████ ███████</p> 347 - <p>███████████████ ███████████████</p> 347 + <p>███████ ████ ████</p> 348 + <p>████ ████████ ██████ █████████ ████ ██</p> 348 349 </div> 349 - <div class="stats"> 350 - <div> 351 - <span>██</span> Followers 352 - </div> 353 - <div> 354 - <span>██</span> Following 350 + <div class="account-metadata-box"> 351 + <div class="profile-metadata"> 352 + <div class="profile-field"> 353 + <b class="more-insignificant">███</b> 354 + <p>██████</p> 355 + </div> 356 + <div class="profile-field"> 357 + <b class="more-insignificant">████</b> 358 + <p>███████████</p> 359 + </div> 355 360 </div> 356 - <div> 357 - <span>██</span> Posts 361 + <div class="stats"> 362 + <div> 363 + <span>██</span> Followers 364 + </div> 365 + <div> 366 + <span>██</span> Following 367 + </div> 368 + <div> 369 + <span>██</span> Posts 370 + </div> 358 371 </div> 359 - <div>Joined ██</div> 372 + </div> 373 + <div class="actions"> 374 + <span /> 375 + <span class="buttons"> 376 + <button type="button" title="More" class="plain" disabled> 377 + <Icon icon="more" size="l" alt="More" /> 378 + </button> 379 + </span> 360 380 </div> 361 381 </main> 362 382 </> ··· 379 399 /> 380 400 </div> 381 401 )} 382 - {header && !/missing\.png$/.test(header) && ( 402 + {!!header && !/missing\.png$/.test(header) && ( 383 403 <img 384 404 src={header} 385 405 alt="" ··· 486 506 internal={!standalone} 487 507 /> 488 508 </header> 489 - <main tabIndex="-1"> 509 + <div class="faux-header-bg" aria-hidden="true" /> 510 + <main> 490 511 {!!memorial && <span class="tag">In Memoriam</span>} 491 512 {!!bot && ( 492 513 <span class="tag"> ··· 729 750 </div> 730 751 </div> 731 752 </div> 753 + </main> 754 + <footer> 732 755 <RelatedActions 733 756 info={info} 734 757 instance={instance} 735 758 authenticated={authenticated} 736 759 onRelationshipChange={onRelationshipChange} 737 760 /> 738 - </main> 761 + </footer> 739 762 </> 740 763 ) 741 764 )} ··· 1366 1389 (async () => { 1367 1390 try { 1368 1391 const lists = await masto.v1.lists.list(); 1392 + lists.sort((a, b) => a.title.localeCompare(b.title)); 1369 1393 const listsContainingAccount = await masto.v1.accounts 1370 1394 .$select(accountID) 1371 1395 .lists.list();
+3 -1
src/components/avatar.jsx
··· 2 2 3 3 import { useRef } from 'preact/hooks'; 4 4 5 + import mem from '../utils/mem'; 6 + 5 7 const SIZES = { 6 8 s: 16, 7 9 m: 20, ··· 90 92 ); 91 93 } 92 94 93 - export default Avatar; 95 + export default mem(Avatar);
+50 -44
src/components/background-service.jsx
··· 1 1 import { memo } from 'preact/compat'; 2 2 import { useEffect, useRef, useState } from 'preact/hooks'; 3 + import { useDebouncedCallback } from 'use-debounce'; 3 4 4 5 import { api } from '../utils/api'; 5 6 import states, { saveStatus } from '../utils/states'; ··· 11 12 // - WebSocket to receive notifications when page is visible 12 13 const [visible, setVisible] = useState(true); 13 14 usePageVisibility(setVisible); 14 - useEffect(() => { 15 - let sub; 16 - if (isLoggedIn && visible) { 17 - const { masto, streaming, instance } = api(); 18 - (async () => { 19 - // 1. Get the latest notification 20 - if (states.notificationsLast) { 21 - const notificationsIterator = masto.v1.notifications.list({ 22 - limit: 1, 23 - since_id: states.notificationsLast.id, 24 - }); 25 - const { value: notifications } = await notificationsIterator.next(); 26 - if (notifications?.length) { 27 - let lastReadId; 28 - try { 29 - const markers = await masto.v1.markers.fetch({ 30 - timeline: 'notifications', 31 - }); 32 - lastReadId = markers?.notifications?.lastReadId; 33 - } catch (e) {} 34 - if (lastReadId) { 35 - if (notifications[0].id !== lastReadId) { 36 - states.notificationsShowNew = true; 37 - } 38 - } else { 39 - states.notificationsShowNew = true; 40 - } 15 + const subRef = useRef(); 16 + const debouncedStartNotifications = useDebouncedCallback(() => { 17 + const { masto, streaming, instance } = api(); 18 + (async () => { 19 + // 1. Get the latest notification 20 + if (states.notificationsLast) { 21 + const notificationsIterator = masto.v1.notifications.list({ 22 + limit: 1, 23 + since_id: states.notificationsLast.id, 24 + }); 25 + const { value: notifications } = await notificationsIterator.next(); 26 + if (notifications?.length) { 27 + let lastReadId; 28 + try { 29 + const markers = await masto.v1.markers.fetch({ 30 + timeline: 'notifications', 31 + }); 32 + lastReadId = markers?.notifications?.lastReadId; 33 + } catch (e) {} 34 + if (lastReadId) { 35 + states.notificationsShowNew = notifications[0].id !== lastReadId; 36 + } else { 37 + states.notificationsShowNew = true; 41 38 } 42 39 } 40 + } 43 41 44 - // 2. Start streaming 45 - if (streaming) { 46 - sub = streaming.user.notification.subscribe(); 47 - console.log('🎏 Streaming notification', sub); 48 - for await (const entry of sub) { 49 - if (!sub) break; 50 - console.log('🔔🔔 Notification entry', entry); 51 - if (entry.event === 'notification') { 52 - console.log('🔔🔔 Notification', entry); 53 - saveStatus(entry.payload, instance, { 54 - skipThreading: true, 55 - }); 56 - } 57 - states.notificationsShowNew = true; 42 + // 2. Start streaming 43 + if (streaming) { 44 + let sub = (subRef.current = streaming.user.notification.subscribe()); 45 + console.log('🎏 Streaming notification', sub); 46 + for await (const entry of sub) { 47 + if (!sub) break; 48 + console.log('🔔🔔 Notification entry', entry); 49 + if (entry.event === 'notification') { 50 + console.log('🔔🔔 Notification', entry); 51 + saveStatus(entry.payload, instance, { 52 + skipThreading: true, 53 + }); 58 54 } 55 + states.notificationsShowNew = true; 59 56 } 60 - })(); 57 + } 58 + })(); 59 + }, 3000); 60 + useEffect(() => { 61 + // let sub; 62 + if (isLoggedIn && visible) { 63 + debouncedStartNotifications(); 61 64 } 62 65 return () => { 63 - sub?.unsubscribe?.(); 64 - sub = null; 66 + // sub?.unsubscribe?.(); 67 + // sub = null; 68 + debouncedStartNotifications?.cancel?.(); 69 + subRef.current?.unsubscribe?.(); 70 + subRef.current = null; 65 71 }; 66 72 }, [visible, isLoggedIn]); 67 73
+2 -2
src/components/compose.css
··· 56 56 57 57 @media (min-width: 40em) { 58 58 #compose-container textarea { 59 - font-size: 150%; 60 - font-size: calc(100% + 50% / var(--text-weight)); 59 + /* font-size: 150%; 60 + font-size: calc(100% + 50% / var(--text-weight)); */ 61 61 max-height: 65vh; 62 62 } 63 63 }
+26 -8
src/components/compose.jsx
··· 23 23 getCurrentAccount, 24 24 getCurrentAccountNS, 25 25 getCurrentInstance, 26 + getCurrentInstanceConfiguration, 26 27 } from '../utils/store-utils'; 27 28 import supports from '../utils/supports'; 28 29 import useInterval from '../utils/useInterval'; ··· 119 120 const currentAccount = getCurrentAccount(); 120 121 const currentAccountInfo = currentAccount.info; 121 122 122 - const { configuration } = getCurrentInstance(); 123 + const configuration = getCurrentInstanceConfiguration(); 123 124 console.log('⚙️ Configuration', configuration); 124 125 125 126 const { 126 - statuses: { maxCharacters, maxMediaAttachments, charactersReservedPerUrl }, 127 + statuses: { 128 + maxCharacters, 129 + maxMediaAttachments, 130 + charactersReservedPerUrl, 131 + } = {}, 127 132 mediaAttachments: { 128 - supportedMimeTypes, 133 + supportedMimeTypes = [], 129 134 imageSizeLimit, 130 135 imageMatrixLimit, 131 136 videoSizeLimit, 132 137 videoMatrixLimit, 133 138 videoFrameRateLimit, 134 - }, 135 - polls: { maxOptions, maxCharactersPerOption, maxExpiration, minExpiration }, 136 - } = configuration; 139 + } = {}, 140 + polls: { 141 + maxOptions, 142 + maxCharactersPerOption, 143 + maxExpiration, 144 + minExpiration, 145 + } = {}, 146 + } = configuration || {}; 137 147 138 148 const textareaRef = useRef(); 139 149 const spoilerTextRef = useRef(); ··· 377 387 enableOnFormTags: true, 378 388 // Use keyup because Esc keydown will close the confirm dialog on Safari 379 389 keyup: true, 390 + ignoreEventWhen: (e) => { 391 + const modals = document.querySelectorAll('#modal-container > *'); 392 + const hasModal = !!modals; 393 + const hasOnlyComposer = 394 + modals.length === 1 && modals[0].querySelector('#compose-container'); 395 + console.log('hasModal', hasModal, 'hasOnlyComposer', hasOnlyComposer); 396 + return hasModal && !hasOnlyComposer; 397 + }, 380 398 }, 381 399 ); 382 400 ··· 1200 1218 const [text, setText] = useState(ref.current?.value || ''); 1201 1219 const { maxCharacters, performSearch = () => {}, ...textareaProps } = props; 1202 1220 const snapStates = useSnapshot(states); 1203 - const charCount = snapStates.composerCharacterCount; 1221 + // const charCount = snapStates.composerCharacterCount; 1204 1222 1205 1223 const customEmojis = useRef(); 1206 1224 useEffect(() => { ··· 1432 1450 style={{ 1433 1451 width: '100%', 1434 1452 height: '4em', 1435 - '--text-weight': (1 + charCount / 140).toFixed(1) || 1, 1453 + // '--text-weight': (1 + charCount / 140).toFixed(1) || 1, 1436 1454 }} 1437 1455 /> 1438 1456 </text-expander>
+1
src/components/icon.jsx
··· 101 101 'account-warning': () => import('@iconify-icons/mingcute/user-warning-line'), 102 102 keyboard: () => import('@iconify-icons/mingcute/keyboard-line'), 103 103 cloud: () => import('@iconify-icons/mingcute/cloud-line'), 104 + month: () => import('@iconify-icons/mingcute/calendar-month-line'), 104 105 }; 105 106 106 107 function Icon({
+6 -2
src/components/keyboard-shortcuts-help.jsx
··· 135 135 keys: <kbd>r</kbd>, 136 136 }, 137 137 { 138 - action: 'Favourite', 139 - keys: <kbd>f</kbd>, 138 + action: 'Like (favourite)', 139 + keys: ( 140 + <> 141 + <kbd>l</kbd> or <kbd>f</kbd> 142 + </> 143 + ), 140 144 }, 141 145 { 142 146 action: 'Boost',
+1 -1
src/components/media-alt-modal.jsx
··· 24 24 ); 25 25 26 26 return ( 27 - <div class="sheet"> 27 + <div class="sheet" tabindex="-1"> 28 28 {!!onClose && ( 29 29 <button type="button" class="sheet-close outer" onClick={onClose}> 30 30 <Icon icon="x" />
+28 -31
src/components/media-modal.jsx
··· 3 3 import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'; 4 4 import { useHotkeys } from 'react-hotkeys-hook'; 5 5 6 + import { oklab2rgb, rgb2oklab } from '../utils/color-utils'; 7 + import states from '../utils/states'; 8 + 6 9 import Icon from './icon'; 7 10 import Link from './link'; 8 11 import Media from './media'; 9 - import MediaAltModal from './media-alt-modal'; 10 12 import MenuLink from './menu-link'; 11 - import Modal from './modal'; 12 13 13 14 function MediaModal({ 14 15 mediaAttachments, ··· 64 65 }; 65 66 }, []); 66 67 67 - useHotkeys('esc', onClose, [onClose]); 68 - 69 - const [showMediaAlt, setShowMediaAlt] = useState(false); 68 + useHotkeys( 69 + 'esc', 70 + onClose, 71 + { 72 + ignoreEventWhen: (e) => { 73 + const hasModal = !!document.querySelector('#modal-container > *'); 74 + return hasModal; 75 + }, 76 + }, 77 + [onClose], 78 + ); 70 79 71 80 useEffect(() => { 72 81 let handleScroll = () => { ··· 112 121 > 113 122 {mediaAttachments?.map((media, i) => { 114 123 const { blurhash } = media; 115 - const rgbAverageColor = blurhash 116 - ? getBlurHashAverageColor(blurhash) 117 - : null; 124 + let accentColor; 125 + if (blurhash) { 126 + const averageColor = getBlurHashAverageColor(blurhash); 127 + const labAverageColor = rgb2oklab(averageColor); 128 + accentColor = oklab2rgb([ 129 + 0.6, 130 + labAverageColor[1], 131 + labAverageColor[2], 132 + ]); 133 + } 118 134 return ( 119 135 <div 120 136 class="carousel-item" 121 137 style={{ 122 - '--average-color': `rgb(${rgbAverageColor?.join(',')})`, 123 - '--average-color-alpha': `rgba(${rgbAverageColor?.join( 124 - ',', 125 - )}, .5)`, 138 + '--accent-color': `rgb(${accentColor?.join(',')})`, 139 + '--accent-alpha-color': `rgba(${accentColor?.join(',')}, 0.4)`, 126 140 }} 127 141 tabindex="0" 128 142 key={media.id} ··· 139 153 class="media-alt" 140 154 hidden={!showControls} 141 155 onClick={() => { 142 - setShowMediaAlt({ 156 + states.showMediaAlt = { 143 157 alt: media.description, 144 158 lang, 145 - }); 159 + }; 146 160 }} 147 161 > 148 162 <span class="alt-badge">ALT</span> ··· 273 287 <Icon icon="arrow-right" /> 274 288 </button> 275 289 </div> 276 - )} 277 - {!!showMediaAlt && ( 278 - <Modal 279 - class="light" 280 - onClick={(e) => { 281 - if (e.target === e.currentTarget) { 282 - setShowMediaAlt(false); 283 - carouselRef.current.focus(); 284 - } 285 - }} 286 - > 287 - <MediaAltModal 288 - alt={showMediaAlt.alt || showMediaAlt} 289 - lang={showMediaAlt?.lang} 290 - onClose={() => setShowMediaAlt(false)} 291 - /> 292 - </Modal> 293 290 )} 294 291 </div> 295 292 );
+12 -9
src/components/media.jsx
··· 170 170 const maxAspectHeight = 171 171 window.innerHeight * (orientation === 'portrait' ? 0.45 : 0.33); 172 172 const maxHeight = orientation === 'portrait' ? 0 : 160; 173 - const mediaStyles = { 174 - '--width': `${width}px`, 175 - '--height': `${height}px`, 176 - // Calculate '--aspectWidth' based on aspect ratio calculated from '--width' and '--height', max height has to be 160px 177 - '--aspectWidth': `${ 178 - (width / height) * Math.max(maxHeight, maxAspectHeight) 179 - }px`, 180 - aspectRatio: `${width} / ${height}`, 181 - }; 173 + const mediaStyles = 174 + width && height 175 + ? { 176 + '--width': `${width}px`, 177 + '--height': `${height}px`, 178 + // Calculate '--aspectWidth' based on aspect ratio calculated from '--width' and '--height', max height has to be 160px 179 + '--aspectWidth': `${ 180 + (width / height) * Math.max(maxHeight, maxAspectHeight) 181 + }px`, 182 + aspectRatio: `${width} / ${height}`, 183 + } 184 + : {}; 182 185 183 186 const longDesc = isMediaCaptionLong(description); 184 187 const showInlineDesc =
+16 -3
src/components/modal.jsx
··· 20 20 return () => clearTimeout(timer); 21 21 }, []); 22 22 23 - const escRef = useHotkeys('esc', onClose, [onClose], { 24 - enabled: !!onClose, 25 - }); 23 + const escRef = useHotkeys( 24 + 'esc', 25 + () => { 26 + setTimeout(() => { 27 + onClose?.(); 28 + }, 0); 29 + }, 30 + { 31 + enabled: !!onClose, 32 + // Using keyup and setTimeout above 33 + // This will run "later" to prevent clash with esc handlers from other components 34 + keydown: false, 35 + keyup: true, 36 + }, 37 + [onClose], 38 + ); 26 39 27 40 const Modal = ( 28 41 <div
+2 -4
src/components/modals.jsx
··· 183 183 {!!snapStates.showMediaAlt && ( 184 184 <Modal 185 185 class="light" 186 - onClick={(e) => { 187 - if (e.target === e.currentTarget) { 188 - states.showMediaAlt = false; 189 - } 186 + onClose={(e) => { 187 + states.showMediaAlt = false; 190 188 }} 191 189 > 192 190 <MediaAltModal
+1 -1
src/components/nav-menu.jsx
··· 192 192 <Icon icon="bookmark" size="l" /> <span>Bookmarks</span> 193 193 </MenuLink> 194 194 <MenuLink to="/f"> 195 - <Icon icon="heart" size="l" /> <span>Favourites</span> 195 + <Icon icon="heart" size="l" /> <span>Likes</span> 196 196 </MenuLink> 197 197 </> 198 198 )}
+1 -5
src/components/notification-service.jsx
··· 77 77 } 78 78 } 79 79 } else { 80 - console.warn( 81 - '🛎️ Notification not found', 82 - notificationID, 83 - notificationAccessToken, 84 - ); 80 + console.warn('🛎️ Notification not found', id); 85 81 } 86 82 })(); 87 83 }, [id, accessToken]);
+20 -17
src/components/notification.jsx
··· 1 + import { memo } from 'preact/compat'; 2 + 1 3 import shortenNumber from '../utils/shorten-number'; 2 4 import states from '../utils/states'; 3 5 import store from '../utils/store'; ··· 47 49 reblog_reply: 'boosted your reply.', 48 50 follow: 'followed you.', 49 51 follow_request: 'requested to follow you.', 50 - favourite: 'favourited your post.', 51 - 'favourite+account': (count) => `favourited ${count} of your posts.`, 52 - favourite_reply: 'favourited your reply.', 52 + favourite: 'liked your post.', 53 + 'favourite+account': (count) => `liked ${count} of your posts.`, 54 + favourite_reply: 'liked your reply.', 53 55 poll: 'A poll you have voted in or created has ended.', 54 56 'poll-self': 'A poll you have created has ended.', 55 57 'poll-voted': 'A poll you have voted in has ended.', 56 58 update: 'A post you interacted with has been edited.', 57 - 'favourite+reblog': 'boosted & favourited your post.', 59 + 'favourite+reblog': 'boosted & liked your post.', 58 60 'favourite+reblog+account': (count) => 59 - `boosted & favourited ${count} of your posts.`, 60 - 'favourite+reblog_reply': 'boosted & favourited your reply.', 61 + `boosted & liked ${count} of your posts.`, 62 + 'favourite+reblog_reply': 'boosted & liked your reply.', 61 63 'admin.sign_up': 'signed up.', 62 64 'admin.report': (targetAccount) => <>reported {targetAccount}</>, 63 65 }; 64 66 65 67 const AVATARS_LIMIT = 50; 66 68 67 - function Notification({ notification, instance, reload, isStatic }) { 69 + function Notification({ notification, instance, isStatic }) { 68 70 const { id, status, account, report, _accounts, _statuses } = notification; 69 71 let { type } = notification; 70 72 ··· 140 142 141 143 const genericAccountsHeading = 142 144 { 143 - 'favourite+reblog': 'Boosted/Favourited by…', 144 - favourite: 'Favourited by…', 145 + 'favourite+reblog': 'Boosted/Liked by…', 146 + favourite: 'Liked by…', 145 147 reblog: 'Boosted by…', 146 148 follow: 'Followed by…', 147 149 }[type] || 'Accounts'; ··· 153 155 }; 154 156 }; 155 157 158 + console.debug('RENDER Notification', notification.id); 159 + 156 160 return ( 157 - <div class={`notification notification-${type}`} tabIndex="0"> 161 + <div 162 + class={`notification notification-${type}`} 163 + data-notification-id={id} 164 + tabIndex="0" 165 + > 158 166 <div 159 167 class={`notification-type notification-${type}`} 160 168 title={formattedCreatedAt} ··· 207 215 )} 208 216 </p> 209 217 {type === 'follow_request' && ( 210 - <FollowRequestButtons 211 - accountID={account.id} 212 - onChange={() => { 213 - // reload(); 214 - }} 215 - /> 218 + <FollowRequestButtons accountID={account.id} /> 216 219 )} 217 220 </> 218 221 )} ··· 327 330 return <Link {...props} data-read-more="Read more →" ref={ref} />; 328 331 } 329 332 330 - export default Notification; 333 + export default memo(Notification);
+1 -1
src/components/shortcuts-settings.css
··· 85 85 transform: scale(0.975); 86 86 transition: all 0.2s ease-out; 87 87 } 88 - #shortcuts-settings-container .shortcuts-view-mode label:has(input:checked) { 88 + #shortcuts-settings-container .shortcuts-view-mode label.checked { 89 89 box-shadow: inset 0 0 0 3px var(--link-color); 90 90 } 91 91 #shortcuts-settings-container
+30 -23
src/components/shortcuts-settings.jsx
··· 1 1 import './shortcuts-settings.css'; 2 2 3 + import { useAutoAnimate } from '@formkit/auto-animate/preact'; 3 4 import { 4 5 compressToEncodedURIComponent, 5 6 decompressFromEncodedURIComponent, ··· 45 46 search: 'Search', 46 47 'account-statuses': 'Account', 47 48 bookmarks: 'Bookmarks', 48 - favourites: 'Favourites', 49 + favourites: 'Likes', 49 50 hashtag: 'Hashtag', 50 51 trending: 'Trending', 51 52 mentions: 'Mentions', ··· 177 178 }, 178 179 favourites: { 179 180 id: 'favourites', 180 - title: 'Favourites', 181 + title: 'Likes', 181 182 path: '/f', 182 183 icon: 'heart', 183 184 }, ··· 200 201 const [followedHashtags, setFollowedHashtags] = useState([]); 201 202 const [showForm, setShowForm] = useState(false); 202 203 const [showImportExport, setShowImportExport] = useState(false); 204 + 205 + const [shortcutsListParent] = useAutoAnimate(); 203 206 204 207 useEffect(() => { 205 208 (async () => { 206 209 try { 207 210 const lists = await masto.v1.lists.list(); 211 + lists.sort((a, b) => a.title.localeCompare(b.title)); 208 212 setLists(lists); 209 213 } catch (e) { 210 214 console.error(e); ··· 267 271 label: 'Multi-column', 268 272 imgURL: multiColumnUrl, 269 273 }, 270 - ].map(({ value, label, imgURL }) => ( 271 - <label> 272 - <input 273 - type="radio" 274 - name="shortcuts-view-mode" 275 - value={value} 276 - checked={ 277 - snapStates.settings.shortcutsViewMode === value || 278 - (value === 'float-button' && 279 - !snapStates.settings.shortcutsViewMode) 280 - } 281 - onChange={(e) => { 282 - states.settings.shortcutsViewMode = e.target.value; 283 - }} 284 - />{' '} 285 - <img src={imgURL} alt="" width="80" height="58" />{' '} 286 - <span>{label}</span> 287 - </label> 288 - ))} 274 + ].map(({ value, label, imgURL }) => { 275 + const checked = 276 + snapStates.settings.shortcutsViewMode === value || 277 + (value === 'float-button' && 278 + !snapStates.settings.shortcutsViewMode); 279 + return ( 280 + <label key={value} class={checked ? 'checked' : ''}> 281 + <input 282 + type="radio" 283 + name="shortcuts-view-mode" 284 + value={value} 285 + checked={checked} 286 + onChange={(e) => { 287 + states.settings.shortcutsViewMode = e.target.value; 288 + }} 289 + />{' '} 290 + <img src={imgURL} alt="" width="80" height="58" />{' '} 291 + <span>{label}</span> 292 + </label> 293 + ); 294 + })} 289 295 </div> 290 296 {/* <select 291 297 value={snapStates.settings.shortcutsViewMode || 'float-button'} ··· 315 321 </details> 316 322 </p> */} 317 323 {shortcuts.length > 0 ? ( 318 - <ol class="shortcuts-list"> 324 + <ol class="shortcuts-list" ref={shortcutsListParent}> 319 325 {shortcuts.filter(Boolean).map((shortcut, i) => { 320 - const key = i + Object.values(shortcut); 326 + // const key = i + Object.values(shortcut); 327 + const key = Object.values(shortcut).join('-'); 321 328 const { type } = shortcut; 322 329 if (!SHORTCUTS_META[type]) return null; 323 330 let { icon, title, subtitle } = SHORTCUTS_META[type];
+9 -2
src/components/shortcuts.jsx
··· 1 1 import './shortcuts.css'; 2 2 3 3 import { Menu, MenuItem } from '@szhsin/react-menu'; 4 + import { memo } from 'preact/compat'; 4 5 import { useMemo, useRef } from 'preact/hooks'; 5 6 import { useHotkeys } from 'react-hotkeys-hook'; 6 7 import { useNavigate } from 'react-router-dom'; ··· 18 19 function Shortcuts() { 19 20 const { instance } = api(); 20 21 const snapStates = useSnapshot(states); 21 - const { shortcuts } = snapStates; 22 + const { shortcuts, settings } = snapStates; 22 23 23 24 if (!shortcuts.length) { 25 + return null; 26 + } 27 + if ( 28 + settings.shortcutsColumnsMode || 29 + settings.shortcutsViewMode === 'multi-column' 30 + ) { 24 31 return null; 25 32 } 26 33 ··· 180 187 ); 181 188 } 182 189 183 - export default Shortcuts; 190 + export default memo(Shortcuts);
+94 -63
src/components/status.jsx
··· 512 512 )}{' '} 513 513 {favouritesCount > 0 && ( 514 514 <span> 515 - <Icon icon="heart" alt="Favourites" size="s" />{' '} 515 + <Icon icon="heart" alt="Likes" size="s" />{' '} 516 516 <span>{shortenNumber(favouritesCount)}</span> 517 517 </span> 518 518 )} ··· 550 550 <MenuItem onClick={() => setShowReactions(true)}> 551 551 <Icon icon="react" /> 552 552 <span> 553 - Boosted/Favourited by<span class="more-insignificant">…</span> 553 + Boosted/Liked by<span class="more-insignificant">…</span> 554 554 </span> 555 555 </MenuItem> 556 556 )} ··· 603 603 if (!isSizeLarge) { 604 604 showToast( 605 605 favourited 606 - ? `Unfavourited @${username || acct}'s post` 607 - : `Favourited @${username || acct}'s post`, 606 + ? `Unliked @${username || acct}'s post` 607 + : `Liked @${username || acct}'s post`, 608 608 ); 609 609 } 610 610 } catch (e) {} ··· 616 616 color: favourited && 'var(--favourite-color)', 617 617 }} 618 618 /> 619 - <span>{favourited ? 'Unfavourite' : 'Favourite'}</span> 619 + <span>{favourited ? 'Unlike' : 'Like'}</span> 620 620 </MenuItem> 621 621 </div> 622 622 <div class="menu-horizontal"> ··· 794 794 795 795 const contextMenuRef = useRef(); 796 796 const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); 797 - const [contextMenuAnchorPoint, setContextMenuAnchorPoint] = useState({ 798 - x: 0, 799 - y: 0, 800 - }); 797 + const [contextMenuProps, setContextMenuProps] = useState({}); 801 798 const isIOS = 802 799 window.ontouchstart !== undefined && 803 800 /iPad|iPhone|iPod/.test(navigator.userAgent); ··· 814 811 const link = e.target.closest('a'); 815 812 if (link && /^https?:\/\//.test(link.getAttribute('href'))) return; 816 813 e.preventDefault(); 817 - setContextMenuAnchorPoint({ 818 - x: clientX, 819 - y: clientY, 814 + setContextMenuProps({ 815 + anchorPoint: { 816 + x: clientX, 817 + y: clientY, 818 + }, 819 + direction: 'right', 820 820 }); 821 821 setIsContextMenuOpen(true); 822 822 } ··· 836 836 enabled: hotkeysEnabled, 837 837 }); 838 838 const fRef = useHotkeys( 839 - 'f', 839 + 'f, l', 840 840 () => { 841 841 try { 842 842 favouriteStatus(); 843 843 if (!isSizeLarge) { 844 844 showToast( 845 845 favourited 846 - ? `Unfavourited @${username || acct}'s post` 847 - : `Favourited @${username || acct}'s post`, 846 + ? `Unliked @${username || acct}'s post` 847 + : `Liked @${username || acct}'s post`, 848 848 ); 849 849 } 850 850 } catch (e) {} ··· 996 996 const link = e.target.closest('a'); 997 997 if (link && /^https?:\/\//.test(link.getAttribute('href'))) return; 998 998 e.preventDefault(); 999 - setContextMenuAnchorPoint({ 1000 - x: e.clientX, 1001 - y: e.clientY, 999 + setContextMenuProps({ 1000 + anchorPoint: { 1001 + x: e.clientX, 1002 + y: e.clientY, 1003 + }, 1004 + direction: 'right', 1002 1005 }); 1003 1006 setIsContextMenuOpen(true); 1004 1007 }} ··· 1008 1011 <ControlledMenu 1009 1012 ref={contextMenuRef} 1010 1013 state={isContextMenuOpen ? 'open' : undefined} 1011 - anchorPoint={contextMenuAnchorPoint} 1012 - direction="right" 1014 + {...contextMenuProps} 1013 1015 onClose={(e) => { 1014 1016 setIsContextMenuOpen(false); 1015 1017 // statusRef.current?.focus?.(); ··· 1086 1088 (_deleted ? ( 1087 1089 <span class="status-deleted-tag">Deleted</span> 1088 1090 ) : url && !previewMode && !quoted ? ( 1089 - <Menu 1090 - instanceRef={menuInstanceRef} 1091 - portal={{ 1092 - target: document.body, 1091 + <Link 1092 + to={instance ? `/${instance}/s/${id}` : `/s/${id}`} 1093 + onClick={(e) => { 1094 + e.preventDefault(); 1095 + e.stopPropagation(); 1096 + onStatusLinkClick?.(e, status); 1097 + setContextMenuProps({ 1098 + anchorRef: { 1099 + current: e.currentTarget, 1100 + }, 1101 + align: 'end', 1102 + direction: 'bottom', 1103 + gap: 4, 1104 + }); 1105 + setIsContextMenuOpen(true); 1093 1106 }} 1094 - containerProps={{ 1095 - style: { 1096 - // Higher than the backdrop 1097 - zIndex: 1001, 1098 - }, 1099 - onClick: (e) => { 1100 - if (e.target === e.currentTarget) 1101 - menuInstanceRef.current?.closeMenu?.(); 1102 - }, 1103 - }} 1104 - align="end" 1105 - gap={4} 1106 - overflow="auto" 1107 - viewScroll="close" 1108 - boundingBoxPadding="8 8 8 8" 1109 - unmountOnClose 1110 - menuButton={({ open }) => ( 1111 - <Link 1112 - to={instance ? `/${instance}/s/${id}` : `/s/${id}`} 1113 - onClick={(e) => { 1114 - e.preventDefault(); 1115 - e.stopPropagation(); 1116 - onStatusLinkClick?.(e, status); 1117 - }} 1118 - class={`time ${open ? 'is-open' : ''}`} 1119 - > 1120 - <Icon 1121 - icon={visibilityIconsMap[visibility]} 1122 - alt={visibilityText[visibility]} 1123 - size="s" 1124 - />{' '} 1125 - <RelativeTime datetime={createdAtDate} format="micro" /> 1126 - </Link> 1127 - )} 1107 + class={`time ${ 1108 + isContextMenuOpen && contextMenuProps?.anchorRef 1109 + ? 'is-open' 1110 + : '' 1111 + }`} 1128 1112 > 1129 - {StatusMenuItems} 1130 - </Menu> 1113 + <Icon 1114 + icon={visibilityIconsMap[visibility]} 1115 + alt={visibilityText[visibility]} 1116 + size="s" 1117 + />{' '} 1118 + <RelativeTime datetime={createdAtDate} format="micro" /> 1119 + </Link> 1131 1120 ) : ( 1121 + // <Menu 1122 + // instanceRef={menuInstanceRef} 1123 + // portal={{ 1124 + // target: document.body, 1125 + // }} 1126 + // containerProps={{ 1127 + // style: { 1128 + // // Higher than the backdrop 1129 + // zIndex: 1001, 1130 + // }, 1131 + // onClick: (e) => { 1132 + // if (e.target === e.currentTarget) 1133 + // menuInstanceRef.current?.closeMenu?.(); 1134 + // }, 1135 + // }} 1136 + // align="end" 1137 + // gap={4} 1138 + // overflow="auto" 1139 + // viewScroll="close" 1140 + // boundingBoxPadding="8 8 8 8" 1141 + // unmountOnClose 1142 + // menuButton={({ open }) => ( 1143 + // <Link 1144 + // to={instance ? `/${instance}/s/${id}` : `/s/${id}`} 1145 + // onClick={(e) => { 1146 + // e.preventDefault(); 1147 + // e.stopPropagation(); 1148 + // onStatusLinkClick?.(e, status); 1149 + // }} 1150 + // class={`time ${open ? 'is-open' : ''}`} 1151 + // > 1152 + // <Icon 1153 + // icon={visibilityIconsMap[visibility]} 1154 + // alt={visibilityText[visibility]} 1155 + // size="s" 1156 + // />{' '} 1157 + // <RelativeTime datetime={createdAtDate} format="micro" /> 1158 + // </Link> 1159 + // )} 1160 + // > 1161 + // {StatusMenuItems} 1162 + // </Menu> 1132 1163 <span class="time"> 1133 1164 <Icon 1134 1165 icon={visibilityIconsMap[visibility]} ··· 1497 1528 <div class="action has-count"> 1498 1529 <StatusButton 1499 1530 checked={favourited} 1500 - title={['Favourite', 'Unfavourite']} 1501 - alt={['Favourite', 'Favourited']} 1531 + title={['Like', 'Unlike']} 1532 + alt={['Like', 'Liked']} 1502 1533 class="favourite-button" 1503 1534 icon="heart" 1504 1535 count={favouritesCount} ··· 1931 1962 </button> 1932 1963 )} 1933 1964 <header> 1934 - <h2>Boosted/Favourited by…</h2> 1965 + <h2>Boosted/Liked by…</h2> 1935 1966 </header> 1936 1967 <main> 1937 1968 {accounts.length > 0 ? (
+20 -12
src/components/timeline.jsx
··· 1 - import { useIdle } from '@uidotdev/usehooks'; 2 1 import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; 3 2 import { useHotkeys } from 'react-hotkeys-hook'; 4 3 import { InView } from 'react-intersection-observer'; ··· 211 210 } 212 211 }, [nearReachEnd, showMore]); 213 212 214 - const idle = useIdle(5000); 215 - console.debug('🧘‍♀️ IDLE', idle); 216 213 const loadOrCheckUpdates = useCallback( 217 - async ({ disableHoverCheck = false } = {}) => { 214 + async ({ disableIdleCheck = false } = {}) => { 218 215 console.log('✨ Load or check updates', { 219 216 autoRefresh: snapStates.settings.autoRefresh, 220 217 scrollTop: scrollableRef.current.scrollTop, 221 - disableHoverCheck, 222 - idle, 218 + disableIdleCheck, 219 + idle: window.__IDLE__, 223 220 inBackground: inBackground(), 224 221 }); 225 222 if ( 226 223 snapStates.settings.autoRefresh && 227 224 scrollableRef.current.scrollTop === 0 && 228 - (disableHoverCheck || idle) && 225 + (disableIdleCheck || window.__IDLE__) && 229 226 !inBackground() 230 227 ) { 231 228 console.log('✨ Load updates', snapStates.settings.autoRefresh); ··· 239 236 } 240 237 } 241 238 }, 242 - [id, idle, loadItems, checkForUpdates, snapStates.settings.autoRefresh], 239 + [id, loadItems, checkForUpdates, snapStates.settings.autoRefresh], 240 + ); 241 + const debouncedLoadOrCheckUpdates = useDebouncedCallback( 242 + loadOrCheckUpdates, 243 + 3000, 243 244 ); 244 245 245 246 const lastHiddenTime = useRef(); ··· 248 249 if (visible) { 249 250 const timeDiff = Date.now() - lastHiddenTime.current; 250 251 if (!lastHiddenTime.current || timeDiff > 1000 * 60) { 251 - loadOrCheckUpdates({ 252 - disableHoverCheck: true, 252 + // 1 minute 253 + debouncedLoadOrCheckUpdates({ 254 + disableIdleCheck: true, 253 255 }); 254 256 } 255 257 } else { 256 258 lastHiddenTime.current = Date.now(); 259 + debouncedLoadOrCheckUpdates.cancel(); 257 260 } 258 261 setVisible(visible); 259 262 }, ··· 609 612 610 613 function TimelineStatusCompact({ status, instance }) { 611 614 const snapStates = useSnapshot(states); 612 - const { id } = status; 615 + const { id, visibility } = status; 613 616 const statusPeekText = statusPeek(status); 614 617 const sKey = statusKey(id, instance); 615 618 return ( 616 - <article class="status compact-thread" tabindex="-1"> 619 + <article 620 + class={`status compact-thread ${ 621 + visibility === 'direct' ? 'visibility-direct' : '' 622 + }`} 623 + tabindex="-1" 624 + > 617 625 {!!snapStates.statusThreadNumber[sKey] ? ( 618 626 <div class="status-thread-badge"> 619 627 <Icon icon="thread" size="s" />
+5 -2
src/components/translation-block.jsx
··· 20 20 // Using other API instances instead of lingva.ml because of this bug (slashes don't work): 21 21 // https://github.com/thedaviddelta/lingva-translate/issues/68 22 22 const LINGVA_INSTANCES = [ 23 - 'lingva.garudalinux.org', 24 23 'lingva.lunar.icu', 24 + 'lingva.garudalinux.org', 25 25 'translate.plausibility.cloud', 26 26 ]; 27 27 let currentLingvaInstance = 0; ··· 35 35 text, 36 36 )}`, 37 37 ) 38 - .then((res) => res.json()) 38 + .then((res) => { 39 + if (!res.ok) throw new Error(res.statusText); 40 + return res.json(); 41 + }) 39 42 .then((res) => { 40 43 return { 41 44 provider: 'lingva',
+12 -2
src/index.css
··· 53 53 --divider-color: rgba(0, 0, 0, 0.1); 54 54 --backdrop-color: rgba(0, 0, 0, 0.05); 55 55 --backdrop-darker-color: rgba(0, 0, 0, 0.25); 56 - --backdrop-solid-color: #ccc; 56 + --backdrop-solid-color: #eee; 57 57 --img-bg-color: rgba(128, 128, 128, 0.2); 58 58 --loader-color: #1c1e2199; 59 59 --comment-line-color: #e5e5e5; ··· 106 106 --divider-color: rgba(255, 255, 255, 0.1); 107 107 --bg-blur-color: #24252699; 108 108 --backdrop-color: rgba(0, 0, 0, 0.5); 109 - --backdrop-solid-color: #333; 109 + --backdrop-solid-color: #111; 110 110 --loader-color: #f0f2f599; 111 111 --comment-line-color: #565656; 112 112 --drop-shadow-color: rgba(0, 0, 0, 0.5); ··· 146 146 body { 147 147 overscroll-behavior-y: none; 148 148 } 149 + } 150 + 151 + p { 152 + /* 153 + white-space is shorthand for two values; white-space-collapse and text-wrap 154 + https://developer.mozilla.org/en-US/docs/Web/CSS/white-space 155 + !important is needed to override higher specificity when elements are styled 156 + with `white-space` and 1 value, which doesn't have "pretty" 157 + */ 158 + text-wrap: pretty !important; 149 159 } 150 160 151 161 a {
+269 -9
src/pages/account-statuses.jsx
··· 10 10 import Menu2 from '../components/menu2'; 11 11 import Timeline from '../components/timeline'; 12 12 import { api } from '../utils/api'; 13 + import pmem from '../utils/pmem'; 13 14 import showToast from '../utils/show-toast'; 14 15 import states from '../utils/states'; 15 16 import { saveStatus } from '../utils/states'; 16 17 import useTitle from '../utils/useTitle'; 17 18 18 19 const LIMIT = 20; 20 + const MIN_YEAR = 1983; 21 + const MIN_YEAR_MONTH = `${MIN_YEAR}-01`; // Birth of the Internet 22 + 23 + const supportsInputMonth = (() => { 24 + try { 25 + const input = document.createElement('input'); 26 + input.setAttribute('type', 'month'); 27 + return input.type === 'month'; 28 + } catch (e) { 29 + return false; 30 + } 31 + })(); 32 + 33 + async function _isSearchEnabled(instance) { 34 + const { masto } = api({ instance }); 35 + const results = await masto.v2.search.fetch({ 36 + q: 'from:me', 37 + type: 'statuses', 38 + limit: 1, 39 + }); 40 + return !!results?.statuses?.length; 41 + } 42 + const isSearchEnabled = pmem(_isSearchEnabled); 19 43 20 44 function AccountStatuses() { 21 45 const snapStates = useSnapshot(states); 22 46 const { id, ...params } = useParams(); 23 47 const [searchParams, setSearchParams] = useSearchParams(); 48 + const month = searchParams.get('month'); 24 49 const excludeReplies = !searchParams.get('replies'); 25 50 const excludeBoosts = !!searchParams.get('boosts'); 26 51 const tagged = searchParams.get('tagged'); 27 52 const media = !!searchParams.get('media'); 28 53 const { masto, instance, authenticated } = api({ instance: params.instance }); 29 54 const accountStatusesIterator = useRef(); 55 + 56 + const allSearchParams = [month, excludeReplies, excludeBoosts, tagged, media]; 57 + const [account, setAccount] = useState(); 58 + const searchOffsetRef = useRef(0); 59 + useEffect(() => { 60 + searchOffsetRef.current = 0; 61 + }, allSearchParams); 62 + 63 + const sameCurrentInstance = useMemo( 64 + () => instance === api().instance, 65 + [instance], 66 + ); 67 + const [searchEnabled, setSearchEnabled] = useState(false); 68 + useEffect(() => { 69 + // Only enable for current logged-in instance 70 + // Most remote instances don't allow unauthenticated searches 71 + if (!sameCurrentInstance) return; 72 + if (!account?.acct) return; 73 + (async () => { 74 + const enabled = await isSearchEnabled(instance); 75 + console.log({ enabled }); 76 + setSearchEnabled(enabled); 77 + })(); 78 + }, [instance, sameCurrentInstance, account?.acct]); 79 + 30 80 async function fetchAccountStatuses(firstLoad) { 81 + const isValidMonth = /^\d{4}-[01]\d$/.test(month); 82 + const isValidYear = month?.split?.('-')?.[0] >= MIN_YEAR; 83 + if (isValidMonth && isValidYear) { 84 + if (!account) { 85 + return { 86 + value: [], 87 + done: true, 88 + }; 89 + } 90 + const [_year, _month] = month.split('-'); 91 + const monthIndex = parseInt(_month, 10) - 1; 92 + // YYYY-MM (no day) 93 + // Search options: 94 + // - from:account 95 + // - after:YYYY-MM-DD (non-inclusive) 96 + // - before:YYYY-MM-DD (non-inclusive) 97 + 98 + // Last day of previous month 99 + const after = new Date(_year, monthIndex, 0); 100 + const afterStr = `${after.getFullYear()}-${(after.getMonth() + 1) 101 + .toString() 102 + .padStart(2, '0')}-${after.getDate().toString().padStart(2, '0')}`; 103 + // First day of next month 104 + const before = new Date(_year, monthIndex + 1, 1); 105 + const beforeStr = `${before.getFullYear()}-${(before.getMonth() + 1) 106 + .toString() 107 + .padStart(2, '0')}-${before.getDate().toString().padStart(2, '0')}`; 108 + console.log({ 109 + month, 110 + _year, 111 + _month, 112 + monthIndex, 113 + after, 114 + before, 115 + afterStr, 116 + beforeStr, 117 + }); 118 + 119 + let limit; 120 + if (firstLoad) { 121 + limit = LIMIT + 1; 122 + searchOffsetRef.current = 0; 123 + } else { 124 + limit = LIMIT + searchOffsetRef.current + 1; 125 + searchOffsetRef.current += LIMIT; 126 + } 127 + 128 + const searchResults = await masto.v2.search.fetch({ 129 + q: `from:${account.acct} after:${afterStr} before:${beforeStr}`, 130 + type: 'statuses', 131 + limit, 132 + offset: searchOffsetRef.current, 133 + }); 134 + if (searchResults?.statuses?.length) { 135 + const value = searchResults.statuses.slice(0, LIMIT); 136 + value.forEach((item) => { 137 + saveStatus(item, instance); 138 + }); 139 + const done = searchResults.statuses.length <= LIMIT; 140 + return { value, done }; 141 + } else { 142 + return { value: [], done: true }; 143 + } 144 + } 145 + 31 146 const results = []; 32 147 if (firstLoad) { 33 148 const { value: pinnedStatuses } = await masto.v1.accounts ··· 78 193 }; 79 194 } 80 195 81 - const [account, setAccount] = useState(); 82 196 const [featuredTags, setFeaturedTags] = useState([]); 83 197 useTitle( 84 198 `${account?.displayName ? account.displayName + ' ' : ''}@${ ··· 98 212 try { 99 213 const featuredTags = await masto.v1.accounts 100 214 .$select(id) 101 - .featuredTags.list(id); 215 + .featuredTags.list(); 102 216 console.log({ featuredTags }); 103 217 setFeaturedTags(featuredTags); 104 218 } catch (e) { ··· 112 226 const filterBarRef = useRef(); 113 227 const TimelineStart = useMemo(() => { 114 228 const cachedAccount = snapStates.accounts[`${id}@${instance}`]; 115 - const filtered = !excludeReplies || excludeBoosts || tagged || media; 229 + const filtered = 230 + !excludeReplies || excludeBoosts || tagged || media || !!month; 116 231 return ( 117 232 <> 118 233 <AccountInfo ··· 170 285 </Link> 171 286 {featuredTags.map((tag) => ( 172 287 <Link 288 + key={tag.id} 173 289 to={`/${instance}/a/${id}${ 174 290 tagged === tag.name 175 291 ? '' ··· 192 308 {/* <span class="filter-count">{tag.statusesCount}</span> */} 193 309 </Link> 194 310 ))} 311 + {searchEnabled && 312 + (supportsInputMonth ? ( 313 + <label class={`filter-field ${month ? 'is-active' : ''}`}> 314 + <Icon icon="month" size="l" /> 315 + <input 316 + type="month" 317 + disabled={!account?.acct} 318 + value={month || ''} 319 + min={MIN_YEAR_MONTH} 320 + max={new Date().toISOString().slice(0, 7)} 321 + onInput={(e) => { 322 + const { value, validity } = e.currentTarget; 323 + if (!validity.valid) return; 324 + setSearchParams( 325 + value 326 + ? { 327 + month: value, 328 + } 329 + : {}, 330 + ); 331 + }} 332 + /> 333 + </label> 334 + ) : ( 335 + // Fallback to <select> for month and <input type="number"> for year 336 + <MonthPicker 337 + class={`filter-field ${month ? 'is-active' : ''}`} 338 + disabled={!account?.acct} 339 + value={month || ''} 340 + min={MIN_YEAR_MONTH} 341 + max={new Date().toISOString().slice(0, 7)} 342 + onInput={(e) => { 343 + const { value, validity } = e; 344 + if (!validity.valid) return; 345 + setSearchParams( 346 + value 347 + ? { 348 + month: value, 349 + } 350 + : {}, 351 + ); 352 + }} 353 + /> 354 + ))} 195 355 </div> 196 356 </> 197 357 ); ··· 199 359 id, 200 360 instance, 201 361 authenticated, 202 - excludeReplies, 203 - excludeBoosts, 204 362 featuredTags, 205 - tagged, 206 - media, 363 + searchEnabled, 364 + ...allSearchParams, 207 365 ]); 208 366 209 367 useEffect(() => { ··· 218 376 (filterBarRef.current.offsetWidth - active.offsetWidth) / 2, 219 377 }); 220 378 } 221 - }, [featuredTags, tagged, media, excludeReplies, excludeBoosts]); 379 + }, [featuredTags, searchEnabled, ...allSearchParams]); 222 380 223 381 const accountInstance = useMemo(() => { 224 382 if (!account?.url) return null; ··· 258 416 useItemID 259 417 boostsCarousel={snapStates.settings.boostsCarousel} 260 418 timelineStart={TimelineStart} 261 - refresh={[excludeReplies, excludeBoosts, tagged, media].toString()} 419 + refresh={[ 420 + excludeReplies, 421 + excludeBoosts, 422 + tagged, 423 + media, 424 + month + account?.acct, 425 + ].toString()} 262 426 headerEnd={ 263 427 <Menu2 264 428 portal ··· 300 464 </Menu2> 301 465 } 302 466 /> 467 + ); 468 + } 469 + 470 + function MonthPicker(props) { 471 + const { 472 + class: className, 473 + disabled, 474 + value, 475 + min, 476 + max, 477 + onInput = () => {}, 478 + } = props; 479 + const [_year, _month] = value?.split('-') || []; 480 + const monthFieldRef = useRef(); 481 + const yearFieldRef = useRef(); 482 + 483 + const checkValidity = (month, year) => { 484 + const [minYear, minMonth] = min?.split('-') || []; 485 + const [maxYear, maxMonth] = max?.split('-') || []; 486 + if (year < minYear) return false; 487 + if (year > maxYear) return false; 488 + if (year === minYear && month < minMonth) return false; 489 + if (year === maxYear && month > maxMonth) return false; 490 + return true; 491 + }; 492 + 493 + return ( 494 + <div class={className}> 495 + <Icon icon="month" size="l" /> 496 + <select 497 + ref={monthFieldRef} 498 + disabled={disabled} 499 + value={_month || ''} 500 + onInput={(e) => { 501 + const { value: month } = e.currentTarget; 502 + const year = yearFieldRef.current.value; 503 + if (!checkValidity(month, year)) 504 + return { 505 + value: '', 506 + validity: { 507 + valid: false, 508 + }, 509 + }; 510 + onInput({ 511 + value: month ? `${year}-${month}` : '', 512 + validity: { 513 + valid: true, 514 + }, 515 + }); 516 + }} 517 + > 518 + <option value="">Month</option> 519 + <option disabled>-----</option> 520 + {Array.from({ length: 12 }, (_, i) => ( 521 + <option 522 + value={ 523 + // Month is 1-indexed 524 + (i + 1).toString().padStart(2, '0') 525 + } 526 + key={i} 527 + > 528 + {new Date(0, i).toLocaleString('default', { 529 + month: 'long', 530 + })} 531 + </option> 532 + ))} 533 + </select>{' '} 534 + <input 535 + ref={yearFieldRef} 536 + type="number" 537 + disabled={disabled} 538 + value={_year || new Date().getFullYear()} 539 + min={min?.slice(0, 4) || MIN_YEAR} 540 + max={max?.slice(0, 4) || new Date().getFullYear()} 541 + onInput={(e) => { 542 + const { value: year, validity } = e.currentTarget; 543 + const month = monthFieldRef.current.value; 544 + if (!validity.valid || !checkValidity(month, year)) 545 + return { 546 + value: '', 547 + validity: { 548 + valid: false, 549 + }, 550 + }; 551 + onInput({ 552 + value: year ? `${year}-${month}` : '', 553 + validity: { 554 + valid: true, 555 + }, 556 + }); 557 + }} 558 + style={{ 559 + width: '4.5em', 560 + }} 561 + /> 562 + </div> 303 563 ); 304 564 } 305 565
+7 -6
src/pages/accounts.jsx
··· 1 1 import './accounts.css'; 2 2 3 + import { useAutoAnimate } from '@formkit/auto-animate/preact'; 3 4 import { Menu, MenuDivider, MenuItem } from '@szhsin/react-menu'; 4 - import { useReducer, useState } from 'preact/hooks'; 5 + import { useReducer } from 'preact/hooks'; 5 6 6 7 import Avatar from '../components/avatar'; 7 8 import Icon from '../components/icon'; ··· 18 19 const accounts = store.local.getJSON('accounts'); 19 20 const currentAccount = store.session.get('currentAccount'); 20 21 const moreThanOneAccount = accounts.length > 1; 21 - const [currentDefault, setCurrentDefault] = useState(0); 22 22 23 23 const [_, reload] = useReducer((x) => x + 1, 0); 24 + const [accountsListParent] = useAutoAnimate(); 24 25 25 26 return ( 26 27 <div id="accounts-container" class="sheet" tabIndex="-1"> ··· 34 35 </header> 35 36 <main> 36 37 <section> 37 - <ul class="accounts-list"> 38 + <ul class="accounts-list" ref={accountsListParent}> 38 39 {accounts.map((account, i) => { 39 40 const isCurrent = account.info.id === currentAccount; 40 - const isDefault = i === (currentDefault || 0); 41 + const isDefault = i === 0; // first account is always default 41 42 return ( 42 - <li key={i + account.id}> 43 + <li key={account.info.id}> 43 44 <div> 44 45 {moreThanOneAccount && ( 45 46 <span class={`current ${isCurrent ? 'is-current' : ''}`}> ··· 120 121 accounts.splice(i, 1); 121 122 accounts.unshift(account); 122 123 store.local.setJSON('accounts', accounts); 123 - setCurrentDefault(i); 124 + reload(); 124 125 }} 125 126 > 126 127 <Icon icon="check-circle" />
+4 -4
src/pages/favourites.jsx
··· 7 7 const LIMIT = 20; 8 8 9 9 function Favourites() { 10 - useTitle('Favourites', '/f'); 10 + useTitle('Likes', '/f'); 11 11 const { masto, instance } = api(); 12 12 const favouritesIterator = useRef(); 13 13 async function fetchFavourites(firstLoad) { ··· 19 19 20 20 return ( 21 21 <Timeline 22 - title="Favourites" 22 + title="Likes" 23 23 id="favourites" 24 - emptyText="No favourites yet. Go favourite something!" 25 - errorText="Unable to load favourites" 24 + emptyText="No likes yet. Go like something!" 25 + errorText="Unable to load likes" 26 26 instance={instance} 27 27 fetchItems={fetchFavourites} 28 28 />
+1
src/pages/lists.jsx
··· 23 23 (async () => { 24 24 try { 25 25 const lists = await masto.v1.lists.list(); 26 + lists.sort((a, b) => a.title.localeCompare(b.title)); 26 27 console.log(lists); 27 28 setLists(lists); 28 29 setUIState('default');
+1
src/pages/notifications-menu.css
··· 23 23 padding: 0; 24 24 height: 40em; 25 25 overflow: auto; 26 + overscroll-behavior: contain; 26 27 } 27 28 .notifications-menu .status { 28 29 font-size: inherit;
+47 -41
src/pages/notifications.jsx
··· 1 1 import './notifications.css'; 2 2 3 - import { useIdle } from '@uidotdev/usehooks'; 3 + import { Fragment } from 'preact'; 4 4 import { memo } from 'preact/compat'; 5 5 import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; 6 + import { InView } from 'react-intersection-observer'; 6 7 import { useSearchParams } from 'react-router-dom'; 7 8 import { useSnapshot } from 'valtio'; 8 9 ··· 166 167 } 167 168 }, [reachStart]); 168 169 169 - useEffect(() => { 170 - if (nearReachEnd && showMore) { 171 - loadNotifications(); 172 - } 173 - }, [nearReachEnd, showMore]); 170 + // useEffect(() => { 171 + // if (nearReachEnd && showMore) { 172 + // loadNotifications(); 173 + // } 174 + // }, [nearReachEnd, showMore]); 174 175 175 - const idle = useIdle(5000); 176 - console.debug('🧘‍♀️ IDLE', idle); 177 176 const loadUpdates = useCallback(() => { 178 177 console.log('✨ Load updates', { 179 178 autoRefresh: snapStates.settings.autoRefresh, ··· 185 184 if ( 186 185 snapStates.settings.autoRefresh && 187 186 scrollableRef.current?.scrollTop === 0 && 188 - idle && 187 + window.__IDLE__ && 189 188 !inBackground() && 190 189 snapStates.notificationsShowNew && 191 190 uiState !== 'loading' ··· 193 192 loadNotifications(true); 194 193 } 195 194 }, [ 196 - idle, 197 195 snapStates.notificationsShowNew, 198 196 snapStates.settings.autoRefresh, 199 197 uiState, ··· 220 218 } 221 219 }, [notificationID, notificationAccessToken]); 222 220 223 - useEffect(() => { 224 - if (uiState === 'default') { 225 - (async () => { 226 - try { 227 - const registration = await getRegistration(); 228 - if (registration?.getNotifications) { 229 - const notifications = await registration.getNotifications(); 230 - console.log('🔔 Push notifications', notifications); 231 - // Close all notifications? 232 - // notifications.forEach((notification) => { 233 - // notification.close(); 234 - // }); 235 - } 236 - } catch (e) {} 237 - })(); 238 - } 239 - }, [uiState]); 221 + // useEffect(() => { 222 + // if (uiState === 'default') { 223 + // (async () => { 224 + // try { 225 + // const registration = await getRegistration(); 226 + // if (registration?.getNotifications) { 227 + // const notifications = await registration.getNotifications(); 228 + // console.log('🔔 Push notifications', notifications); 229 + // // Close all notifications? 230 + // // notifications.forEach((notification) => { 231 + // // notification.close(); 232 + // // }); 233 + // } 234 + // } catch (e) {} 235 + // })(); 236 + // } 237 + // }, [uiState]); 240 238 241 239 return ( 242 240 <div ··· 412 410 hideTime: true, 413 411 }); 414 412 return ( 415 - <> 413 + <Fragment key={notification.id}> 416 414 {differentDay && <h2 class="timeline-header">{heading}</h2>} 417 415 <Notification 418 416 instance={instance} 419 417 notification={notification} 420 418 key={notification.id} 421 - reload={() => { 422 - loadNotifications(true); 423 - loadFollowRequests(); 424 - }} 425 419 /> 426 - </> 420 + </Fragment> 427 421 ); 428 422 })} 429 423 </> ··· 458 452 </> 459 453 )} 460 454 {showMore && ( 461 - <button 462 - type="button" 463 - class="plain block" 464 - disabled={uiState === 'loading'} 465 - onClick={() => loadNotifications()} 466 - style={{ marginBlockEnd: '6em' }} 455 + <InView 456 + onChange={(inView) => { 457 + if (inView) { 458 + loadNotifications(); 459 + } 460 + }} 467 461 > 468 - {uiState === 'loading' ? <Loader abrupt /> : <>Show more&hellip;</>} 469 - </button> 462 + <button 463 + type="button" 464 + class="plain block" 465 + disabled={uiState === 'loading'} 466 + onClick={() => loadNotifications()} 467 + style={{ marginBlockEnd: '6em' }} 468 + > 469 + {uiState === 'loading' ? ( 470 + <Loader abrupt /> 471 + ) : ( 472 + <>Show more&hellip;</> 473 + )} 474 + </button> 475 + </InView> 470 476 )} 471 477 </div> 472 478 </div>
+8 -2
src/pages/search.css
··· 40 40 } 41 41 42 42 @media (min-width: 40em) { 43 - #search-page header input { 44 - background-color: var(--bg-color); 43 + #search-page { 44 + header input { 45 + background-color: var(--bg-color); 46 + } 47 + 48 + .filter-bar { 49 + margin-top: 8px; 50 + } 45 51 } 46 52 } 47 53
+10 -2
src/pages/search.jsx
··· 71 71 }; 72 72 73 73 function loadResults(firstLoad) { 74 + if (!firstLoad && !authenticated) { 75 + // Search results pagination is only available to authenticated users 76 + return; 77 + } 78 + 74 79 setUIState('loading'); 75 80 if (firstLoad && !type) { 76 81 setStatusResults(statusResults.slice(0, SHORT_LIMIT)); ··· 89 94 params.type = type; 90 95 if (authenticated) params.offset = offsetRef.current; 91 96 } 97 + 92 98 try { 93 99 const results = await masto.v2.search.fetch(params); 94 100 console.log(results); ··· 141 147 return ( 142 148 <div id="search-page" class="deck-container" ref={scrollableRef}> 143 149 <div class="timeline-deck deck"> 144 - <header> 150 + <header class={uiState === 'loading' ? 'loading' : ''}> 145 151 <div class="header-grid"> 146 152 <div class="header-side"> 147 153 <NavMenu /> ··· 181 187 return 0; 182 188 }) 183 189 .map((link) => ( 184 - <Link to={link.to}>{link.label}</Link> 190 + <Link to={link.to} key={link.type}> 191 + {link.label} 192 + </Link> 185 193 ))} 186 194 </div> 187 195 )}
+7
src/pages/settings.css
··· 140 140 color: var(--link-color); 141 141 vertical-align: middle; 142 142 } 143 + 144 + #settings-container .version-string { 145 + padding: 4px; 146 + font-family: var(--monospace-font); 147 + font-size: 85%; 148 + text-align: center; 149 + }
+34 -16
src/pages/settings.jsx
··· 18 18 removeSubscription, 19 19 updateSubscription, 20 20 } from '../utils/push-notifications'; 21 + import showToast from '../utils/show-toast'; 21 22 import states from '../utils/states'; 22 23 import store from '../utils/store'; 23 24 ··· 508 509 </p> 509 510 {__BUILD_TIME__ && ( 510 511 <p> 511 - <span class="insignificant">Last build:</span>{' '} 512 - <RelativeTime datetime={new Date(__BUILD_TIME__)} />{' '} 513 - {__COMMIT_HASH__ && ( 514 - <> 515 - ( 516 - <a 517 - href={`https://github.com/cheeaun/phanpy/commit/${__COMMIT_HASH__}`} 518 - target="_blank" 519 - rel="noopener noreferrer" 520 - > 521 - <code>{__COMMIT_HASH__}</code> 522 - </a> 523 - ) 524 - </> 525 - )} 512 + Version:{' '} 513 + <input 514 + type="text" 515 + class="version-string" 516 + readOnly 517 + size="18" // Manually calculated here 518 + value={`${__BUILD_TIME__.slice(0, 10).replace(/-/g, '.')}${ 519 + __COMMIT_HASH__ ? `.${__COMMIT_HASH__}` : '' 520 + }`} 521 + onClick={(e) => { 522 + e.target.select(); 523 + // Copy to clipboard 524 + try { 525 + navigator.clipboard.writeText(e.target.value); 526 + showToast('Version string copied'); 527 + } catch (e) { 528 + console.warn(e); 529 + showToast('Unable to copy version string'); 530 + } 531 + }} 532 + />{' '} 533 + <span class="ib insignificant"> 534 + ( 535 + <a 536 + href={`https://github.com/cheeaun/phanpy/commit/${__COMMIT_HASH__}`} 537 + target="_blank" 538 + rel="noopener noreferrer" 539 + > 540 + <RelativeTime datetime={new Date(__BUILD_TIME__)} /> 541 + </a> 542 + ) 543 + </span> 526 544 </p> 527 545 )} 528 546 </section> ··· 709 727 }, 710 728 { 711 729 value: 'favourite', 712 - label: 'Favourites', 730 + label: 'Likes', 713 731 }, 714 732 { 715 733 value: 'reblog',
+1 -1
src/pages/status.css
··· 31 31 .ancestors-indicator { 32 32 font-size: 70% !important; 33 33 34 - & > .avatar:not(:first-child) { 34 + & > .avatar ~ .avatar { 35 35 margin-left: -4px; 36 36 } 37 37 }
+30 -4
src/pages/status.jsx
··· 168 168 const [searchParams, setSearchParams] = useSearchParams(); 169 169 const mediaParam = searchParams.get('media'); 170 170 const showMedia = parseInt(mediaParam, 10) > 0; 171 - const [viewMode, setViewMode] = useState(searchParams.get('view')); 171 + const firstLoad = useRef(!states.prevLocation && history.length === 1); 172 + const [viewMode, setViewMode] = useState( 173 + searchParams.get('view') || firstLoad.current ? 'full' : null, 174 + ); 172 175 const translate = !!parseInt(searchParams.get('translate')); 173 176 const { masto, instance } = api({ instance: propInstance }); 174 177 const { ··· 534 537 // If media is open, esc to close media first 535 538 // Else close the status page 536 539 enabled: !showMedia, 540 + ignoreEventWhen: (e) => { 541 + const hasModal = !!document.querySelector('#modal-container > *'); 542 + return hasModal; 543 + }, 537 544 }, 538 545 ); 539 546 // For backspace, will always close both media and status page ··· 839 846 ref={scrollableRef} 840 847 class={`status-deck deck contained ${ 841 848 statuses.length > 1 ? 'padded-bottom' : '' 842 - } ${initialPageState.current === 'status' ? 'slide-in' : ''} ${ 843 - viewMode ? `deck-view-${viewMode}` : '' 844 - }`} 849 + } ${ 850 + initialPageState.current === 'status' && !firstLoad.current 851 + ? 'slide-in' 852 + : '' 853 + } ${viewMode ? `deck-view-${viewMode}` : ''}`} 845 854 onAnimationEnd={(e) => { 846 855 // Fix the bounce effect when switching viewMode 847 856 // `slide-in` animation kicks in when switching viewMode ··· 959 968 )} 960 969 </h1> 961 970 <div class="header-side"> 971 + <button 972 + type="button" 973 + class="plain4" 974 + style={{ 975 + display: viewMode === 'full' ? '' : 'none', 976 + }} 977 + onClick={() => { 978 + setViewMode(null); 979 + searchParams.delete('media'); 980 + searchParams.delete('media-only'); 981 + searchParams.delete('view'); 982 + setSearchParams(searchParams); 983 + }} 984 + title="Switch to Side Peek view" 985 + > 986 + <Icon icon="layout4" size="l" /> 987 + </button> 962 988 <Menu 963 989 align="end" 964 990 portal={{
+191
src/pages/trending.css
··· 1 + .links-bar { 2 + position: relative; 3 + display: flex; 4 + padding: 16px 16px 20px 16px; 5 + gap: 16px; 6 + overflow-x: auto; 7 + background-color: var(--bg-faded-color); 8 + mask-image: linear-gradient( 9 + to right, 10 + transparent, 11 + black 16px, 12 + black calc(100% - 16px), 13 + transparent 14 + ); 15 + text-shadow: 0 1px var(--bg-blur-color); 16 + transition: opacity 0.3s ease-out; 17 + 18 + &:not(#columns &) { 19 + @media (min-width: 40em) { 20 + width: 95vw; 21 + max-width: calc(320px * 3.3); 22 + transform: translateX(calc(-50% + var(--main-width) / 2)); 23 + } 24 + } 25 + 26 + & > header { 27 + width: 1.2em; 28 + white-space: nowrap; 29 + position: relative; 30 + flex-shrink: 0; 31 + 32 + h3 { 33 + font-size: 90%; 34 + font-style: italic; 35 + margin: 0; 36 + padding: 0; 37 + text-transform: uppercase; 38 + color: var(--text-insignificant-color); 39 + position: absolute; 40 + top: 8px; 41 + left: 0; 42 + transform-origin: top left; 43 + transform: rotate(-90deg) translateX(-100%); 44 + user-select: none; 45 + background-image: linear-gradient( 46 + to left, 47 + var(--text-color), 48 + var(--link-color) 49 + ); 50 + background-clip: text; 51 + text-fill-color: transparent; 52 + -webkit-text-fill-color: transparent; 53 + } 54 + } 55 + 56 + a { 57 + min-width: 240px; 58 + flex-grow: 1; 59 + max-width: 320px; 60 + text-decoration: none; 61 + color: inherit; 62 + border-radius: 16px; 63 + overflow: hidden; 64 + background-color: var(--accent-alpha-color); 65 + border: 4px solid transparent; 66 + box-shadow: 0 4px 8px -2px var(--drop-shadow-color); 67 + transition: all 0.15s ease-out; 68 + display: flex; 69 + background-image: linear-gradient( 70 + to bottom, 71 + var(--accent-color, var(--link-text-color)) -50%, 72 + transparent 73 + ); 74 + background-clip: border-box; 75 + background-origin: border-box; 76 + min-height: 160px; 77 + height: 320px; 78 + max-height: 50vh; 79 + 80 + &:not(:active):is(:hover, :focus-visible) { 81 + border-color: var(--accent-color, var(--link-light-color)); 82 + box-shadow: 0 4px 8px var(--drop-shadow-color), 83 + 0 8px 16px var(--drop-shadow-color); 84 + transform-origin: center bottom; 85 + transform: scale(1.02); 86 + 87 + img { 88 + animation: position-object 5s ease-in-out 1s 5; 89 + } 90 + } 91 + 92 + &:active { 93 + transition: none; 94 + transform: scale(1.015); 95 + filter: brightness(0.8); 96 + } 97 + 98 + article { 99 + display: flex; 100 + flex-direction: column; 101 + justify-content: flex-end; 102 + background-color: var(--bg-color); 103 + background-repeat: no-repeat; 104 + background-image: linear-gradient( 105 + to bottom, 106 + var(--accent-alpha-color) 70%, 107 + var(--bg-color) 100% 108 + ); 109 + transition: background-position-y 0.15s ease-out; 110 + 111 + &:is(:hover, :focus-visible) { 112 + background-position-y: -40px; 113 + } 114 + 115 + figure { 116 + flex-grow: 1; 117 + margin: 0 0 -16px; 118 + padding: 0; 119 + position: relative; 120 + } 121 + 122 + img { 123 + position: absolute; 124 + inset: 0; 125 + width: 100%; 126 + height: 100%; 127 + object-fit: cover; 128 + vertical-align: top; 129 + mask-image: linear-gradient( 130 + to bottom, 131 + hsl(0, 0%, 0%) 0%, 132 + hsla(0, 0%, 0%, 0.987) 14%, 133 + hsla(0, 0%, 0%, 0.951) 26.2%, 134 + hsla(0, 0%, 0%, 0.896) 36.8%, 135 + hsla(0, 0%, 0%, 0.825) 45.9%, 136 + hsla(0, 0%, 0%, 0.741) 53.7%, 137 + hsla(0, 0%, 0%, 0.648) 60.4%, 138 + hsla(0, 0%, 0%, 0.55) 66.2%, 139 + hsla(0, 0%, 0%, 0.45) 71.2%, 140 + hsla(0, 0%, 0%, 0.352) 75.6%, 141 + hsla(0, 0%, 0%, 0.259) 79.6%, 142 + hsla(0, 0%, 0%, 0.175) 83.4%, 143 + hsla(0, 0%, 0%, 0.104) 87.2%, 144 + hsla(0, 0%, 0%, 0.049) 91.1%, 145 + hsla(0, 0%, 0%, 0.013) 95.3%, 146 + hsla(0, 0%, 0%, 0) 100% 147 + ); 148 + } 149 + } 150 + 151 + .article-body { 152 + padding: 0 8px 8px; 153 + line-height: 1.3; 154 + flex-shrink: 0; 155 + } 156 + 157 + .article-meta { 158 + color: var(--text-insignificant-color); 159 + font-size: 90%; 160 + white-space: nowrap; 161 + overflow: hidden; 162 + text-overflow: ellipsis; 163 + } 164 + 165 + &:hover .domain { 166 + color: var(--link-text-color); 167 + } 168 + 169 + h1 { 170 + font-weight: normal; 171 + font-size: inherit; 172 + margin: 0; 173 + padding: 0; 174 + text-wrap: balance; 175 + display: -webkit-box; 176 + -webkit-line-clamp: 3; 177 + -webkit-box-orient: vertical; 178 + overflow: hidden; 179 + } 180 + 181 + p { 182 + color: var(--text-insignificant-color); 183 + margin: 0; 184 + display: -webkit-box; 185 + -webkit-line-clamp: 2; 186 + -webkit-box-orient: vertical; 187 + overflow: hidden; 188 + font-size: 90%; 189 + } 190 + } 191 + }
+151 -20
src/pages/trending.jsx
··· 1 + import './trending.css'; 2 + 1 3 import { MenuItem } from '@szhsin/react-menu'; 4 + import { getBlurHashAverageColor } from 'fast-blurhash'; 2 5 import { useMemo, useRef, useState } from 'preact/hooks'; 3 6 import { useNavigate, useParams } from 'react-router-dom'; 4 7 import { useSnapshot } from 'valtio'; ··· 6 9 import Icon from '../components/icon'; 7 10 import Link from '../components/link'; 8 11 import Menu2 from '../components/menu2'; 12 + import RelativeTime from '../components/relative-time'; 9 13 import Timeline from '../components/timeline'; 10 14 import { api } from '../utils/api'; 15 + import { oklab2rgb, rgb2oklab } from '../utils/color-utils'; 11 16 import { filteredItems } from '../utils/filters'; 17 + import pmem from '../utils/pmem'; 12 18 import states from '../utils/states'; 13 19 import { saveStatus } from '../utils/states'; 14 20 import useTitle from '../utils/useTitle'; 15 21 16 22 const LIMIT = 20; 17 23 24 + const fetchLinks = pmem( 25 + (masto) => { 26 + return masto.v1.trends.links.list().next(); 27 + }, 28 + { 29 + // News last much longer 30 + maxAge: 10 * 60 * 1000, // 10 minutes 31 + }, 32 + ); 33 + 18 34 function Trending({ columnMode, ...props }) { 19 35 const snapStates = useSnapshot(states); 20 36 const params = columnMode ? {} : useParams(); ··· 27 43 const latestItem = useRef(); 28 44 29 45 const [hashtags, setHashtags] = useState([]); 46 + const [links, setLinks] = useState([]); 30 47 const trendIterator = useRef(); 31 48 async function fetchTrend(firstLoad) { 32 49 if (firstLoad || !trendIterator.current) { ··· 38 55 try { 39 56 const iterator = masto.v1.trends.tags.list(); 40 57 const { value: tags } = await iterator.next(); 41 - console.log(tags); 42 - setHashtags(tags); 58 + console.log('tags', tags); 59 + if (tags?.length) { 60 + setHashtags(tags); 61 + } 62 + } catch (e) { 63 + console.error(e); 64 + } 65 + 66 + // Get links 67 + try { 68 + const { value } = await fetchLinks(masto); 69 + // 4 types available: link, photo, video, rich 70 + // Only want links for now 71 + const links = value?.filter?.((link) => link.type === 'link'); 72 + console.log('links', links); 73 + if (links?.length) { 74 + setLinks(links); 75 + } 43 76 } catch (e) { 44 77 console.error(e); 45 78 } ··· 84 117 } 85 118 86 119 const TimelineStart = useMemo(() => { 87 - if (!hashtags.length) return null; 88 120 return ( 89 - <div class="filter-bar"> 90 - <Icon icon="chart" class="insignificant" size="l" /> 91 - {hashtags.map((tag, i) => { 92 - const { name, history } = tag; 93 - const total = history.reduce((acc, cur) => acc + +cur.uses, 0); 94 - return ( 95 - <Link to={`/${instance}/t/${name}`}> 96 - <span> 97 - <span class="more-insignificant">#</span> 98 - {name} 99 - </span> 100 - <span class="filter-count">{total.toLocaleString()}</span> 101 - </Link> 102 - ); 103 - })} 104 - </div> 121 + <> 122 + {!!hashtags.length && ( 123 + <div class="filter-bar"> 124 + <Icon icon="chart" class="insignificant" size="l" /> 125 + {hashtags.map((tag, i) => { 126 + const { name, history } = tag; 127 + const total = history.reduce((acc, cur) => acc + +cur.uses, 0); 128 + return ( 129 + <Link to={`/${instance}/t/${name}`} key={name}> 130 + <span> 131 + <span class="more-insignificant">#</span> 132 + {name} 133 + </span> 134 + <span class="filter-count">{total.toLocaleString()}</span> 135 + </Link> 136 + ); 137 + })} 138 + </div> 139 + )} 140 + {!!links.length && ( 141 + <div class="links-bar"> 142 + <header> 143 + <h3>Trending News</h3> 144 + </header> 145 + {links.map((link) => { 146 + const { 147 + authorName, 148 + authorUrl, 149 + blurhash, 150 + description, 151 + height, 152 + image, 153 + imageDescription, 154 + language, 155 + providerName, 156 + providerUrl, 157 + publishedAt, 158 + title, 159 + url, 160 + width, 161 + } = link; 162 + const domain = new URL(url).hostname 163 + .replace(/^www\./, '') 164 + .replace(/\/$/, ''); 165 + let accentColor; 166 + if (blurhash) { 167 + const averageColor = getBlurHashAverageColor(blurhash); 168 + const labAverageColor = rgb2oklab(averageColor); 169 + accentColor = oklab2rgb([ 170 + 0.6, 171 + labAverageColor[1], 172 + labAverageColor[2], 173 + ]); 174 + } 175 + 176 + return ( 177 + <a 178 + key={url} 179 + href={url} 180 + target="_blank" 181 + rel="noopener noreferrer" 182 + style={ 183 + accentColor 184 + ? { 185 + '--accent-color': `rgb(${accentColor.join(',')})`, 186 + '--accent-alpha-color': `rgba(${accentColor.join( 187 + ',', 188 + )}, 0.4)`, 189 + } 190 + : {} 191 + } 192 + > 193 + <article> 194 + <figure> 195 + <img 196 + src={image} 197 + alt={imageDescription} 198 + width={width} 199 + height={height} 200 + loading="lazy" 201 + /> 202 + </figure> 203 + <div class="article-body"> 204 + <header> 205 + <div class="article-meta"> 206 + <span class="domain">{domain}</span>{' '} 207 + {!!publishedAt && <>&middot; </>} 208 + {!!publishedAt && ( 209 + <> 210 + <RelativeTime 211 + datetime={publishedAt} 212 + format="micro" 213 + /> 214 + </> 215 + )} 216 + </div> 217 + {!!title && ( 218 + <h1 class="title" lang={language} dir="auto"> 219 + {title} 220 + </h1> 221 + )} 222 + </header> 223 + {!!description && ( 224 + <p class="description" lang={language} dir="auto"> 225 + {description} 226 + </p> 227 + )} 228 + </div> 229 + </article> 230 + </a> 231 + ); 232 + })} 233 + </div> 234 + )} 235 + </> 105 236 ); 106 - }, [hashtags]); 237 + }, [hashtags, links]); 107 238 108 239 return ( 109 240 <Timeline
+52
src/utils/color-utils.js
··· 1 + // https://gist.github.com/earthbound19/e7fe15fdf8ca3ef814750a61bc75b5ce 2 + function clamp(value, min, max) { 3 + return Math.max(Math.min(value, max), min); 4 + } 5 + 6 + const gammaToLinear = (c) => 7 + c >= 0.04045 ? Math.pow((c + 0.055) / 1.055, 2.4) : c / 12.92; 8 + const linearToGamma = (c) => 9 + c >= 0.0031308 ? 1.055 * Math.pow(c, 1 / 2.4) - 0.055 : 12.92 * c; 10 + 11 + export function rgb2oklab([r, g, b]) { 12 + r = gammaToLinear(r / 255); 13 + g = gammaToLinear(g / 255); 14 + b = gammaToLinear(b / 255); 15 + var l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; 16 + var m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; 17 + var s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; 18 + l = Math.cbrt(l); 19 + m = Math.cbrt(m); 20 + s = Math.cbrt(s); 21 + return [ 22 + l * +0.2104542553 + m * +0.793617785 + s * -0.0040720468, 23 + l * +1.9779984951 + m * -2.428592205 + s * +0.4505937099, 24 + l * +0.0259040371 + m * +0.7827717662 + s * -0.808675766, 25 + ]; 26 + } 27 + 28 + export function oklab2rgb([L, a, b]) { 29 + var l = L + a * +0.3963377774 + b * +0.2158037573; 30 + var m = L + a * -0.1055613458 + b * -0.0638541728; 31 + var s = L + a * -0.0894841775 + b * -1.291485548; 32 + // The ** operator here cubes; same as l_*l_*l_ in the C++ example: 33 + l = l ** 3; 34 + m = m ** 3; 35 + s = s ** 3; 36 + var r = l * +4.0767416621 + m * -3.3077115913 + s * +0.2309699292; 37 + var g = l * -1.2684380046 + m * +2.6097574011 + s * -0.3413193965; 38 + var b = l * -0.0041960863 + m * -0.7034186147 + s * +1.707614701; 39 + // Convert linear RGB values returned from oklab math to sRGB for our use before returning them: 40 + r = 255 * linearToGamma(r); 41 + g = 255 * linearToGamma(g); 42 + b = 255 * linearToGamma(b); 43 + // OPTION: clamp r g and b values to the range 0-255; but if you use the values immediately to draw, JavaScript clamps them on use: 44 + r = clamp(r, 0, 255); 45 + g = clamp(g, 0, 255); 46 + b = clamp(b, 0, 255); 47 + // OPTION: round the values. May not be necessary if you use them immediately for rendering in JavaScript, as JavaScript (also) discards decimals on render: 48 + r = Math.round(r); 49 + g = Math.round(g); 50 + b = Math.round(b); 51 + return [r, g, b]; 52 + }
+5 -2
src/utils/group-notifications.jsx
··· 6 6 const cleanNotifications = []; 7 7 for (let i = 0, j = 0; i < notifications.length; i++) { 8 8 const notification = notifications[i]; 9 - const { status, account, type, createdAt } = notification; 9 + const { id, status, account, type, createdAt } = notification; 10 10 const date = new Date(createdAt).toLocaleDateString(); 11 11 let virtualType = type; 12 12 if (type === 'favourite' || type === 'reblog') { ··· 23 23 if (mappedAccount) { 24 24 mappedAccount._types.push(type); 25 25 mappedAccount._types.sort().reverse(); 26 + mappedNotification.id += `-${id}`; 26 27 } else { 27 28 account._types = [type]; 28 29 mappedNotification._accounts.push(account); 30 + mappedNotification.id += `-${id}`; 29 31 } 30 32 } else { 31 33 account._types = [type]; ··· 47 49 const cleanNotifications2 = []; 48 50 for (let i = 0, j = 0; i < cleanNotifications.length; i++) { 49 51 const notification = cleanNotifications[i]; 50 - const { account, _accounts, type, createdAt } = notification; 52 + const { id, account, _accounts, type, createdAt } = notification; 51 53 const date = new Date(createdAt).toLocaleDateString(); 52 54 if (type === 'favourite+reblog' && account && _accounts.length === 1) { 53 55 const key = `${account?.id}-${type}-${date}`; 54 56 const mappedNotification = notificationsMap2[key]; 55 57 if (mappedNotification) { 56 58 mappedNotification._statuses.push(notification.status); 59 + mappedNotification.id += `-${id}`; 57 60 } else { 58 61 let n = (notificationsMap2[key] = { 59 62 ...notification,
+39
src/utils/store-utils.js
··· 81 81 return {}; 82 82 } 83 83 } 84 + 85 + // Massage these instance configurations to match the Mastodon API 86 + // - Pleroma 87 + function getInstanceConfiguration(instance) { 88 + const { 89 + configuration, 90 + maxMediaAttachments, 91 + maxTootChars, 92 + pleroma, 93 + pollLimits, 94 + } = instance; 95 + 96 + const statuses = configuration?.statuses || {}; 97 + if (maxMediaAttachments) { 98 + statuses.maxMediaAttachments ??= maxMediaAttachments; 99 + } 100 + if (maxTootChars) { 101 + statuses.maxCharacters ??= maxTootChars; 102 + } 103 + 104 + const polls = configuration?.polls || {}; 105 + if (pollLimits) { 106 + polls.maxCharactersPerOption ??= pollLimits.maxOptionChars; 107 + polls.maxExpiration ??= pollLimits.maxExpiration; 108 + polls.maxOptions ??= pollLimits.maxOptions; 109 + polls.minExpiration ??= pollLimits.minExpiration; 110 + } 111 + 112 + return { 113 + ...configuration, 114 + statuses, 115 + polls, 116 + }; 117 + } 118 + 119 + export function getCurrentInstanceConfiguration() { 120 + const instance = getCurrentInstance(); 121 + return getInstanceConfiguration(instance); 122 + }