this repo has no description
0
fork

Configure Feed

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

Merge pull request #375 from cheeaun/main

Update from main

authored by

Chee Aun and committed by
GitHub
8aa05422 1e21f519

+1548 -675
+4 -3
.env
··· 1 - VITE_CLIENT_NAME=Phanpy 2 - VITE_CLIENT_ID=social.phanpy 3 - VITE_WEBSITE=https://phanpy.social 1 + PHANPY_CLIENT_NAME=Phanpy 2 + PHANPY_WEBSITE=https://phanpy.social 3 + PHANPY_LINGVA_INSTANCES="lingva.phanpy.social lingva.lunar.icu lingva.garudalinux.org translate.plausibility.cloud" 4 + PHANPY_PRIVACY_POLICY_URL="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"
+2 -2
PRIVACY.MD
··· 6 6 7 7 Phanpy is hosted on [Cloudflare Pages](https://pages.cloudflare.com/) as a static website. Read more about [Cloudflare's privacy policy](https://www.cloudflare.com/privacypolicy/). 8 8 9 - ## Post translations 9 + ## Translations 10 10 11 - Phanpy uses [Lingva Translate](https://github.com/thedaviddelta/lingva-translate) to translate posts. 11 + Phanpy uses [Lingva API](https://github.com/cheeaun/lingva-api) and [Lingva Translate](https://github.com/thedaviddelta/lingva-translate) as fallbacks for translating post content, profile bio and media description. 12 12 13 13 ## Error logging 14 14
+61 -3
README.md
··· 126 126 127 127 Two ways (choose one): 128 128 129 - 1. (Recommended) Go to [Releases](https://github.com/cheeaun/phanpy/releases) and download the latest `phanpy-dist.zip`. It's pre-built so don't need to run any install/build commands. Extract it. Serve the folder of extracted files. 130 - 2. Download or `git clone` this repository. Build it by running `npm run build` (after `npm install`). Serve the `dist` folder. 129 + ### Easy way 131 130 132 - Try search for "how to self-host static sites" as there are many ways to do it. 131 + Go to [Releases](https://github.com/cheeaun/phanpy/releases) and download the latest `phanpy-dist.zip` or `phanpy-dist.tar.gz`. It's pre-built so don't need to run any install/build commands. Extract it. Serve the folder of extracted files. 132 + 133 + ### Custom-build way 134 + 135 + Requires [Node.js](https://nodejs.org/). 136 + 137 + Download or `git clone` this repository. Use `production` branch for *stable* releases, `main` for *latest*. Build it by running `npm run build` (after `npm install`). Serve the `dist` folder. 138 + 139 + Customization can be done by passing environment variables to the build command. Examples: 140 + 141 + ```bash 142 + PHANPY_APP_TITLE="Phanpy Dev" \ 143 + PHANPY_WEBSITE="https://dev.phanpy.social" \ 144 + npm run build 145 + ``` 146 + 147 + ```bash 148 + PHANPY_DEFAULT_INSTANCE=hachyderm.io \ 149 + PHANPY_DEFAULT_INSTANCE_REGISTRATION_URL=https://hachyderm.io/auth/sign_up \ 150 + PHANPY_PRIVACY_POLICY_URL=https://hachyderm.io/privacy-policy \ 151 + npm run build 152 + ``` 153 + 154 + It's also possible to set them in the `.env` file. 155 + 156 + Available variables: 157 + 158 + - `PHANPY_CLIENT_NAME` (optional, default: `Phanpy`) affects: 159 + - Web page title, shown in the browser window or tab title 160 + - App title, when installed as PWA, shown in the Home screen, macOS dock, Windows taskbar, etc 161 + - OpenGraph card title, when shared on social networks 162 + - Client name, when [registering the app for authentication](https://docs.joinmastodon.org/client/token/#app) and shown as client used on posts in some apps/clients 163 + - `PHANPY_WEBSITE` (optional but recommended, default: `https://phanpy.social`) affects: 164 + - Canonical URL of the website 165 + - OpenGraph card URL, when shared on social networks 166 + - Root path for the OpenGraph card image 167 + - Client URL, when [registering the app for authentication](https://docs.joinmastodon.org/client/token/#app) and shown as client used on posts in some apps/clients 168 + - `PHANPY_DEFAULT_INSTANCE` (optional, no defaults): 169 + - e.g. 'mastodon.social', without `https://` 170 + - Default instance for log-in 171 + - When logging in, the user will be redirected instantly to the instance's authentication page instead of having to manually type the instance URL and submit 172 + - `PHANPY_DEFAULT_INSTANCE_REGISTRATION_URL` (optional, no defaults): 173 + - URL of the instance registration page 174 + - E.g. `https://mastodon.social/auth/sign_up` 175 + - `PHANPY_PRIVACY_POLICY_URL` (optional, default to official instance's privacy policy): 176 + - URL of the privacy policy page 177 + - May specify the instance's own privacy policy 178 + - `PHANPY_LINGVA_INSTANCES` (optional, space-separated list, default: `lingva.phanpy.social [...hard-coded list of fallback instances]`): 179 + - Specify a space-separated list of instances. First will be used as default before falling back to the subsequent instances. If there's only 1 instance, means no fallback. 180 + - May specify a self-hosted Lingva instance, powered by either [lingva-translate](https://github.com/thedaviddelta/lingva-translate) or [lingva-api](https://github.com/cheeaun/lingva-api) 181 + - List of fallback instances hard-coded in `/.env` 182 + - [↗️ List of lingva-translate instances](https://github.com/thedaviddelta/lingva-translate?tab=readme-ov-file#instances) 183 + 184 + ### Static site hosting 185 + 186 + Try online search for "how to self-host static sites" as there are many ways to do it. 187 + 188 + #### Lingva-translate or lingva-api hosting 189 + 190 + See documentation for [lingva-translate](https://github.com/thedaviddelta/lingva-translate) or [lingva-api](https://github.com/cheeaun/lingva-api). 133 191 134 192 ## Community deployments 135 193
+1 -1
compose/index.html
··· 4 4 <meta charset="UTF-8" /> 5 5 <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 - <title>Compose / %VITE_CLIENT_NAME%</title> 7 + <title>Compose / %PHANPY_CLIENT_NAME%</title> 8 8 <meta name="color-scheme" content="dark light" /> 9 9 <meta name="google" content="notranslate" /> 10 10 </head>
+21 -6
index.html
··· 6 6 name="viewport" 7 7 content="width=device-width, initial-scale=1, viewport-fit=cover" 8 8 /> 9 - <title>%VITE_CLIENT_NAME%</title> 9 + <title>%PHANPY_CLIENT_NAME%</title> 10 10 <meta 11 11 name="description" 12 12 content="Minimalistic opinionated Mastodon web client" ··· 14 14 <meta name="color-scheme" content="dark light" /> 15 15 <link rel="icon" type="image/x-icon" href="/favicon.ico" /> 16 16 <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> 17 - <meta name="apple-mobile-web-app-title" content="%VITE_CLIENT_NAME%" /> 17 + <meta name="apple-mobile-web-app-title" content="%PHANPY_CLIENT_NAME%" /> 18 18 <meta name="apple-mobile-web-app-capable" content="yes" /> 19 19 <meta name="mobile-web-app-capable" content="yes" /> 20 - <link rel="canonical" href="%VITE_WEBSITE%" /> 20 + <link rel="canonical" href="%PHANPY_WEBSITE%" /> 21 + <meta 22 + name="" 23 + data-theme-setting="manual" 24 + content="#242526" 25 + data-theme-light-color="#fff" 26 + data-theme-light-color-temp="#ffff" 27 + data-theme-dark-color="#242526" 28 + data-theme-dark-color-temp="#242526ff" 29 + /> 21 30 <meta 22 31 name="theme-color" 32 + data-theme-setting="auto" 23 33 content="#fff" 34 + data-content="#fff" 35 + data-content-temp="#fffa" 24 36 media="(prefers-color-scheme: light)" 25 37 /> 26 38 <meta 27 39 name="theme-color" 40 + data-theme-setting="auto" 28 41 content="#242526" 42 + data-content="#242526" 43 + data-content-temp="#242526aa" 29 44 media="(prefers-color-scheme: dark)" 30 45 /> 31 46 <meta name="google" content="notranslate" /> ··· 33 48 34 49 <!-- Metacrap https://broken-links.com/2015/12/01/little-less-metacrap/ --> 35 50 <meta property="twitter:card" content="summary_large_image" /> 36 - <meta property="og:url" content="%VITE_WEBSITE%" /> 37 - <meta property="og:title" content="%VITE_CLIENT_NAME%" /> 51 + <meta property="og:url" content="%PHANPY_WEBSITE%" /> 52 + <meta property="og:title" content="%PHANPY_CLIENT_NAME%" /> 38 53 <meta 39 54 property="og:description" 40 55 content="Minimalistic opinionated Mastodon web client" 41 56 /> 42 - <meta property="og:image" content="%VITE_WEBSITE%/og-image-2.jpg" /> 57 + <meta property="og:image" content="%PHANPY_WEBSITE%/og-image-2.jpg" /> 43 58 </head> 44 59 <body> 45 60 <div id="app"></div>
+4 -5
package-lock.json
··· 25 25 "lz-string": "~1.5.0", 26 26 "masto": "~6.5.1", 27 27 "moize": "~6.1.6", 28 - "p-retry": "~6.1.0", 28 + "p-retry": "~6.2.0", 29 29 "p-throttle": "~6.1.0", 30 30 "preact": "~10.19.3", 31 31 "react-hotkeys-hook": "~4.4.1", ··· 5681 5681 } 5682 5682 }, 5683 5683 "node_modules/p-retry": { 5684 - "version": "6.1.0", 5685 - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.1.0.tgz", 5686 - "integrity": "sha512-fJLEQ2KqYBJRuaA/8cKMnqhulqNM+bpcjYtXNex2t3mOXKRYPitAJt9NacSf8XAFzcYahSAbKpobiWDSqHSh2g==", 5687 - "license": "MIT", 5684 + "version": "6.2.0", 5685 + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.0.tgz", 5686 + "integrity": "sha512-JA6nkq6hKyWLLasXQXUrO4z8BUZGUt/LjlJxx8Gb2+2ntodU/SS63YZ8b0LUTbQ8ZB9iwOfhEPhg4ykKnn2KsA==", 5688 5687 "dependencies": { 5689 5688 "@types/retry": "0.12.2", 5690 5689 "is-network-error": "^1.0.0",
+1 -1
package.json
··· 27 27 "lz-string": "~1.5.0", 28 28 "masto": "~6.5.1", 29 29 "moize": "~6.1.6", 30 - "p-retry": "~6.1.0", 30 + "p-retry": "~6.2.0", 31 31 "p-throttle": "~6.1.0", 32 32 "preact": "~10.19.3", 33 33 "react-hotkeys-hook": "~4.4.1",
+2 -3
scripts/fetch-lingva-languages.js
··· 1 - // Fetch https://lingva.ml/api/v1/languages/{source|target} 2 1 import fs from 'fs'; 3 2 4 - fetch('https://lingva.ml/api/v1/languages/source') 3 + fetch('https://lingva.phanpy.social/api/v1/languages/source') 5 4 .then((response) => response.json()) 6 5 .then((json) => { 7 6 const file = './src/data/lingva-source-languages.json'; ··· 9 8 fs.writeFileSync(file, JSON.stringify(json.languages, null, '\t'), 'utf8'); 10 9 }); 11 10 12 - fetch('https://lingva.ml/api/v1/languages/target') 11 + fetch('https://lingva.phanpy.social/api/v1/languages/target') 13 12 .then((response) => response.json()) 14 13 .then((json) => { 15 14 const file = './src/data/lingva-target-languages.json';
+24
src/app.css
··· 686 686 pointer-events: none; 687 687 } 688 688 689 + .timeline.contextual > li .replies { 690 + > ul > li:only-child { 691 + > .replies { 692 + > ul > li:only-child { 693 + margin-left: calc(-1 * var(--line-margin-end)); 694 + background-position: calc(16px) 0; 695 + background-size: 100% calc(20px + 8px); 696 + 697 + &:before { 698 + display: none; 699 + } 700 + } 701 + } 702 + } 703 + } 704 + 689 705 .timeline-deck.compact .status { 690 706 max-height: max(25vh, 160px); 691 707 overflow: hidden; ··· 1092 1108 transform: translate(-50%, 0); 1093 1109 font-size: 90%; 1094 1110 pointer-events: auto; 1111 + transition: all 0.3s ease-in-out; 1112 + 1113 + header[hidden] & { 1114 + opacity: 0; 1115 + transform: translate(-50%, -100%) scale(0.9); 1116 + pointer-events: none; 1117 + animation: none !important; 1118 + } 1095 1119 } 1096 1120 .updates-button .icon { 1097 1121 vertical-align: top;
+77 -45
src/app.jsx
··· 124 124 // Related: https://github.com/vitejs/vite/issues/10600 125 125 setTimeout(() => { 126 126 for (const icon in ICONS) { 127 - if (Array.isArray(ICONS[icon])) { 128 - ICONS[icon][0]?.(); 129 - } else { 130 - ICONS[icon]?.(); 131 - } 127 + queueMicrotask(() => { 128 + if (Array.isArray(ICONS[icon])) { 129 + ICONS[icon][0]?.(); 130 + } else { 131 + ICONS[icon]?.(); 132 + } 133 + }); 132 134 } 133 135 }, 5000); 134 136 ··· 193 195 if (isIOS) { 194 196 document.addEventListener('visibilitychange', () => { 195 197 if (document.visibilityState === 'visible') { 196 - // Get current color scheme 197 - const colorScheme = window.matchMedia('(prefers-color-scheme: dark)') 198 - .matches 199 - ? 'dark' 200 - : 'light'; 201 - // Get current theme-color 202 - const $meta = document.querySelector( 203 - `meta[name="theme-color"][media*="${colorScheme}"]`, 204 - ); 205 - const color = $meta?.getAttribute('content'); 206 - if (color) { 207 - let tempColor; 208 - if (/^#/.test(color)) { 209 - // Assume either #RBG or #RRGGBB 210 - if (color.length === 4) { 211 - tempColor = color + 'f'; 212 - } else if (color.length === 7) { 213 - tempColor = color + 'ff'; 214 - } 198 + const theme = store.local.get('theme'); 199 + let $meta; 200 + if (theme) { 201 + // Get current meta 202 + $meta = document.querySelector( 203 + `meta[name="theme-color"][data-theme-setting="manual"]`, 204 + ); 205 + if ($meta) { 206 + const color = $meta.content; 207 + const tempColor = 208 + theme === 'light' 209 + ? $meta.dataset.themeLightColorTemp 210 + : $meta.dataset.themeDarkColorTemp; 211 + $meta.content = tempColor || ''; 212 + setTimeout(() => { 213 + $meta.content = color; 214 + }, 10); 215 215 } 216 - $meta.content = tempColor || ''; 217 - setTimeout(() => { 218 - $meta.content = color; 219 - }, 10); 216 + } else { 217 + // Get current color scheme 218 + const colorScheme = window.matchMedia('(prefers-color-scheme: dark)') 219 + .matches 220 + ? 'dark' 221 + : 'light'; 222 + // Get current theme-color 223 + $meta = document.querySelector( 224 + `meta[name="theme-color"][media*="${colorScheme}"]`, 225 + ); 226 + if ($meta) { 227 + const color = $meta.dataset.content; 228 + const tempColor = $meta.dataset.contentTemp; 229 + $meta.content = tempColor || ''; 230 + setTimeout(() => { 231 + $meta.content = color; 232 + }, 10); 233 + } 220 234 } 221 235 } 222 236 }); 223 237 } 224 238 239 + { 240 + const theme = store.local.get('theme'); 241 + // If there's a theme, it's NOT auto 242 + if (theme) { 243 + // dark | light 244 + document.documentElement.classList.add(`is-${theme}`); 245 + document 246 + .querySelector('meta[name="color-scheme"]') 247 + .setAttribute('content', theme || 'dark light'); 248 + 249 + // Enable manual theme <meta> 250 + const $manualMeta = document.querySelector( 251 + 'meta[data-theme-setting="manual"]', 252 + ); 253 + if ($manualMeta) { 254 + $manualMeta.name = 'theme-color'; 255 + $manualMeta.content = 256 + theme === 'light' 257 + ? $manualMeta.dataset.themeLightColor 258 + : $manualMeta.dataset.themeDarkColor; 259 + } 260 + // Disable auto theme <meta>s 261 + const $autoMetas = document.querySelectorAll( 262 + 'meta[data-theme-setting="auto"]', 263 + ); 264 + $autoMetas.forEach((m) => { 265 + m.name = ''; 266 + }); 267 + } 268 + const textSize = store.local.get('textSize'); 269 + if (textSize) { 270 + document.documentElement.style.setProperty('--text-size', `${textSize}px`); 271 + } 272 + } 273 + 225 274 subscribe(states, (changes) => { 226 275 for (const [action, path, value, prevValue] of changes) { 227 276 // Change #app dataset based on settings.shortcutsViewMode ··· 243 292 function App() { 244 293 const [isLoggedIn, setIsLoggedIn] = useState(false); 245 294 const [uiState, setUIState] = useState('loading'); 246 - 247 - useLayoutEffect(() => { 248 - const theme = store.local.get('theme'); 249 - if (theme) { 250 - document.documentElement.classList.add(`is-${theme}`); 251 - document 252 - .querySelector('meta[name="color-scheme"]') 253 - .setAttribute('content', theme === 'auto' ? 'dark light' : theme); 254 - } 255 - const textSize = store.local.get('textSize'); 256 - if (textSize) { 257 - document.documentElement.style.setProperty( 258 - '--text-size', 259 - `${textSize}px`, 260 - ); 261 - } 262 - }, []); 263 295 264 296 useEffect(() => { 265 297 const instanceURL = store.local.get('instanceURL');
+91 -83
src/components/account-info.css
··· 5 5 overflow-y: auto; 6 6 max-width: 100%; 7 7 --banner-overlap: 44px; 8 + --posting-stats-size: 8px; 9 + --original-color: var(--link-color); 10 + 11 + .note { 12 + font-size: 95%; 13 + line-height: 1.4; 14 + text-wrap: pretty; 15 + margin-bottom: 16px; 16 + 17 + > *:first-child { 18 + margin-top: 0; 19 + padding-top: 0; 20 + } 21 + > *:last-child { 22 + margin-bottom: 0; 23 + padding-bottom: 0; 24 + } 25 + 26 + &:not(:has(p)):not(:empty) { 27 + /* Some notes don't have <p> tags, so we need to add some padding */ 28 + padding: 1em 0; 29 + } 30 + } 31 + 32 + .posting-stats { 33 + font-size: 90%; 34 + color: var(--text-insignificant-color); 35 + background-color: var(--bg-faded-color); 36 + padding: 8px 12px; 37 + 38 + &:is(:hover, :focus-within) { 39 + background-color: var(--link-bg-hover-color); 40 + } 41 + } 42 + 43 + .posting-stats-bar { 44 + --gap: 0.5px; 45 + --gap-color: var(--outline-color); 46 + height: var(--posting-stats-size); 47 + border-radius: var(--posting-stats-size); 48 + overflow: hidden; 49 + margin: 8px 0; 50 + box-shadow: inset 0 0 0 1px var(--outline-color), 51 + inset 0 0 0 1.5px var(--bg-blur-color); 52 + background-color: var(--bg-color); 53 + background-repeat: no-repeat; 54 + animation: swoosh-bg-image 0.3s ease-in-out 0.3s both; 55 + background-image: linear-gradient( 56 + to right, 57 + var(--original-color) 0%, 58 + var(--original-color) calc(var(--originals-percentage) - var(--gap)), 59 + var(--gap-color) calc(var(--originals-percentage) - var(--gap)), 60 + var(--gap-color) calc(var(--originals-percentage) + var(--gap)), 61 + var(--reply-to-color) calc(var(--originals-percentage) + var(--gap)), 62 + var(--reply-to-color) calc(var(--replies-percentage) - var(--gap)), 63 + var(--gap-color) calc(var(--replies-percentage) - var(--gap)), 64 + var(--gap-color) calc(var(--replies-percentage) + var(--gap)), 65 + var(--reblog-color) calc(var(--replies-percentage) + var(--gap)), 66 + var(--reblog-color) 100% 67 + ); 68 + } 69 + 70 + .posting-stats-legends { 71 + font-size: 12px; 72 + text-transform: uppercase; 73 + } 74 + 75 + .posting-stats-legend-item { 76 + display: inline-block; 77 + width: var(--posting-stats-size); 78 + height: var(--posting-stats-size); 79 + border-radius: var(--posting-stats-size); 80 + background-color: var(--text-insignificant-color); 81 + vertical-align: middle; 82 + margin: 0 4px 2px; 83 + /* border: 1px solid var(--outline-color); */ 84 + box-shadow: inset 0 0 0 1px var(--outline-color), 85 + inset 0 0 0 1.5px var(--bg-blur-color); 86 + 87 + &.posting-stats-legend-item-originals { 88 + background-color: var(--original-color); 89 + } 90 + &.posting-stats-legend-item-replies { 91 + background-color: var(--reply-to-color); 92 + } 93 + &.posting-stats-legend-item-boosts { 94 + background-color: var(--reblog-color); 95 + } 96 + } 8 97 } 9 98 10 99 .account-container.skeleton { ··· 252 341 } 253 342 } 254 343 255 - .account-container .note { 256 - font-size: 95%; 257 - line-height: 1.4; 258 - text-wrap: pretty; 259 - } 260 - .account-container .note:not(:has(p)):not(:empty) { 261 - /* Some notes don't have <p> tags, so we need to add some padding */ 262 - padding: 1em 0; 263 - } 264 - 265 344 .account-container .stats { 266 345 display: flex; 267 346 /* flex-wrap: wrap; */ ··· 413 492 position: relative; 414 493 } 415 494 .timeline-start .account-container header { 416 - padding: 16px 16px 1px; 495 + padding: 16px; 417 496 animation: none; 418 497 } 419 498 .timeline-start .account-container main { ··· 521 600 } 522 601 523 602 main { 524 - margin-top: -8px; 603 + /* margin-top: -8px; */ 525 604 padding-top: 1px; 526 605 padding-bottom: 16px; 527 606 } ··· 603 682 604 683 &.loading { 605 684 animation: loading-spin 0.35s linear both infinite !important; 606 - } 607 - } 608 - 609 - .account-container { 610 - --posting-stats-size: 8px; 611 - --original-color: var(--link-color); 612 - 613 - .posting-stats { 614 - font-size: 90%; 615 - color: var(--text-insignificant-color); 616 - background-color: var(--bg-faded-color); 617 - padding: 8px 12px; 618 - 619 - &:is(:hover, :focus-within) { 620 - background-color: var(--link-bg-hover-color); 621 - } 622 - } 623 - 624 - .posting-stats-bar { 625 - --gap: 0.5px; 626 - --gap-color: var(--outline-color); 627 - height: var(--posting-stats-size); 628 - border-radius: var(--posting-stats-size); 629 - overflow: hidden; 630 - margin: 8px 0; 631 - box-shadow: inset 0 0 0 1px var(--outline-color), 632 - inset 0 0 0 1.5px var(--bg-blur-color); 633 - background-color: var(--bg-color); 634 - background-repeat: no-repeat; 635 - animation: swoosh-bg-image 0.3s ease-in-out 0.3s both; 636 - background-image: linear-gradient( 637 - to right, 638 - var(--original-color) 0%, 639 - var(--original-color) calc(var(--originals-percentage) - var(--gap)), 640 - var(--gap-color) calc(var(--originals-percentage) - var(--gap)), 641 - var(--gap-color) calc(var(--originals-percentage) + var(--gap)), 642 - var(--reply-to-color) calc(var(--originals-percentage) + var(--gap)), 643 - var(--reply-to-color) calc(var(--replies-percentage) - var(--gap)), 644 - var(--gap-color) calc(var(--replies-percentage) - var(--gap)), 645 - var(--gap-color) calc(var(--replies-percentage) + var(--gap)), 646 - var(--reblog-color) calc(var(--replies-percentage) + var(--gap)), 647 - var(--reblog-color) 100% 648 - ); 649 - } 650 - 651 - .posting-stats-legends { 652 - font-size: 12px; 653 - text-transform: uppercase; 654 - } 655 - 656 - .posting-stats-legend-item { 657 - display: inline-block; 658 - width: var(--posting-stats-size); 659 - height: var(--posting-stats-size); 660 - border-radius: var(--posting-stats-size); 661 - background-color: var(--text-insignificant-color); 662 - vertical-align: middle; 663 - margin: 0 4px 2px; 664 - /* border: 1px solid var(--outline-color); */ 665 - box-shadow: inset 0 0 0 1px var(--outline-color), 666 - inset 0 0 0 1.5px var(--bg-blur-color); 667 - 668 - &.posting-stats-legend-item-originals { 669 - background-color: var(--original-color); 670 - } 671 - &.posting-stats-legend-item-replies { 672 - background-color: var(--reply-to-color); 673 - } 674 - &.posting-stats-legend-item-boosts { 675 - background-color: var(--reblog-color); 676 - } 677 685 } 678 686 } 679 687
+1 -1
src/components/account-info.jsx
··· 923 923 <div class="actions"> 924 924 <span> 925 925 {followedBy ? ( 926 - <span class="tag">Following you</span> 926 + <span class="tag">Follows you</span> 927 927 ) : !!lastStatusAt ? ( 928 928 <small class="insignificant"> 929 929 Last post:{' '}
+25 -22
src/components/avatar.jsx
··· 62 62 if (avatarRef.current) avatarRef.current.dataset.loaded = true; 63 63 if (alphaCache[url] !== undefined) return; 64 64 if (isMissing) return; 65 - try { 66 - // Check if image has alpha channel 67 - const { width, height } = e.target; 68 - if (canvas.width !== width) canvas.width = width; 69 - if (canvas.height !== height) canvas.height = height; 70 - ctx.drawImage(e.target, 0, 0); 71 - const allPixels = ctx.getImageData(0, 0, width, height); 72 - // At least 10% of pixels have alpha <= 128 73 - const hasAlpha = 74 - allPixels.data.filter((pixel, i) => i % 4 === 3 && pixel <= 128) 75 - .length / 76 - (allPixels.data.length / 4) > 77 - 0.1; 78 - if (hasAlpha) { 79 - // console.log('hasAlpha', hasAlpha, allPixels.data); 80 - avatarRef.current.classList.add('has-alpha'); 65 + queueMicrotask(() => { 66 + try { 67 + // Check if image has alpha channel 68 + const { width, height } = e.target; 69 + if (canvas.width !== width) canvas.width = width; 70 + if (canvas.height !== height) canvas.height = height; 71 + ctx.drawImage(e.target, 0, 0); 72 + const allPixels = ctx.getImageData(0, 0, width, height); 73 + // At least 10% of pixels have alpha <= 128 74 + const hasAlpha = 75 + allPixels.data.filter( 76 + (pixel, i) => i % 4 === 3 && pixel <= 128, 77 + ).length / 78 + (allPixels.data.length / 4) > 79 + 0.1; 80 + if (hasAlpha) { 81 + // console.log('hasAlpha', hasAlpha, allPixels.data); 82 + avatarRef.current.classList.add('has-alpha'); 83 + } 84 + alphaCache[url] = hasAlpha; 85 + ctx.clearRect(0, 0, width, height); 86 + } catch (e) { 87 + // Silent fail 88 + alphaCache[url] = false; 81 89 } 82 - alphaCache[url] = hasAlpha; 83 - ctx.clearRect(0, 0, width, height); 84 - } catch (e) { 85 - // Silent fail 86 - alphaCache[url] = false; 87 - } 90 + }); 88 91 }} 89 92 /> 90 93 )}
+17
src/components/compose.css
··· 501 501 padding-inline: 24px; 502 502 } 503 503 } 504 + 505 + @keyframes breathe { 506 + 0% { 507 + opacity: 1; 508 + } 509 + 40% { 510 + opacity: 0.4; 511 + } 512 + 100% { 513 + opacity: 1; 514 + } 515 + } 516 + 504 517 #media-sheet { 505 518 .media-form { 506 519 flex: 1; ··· 514 527 resize: none; 515 528 width: 100%; 516 529 /* height: 10em; */ 530 + 531 + &.loading { 532 + animation: skeleton-breathe 1.5s linear infinite; 533 + } 517 534 } 518 535 519 536 footer {
+73 -3
src/components/compose.jsx
··· 1 1 import './compose.css'; 2 2 3 3 import '@github/text-expander-element'; 4 + import { MenuItem } from '@szhsin/react-menu'; 4 5 import equal from 'fast-deep-equal'; 5 6 import { forwardRef } from 'preact/compat'; 6 7 import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; ··· 11 12 import { useDebouncedCallback, useThrottledCallback } from 'use-debounce'; 12 13 import { useSnapshot } from 'valtio'; 13 14 15 + import Menu2 from '../components/menu2'; 14 16 import supportedLanguages from '../data/status-supported-languages'; 15 17 import urlRegex from '../data/url-regex'; 16 18 import { api } from '../utils/api'; ··· 19 21 import localeMatch from '../utils/locale-match'; 20 22 import openCompose from '../utils/open-compose'; 21 23 import shortenNumber from '../utils/shorten-number'; 24 + import showToast from '../utils/show-toast'; 22 25 import states, { saveStatus } from '../utils/states'; 23 26 import store from '../utils/store'; 24 27 import { ··· 38 41 import Loader from './loader'; 39 42 import Modal from './modal'; 40 43 import Status from './status'; 44 + 45 + const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env; 41 46 42 47 const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => { 43 48 const [code, common, native] = l; ··· 1291 1296 const { masto } = api(); 1292 1297 const [text, setText] = useState(ref.current?.value || ''); 1293 1298 const { maxCharacters, performSearch = () => {}, ...textareaProps } = props; 1294 - const snapStates = useSnapshot(states); 1299 + // const snapStates = useSnapshot(states); 1295 1300 // const charCount = snapStates.composerCharacterCount; 1296 1301 1297 1302 const customEmojis = useRef(); ··· 1645 1650 onDescriptionChange = () => {}, 1646 1651 onRemove = () => {}, 1647 1652 }) { 1653 + const [uiState, setUIState] = useState('default'); 1648 1654 const supportsEdit = supports('@mastodon/edit-media-attributes'); 1649 1655 const { type, id, file } = attachment; 1650 1656 const url = useMemo( ··· 1653 1659 ); 1654 1660 console.log({ attachment }); 1655 1661 const [description, setDescription] = useState(attachment.description); 1656 - const suffixType = type.split('/')[0]; 1662 + const [suffixType, subtype] = type.split('/'); 1657 1663 const debouncedOnDescriptionChange = useDebouncedCallback( 1658 1664 onDescriptionChange, 1659 1665 250, ··· 1699 1705 autoCorrect="on" 1700 1706 spellCheck="true" 1701 1707 dir="auto" 1702 - disabled={disabled} 1708 + disabled={disabled || uiState === 'loading'} 1709 + class={uiState === 'loading' ? 'loading' : ''} 1703 1710 maxlength="1500" // Not unicode-aware :( 1704 1711 // TODO: Un-hard-code this maxlength, ref: https://github.com/mastodon/mastodon/blob/b59fb28e90bc21d6fd1a6bafd13cfbd81ab5be54/app/models/media_attachment.rb#L39 1705 1712 onInput={(e) => { ··· 1712 1719 </> 1713 1720 ); 1714 1721 1722 + const toastRef = useRef(null); 1723 + useEffect(() => { 1724 + return () => { 1725 + toastRef.current?.hideToast?.(); 1726 + }; 1727 + }, []); 1728 + 1715 1729 return ( 1716 1730 <> 1717 1731 <div class="media-attachment"> ··· 1785 1799 <div class="media-form"> 1786 1800 {descTextarea} 1787 1801 <footer> 1802 + {suffixType === 'image' && 1803 + /^(png|jpe?g|gif|webp)$/i.test(subtype) && 1804 + !!states.settings.mediaAltGenerator && 1805 + !!IMG_ALT_API_URL && ( 1806 + <Menu2 1807 + portal={{ 1808 + target: document.body, 1809 + }} 1810 + containerProps={{ 1811 + style: { 1812 + zIndex: 1001, 1813 + }, 1814 + }} 1815 + align="center" 1816 + position="anchor" 1817 + overflow="auto" 1818 + menuButton={ 1819 + <button type="button" title="More" class="plain"> 1820 + <Icon icon="more" size="l" alt="More" /> 1821 + </button> 1822 + } 1823 + > 1824 + <MenuItem 1825 + disabled={uiState === 'loading'} 1826 + onClick={() => { 1827 + setUIState('loading'); 1828 + toastRef.current = showToast({ 1829 + text: 'Generating description. Please wait...', 1830 + duration: -1, 1831 + }); 1832 + // POST with multipart 1833 + (async function () { 1834 + try { 1835 + const body = new FormData(); 1836 + body.append('image', file); 1837 + const response = await fetch(IMG_ALT_API_URL, { 1838 + method: 'POST', 1839 + body, 1840 + }).then((r) => r.json()); 1841 + setDescription(response.description); 1842 + } catch (e) { 1843 + console.error(e); 1844 + showToast('Failed to generate description'); 1845 + } finally { 1846 + setUIState('default'); 1847 + toastRef.current?.hideToast?.(); 1848 + } 1849 + })(); 1850 + }} 1851 + > 1852 + <Icon icon="sparkles2" /> 1853 + <span>Generate description…</span> 1854 + </MenuItem> 1855 + </Menu2> 1856 + )} 1788 1857 <button 1789 1858 type="button" 1790 1859 class="light block" 1791 1860 onClick={() => { 1792 1861 setShowModal(false); 1793 1862 }} 1863 + disabled={uiState === 'loading'} 1794 1864 > 1795 1865 Done 1796 1866 </button>
+17 -8
src/components/icon.jsx
··· 1 - import { memo } from 'preact/compat'; 2 - import { useEffect, useState } from 'preact/hooks'; 1 + import { useEffect, useRef, useState } from 'preact/hooks'; 3 2 4 3 const SIZES = { 5 4 s: 12, ··· 70 69 history: () => import('@iconify-icons/mingcute/history-line'), 71 70 share: () => import('@iconify-icons/mingcute/share-2-line'), 72 71 sparkles: () => import('@iconify-icons/mingcute/sparkles-line'), 72 + sparkles2: () => import('@iconify-icons/mingcute/sparkles-2-line'), 73 73 exit: () => import('@iconify-icons/mingcute/exit-line'), 74 74 translate: () => import('@iconify-icons/mingcute/translate-line'), 75 75 play: () => import('@iconify-icons/mingcute/play-fill'), ··· 105 105 month: () => import('@iconify-icons/mingcute/calendar-month-line'), 106 106 media: () => import('@iconify-icons/mingcute/photo-album-line'), 107 107 speak: () => import('@iconify-icons/mingcute/radar-line'), 108 + building: () => import('@iconify-icons/mingcute/building-5-line'), 108 109 }; 110 + 111 + const ICONDATA = {}; 109 112 110 113 function Icon({ 111 114 icon, ··· 124 127 [iconBlock, rotate, flip] = iconBlock; 125 128 } 126 129 127 - const [iconData, setIconData] = useState(null); 128 - useEffect(async () => { 129 - const icon = await iconBlock(); 130 - setIconData(icon.default); 131 - }, [iconBlock]); 130 + const [iconData, setIconData] = useState(ICONDATA[icon]); 131 + const currentIcon = useRef(icon); 132 + useEffect(() => { 133 + if (iconData && currentIcon.current === icon) return; 134 + (async () => { 135 + const iconB = await iconBlock(); 136 + setIconData(iconB.default); 137 + ICONDATA[icon] = iconB.default; 138 + })(); 139 + currentIcon.current = icon; 140 + }, [icon]); 132 141 133 142 return ( 134 143 <span ··· 157 166 ); 158 167 } 159 168 160 - export default memo(Icon); 169 + export default Icon;
+53 -1
src/components/media-modal.jsx
··· 1 - import { Menu } from '@szhsin/react-menu'; 1 + import { MenuDivider, MenuItem } from '@szhsin/react-menu'; 2 2 import { getBlurHashAverageColor } from 'fast-blurhash'; 3 3 import { 4 4 useEffect, ··· 10 10 import { useHotkeys } from 'react-hotkeys-hook'; 11 11 12 12 import { oklab2rgb, rgb2oklab } from '../utils/color-utils'; 13 + import showToast from '../utils/show-toast'; 13 14 import states from '../utils/states'; 14 15 15 16 import Icon from './icon'; ··· 18 19 import Menu2 from './menu2'; 19 20 import MenuLink from './menu-link'; 20 21 22 + const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env; 23 + 21 24 function MediaModal({ 22 25 mediaAttachments, 23 26 statusID, ··· 26 29 index = 0, 27 30 onClose = () => {}, 28 31 }) { 32 + const [uiState, setUIState] = useState('default'); 29 33 const carouselRef = useRef(null); 30 34 31 35 const [currentIndex, setCurrentIndex] = useState(index); ··· 144 148 ); 145 149 }, [mediaAccentColors]); 146 150 151 + let toastRef = useRef(null); 152 + useEffect(() => { 153 + return () => { 154 + toastRef.current?.hideToast?.(); 155 + }; 156 + }, []); 157 + 147 158 return ( 148 159 <div 149 160 class={`media-modal-container media-modal-count-${mediaAttachments?.length}`} ··· 284 295 <Icon icon="popout" /> 285 296 <span>Open original media</span> 286 297 </MenuLink> 298 + {import.meta.env.DEV && // Only dev for now 299 + !!states.settings.mediaAltGenerator && 300 + !!IMG_ALT_API_URL && 301 + !!mediaAttachments[currentIndex]?.url && 302 + !mediaAttachments[currentIndex]?.description && 303 + mediaAttachments[currentIndex]?.type === 'image' && ( 304 + <> 305 + <MenuDivider /> 306 + <MenuItem 307 + disabled={uiState === 'loading'} 308 + onClick={() => { 309 + setUIState('loading'); 310 + toastRef.current = showToast({ 311 + text: 'Attempting to describe image. Please wait...', 312 + duration: -1, 313 + }); 314 + (async function () { 315 + try { 316 + const response = await fetch( 317 + `${IMG_ALT_API_URL}?image=${encodeURIComponent( 318 + mediaAttachments[currentIndex]?.url, 319 + )}`, 320 + ).then((r) => r.json()); 321 + states.showMediaAlt = { 322 + alt: response.description, 323 + }; 324 + } catch (e) { 325 + console.error(e); 326 + showToast('Failed to describe image'); 327 + } finally { 328 + setUIState('default'); 329 + toastRef.current?.hideToast?.(); 330 + } 331 + })(); 332 + }} 333 + > 334 + <Icon icon="sparkles2" /> 335 + <span>Describe image…</span> 336 + </MenuItem> 337 + </> 338 + )} 287 339 </Menu2>{' '} 288 340 <Link 289 341 to={`${instance ? `/${instance}` : ''}/s/${statusID}${
+18 -3
src/components/media-post.css
··· 3 3 position: relative; 4 4 animation: appear-smooth 1s ease-out; 5 5 6 - &:is(.filtered, .has-spoiler) :is(img, video) { 7 - filter: blur(32px); 6 + &:is(.filtered, .has-spoiler:not(.show-media)) :is(img, video) { 7 + /* filter: blur(32px); 8 8 image-rendering: crisp-edges; 9 - image-rendering: pixelated; 9 + image-rendering: pixelated; */ 10 + opacity: 0; 10 11 animation: none !important; 11 12 } 12 13 ··· 48 49 } 49 50 } 50 51 52 + &.has-spoiler.show-media[data-spoiler-text]:before { 53 + mix-blend-mode: normal; 54 + backdrop-filter: blur(4px); 55 + } 56 + 51 57 .media { 52 58 border-radius: var(--item-radius); 53 59 overflow: hidden; ··· 65 71 66 72 &:not(.media-audio) { 67 73 background-color: var(--average-color, var(--media-bg-color)); 74 + background-clip: padding-box; 68 75 } 69 76 70 77 @media (hover: hover) { ··· 103 110 animation: position-object 5s ease-in-out 0.1s 5; 104 111 animation-duration: var(--anim-duration, 5s); 105 112 } 113 + } 114 + 115 + &.has-spoiler .media:not(.media-audio) { 116 + background-image: radial-gradient( 117 + circle at 50% 50%, 118 + var(--average-color, var(--bg-faded-color)), 119 + var(--bg-color) 20em 120 + ); 106 121 } 107 122 }
+8 -5
src/components/media-post.jsx
··· 103 103 104 104 console.debug('RENDER Media post', id, status?.account.displayName); 105 105 106 - // const readingExpandSpoilers = useMemo(() => { 107 - // const prefs = store.account.get('preferences') || {}; 108 - // return !!prefs['reading:expand:spoilers']; 109 - // }, []); 110 - const hasSpoiler = spoilerText || sensitive; 106 + const hasSpoiler = sensitive; 107 + const readingExpandMedia = useMemo(() => { 108 + // default | show_all | hide_all 109 + const prefs = store.account.get('preferences') || {}; 110 + return prefs['reading:expand:media'] || 'default'; 111 + }, []); 112 + const showSpoilerMedia = readingExpandMedia === 'show_all'; 111 113 112 114 const Parent = parent || 'div'; 113 115 ··· 131 133 media-post 132 134 ${filterInfo ? 'filtered' : ''} 133 135 ${hasSpoiler ? 'has-spoiler' : ''} 136 + ${showSpoilerMedia ? 'show-media' : ''} 134 137 `} 135 138 > 136 139 <Media
+8 -7
src/components/media.jsx
··· 96 96 97 97 const videoRef = useRef(); 98 98 99 - let focalBackgroundPosition; 99 + let focalPosition; 100 100 if (focus) { 101 101 // Convert focal point to CSS background position 102 102 // Formula from jquery-focuspoint ··· 105 105 // x = 1, y = -1 => 100% 100% 106 106 const x = ((focus.x + 1) / 2) * 100; 107 107 const y = ((1 - focus.y) / 2) * 100; 108 - focalBackgroundPosition = `${x.toFixed(0)}% ${y.toFixed(0)}%`; 108 + focalPosition = `${x.toFixed(0)}% ${y.toFixed(0)}%`; 109 109 } 110 110 111 111 const mediaRef = useRef(); ··· 290 290 data-orientation={orientation} 291 291 loading="lazy" 292 292 style={{ 293 - backgroundColor: 294 - rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`, 295 - backgroundPosition: focalBackgroundPosition || 'center', 293 + // backgroundColor: 294 + // rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`, 295 + // backgroundPosition: focalBackgroundPosition || 'center', 296 296 // Duration based on width or height in pixels 297 + objectPosition: focalPosition || 'center', 297 298 // 100px per second (rough estimate) 298 299 // Clamp between 5s and 120s 299 300 '--anim-duration': `${Math.min( ··· 302 303 )}s`, 303 304 }} 304 305 onLoad={(e) => { 305 - e.target.closest('.media-image').style.backgroundImage = ''; 306 + // e.target.closest('.media-image').style.backgroundImage = ''; 306 307 e.target.dataset.loaded = true; 307 308 }} 308 309 onError={(e) => { ··· 357 358 <Parent 358 359 class={`media ${className} media-${isGIF ? 'gif' : 'video'} ${ 359 360 autoGIFAnimate ? 'media-contain' : '' 360 - }`} 361 + } ${hoverAnimate ? 'media-hover-animate' : ''}`} 361 362 data-orientation={orientation} 362 363 data-formatted-duration={ 363 364 !showOriginal ? formattedDuration : undefined
+1 -1
src/components/nav-menu.jsx
··· 202 202 <Icon icon="search" size="l" /> <span>Search</span> 203 203 </MenuLink> 204 204 <MenuLink to={`/${instance}/p/l`}> 205 - <Icon icon="group" size="l" /> <span>Local</span> 205 + <Icon icon="building" size="l" /> <span>Local</span> 206 206 </MenuLink> 207 207 <MenuLink to={`/${instance}/p`}> 208 208 <Icon icon="earth" size="l" /> <span>Federated</span>
+13 -31
src/components/relative-time.jsx
··· 8 8 import dayjsTwitter from 'dayjs-twitter'; 9 9 import localizedFormat from 'dayjs/plugin/localizedFormat'; 10 10 import relativeTime from 'dayjs/plugin/relativeTime'; 11 - import { useEffect, useState } from 'preact/hooks'; 12 11 13 12 dayjs.extend(dayjsTwitter); 14 13 dayjs.extend(localizedFormat); ··· 19 18 export default function RelativeTime({ datetime, format }) { 20 19 if (!datetime) return null; 21 20 const date = dayjs(datetime); 22 - const [dateStr, setDateStr] = useState(''); 23 - 24 - useEffect(() => { 25 - let timer, raf; 26 - const update = () => { 27 - raf = requestAnimationFrame(() => { 28 - let str; 29 - if (format === 'micro') { 30 - // If date <= 1 day ago or day is within this year 31 - const now = dayjs(); 32 - const dayDiff = now.diff(date, 'day'); 33 - if (dayDiff <= 1 || now.year() === date.year()) { 34 - str = date.twitter(); 35 - } else { 36 - str = dtf.format(date.toDate()); 37 - } 38 - } else { 39 - str = date.fromNow(); 40 - } 41 - setDateStr(str); 42 - 43 - timer = setTimeout(update, 30_000); 44 - }); 45 - }; 46 - raf = requestAnimationFrame(update); 47 - return () => { 48 - clearTimeout(timer); 49 - cancelAnimationFrame(raf); 50 - }; 51 - }, [date]); 21 + let dateStr; 22 + if (format === 'micro') { 23 + // If date <= 1 day ago or day is within this year 24 + const now = dayjs(); 25 + const dayDiff = now.diff(date, 'day'); 26 + if (dayDiff <= 1 || now.year() === date.year()) { 27 + dateStr = date.twitter(); 28 + } else { 29 + dateStr = dtf.format(date.toDate()); 30 + } 31 + } else { 32 + dateStr = date.fromNow(); 33 + } 52 34 53 35 return ( 54 36 <time datetime={date.toISOString()} title={date.format('LLLL')}>
+1 -1
src/components/shortcuts-settings.jsx
··· 159 159 title: ({ local }) => (local ? 'Local' : 'Federated'), 160 160 subtitle: ({ instance }) => instance || api().instance, 161 161 path: ({ local, instance }) => `/${instance}/p${local ? '/l' : ''}`, 162 - icon: ({ local }) => (local ? 'group' : 'earth'), 162 + icon: ({ local }) => (local ? 'building' : 'earth'), 163 163 }, 164 164 trending: { 165 165 id: 'trending',
+154 -59
src/components/status.css
··· 499 499 padding-bottom: 10px; 500 500 } 501 501 502 - .status .content-container.has-spoiler .spoiler { 502 + .status 503 + .content-container.has-spoiler 504 + :is(.spoiler-button, .spoiler-media-button):not([hidden]) { 503 505 margin: 4px 0; 504 506 font-size: 90%; 505 507 border: 1px dashed var(--button-bg-color); 506 508 display: flex; 507 - gap: 8px; 509 + gap: 4px; 508 510 align-items: center; 509 511 } 510 - .status 511 - .content-container.has-spoiler:not(.show-spoiler) 512 - .spoiler 513 - ~ *:not(.media-container, .card, .media-figure-multiple), 514 - .status 515 - .content-container.has-spoiler:not(.show-spoiler) 516 - .spoiler 517 - ~ .card 518 - .meta-container, 519 - .status 520 - .content-container.has-spoiler:not(.show-spoiler) 521 - .spoiler 522 - ~ :is(.media-container, .media-figure-multiple) 523 - figcaption { 524 - filter: blur(5px) invert(0.5); 525 - image-rendering: crisp-edges; 526 - image-rendering: pixelated; 527 - pointer-events: none; 528 - user-select: none; 529 - contain: layout; 530 - transform: scale(0.97); 531 - transition: transform 0.1s ease-in-out; 512 + .status .content-container.has-spoiler:not(.show-spoiler) .spoiler-button { 513 + ~ *:not( 514 + .content.truncated, 515 + .media-container, 516 + .card, 517 + .media-figure-multiple, 518 + .spoiler-media-button 519 + ), 520 + ~ .card .meta-container { 521 + /* filter: blur(5px) invert(0.5); 522 + image-rendering: crisp-edges; 523 + image-rendering: pixelated; */ 524 + opacity: 0.2; 525 + text-decoration-thickness: 1.5em; 526 + text-decoration-line: line-through; 527 + text-rendering: optimizeSpeed; 528 + pointer-events: none; 529 + user-select: none; 530 + /* contain: layout; */ 531 + /* transform: scale(0.97); 532 + transition: transform 0.1s ease-in-out; */ 533 + 534 + * { 535 + text-decoration-color: inherit; 536 + text-decoration-thickness: 1.5em; 537 + text-decoration-line: line-through; 538 + text-rendering: optimizeSpeed; 539 + } 540 + 541 + img { 542 + filter: invert(0.5); 543 + background-color: black; 544 + } 545 + } 546 + 547 + ~ .content.truncated { 548 + opacity: 1; 549 + 550 + > * { 551 + opacity: 0.2; 552 + text-decoration-thickness: 1.5em; 553 + text-decoration-line: line-through; 554 + text-rendering: optimizeSpeed; 555 + pointer-events: none; 556 + user-select: none; 557 + 558 + * { 559 + text-decoration-color: inherit; 560 + text-decoration-thickness: 1.5em; 561 + text-decoration-line: line-through; 562 + text-rendering: optimizeSpeed; 563 + } 564 + } 565 + } 566 + 567 + /* ~ :is(.media-container, .media-figure-multiple) .media > *, */ 568 + ~ .card .card-image > img { 569 + display: none; 570 + /* filter: blur(32px); 571 + opacity: 0; 572 + image-rendering: crisp-edges; 573 + image-rendering: pixelated; 574 + animation: none !important; */ 575 + } 576 + } 577 + .status .content-container.has-spoiler:not(.show-media) .spoiler-media-button { 578 + ~ :is(.media-container, .media-figure-multiple) figcaption { 579 + /* filter: blur(5px) invert(0.5); 580 + image-rendering: crisp-edges; 581 + image-rendering: pixelated; */ 582 + opacity: 0.2; 583 + color: inherit; 584 + text-decoration-thickness: 1.5em; 585 + text-decoration-line: line-through; 586 + text-rendering: optimizeSpeed; 587 + pointer-events: none; 588 + user-select: none; 589 + /* contain: layout; */ 590 + /* transform: scale(0.97); 591 + transition: transform 0.1s ease-in-out; */ 592 + 593 + * { 594 + text-decoration-thickness: 1.5em; 595 + text-decoration-line: line-through; 596 + text-rendering: optimizeSpeed; 597 + } 598 + 599 + img { 600 + opacity: 0; 601 + } 602 + } 603 + 604 + ~ :is(.media-container, .media-figure-multiple) .media { 605 + background-image: radial-gradient( 606 + circle at 50% 50%, 607 + var(--average-color, var(--bg-faded-color)), 608 + var(--bg-color) 20em 609 + ); 610 + 611 + > *:not(.media-play, .alt-badge) { 612 + /* display: none; */ 613 + /* filter: blur(32px); */ 614 + opacity: 0; 615 + image-rendering: crisp-edges; 616 + image-rendering: pixelated; 617 + animation: none !important; 618 + } 619 + } 532 620 } 533 621 .status 534 - .content-container.has-spoiler:not(.show-spoiler) 535 - .spoiler 536 - ~ :is(.media-container, .media-figure-multiple) 537 - .media 538 - > *, 539 - .status 540 - .content-container.has-spoiler:not(.show-spoiler) 541 - .spoiler 542 - ~ .card 543 - > img { 544 - filter: blur(32px); 545 - image-rendering: crisp-edges; 546 - image-rendering: pixelated; 547 - animation: none !important; 548 - } 549 - .status .content-container.show-spoiler .spoiler { 622 + .content-container.show-spoiler 623 + :is(.spoiler-button, .spoiler-media-button).spoiling { 550 624 border-style: dotted; 551 625 } 552 - /* .status 553 - .content-container.show-spoiler 554 - .spoiler 555 - ~ *:not(.media-container, .card), 556 - .status .content-container.show-spoiler .spoiler ~ .card .meta-container { 557 - filter: none !important; 558 - transform: none; 559 - pointer-events: auto; 560 - user-select: auto; 561 - text-rendering: auto; 562 - image-rendering: auto; 626 + 627 + .status .content-container .spoiler-divider { 628 + display: flex; 629 + align-items: center; 630 + gap: 4px; 631 + color: var(--text-insignificant-color); 632 + text-transform: uppercase; 633 + font-size: 0.8em; 634 + margin-top: 0.25em; 635 + margin-bottom: 1em; 636 + padding-block: 0.25em; 637 + border-bottom: 1px dashed var(--divider-color); 563 638 } 564 - .status .content-container.show-spoiler .spoiler ~ .media-container .media > *, 565 - .status .content-container.show-spoiler .spoiler ~ .card > img { 566 - filter: none; 567 - image-rendering: auto; 568 - } */ 569 - /* .status .content a:not(.mention):not(:has(span)) { 570 - color: inherit; 571 - } */ 572 639 573 640 .status .content-comment-hint { 574 641 margin-top: 0.25em; ··· 700 767 .status .content ul { 701 768 list-style-type: disc; 702 769 } 770 + .status .content :is(strong, b) { 771 + font-weight: 600; 772 + } 703 773 .status .content .invisible { 704 774 display: none; 705 775 } ··· 751 821 752 822 &:not(.media-figure-multiple .media-container) { 753 823 margin-bottom: -16px; 824 + 825 + &.media-eq1:not(:has(figure)) { 826 + text-align: center; 827 + background-color: var(--img-bg-color); 828 + } 754 829 } 755 830 756 831 .media { ··· 806 881 min-height: 80px; 807 882 border: var(--media-border-width) solid var(--outline-color); 808 883 vertical-align: top; 884 + 885 + &:not(.media-audio) { 886 + background-color: var(--average-color, var(--bg-faded-color)); 887 + background-clip: padding-box; 888 + } 809 889 } 810 890 .status .media-container:not(.media-eq1) .media { 811 891 aspect-ratio: auto !important; ··· 860 940 } 861 941 .status.large .media-container.media-eq1 { 862 942 max-height: min(var(--height), 60vh); 943 + 944 + .media-gif.media-contain { 945 + border-radius: 2px; 946 + } 863 947 } 864 948 .status.large 865 949 .media-container:not(.status-card .media-container).media-eq1 ··· 1079 1163 .status .media-contain video { 1080 1164 object-fit: scale-down !important; 1081 1165 } 1166 + .status .media-eq1 .media-hover-animate { 1167 + transition: border-radius 0.15s ease-out; 1168 + transition-delay: 0.15s; 1169 + 1170 + &:hover { 1171 + transition-delay: 0; 1172 + border-radius: 2px; 1173 + } 1174 + } 1082 1175 /* .status .media-audio { 1083 1176 border: 0; 1084 1177 min-height: 0; ··· 1272 1365 width: 35%; 1273 1366 position: relative; 1274 1367 border-inline-end: 1px solid var(--outline-color); 1368 + background-color: var(--average-color, var(--bg-faded-color)); 1369 + background-clip: padding-box; 1275 1370 } 1276 1371 .card .card-image img { 1277 1372 position: absolute;
+118 -197
src/components/status.jsx
··· 8 8 MenuHeader, 9 9 MenuItem, 10 10 } from '@szhsin/react-menu'; 11 - import { decodeBlurHash } from 'fast-blurhash'; 12 - import pThrottle from 'p-throttle'; 11 + import { decodeBlurHash, getBlurHashAverageColor } from 'fast-blurhash'; 13 12 import { memo } from 'preact/compat'; 14 13 import { 15 14 useCallback, ··· 20 19 useState, 21 20 } from 'preact/hooks'; 22 21 import { useHotkeys } from 'react-hotkeys-hook'; 23 - import { InView } from 'react-intersection-observer'; 24 22 import { useLongPress } from 'use-long-press'; 25 23 import { useSnapshot } from 'valtio'; 26 - import { snapshot } from 'valtio/vanilla'; 27 24 28 25 import AccountBlock from '../components/account-block'; 29 26 import EmojiText from '../components/emoji-text'; ··· 50 47 import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding'; 51 48 import shortenNumber from '../utils/shorten-number'; 52 49 import showToast from '../utils/show-toast'; 50 + import { speak, supportsTTS } from '../utils/speech'; 53 51 import states, { getStatus, saveStatus, statusKey } from '../utils/states'; 54 52 import statusPeek from '../utils/status-peek'; 55 53 import store from '../utils/store'; 54 + import unfurlMastodonLink from '../utils/unfurl-link'; 56 55 import useTruncated from '../utils/useTruncated'; 57 56 import visibilityIconsMap from '../utils/visibility-icons-map'; 58 57 ··· 63 62 import { isMediaCaptionLong } from './media'; 64 63 import MenuLink from './menu-link'; 65 64 import RelativeTime from './relative-time'; 66 - import { speak, supportsTTS } from '../utils/speech'; 67 65 import TranslationBlock from './translation-block'; 68 66 69 67 const SHOW_COMMENT_COUNT_LIMIT = 280; 70 68 const INLINE_TRANSLATE_LIMIT = 140; 71 - const throttle = pThrottle({ 72 - limit: 1, 73 - interval: 1000, 74 - }); 75 69 76 70 function fetchAccount(id, masto) { 77 71 return masto.v1.accounts.$select(id).fetch(); ··· 149 143 const { instance: currentInstance } = api(); 150 144 const sameInstance = instance === currentInstance; 151 145 152 - let sKey = statusKey(statusID, instance); 146 + let sKey = statusKey(statusID || status?.id, instance); 153 147 const snapStates = useSnapshot(states); 154 148 if (!status) { 155 149 status = snapStates.statuses[sKey] || snapStates.statuses[statusID]; ··· 275 269 const prefs = store.account.get('preferences') || {}; 276 270 return !!prefs['reading:expand:spoilers']; 277 271 }, []); 272 + const readingExpandMedia = useMemo(() => { 273 + // default | show_all | hide_all 274 + // Ignore hide_all because it means hide *ALL* media including non-sensitive ones 275 + const prefs = store.account.get('preferences') || {}; 276 + return prefs['reading:expand:media'] || 'default'; 277 + }, []); 278 + // FOR TESTING: 279 + // const readingExpandSpoilers = true; 280 + // const readingExpandMedia = 'show_all'; 278 281 const showSpoiler = 279 - previewMode || readingExpandSpoilers || !!snapStates.spoilers[id] || false; 282 + previewMode || readingExpandSpoilers || !!snapStates.spoilers[id]; 283 + const showSpoilerMedia = 284 + previewMode || 285 + readingExpandMedia === 'show_all' || 286 + !!snapStates.spoilersMedia[id]; 280 287 281 288 if (reblog) { 282 289 // If has statusID, means useItemID (cached in states) ··· 1012 1019 }, 1013 1020 ); 1014 1021 1015 - const hotkeysEnabled = !readOnly && !previewMode; 1022 + const hotkeysEnabled = !readOnly && !previewMode && !quoted; 1016 1023 const rRef = useHotkeys('r, shift+r', replyStatus, { 1017 1024 enabled: hotkeysEnabled, 1018 1025 }); ··· 1078 1085 ); 1079 1086 if (activeStatus) { 1080 1087 const spoilerButton = activeStatus.querySelector( 1081 - 'button.spoiler:not(.spoiling)', 1088 + '.spoiler-button:not(.spoiling)', 1082 1089 ); 1083 1090 if (spoilerButton) { 1084 1091 e.stopPropagation(); 1085 1092 spoilerButton.click(); 1093 + } else { 1094 + const spoilerMediaButton = activeStatus.querySelector( 1095 + '.spoiler-media-button:not(.spoiling)', 1096 + ); 1097 + if (spoilerMediaButton) { 1098 + e.stopPropagation(); 1099 + spoilerMediaButton.click(); 1100 + } 1086 1101 } 1087 1102 } 1088 1103 }); ··· 1487 1502 <div 1488 1503 class={`content-container ${ 1489 1504 spoilerText || sensitive ? 'has-spoiler' : '' 1490 - } ${showSpoiler ? 'show-spoiler' : ''}`} 1505 + } ${showSpoiler ? 'show-spoiler' : ''} ${ 1506 + showSpoilerMedia ? 'show-media' : '' 1507 + }`} 1491 1508 data-content-text-weight={contentTextWeight ? textWeight() : null} 1492 1509 style={ 1493 1510 (isSizeLarge || contentTextWeight) && { ··· 1508 1525 <EmojiText text={spoilerText} emojis={emojis} /> 1509 1526 </p> 1510 1527 </div> 1511 - <button 1512 - class={`light spoiler ${showSpoiler ? 'spoiling' : ''}`} 1513 - type="button" 1514 - disabled={readingExpandSpoilers} 1515 - onClick={(e) => { 1516 - e.preventDefault(); 1517 - e.stopPropagation(); 1518 - if (showSpoiler) { 1519 - delete states.spoilers[id]; 1520 - } else { 1521 - states.spoilers[id] = true; 1522 - } 1523 - }} 1524 - > 1525 - <Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '} 1526 - {readingExpandSpoilers 1527 - ? 'Content warning' 1528 - : showSpoiler 1529 - ? 'Show less' 1530 - : 'Show more'} 1531 - </button> 1528 + {readingExpandSpoilers || previewMode ? ( 1529 + <div class="spoiler-divider"> 1530 + <Icon icon="eye-open" /> Content warning 1531 + </div> 1532 + ) : ( 1533 + <button 1534 + class={`light spoiler-button ${ 1535 + showSpoiler ? 'spoiling' : '' 1536 + }`} 1537 + type="button" 1538 + onClick={(e) => { 1539 + e.preventDefault(); 1540 + e.stopPropagation(); 1541 + if (showSpoiler) { 1542 + delete states.spoilers[id]; 1543 + if (!readingExpandSpoilers) { 1544 + delete states.spoilersMedia[id]; 1545 + } 1546 + } else { 1547 + states.spoilers[id] = true; 1548 + if (!readingExpandSpoilers) { 1549 + states.spoilersMedia[id] = true; 1550 + } 1551 + } 1552 + }} 1553 + > 1554 + <Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '} 1555 + {showSpoiler ? 'Show less' : 'Show content'} 1556 + </button> 1557 + )} 1532 1558 </> 1533 1559 )} 1534 1560 {!!content && ( ··· 1555 1581 a.removeAttribute('target'); 1556 1582 } 1557 1583 }); 1558 - if (previewMode) return; 1584 + // if (previewMode) return; 1559 1585 // Unfurl Mastodon links 1560 - Array.from( 1561 - dom.querySelectorAll( 1562 - 'a[href]:not(.u-url):not(.mention):not(.hashtag)', 1563 - ), 1564 - ) 1565 - .filter((a) => { 1566 - const url = a.href; 1567 - const isPostItself = 1568 - url === status.url || url === status.uri; 1569 - return !isPostItself && isMastodonLinkMaybe(url); 1570 - }) 1571 - .forEach((a, i) => { 1572 - unfurlMastodonLink(currentInstance, a.href).then( 1573 - (result) => { 1574 - if (!result) return; 1575 - a.removeAttribute('target'); 1576 - if (!sKey) return; 1577 - if (!Array.isArray(states.statusQuotes[sKey])) { 1578 - states.statusQuotes[sKey] = []; 1579 - } 1580 - if (!states.statusQuotes[sKey][i]) { 1581 - states.statusQuotes[sKey].splice(i, 0, result); 1582 - } 1583 - }, 1584 - ); 1585 - }); 1586 + // Array.from( 1587 + // dom.querySelectorAll( 1588 + // 'a[href]:not(.u-url):not(.mention):not(.hashtag)', 1589 + // ), 1590 + // ) 1591 + // .filter((a) => { 1592 + // const url = a.href; 1593 + // const isPostItself = 1594 + // url === status.url || url === status.uri; 1595 + // return !isPostItself && isMastodonLinkMaybe(url); 1596 + // }) 1597 + // .forEach((a, i) => { 1598 + // unfurlMastodonLink(currentInstance, a.href).then( 1599 + // (result) => { 1600 + // if (!result) return; 1601 + // a.removeAttribute('target'); 1602 + // if (!sKey) return; 1603 + // if (!Array.isArray(states.statusQuotes[sKey])) { 1604 + // states.statusQuotes[sKey] = []; 1605 + // } 1606 + // if (!states.statusQuotes[sKey][i]) { 1607 + // states.statusQuotes[sKey].splice(i, 0, result); 1608 + // } 1609 + // }, 1610 + // ); 1611 + // }); 1586 1612 }, 1587 1613 }), 1588 1614 }} ··· 1632 1658 text={getPostText(status)} 1633 1659 /> 1634 1660 )} 1635 - {!spoilerText && sensitive && !!mediaAttachments.length && ( 1636 - <button 1637 - class={`plain spoiler ${showSpoiler ? 'spoiling' : ''}`} 1638 - type="button" 1639 - onClick={(e) => { 1640 - e.preventDefault(); 1641 - e.stopPropagation(); 1642 - if (showSpoiler) { 1643 - delete states.spoilers[id]; 1644 - } else { 1645 - states.spoilers[id] = true; 1646 - } 1647 - }} 1648 - > 1649 - <Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} /> Sensitive 1650 - content 1651 - </button> 1652 - )} 1661 + {!previewMode && 1662 + sensitive && 1663 + !!mediaAttachments.length && 1664 + readingExpandMedia !== 'show_all' && ( 1665 + <button 1666 + class={`plain spoiler-media-button ${ 1667 + showSpoilerMedia ? 'spoiling' : '' 1668 + }`} 1669 + type="button" 1670 + hidden={!readingExpandSpoilers && !!spoilerText} 1671 + onClick={(e) => { 1672 + e.preventDefault(); 1673 + e.stopPropagation(); 1674 + if (showSpoilerMedia) { 1675 + delete states.spoilersMedia[id]; 1676 + } else { 1677 + states.spoilersMedia[id] = true; 1678 + } 1679 + }} 1680 + > 1681 + <Icon icon={showSpoilerMedia ? 'eye-open' : 'eye-close'} />{' '} 1682 + {showSpoilerMedia ? 'Show less' : 'Show media'} 1683 + </button> 1684 + )} 1653 1685 {!!mediaAttachments.length && ( 1654 1686 <MultipleMediaFigure 1655 1687 lang={language} ··· 1956 1988 .replace(/^www\./, '') 1957 1989 .replace(/\/$/, ''); 1958 1990 let blurhashImage; 1991 + const rgbAverageColor = 1992 + image && blurhash ? getBlurHashAverageColor(blurhash) : null; 1959 1993 if (!image) { 1960 1994 const w = 44; 1961 1995 const h = 44; ··· 1977 2011 class={`card link ${blurhashImage ? '' : size}`} 1978 2012 lang={language} 1979 2013 dir="auto" 2014 + style={{ 2015 + '--average-color': 2016 + rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`, 2017 + }} 1980 2018 > 1981 2019 <div class="card-image"> 1982 2020 <img ··· 2213 2251 } 2214 2252 } 2215 2253 2216 - const denylistDomains = /(twitter|github)\.com/i; 2217 - const failedUnfurls = {}; 2218 - 2219 - function _unfurlMastodonLink(instance, url) { 2220 - const snapStates = snapshot(states); 2221 - if (denylistDomains.test(url)) { 2222 - return; 2223 - } 2224 - if (failedUnfurls[url]) { 2225 - return; 2226 - } 2227 - const instanceRegex = new RegExp(instance + '/'); 2228 - if (instanceRegex.test(snapStates.unfurledLinks[url]?.url)) { 2229 - return Promise.resolve(snapStates.unfurledLinks[url]); 2230 - } 2231 - console.debug('🦦 Unfurling URL', url); 2232 - 2233 - let remoteInstanceFetch; 2234 - let theURL = url; 2235 - 2236 - // https://elk.zone/domain.com/@stest/123 -> https://domain.com/@stest/123 2237 - if (/\/\/elk\.[^\/]+\/[^\/]+\.[^\/]+/i.test(theURL)) { 2238 - theURL = theURL.replace(/elk\.[^\/]+\//i, ''); 2239 - } 2240 - 2241 - // https://trunks.social/status/domain.com/@stest/123 -> https://domain.com/@stest/123 2242 - if (/\/\/trunks\.[^\/]+\/status\/[^\/]+\.[^\/]+/i.test(theURL)) { 2243 - theURL = theURL.replace(/trunks\.[^\/]+\/status\//i, ''); 2244 - } 2245 - 2246 - // https://phanpy.social/#/domain.com/s/123 -> https://domain.com/statuses/123 2247 - if (/\/#\/[^\/]+\.[^\/]+\/s\/.+/i.test(theURL)) { 2248 - const urlAfterHash = theURL.split('/#/')[1]; 2249 - const finalURL = urlAfterHash.replace(/\/s\//i, '/@fakeUsername/'); 2250 - theURL = `https://${finalURL}`; 2251 - } 2252 - 2253 - let urlObj; 2254 - try { 2255 - urlObj = new URL(theURL); 2256 - } catch (e) { 2257 - return; 2258 - } 2259 - const domain = urlObj.hostname; 2260 - const path = urlObj.pathname; 2261 - // Regex /:username/:id, where username = @username or @username@domain, id = number 2262 - const statusRegex = /\/@([^@\/]+)@?([^\/]+)?\/(\d+)$/i; 2263 - const statusMatch = statusRegex.exec(path); 2264 - if (statusMatch) { 2265 - const id = statusMatch[3]; 2266 - const { masto } = api({ instance: domain }); 2267 - remoteInstanceFetch = masto.v1.statuses 2268 - .$select(id) 2269 - .fetch() 2270 - .then((status) => { 2271 - if (status?.id) { 2272 - return { 2273 - status, 2274 - instance: domain, 2275 - }; 2276 - } else { 2277 - throw new Error('No results'); 2278 - } 2279 - }); 2280 - } 2281 - 2282 - const { masto } = api({ instance }); 2283 - const mastoSearchFetch = masto.v2.search 2284 - .fetch({ 2285 - q: theURL, 2286 - type: 'statuses', 2287 - resolve: true, 2288 - limit: 1, 2289 - }) 2290 - .then((results) => { 2291 - if (results.statuses.length > 0) { 2292 - const status = results.statuses[0]; 2293 - return { 2294 - status, 2295 - instance, 2296 - }; 2297 - } else { 2298 - throw new Error('No results'); 2299 - } 2300 - }); 2301 - 2302 - function handleFulfill(result) { 2303 - const { status, instance } = result; 2304 - const { id } = status; 2305 - const selfURL = `/${instance}/s/${id}`; 2306 - console.debug('🦦 Unfurled URL', url, id, selfURL); 2307 - const data = { 2308 - id, 2309 - instance, 2310 - url: selfURL, 2311 - }; 2312 - states.unfurledLinks[url] = data; 2313 - saveStatus(status, instance, { 2314 - skipThreading: true, 2315 - }); 2316 - return data; 2317 - } 2318 - function handleCatch(e) { 2319 - failedUnfurls[url] = true; 2320 - } 2321 - 2322 - if (remoteInstanceFetch) { 2323 - return Promise.any([remoteInstanceFetch, mastoSearchFetch]) 2324 - .then(handleFulfill) 2325 - .catch(handleCatch); 2326 - } else { 2327 - return mastoSearchFetch.then(handleFulfill).catch(handleCatch); 2328 - } 2329 - } 2330 - 2331 2254 function nicePostURL(url) { 2332 2255 if (!url) return; 2333 2256 const urlObj = new URL(url); ··· 2350 2273 </> 2351 2274 ); 2352 2275 } 2353 - 2354 - const unfurlMastodonLink = throttle(_unfurlMastodonLink); 2355 2276 2356 2277 function FilteredStatus({ 2357 2278 status,
+81 -33
src/components/timeline.jsx
··· 12 12 import useInterval from '../utils/useInterval'; 13 13 import usePageVisibility from '../utils/usePageVisibility'; 14 14 import useScroll from '../utils/useScroll'; 15 + import useScrollFn from '../utils/useScrollFn'; 15 16 16 17 import Icon from './icon'; 17 18 import Link from './link'; ··· 203 204 } 204 205 }); 205 206 206 - const { 207 - scrollDirection, 208 - nearReachStart, 209 - nearReachEnd, 210 - reachStart, 211 - reachEnd, 212 - } = useScroll({ 213 - scrollableRef, 214 - distanceFromEnd: 2, 215 - scrollThresholdStart: 44, 216 - }); 207 + // const { 208 + // scrollDirection, 209 + // nearReachStart, 210 + // nearReachEnd, 211 + // reachStart, 212 + // reachEnd, 213 + // } = useScroll({ 214 + // scrollableRef, 215 + // distanceFromEnd: 2, 216 + // scrollThresholdStart: 44, 217 + // }); 218 + const headerRef = useRef(); 219 + // const [hiddenUI, setHiddenUI] = useState(false); 220 + const [nearReachStart, setNearReachStart] = useState(false); 221 + useScrollFn( 222 + { 223 + scrollableRef, 224 + distanceFromEnd: 2, 225 + scrollThresholdStart: 44, 226 + }, 227 + ({ 228 + scrollDirection, 229 + nearReachStart, 230 + nearReachEnd, 231 + reachStart, 232 + reachEnd, 233 + }) => { 234 + // setHiddenUI(scrollDirection === 'end' && !nearReachEnd); 235 + if (headerRef.current) { 236 + const hiddenUI = scrollDirection === 'end' && !nearReachStart; 237 + headerRef.current.hidden = hiddenUI; 238 + } 239 + setNearReachStart(nearReachStart); 240 + if (reachStart) { 241 + loadItems(true); 242 + } else if (nearReachEnd || (reachEnd && showMore)) { 243 + loadItems(); 244 + } 245 + }, 246 + [], 247 + ); 217 248 218 249 useEffect(() => { 219 250 scrollableRef.current?.scrollTo({ top: 0 }); ··· 223 254 loadItems(true); 224 255 }, [refresh]); 225 256 226 - useEffect(() => { 227 - if (reachStart) { 228 - loadItems(true); 229 - } 230 - }, [reachStart]); 257 + // useEffect(() => { 258 + // if (reachStart) { 259 + // loadItems(true); 260 + // } 261 + // }, [reachStart]); 231 262 232 - useEffect(() => { 233 - if (nearReachEnd || (reachEnd && showMore)) { 234 - loadItems(); 235 - } 236 - }, [nearReachEnd, showMore]); 263 + // useEffect(() => { 264 + // if (nearReachEnd || (reachEnd && showMore)) { 265 + // loadItems(); 266 + // } 267 + // }, [nearReachEnd, showMore]); 237 268 238 269 const prevView = useRef(view); 239 270 useEffect(() => { ··· 304 335 : null, 305 336 ); 306 337 307 - const hiddenUI = scrollDirection === 'end' && !nearReachStart; 338 + // const hiddenUI = scrollDirection === 'end' && !nearReachStart; 308 339 309 340 return ( 310 341 <FilterContext.Provider value={filterContext}> ··· 321 352 > 322 353 <div class="timeline-deck deck"> 323 354 <header 324 - hidden={hiddenUI} 355 + ref={headerRef} 356 + // hidden={hiddenUI} 325 357 onClick={(e) => { 326 358 if (!e.target.closest('a, button')) { 327 359 scrollableRef.current?.scrollTo({ ··· 356 388 </div> 357 389 {items.length > 0 && 358 390 uiState !== 'loading' && 359 - !hiddenUI && 391 + // !hiddenUI && 360 392 showNew && ( 361 393 <button 362 394 class="updates-button shiny-pill" ··· 657 689 658 690 function StatusCarousel({ title, class: className, children }) { 659 691 const carouselRef = useRef(); 660 - const { reachStart, reachEnd, init } = useScroll({ 661 - scrollableRef: carouselRef, 662 - direction: 'horizontal', 663 - }); 664 - useEffect(() => { 665 - init?.(); 666 - }, []); 692 + // const { reachStart, reachEnd, init } = useScroll({ 693 + // scrollableRef: carouselRef, 694 + // direction: 'horizontal', 695 + // }); 696 + const startButtonRef = useRef(); 697 + const endButtonRef = useRef(); 698 + useScrollFn( 699 + { 700 + scrollableRef: carouselRef, 701 + direction: 'horizontal', 702 + init: true, 703 + }, 704 + ({ reachStart, reachEnd }) => { 705 + if (startButtonRef.current) startButtonRef.current.disabled = reachStart; 706 + if (endButtonRef.current) endButtonRef.current.disabled = reachEnd; 707 + }, 708 + [], 709 + ); 710 + // useEffect(() => { 711 + // init?.(); 712 + // }, []); 667 713 668 714 return ( 669 715 <div class={`status-carousel ${className}`}> ··· 671 717 <h3>{title}</h3> 672 718 <span> 673 719 <button 720 + ref={startButtonRef} 674 721 type="button" 675 722 class="small plain2" 676 - disabled={reachStart} 723 + // disabled={reachStart} 677 724 onClick={() => { 678 725 carouselRef.current?.scrollBy({ 679 726 left: -Math.min(320, carouselRef.current?.offsetWidth), ··· 684 731 <Icon icon="chevron-left" /> 685 732 </button>{' '} 686 733 <button 734 + ref={endButtonRef} 687 735 type="button" 688 736 class="small plain2" 689 - disabled={reachEnd} 737 + // disabled={reachEnd} 690 738 onClick={() => { 691 739 carouselRef.current?.scrollBy({ 692 740 left: Math.min(320, carouselRef.current?.offsetWidth),
+6 -9
src/components/translation-block.jsx
··· 12 12 import Icon from './icon'; 13 13 import Loader from './loader'; 14 14 15 + const { PHANPY_LINGVA_INSTANCES } = import.meta.env; 16 + const LINGVA_INSTANCES = PHANPY_LINGVA_INSTANCES 17 + ? PHANPY_LINGVA_INSTANCES.split(/\s+/) 18 + : []; 19 + 15 20 const throttle = pThrottle({ 16 21 limit: 1, 17 22 interval: 2000, 18 23 }); 19 24 20 - // Using other API instances instead of lingva.ml because of this bug (slashes don't work): 21 - // https://github.com/thedaviddelta/lingva-translate/issues/68 22 - const LINGVA_INSTANCES = [ 23 - 'lingva.phanpy.social', 24 - 'lingva.lunar.icu', 25 - 'lingva.garudalinux.org', 26 - 'translate.plausibility.cloud', 27 - ]; 28 25 let currentLingvaInstance = 0; 29 26 30 27 function _lingvaTranslate(text, source, target) { ··· 243 240 ); 244 241 } 245 242 246 - export default TranslationBlock; 243 + export default LINGVA_INSTANCES?.length ? TranslationBlock : () => null;
+7 -1
src/index.css
··· 83 83 --private-note-bg-color: color-mix(in srgb, yellow 20%, var(--bg-color)); 84 84 --private-note-border-color: rgba(0, 0, 0, 0.2); 85 85 86 - /* Video colors won't change based on color scheme */ 86 + /* Media colors won't change based on color scheme */ 87 87 --media-fg-color: #f0f2f5; 88 88 --media-bg-color: #242526; 89 89 --media-outline-color: color-mix(in lch, var(--media-fg-color), transparent); ··· 216 216 text-decoration: none; 217 217 user-select: none; 218 218 } 219 + button[hidden] { 220 + display: none; 221 + } 219 222 :is(button, .button) > * { 220 223 vertical-align: middle; 221 224 pointer-events: none; ··· 334 337 button.large { 335 338 font-size: 125%; 336 339 padding: 12px; 340 + } 341 + textarea:disabled { 342 + background-color: var(--bg-faded-color); 337 343 } 338 344 339 345 :is(input[type='text'], textarea, select).block {
+7 -7
src/pages/account-statuses.jsx
··· 377 377 } 378 378 : {}, 379 379 ); 380 + const [year, month] = value.split('-'); 381 + const monthIndex = parseInt(month, 10) - 1; 382 + const date = new Date(year, monthIndex); 380 383 showToast( 381 - `Showing posts in ${new Date(value).toLocaleString( 382 - 'default', 383 - { 384 - month: 'long', 385 - year: 'numeric', 386 - }, 387 - )}`, 384 + `Showing posts in ${date.toLocaleString('default', { 385 + month: 'long', 386 + year: 'numeric', 387 + })}`, 388 388 ); 389 389 }} 390 390 />
+16 -5
src/pages/login.jsx
··· 12 12 import store from '../utils/store'; 13 13 import useTitle from '../utils/useTitle'; 14 14 15 + const { PHANPY_DEFAULT_INSTANCE: DEFAULT_INSTANCE } = import.meta.env; 16 + 15 17 function Login() { 16 18 useTitle('Log in'); 17 19 const instanceURLRef = useRef(); ··· 19 21 const [uiState, setUIState] = useState('default'); 20 22 const [searchParams] = useSearchParams(); 21 23 const instance = searchParams.get('instance'); 24 + const submit = searchParams.get('submit'); 22 25 const [instanceText, setInstanceText] = useState( 23 26 instance || cachedInstanceURL?.toLowerCase() || '', 24 27 ); ··· 129 132 submitInstance(selectedInstanceText); 130 133 }; 131 134 135 + if (submit) { 136 + useEffect(() => { 137 + submitInstance(instance || selectedInstanceText); 138 + }, []); 139 + } 140 + 132 141 return ( 133 142 <main id="login" style={{ textAlign: 'center' }}> 134 143 <form onSubmit={onSubmit}> ··· 200 209 </div> 201 210 <Loader hidden={uiState !== 'loading'} /> 202 211 <hr /> 203 - <p> 204 - <a href="https://joinmastodon.org/servers" target="_blank"> 205 - Don't have an account? Create one! 206 - </a> 207 - </p> 212 + {!DEFAULT_INSTANCE && ( 213 + <p> 214 + <a href="https://joinmastodon.org/servers" target="_blank"> 215 + Don't have an account? Create one! 216 + </a> 217 + </p> 218 + )} 208 219 <p> 209 220 <Link to="/">Go home</Link> 210 221 </p>
+101 -14
src/pages/settings.jsx
··· 24 24 25 25 const DEFAULT_TEXT_SIZE = 16; 26 26 const TEXT_SIZES = [15, 16, 17, 18, 19, 20]; 27 + const { 28 + PHANPY_WEBSITE: WEBSITE, 29 + PHANPY_PRIVACY_POLICY_URL: PRIVACY_POLICY_URL, 30 + PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL, 31 + } = import.meta.env; 27 32 28 33 function Settings({ onClose }) { 29 34 const snapStates = useSnapshot(states); ··· 82 87 83 88 if (theme === 'auto') { 84 89 html.classList.remove('is-light', 'is-dark'); 90 + 91 + // Disable manual theme <meta> 92 + const $manualMeta = document.querySelector( 93 + 'meta[data-theme-setting="manual"]', 94 + ); 95 + if ($manualMeta) { 96 + $manualMeta.name = ''; 97 + } 98 + // Enable auto theme <meta>s 99 + const $autoMetas = document.querySelectorAll( 100 + 'meta[data-theme-setting="auto"]', 101 + ); 102 + $autoMetas.forEach((m) => { 103 + m.name = 'theme-color'; 104 + }); 85 105 } else { 86 106 html.classList.toggle('is-light', theme === 'light'); 87 107 html.classList.toggle('is-dark', theme === 'dark'); 108 + 109 + // Enable manual theme <meta> 110 + const $manualMeta = document.querySelector( 111 + 'meta[data-theme-setting="manual"]', 112 + ); 113 + if ($manualMeta) { 114 + $manualMeta.name = 'theme-color'; 115 + $manualMeta.content = 116 + theme === 'light' 117 + ? $manualMeta.dataset.themeLightColor 118 + : $manualMeta.dataset.themeDarkColor; 119 + } 120 + // Disable auto theme <meta>s 121 + const $autoMetas = document.querySelectorAll( 122 + 'meta[data-theme-setting="auto"]', 123 + ); 124 + $autoMetas.forEach((m) => { 125 + m.name = ''; 126 + }); 88 127 } 89 128 document 90 129 .querySelector('meta[name="color-scheme"]') ··· 350 389 </p> 351 390 <p class="insignificant"> 352 391 <small> 353 - Note: This feature uses an external API to translate, 392 + Note: This feature uses external translation services, 354 393 powered by{' '} 394 + <a 395 + href="https://github.com/cheeaun/lingva-api" 396 + target="_blank" 397 + rel="noopener noreferrer" 398 + > 399 + Lingva API 400 + </a>{' '} 401 + &amp;{' '} 355 402 <a 356 403 href="https://github.com/thedaviddelta/lingva-translate" 357 404 target="_blank" ··· 386 433 </div> 387 434 </div> 388 435 </li> 436 + {!!IMG_ALT_API_URL && ( 437 + <li> 438 + <label> 439 + <input 440 + type="checkbox" 441 + checked={snapStates.settings.mediaAltGenerator} 442 + onChange={(e) => { 443 + states.settings.mediaAltGenerator = e.target.checked; 444 + }} 445 + />{' '} 446 + Image description generator{' '} 447 + <Icon icon="sparkles2" class="more-insignificant" /> 448 + </label> 449 + <div class="sub-section insignificant"> 450 + <small>Only for new images while composing new posts.</small> 451 + </div> 452 + <div class="sub-section insignificant"> 453 + <small> 454 + Note: This feature uses external AI service, powered by{' '} 455 + <a 456 + href="https://github.com/cheeaun/img-alt-api" 457 + target="_blank" 458 + rel="noopener noreferrer" 459 + > 460 + img-alt-api 461 + </a> 462 + . May not work well. Only for images and in English. 463 + </small> 464 + </div> 465 + </li> 466 + )} 389 467 <li> 390 468 <label> 391 469 <input ··· 501 579 </a>{' '} 502 580 &middot;{' '} 503 581 <a 504 - href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD" 582 + href={PRIVACY_POLICY_URL} 505 583 target="_blank" 506 584 rel="noopener noreferrer" 507 585 > ··· 510 588 </p> 511 589 {__BUILD_TIME__ && ( 512 590 <p> 513 - Version:{' '} 591 + {WEBSITE && ( 592 + <> 593 + <span class="insignificant">Site:</span>{' '} 594 + {WEBSITE.replace(/https?:\/\//g, '').replace(/\/$/, '')} 595 + <br /> 596 + </> 597 + )} 598 + <span class="insignificant">Version:</span>{' '} 514 599 <input 515 600 type="text" 516 601 class="version-string" ··· 531 616 } 532 617 }} 533 618 />{' '} 534 - <span class="ib insignificant"> 535 - ( 536 - <a 537 - href={`https://github.com/cheeaun/phanpy/commit/${__COMMIT_HASH__}`} 538 - target="_blank" 539 - rel="noopener noreferrer" 540 - > 541 - <RelativeTime datetime={new Date(__BUILD_TIME__)} /> 542 - </a> 543 - ) 544 - </span> 619 + {!__FAKE_COMMIT_HASH__ && ( 620 + <span class="ib insignificant"> 621 + ( 622 + <a 623 + href={`https://github.com/cheeaun/phanpy/commit/${__COMMIT_HASH__}`} 624 + target="_blank" 625 + rel="noopener noreferrer" 626 + > 627 + <RelativeTime datetime={new Date(__BUILD_TIME__)} /> 628 + </a> 629 + ) 630 + </span> 631 + )} 545 632 </p> 546 633 )} 547 634 </section>
+16 -6
src/pages/status.jsx
··· 819 819 onMediaClick={handleMediaClick} 820 820 onStatusLinkClick={handleStatusLinkClick} 821 821 /> 822 - {ancestor && isThread && repliesCount > 1 && ( 822 + {ancestor && repliesCount > 1 && ( 823 823 <div class="replies-link"> 824 824 <Icon icon="comment2" />{' '} 825 825 <span title={repliesCount}> ··· 900 900 } 901 901 return STATUS_URL_REGEX.test(states.prevLocation?.pathname); 902 902 }, [sKey]); 903 + 904 + const moreStatusesKeys = useMemo(() => { 905 + if (!showMore) return []; 906 + const ids = []; 907 + function getIDs(status) { 908 + ids.push(status.id); 909 + if (status.replies) { 910 + status.replies.forEach(getIDs); 911 + } 912 + } 913 + statuses.slice(limit).forEach(getIDs); 914 + return ids.map((id) => statusKey(id, instance)); 915 + }, [showMore, statuses, limit, instance]); 903 916 904 917 return ( 905 918 <div ··· 1098 1111 // Click all buttons with class .spoiler but not .spoiling 1099 1112 const buttons = Array.from( 1100 1113 scrollableRef.current.querySelectorAll( 1101 - 'button.spoiler:not(.spoiling)', 1114 + '.spoiler-button:not(.spoiling), .spoiler-media-button:not(.spoiling)', 1102 1115 ), 1103 1116 ); 1104 1117 buttons.forEach((button) => { ··· 1156 1169 disabled={uiState === 'loading'} 1157 1170 onClick={() => setLimit((l) => l + LIMIT)} 1158 1171 style={{ marginBlockEnd: '6em' }} 1159 - data-state-post-ids={statuses 1160 - .slice(limit) 1161 - .map((s) => statusKey(s.id, instance)) 1162 - .join(' ')} 1172 + data-state-post-ids={moreStatusesKeys.join(' ')} 1163 1173 > 1164 1174 <div class="ib avatars-bunch"> 1165 1175 {/* show avatars for first 5 statuses */}
+12
src/pages/welcome.css
··· 75 75 margin-top: 0; 76 76 } 77 77 78 + .app-site-version { 79 + text-align: center; 80 + opacity: 0.5; 81 + color: var(--text-insignificant-color); 82 + font-family: var(--monospace-font), monospace; 83 + 84 + small { 85 + font-size: 11px; 86 + letter-spacing: -0.2px; 87 + } 88 + } 89 + 78 90 #why-container { 79 91 padding: 0 16px; 80 92 }
+46 -11
src/pages/welcome.jsx
··· 12 12 import states from '../utils/states'; 13 13 import useTitle from '../utils/useTitle'; 14 14 15 + const { 16 + PHANPY_DEFAULT_INSTANCE: DEFAULT_INSTANCE, 17 + PHANPY_WEBSITE: WEBSITE, 18 + PHANPY_PRIVACY_POLICY_URL: PRIVACY_POLICY_URL, 19 + PHANPY_DEFAULT_INSTANCE_REGISTRATION_URL: DEFAULT_INSTANCE_REGISTRATION_URL, 20 + } = import.meta.env; 21 + const appSite = WEBSITE 22 + ? WEBSITE.replace(/https?:\/\//g, '').replace(/\/$/, '') 23 + : null; 24 + const appVersion = __BUILD_TIME__ 25 + ? `${__BUILD_TIME__.slice(0, 10).replace(/-/g, '.')}${ 26 + __COMMIT_HASH__ ? `.${__COMMIT_HASH__}` : '' 27 + }` 28 + : null; 29 + 15 30 function Welcome() { 16 31 useTitle(null, ['/', '/welcome']); 17 32 return ( ··· 33 48 </h1> 34 49 <p class="desc">A minimalistic opinionated Mastodon web client.</p> 35 50 <p> 36 - <Link to="/login" class="button"> 37 - Log in with Mastodon 51 + <Link 52 + to={ 53 + DEFAULT_INSTANCE 54 + ? `/login?instance=${DEFAULT_INSTANCE}&submit=1` 55 + : '/login' 56 + } 57 + class="button" 58 + > 59 + {DEFAULT_INSTANCE ? 'Log in' : 'Log in with Mastodon'} 38 60 </Link> 39 61 </p> 40 - <p class="insignificant"> 62 + {DEFAULT_INSTANCE && DEFAULT_INSTANCE_REGISTRATION_URL && ( 63 + <p> 64 + <a href={DEFAULT_INSTANCE_REGISTRATION_URL} class="button plain5"> 65 + Sign up 66 + </a> 67 + </p> 68 + )} 69 + {!DEFAULT_INSTANCE && ( 70 + <p class="insignificant"> 71 + <small> 72 + Connect your existing Mastodon/Fediverse account. 73 + <br /> 74 + Your credentials are not stored on this server. 75 + </small> 76 + </p> 77 + )} 78 + </div> 79 + {(appSite || appVersion) && ( 80 + <p class="app-site-version"> 41 81 <small> 42 - Connect your existing Mastodon/Fediverse account. 43 - <br /> 44 - Your credentials are not stored on this server. 82 + {appSite} {appVersion} 45 83 </small> 46 84 </p> 47 - </div> 85 + )} 48 86 <p> 49 87 <a href="https://github.com/cheeaun/phanpy" target="_blank"> 50 88 Built ··· 61 99 @cheeaun 62 100 </a> 63 101 .{' '} 64 - <a 65 - href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD" 66 - target="_blank" 67 - > 102 + <a href={PRIVACY_POLICY_URL} target="_blank"> 68 103 Privacy Policy 69 104 </a> 70 105 .
+17
src/utils/api.js
··· 4 4 import { 5 5 getAccount, 6 6 getAccountByAccessToken, 7 + getAccountByInstance, 7 8 getCurrentAccount, 8 9 saveAccount, 9 10 } from './store-utils'; ··· 244 245 masto: currentAccountApi.masto, 245 246 streaming: currentAccountApi.streaming, 246 247 client: currentAccountApi, 248 + authenticated: true, 249 + instance, 250 + }; 251 + } 252 + 253 + const instanceAccount = getAccountByInstance(instance); 254 + if (instanceAccount) { 255 + const accessToken = instanceAccount.accessToken; 256 + const client = 257 + accountApis[instance]?.[accessToken] || 258 + initClient({ instance, accessToken }); 259 + const { masto, streaming } = client; 260 + return { 261 + masto, 262 + streaming, 263 + client, 247 264 authenticated: true, 248 265 instance, 249 266 };
+1 -1
src/utils/auth.js
··· 1 - const { VITE_CLIENT_NAME: CLIENT_NAME, VITE_WEBSITE: WEBSITE } = import.meta 1 + const { PHANPY_CLIENT_NAME: CLIENT_NAME, PHANPY_WEBSITE: WEBSITE } = import.meta 2 2 .env; 3 3 4 4 const SCOPES = 'read write follow push';
+63 -62
src/utils/enhance-content.js
··· 10 10 const dom = document.createElement('div'); 11 11 dom.innerHTML = enhancedContent; 12 12 const hasLink = /<a/i.test(enhancedContent); 13 - const hasCodeBlock = enhancedContent.indexOf('```') !== -1; 13 + const hasCodeBlock = enhancedContent.includes('```'); 14 14 15 15 if (hasLink) { 16 16 // Add target="_blank" to all links with no target="_blank" 17 17 // E.g. `note` in `account` 18 - const noTargetBlankLinks = Array.from( 19 - dom.querySelectorAll('a:not([target="_blank"])'), 20 - ); 18 + const noTargetBlankLinks = dom.querySelectorAll('a:not([target="_blank"])'); 21 19 noTargetBlankLinks.forEach((link) => { 22 20 link.setAttribute('target', '_blank'); 23 21 }); 24 22 25 23 // Remove all classes except `u-url`, `mention`, `hashtag` 26 - const links = Array.from(dom.querySelectorAll('a[class]')); 24 + const links = dom.querySelectorAll('a[class]'); 27 25 links.forEach((link) => { 28 - Array.from(link.classList).forEach((c) => { 26 + link.classList.forEach((c) => { 29 27 if (!whitelistLinkClasses.includes(c)) { 30 28 link.classList.remove(c); 31 29 } ··· 35 33 36 34 // Add 'has-url-text' to all links that contains a url 37 35 if (hasLink) { 38 - const links = Array.from(dom.querySelectorAll('a[href]')); 36 + const links = dom.querySelectorAll('a[href]'); 39 37 links.forEach((link) => { 40 38 if (/^https?:\/\//i.test(link.textContent.trim())) { 41 39 link.classList.add('has-url-text'); ··· 45 43 46 44 // Spanify un-spanned mentions 47 45 if (hasLink) { 48 - const links = Array.from(dom.querySelectorAll('a[href]')); 46 + const links = dom.querySelectorAll('a[href]'); 49 47 const usernames = []; 50 48 links.forEach((link) => { 51 49 const text = link.innerText.trim(); ··· 56 54 const [_, username, domain] = text.split('@'); 57 55 if (!hasChildren) { 58 56 if ( 59 - !usernames.find(([u]) => u === username) || 60 - usernames.find(([u, d]) => u === username && d === domain) 57 + !usernames.some(([u]) => u === username) || 58 + usernames.some(([u, d]) => u === username && d === domain) 61 59 ) { 62 60 link.innerHTML = `@<span>${username}</span>`; 63 61 usernames.push([username, domain]); ··· 79 77 // ====== 80 78 // Convert :shortcode: to <img /> 81 79 let textNodes; 82 - if (enhancedContent.indexOf(':') !== -1) { 80 + if (enhancedContent.includes(':')) { 83 81 textNodes = extractTextNodes(dom); 84 82 textNodes.forEach((node) => { 85 83 let html = node.nodeValue ··· 90 88 html = emojifyText(html, emojis); 91 89 } 92 90 fauxDiv.innerHTML = html; 93 - const nodes = Array.from(fauxDiv.childNodes); 94 - node.replaceWith(...nodes); 91 + // const nodes = [...fauxDiv.childNodes]; 92 + node.replaceWith(...fauxDiv.childNodes); 95 93 }); 96 94 } 97 95 ··· 99 97 // =========== 100 98 // Convert ```code``` to <pre><code>code</code></pre> 101 99 if (hasCodeBlock) { 102 - const blocks = Array.from(dom.querySelectorAll('p')).filter((p) => 100 + const blocks = [...dom.querySelectorAll('p')].filter((p) => 103 101 /^```[^]+```$/g.test(p.innerText.trim()), 104 102 ); 105 103 blocks.forEach((block) => { ··· 113 111 114 112 // Convert multi-paragraph code blocks to <pre><code>code</code></pre> 115 113 if (hasCodeBlock) { 116 - const paragraphs = Array.from(dom.querySelectorAll('p')); 114 + const paragraphs = [...dom.querySelectorAll('p')]; 117 115 // Filter out paragraphs with ``` in beginning only 118 116 const codeBlocks = paragraphs.filter((p) => /^```/g.test(p.innerText)); 119 117 // For each codeBlocks, get all paragraphs until the last paragraph with ``` at the end only ··· 153 151 // INLINE CODE 154 152 // =========== 155 153 // Convert `code` to <code>code</code> 156 - if (enhancedContent.indexOf('`') !== -1) { 154 + if (enhancedContent.includes('`')) { 157 155 textNodes = extractTextNodes(dom); 158 156 textNodes.forEach((node) => { 159 157 let html = node.nodeValue ··· 164 162 html = html.replaceAll(/(`[^]+?`)/g, '<code>$1</code>'); 165 163 } 166 164 fauxDiv.innerHTML = html; 167 - const nodes = Array.from(fauxDiv.childNodes); 168 - node.replaceWith(...nodes); 165 + // const nodes = [...fauxDiv.childNodes]; 166 + node.replaceWith(...fauxDiv.childNodes); 169 167 }); 170 168 } 171 169 ··· 188 186 ); 189 187 } 190 188 fauxDiv.innerHTML = html; 191 - const nodes = Array.from(fauxDiv.childNodes); 192 - node.replaceWith(...nodes); 189 + // const nodes = [...fauxDiv.childNodes]; 190 + node.replaceWith(...fauxDiv.childNodes); 193 191 }); 194 192 } 195 193 196 194 // HASHTAG STUFFING 197 195 // ================ 198 196 // Get the <p> that contains a lot of hashtags, add a class to it 199 - if (enhancedContent.indexOf('#') !== -1) { 197 + if (enhancedContent.includes('#')) { 200 198 let prevIndex = null; 201 - const hashtagStuffedParagraphs = Array.from( 202 - dom.querySelectorAll('p'), 203 - ).filter((p, index) => { 204 - let hashtagCount = 0; 205 - for (let i = 0; i < p.childNodes.length; i++) { 206 - const node = p.childNodes[i]; 199 + const hashtagStuffedParagraphs = [...dom.querySelectorAll('p')].filter( 200 + (p, index) => { 201 + let hashtagCount = 0; 202 + for (let i = 0; i < p.childNodes.length; i++) { 203 + const node = p.childNodes[i]; 207 204 208 - if (node.nodeType === Node.TEXT_NODE) { 209 - const text = node.textContent.trim(); 210 - if (text !== '') { 211 - return false; 212 - } 213 - } else if (node.tagName === 'BR') { 214 - // Ignore <br /> 215 - } else if (node.tagName === 'A') { 216 - const linkText = node.textContent.trim(); 217 - if (!linkText || !linkText.startsWith('#')) { 218 - return false; 205 + if (node.nodeType === Node.TEXT_NODE) { 206 + const text = node.textContent.trim(); 207 + if (text !== '') { 208 + return false; 209 + } 210 + } else if (node.tagName === 'BR') { 211 + // Ignore <br /> 212 + } else if (node.tagName === 'A') { 213 + const linkText = node.textContent.trim(); 214 + if (!linkText || !linkText.startsWith('#')) { 215 + return false; 216 + } else { 217 + hashtagCount++; 218 + } 219 219 } else { 220 - hashtagCount++; 220 + return false; 221 221 } 222 - } else { 223 - return false; 224 222 } 225 - } 226 - // Only consider "stuffing" if: 227 - // - there are more than 3 hashtags 228 - // - there are more than 1 hashtag in adjacent paragraphs 229 - if (hashtagCount > 3) { 230 - prevIndex = index; 231 - return true; 232 - } 233 - if (hashtagCount > 1 && prevIndex && index === prevIndex + 1) { 234 - prevIndex = index; 235 - return true; 236 - } 237 - }); 223 + // Only consider "stuffing" if: 224 + // - there are more than 3 hashtags 225 + // - there are more than 1 hashtag in adjacent paragraphs 226 + if (hashtagCount > 3) { 227 + prevIndex = index; 228 + return true; 229 + } 230 + if (hashtagCount > 1 && prevIndex && index === prevIndex + 1) { 231 + prevIndex = index; 232 + return true; 233 + } 234 + }, 235 + ); 238 236 if (hashtagStuffedParagraphs?.length) { 239 237 hashtagStuffedParagraphs.forEach((p) => { 240 238 p.classList.add('hashtag-stuffing'); ··· 244 242 } 245 243 246 244 if (postEnhanceDOM) { 247 - postEnhanceDOM(dom); // mutate dom 245 + queueMicrotask(() => postEnhanceDOM(dom)); 246 + // postEnhanceDOM(dom); // mutate dom 248 247 } 249 248 250 249 enhancedContent = dom.innerHTML; ··· 290 289 ); 291 290 function extractTextNodes(dom, opts = {}) { 292 291 const textNodes = []; 292 + const rejectFilterMap = Object.assign( 293 + {}, 294 + defaultRejectFilterMap, 295 + opts.rejectFilter?.reduce((acc, cur) => { 296 + acc[cur] = true; 297 + return acc; 298 + }, {}), 299 + ); 293 300 const walk = document.createTreeWalker( 294 301 dom, 295 302 NodeFilter.SHOW_TEXT, 296 303 { 297 304 acceptNode(node) { 298 - if (defaultRejectFilterMap[node.parentNode.nodeName]) { 299 - return NodeFilter.FILTER_REJECT; 300 - } 301 - if ( 302 - opts.rejectFilter && 303 - opts.rejectFilter.includes(node.parentNode.nodeName) 304 - ) { 305 + if (rejectFilterMap[node.parentNode.nodeName]) { 305 306 return NodeFilter.FILTER_REJECT; 306 307 } 307 308 return NodeFilter.FILTER_ACCEPT;
+7 -5
src/utils/get-translate-target-language.jsx
··· 8 8 ...navigator.languages, 9 9 ]; 10 10 11 + const localeTargetLanguages = localeMatch( 12 + locales, 13 + translationTargetLanguages.map((l) => l.code.replace('_', '-')), // The underscore will fail Intl.Locale inside `match` 14 + 'en', 15 + ); 16 + 11 17 function getTranslateTargetLanguage(fromSettings = false) { 12 18 if (fromSettings) { 13 19 const { contentTranslationTargetLanguage } = states.settings; ··· 15 21 return contentTranslationTargetLanguage; 16 22 } 17 23 } 18 - return localeMatch( 19 - locales, 20 - translationTargetLanguages.map((l) => l.code.replace('_', '-')), // The underscore will fail Intl.Locale inside `match` 21 - 'en', 22 - ); 24 + return localeTargetLanguages; 23 25 } 24 26 25 27 export default getTranslateTargetLanguage;
+1 -2
src/utils/isMastodonLinkMaybe.jsx
··· 3 3 const { pathname, hash } = new URL(url); 4 4 return ( 5 5 /^\/.*\/\d+$/i.test(pathname) || 6 - /^\/@[^/]+\/(statuses|posts)\/\w+\/?$/i.test(pathname) || // GoToSocial, Takahe 6 + /^\/(@[^/]+|users\/[^/]+)\/(statuses|posts)\/\w+\/?$/i.test(pathname) || // GoToSocial, Takahe 7 7 /^\/notes\/[a-z0-9]+$/i.test(pathname) || // Misskey, Firefish 8 - /^\/notes\/[a-z0-9]+$/i.test(pathname) || // Misskey, Calckey 9 8 /^\/(notice|objects)\/[a-z0-9-]+$/i.test(pathname) || // Pleroma 10 9 /#\/[^\/]+\.[^\/]+\/s\/.+/i.test(hash) // Phanpy 🫣 11 10 );
+19 -7
src/utils/nice-date-time.js
··· 1 + import mem from './mem'; 2 + 1 3 const { locale } = new Intl.DateTimeFormat().resolvedOptions(); 2 4 3 - function niceDateTime(date, { hideTime, formatOpts } = {}) { 4 - if (!(date instanceof Date)) { 5 - date = new Date(date); 6 - } 5 + const _DateTimeFormat = (opts) => { 6 + const { dateYear, hideTime, formatOpts } = opts || {}; 7 7 const currentYear = new Date().getFullYear(); 8 - const dateText = Intl.DateTimeFormat(locale, { 8 + return Intl.DateTimeFormat(locale, { 9 9 // Show year if not current year 10 - year: date.getFullYear() === currentYear ? undefined : 'numeric', 10 + year: dateYear === currentYear ? undefined : 'numeric', 11 11 month: 'short', 12 12 day: 'numeric', 13 13 // Hide time if requested 14 14 hour: hideTime ? undefined : 'numeric', 15 15 minute: hideTime ? undefined : 'numeric', 16 16 ...formatOpts, 17 - }).format(date); 17 + }); 18 + }; 19 + const DateTimeFormat = mem(_DateTimeFormat); 20 + 21 + function niceDateTime(date, dtfOpts) { 22 + if (!(date instanceof Date)) { 23 + date = new Date(date); 24 + } 25 + const DTF = DateTimeFormat({ 26 + dateYear: date.getFullYear(), 27 + ...dtfOpts, 28 + }); 29 + const dateText = DTF.format(date); 18 30 return dateText; 19 31 } 20 32
+1
src/utils/show-toast.js
··· 23 23 } else { 24 24 toast.showToast(); 25 25 } 26 + return toast; 26 27 } 27 28 28 29 export default showToast;
+67 -14
src/utils/states.js
··· 2 2 import { subscribeKey } from 'valtio/utils'; 3 3 4 4 import { api } from './api'; 5 + import isMastodonLinkMaybe from './isMastodonLinkMaybe'; 5 6 import pmem from './pmem'; 6 7 import rateLimit from './ratelimit'; 7 8 import store from './store'; 9 + import unfurlMastodonLink from './unfurl-link'; 8 10 9 11 const states = proxy({ 10 12 appVersion: {}, ··· 29 31 counter: 0, 30 32 }, 31 33 spoilers: {}, 34 + spoilersMedia: {}, 32 35 scrollPositions: {}, 33 36 unfurledLinks: {}, 34 37 statusQuotes: {}, ··· 58 61 contentTranslationTargetLanguage: null, 59 62 contentTranslationHideLanguages: [], 60 63 contentTranslationAutoInline: false, 64 + mediaAltGenerator: false, 61 65 cloakMode: false, 62 66 }, 63 67 }); ··· 86 90 store.account.get('settings-contentTranslationHideLanguages') || []; 87 91 states.settings.contentTranslationAutoInline = 88 92 store.account.get('settings-contentTranslationAutoInline') ?? false; 93 + states.settings.mediaAltGenerator = 94 + store.account.get('settings-mediaAltGenerator') ?? false; 89 95 states.settings.cloakMode = store.account.get('settings-cloakMode') ?? false; 90 96 } 91 97 ··· 121 127 states.settings.contentTranslationHideLanguages, 122 128 ); 123 129 } 130 + if (path.join('.') === 'settings.mediaAltGenerator') { 131 + store.account.set('settings-mediaAltGenerator', !!value); 132 + } 124 133 if (path?.[0] === 'shortcuts') { 125 134 store.account.set('shortcuts', states.shortcuts); 126 135 } ··· 161 170 opts = instance; 162 171 instance = null; 163 172 } 164 - const { override, skipThreading } = Object.assign( 165 - { override: true, skipThreading: false }, 166 - opts, 167 - ); 173 + const { 174 + override = true, 175 + skipThreading = false, 176 + skipUnfurling = false, 177 + } = opts || {}; 168 178 if (!status) return; 169 179 const oldStatus = getStatus(status.id, instance); 170 180 if (!override && oldStatus) return; 171 - const key = statusKey(status.id, instance); 172 - if (oldStatus?._pinned) status._pinned = oldStatus._pinned; 173 - // if (oldStatus?._filtered) status._filtered = oldStatus._filtered; 174 - states.statuses[key] = status; 175 - if (status.reblog) { 176 - const key = statusKey(status.reblog.id, instance); 177 - states.statuses[key] = status.reblog; 178 - } 181 + queueMicrotask(() => { 182 + const key = statusKey(status.id, instance); 183 + if (oldStatus?._pinned) status._pinned = oldStatus._pinned; 184 + // if (oldStatus?._filtered) status._filtered = oldStatus._filtered; 185 + states.statuses[key] = status; 186 + if (status.reblog) { 187 + const key = statusKey(status.reblog.id, instance); 188 + states.statuses[key] = status.reblog; 189 + } 190 + }); 179 191 180 192 // THREAD TRAVERSER 181 193 if (!skipThreading) { 182 - requestAnimationFrame(() => { 194 + queueMicrotask(() => { 183 195 threadifyStatus(status, instance); 184 196 if (status.reblog) { 185 - threadifyStatus(status.reblog, instance); 197 + queueMicrotask(() => { 198 + threadifyStatus(status.reblog, instance); 199 + }); 186 200 } 201 + }); 202 + } 203 + 204 + // UNFURLER 205 + if (!skipUnfurling) { 206 + queueMicrotask(() => { 207 + unfurlStatus(status, instance); 187 208 }); 188 209 } 189 210 } ··· 228 249 }); 229 250 } 230 251 export const threadifyStatus = rateLimit(_threadifyStatus, 100); 252 + 253 + const fauxDiv = document.createElement('div'); 254 + export function unfurlStatus(status, instance) { 255 + const { instance: currentInstance } = api(); 256 + const content = status.reblog?.content || status.content; 257 + const hasLink = /<a/i.test(content); 258 + if (hasLink) { 259 + const sKey = statusKey(status?.reblog?.id || status?.id, instance); 260 + fauxDiv.innerHTML = content; 261 + const links = fauxDiv.querySelectorAll( 262 + 'a[href]:not(.u-url):not(.mention):not(.hashtag)', 263 + ); 264 + [...links] 265 + .filter((a) => { 266 + const url = a.href; 267 + const isPostItself = url === status.url || url === status.uri; 268 + return !isPostItself && isMastodonLinkMaybe(url); 269 + }) 270 + .forEach((a, i) => { 271 + unfurlMastodonLink(currentInstance, a.href).then((result) => { 272 + if (!result) return; 273 + if (!sKey) return; 274 + if (!Array.isArray(states.statusQuotes[sKey])) { 275 + states.statusQuotes[sKey] = []; 276 + } 277 + if (!states.statusQuotes[sKey][i]) { 278 + states.statusQuotes[sKey].splice(i, 0, result); 279 + } 280 + }); 281 + }); 282 + } 283 + } 231 284 232 285 const fetchStatus = pmem((statusID, masto) => { 233 286 return masto.v1.statuses.$select(statusID).fetch();
+5
src/utils/store-utils.js
··· 10 10 return accounts.find((a) => a.accessToken === accessToken); 11 11 } 12 12 13 + export function getAccountByInstance(instance) { 14 + const accounts = store.local.getJSON('accounts') || []; 15 + return accounts.find((a) => a.instanceURL === instance); 16 + } 17 + 13 18 export function getCurrentAccount() { 14 19 if (!window.__IGNORE_GET_ACCOUNT_ERROR__) { 15 20 // Track down getCurrentAccount() calls before account-based states are initialized
+136
src/utils/unfurl-link.jsx
··· 1 + import pThrottle from 'p-throttle'; 2 + import { snapshot } from 'valtio/vanilla'; 3 + 4 + import { api } from './api'; 5 + import states, { saveStatus } from './states'; 6 + 7 + export const throttle = pThrottle({ 8 + limit: 1, 9 + interval: 1000, 10 + }); 11 + 12 + const denylistDomains = /(twitter|github)\.com/i; 13 + const failedUnfurls = {}; 14 + function _unfurlMastodonLink(instance, url) { 15 + const snapStates = snapshot(states); 16 + if (denylistDomains.test(url)) { 17 + return; 18 + } 19 + if (failedUnfurls[url]) { 20 + return; 21 + } 22 + const instanceRegex = new RegExp(instance + '/'); 23 + if (instanceRegex.test(snapStates.unfurledLinks[url]?.url)) { 24 + return Promise.resolve(snapStates.unfurledLinks[url]); 25 + } 26 + console.debug('🦦 Unfurling URL', url); 27 + 28 + let remoteInstanceFetch; 29 + let theURL = url; 30 + 31 + // https://elk.zone/domain.com/@stest/123 -> https://domain.com/@stest/123 32 + if (/\/\/elk\.[^\/]+\/[^\/]+\.[^\/]+/i.test(theURL)) { 33 + theURL = theURL.replace(/elk\.[^\/]+\//i, ''); 34 + } 35 + 36 + // https://trunks.social/status/domain.com/@stest/123 -> https://domain.com/@stest/123 37 + if (/\/\/trunks\.[^\/]+\/status\/[^\/]+\.[^\/]+/i.test(theURL)) { 38 + theURL = theURL.replace(/trunks\.[^\/]+\/status\//i, ''); 39 + } 40 + 41 + // https://phanpy.social/#/domain.com/s/123 -> https://domain.com/statuses/123 42 + if (/\/#\/[^\/]+\.[^\/]+\/s\/.+/i.test(theURL)) { 43 + const urlAfterHash = theURL.split('/#/')[1]; 44 + const finalURL = urlAfterHash.replace(/\/s\//i, '/@fakeUsername/'); 45 + theURL = `https://${finalURL}`; 46 + } 47 + 48 + let urlObj; 49 + try { 50 + urlObj = new URL(theURL); 51 + } catch (e) { 52 + return; 53 + } 54 + const domain = urlObj.hostname; 55 + const path = urlObj.pathname; 56 + // Regex /:username/:id, where username = @username or @username@domain, id = number 57 + const statusRegex = /\/@([^@\/]+)@?([^\/]+)?\/(\d+)$/i; 58 + const statusMatch = statusRegex.exec(path); 59 + if (statusMatch) { 60 + const id = statusMatch[3]; 61 + const { masto } = api({ instance: domain }); 62 + remoteInstanceFetch = masto.v1.statuses 63 + .$select(id) 64 + .fetch() 65 + .then((status) => { 66 + if (status?.id) { 67 + return { 68 + status, 69 + instance: domain, 70 + }; 71 + } else { 72 + throw new Error('No results'); 73 + } 74 + }); 75 + } 76 + 77 + const { masto } = api({ instance }); 78 + const mastoSearchFetch = masto.v2.search 79 + .fetch({ 80 + q: theURL, 81 + type: 'statuses', 82 + resolve: true, 83 + limit: 1, 84 + }) 85 + .then((results) => { 86 + if (results.statuses.length > 0) { 87 + const status = results.statuses[0]; 88 + return { 89 + status, 90 + instance, 91 + }; 92 + } else { 93 + throw new Error('No results'); 94 + } 95 + }); 96 + 97 + function handleFulfill(result) { 98 + const { status, instance } = result; 99 + const { id } = status; 100 + const selfURL = `/${instance}/s/${id}`; 101 + console.debug('🦦 Unfurled URL', url, id, selfURL); 102 + const data = { 103 + id, 104 + instance, 105 + url: selfURL, 106 + }; 107 + states.unfurledLinks[url] = data; 108 + saveStatus(status, instance, { 109 + skipThreading: true, 110 + }); 111 + return data; 112 + } 113 + function handleCatch(e) { 114 + failedUnfurls[url] = true; 115 + } 116 + 117 + if (remoteInstanceFetch) { 118 + // return Promise.any([remoteInstanceFetch, mastoSearchFetch]) 119 + // .then(handleFulfill) 120 + // .catch(handleCatch); 121 + // If mastoSearchFetch is fulfilled within 3s, return it, else return remoteInstanceFetch 122 + const finalPromise = Promise.race([ 123 + mastoSearchFetch, 124 + new Promise((resolve, reject) => setTimeout(reject, 3000)), 125 + ]).catch(() => { 126 + // If remoteInstanceFetch is fullfilled, return it, else return mastoSearchFetch 127 + return remoteInstanceFetch.catch(() => mastoSearchFetch); 128 + }); 129 + return finalPromise.then(handleFulfill).catch(handleCatch); 130 + } else { 131 + return mastoSearchFetch.then(handleFulfill).catch(handleCatch); 132 + } 133 + } 134 + 135 + const unfurlMastodonLink = throttle(_unfurlMastodonLink); 136 + export default unfurlMastodonLink;
+126
src/utils/useScrollFn.js
··· 1 + import { useEffect, useLayoutEffect, useState } from 'preact/hooks'; 2 + 3 + export default function useScrollFn( 4 + { 5 + scrollableRef, 6 + distanceFromStart = 1, // ratio of clientHeight/clientWidth 7 + distanceFromEnd = 1, // ratio of clientHeight/clientWidth 8 + scrollThresholdStart = 10, 9 + scrollThresholdEnd = 10, 10 + direction = 'vertical', 11 + distanceFromStartPx: _distanceFromStartPx, 12 + distanceFromEndPx: _distanceFromEndPx, 13 + init, 14 + } = {}, 15 + callback, 16 + deps, 17 + ) { 18 + if (!callback) return; 19 + const [scrollDirection, setScrollDirection] = useState(null); 20 + const [reachStart, setReachStart] = useState(false); 21 + const [reachEnd, setReachEnd] = useState(false); 22 + const [nearReachStart, setNearReachStart] = useState(false); 23 + const [nearReachEnd, setNearReachEnd] = useState(false); 24 + const isVertical = direction === 'vertical'; 25 + 26 + useLayoutEffect(() => { 27 + const scrollableElement = scrollableRef.current; 28 + if (!scrollableElement) return {}; 29 + let previousScrollStart = isVertical 30 + ? scrollableElement.scrollTop 31 + : scrollableElement.scrollLeft; 32 + 33 + function onScroll() { 34 + const { 35 + scrollTop, 36 + scrollLeft, 37 + scrollHeight, 38 + scrollWidth, 39 + clientHeight, 40 + clientWidth, 41 + } = scrollableElement; 42 + const scrollStart = isVertical ? scrollTop : scrollLeft; 43 + const scrollDimension = isVertical ? scrollHeight : scrollWidth; 44 + const clientDimension = isVertical ? clientHeight : clientWidth; 45 + const scrollDistance = Math.abs(scrollStart - previousScrollStart); 46 + const distanceFromStartPx = 47 + _distanceFromStartPx || 48 + Math.min( 49 + clientDimension * distanceFromStart, 50 + scrollDimension, 51 + scrollStart, 52 + ); 53 + const distanceFromEndPx = 54 + _distanceFromEndPx || 55 + Math.min( 56 + clientDimension * distanceFromEnd, 57 + scrollDimension, 58 + scrollDimension - scrollStart - clientDimension, 59 + ); 60 + 61 + if ( 62 + scrollDistance >= 63 + (previousScrollStart < scrollStart 64 + ? scrollThresholdEnd 65 + : scrollThresholdStart) 66 + ) { 67 + setScrollDirection(previousScrollStart < scrollStart ? 'end' : 'start'); 68 + previousScrollStart = scrollStart; 69 + } 70 + 71 + setReachStart(scrollStart <= 0); 72 + setReachEnd(scrollStart + clientDimension >= scrollDimension); 73 + setNearReachStart(scrollStart <= distanceFromStartPx); 74 + setNearReachEnd( 75 + scrollStart + clientDimension >= scrollDimension - distanceFromEndPx, 76 + ); 77 + } 78 + 79 + scrollableElement.addEventListener('scroll', onScroll, { passive: true }); 80 + 81 + return () => scrollableElement.removeEventListener('scroll', onScroll); 82 + }, [ 83 + distanceFromStart, 84 + distanceFromEnd, 85 + scrollThresholdStart, 86 + scrollThresholdEnd, 87 + ]); 88 + 89 + useEffect(() => { 90 + callback({ 91 + scrollDirection, 92 + reachStart, 93 + reachEnd, 94 + nearReachStart, 95 + nearReachEnd, 96 + }); 97 + }, [ 98 + scrollDirection, 99 + reachStart, 100 + reachEnd, 101 + nearReachStart, 102 + nearReachEnd, 103 + ...deps, 104 + ]); 105 + 106 + useEffect(() => { 107 + if (init && scrollableRef.current) { 108 + queueMicrotask(() => { 109 + scrollableRef.current.dispatchEvent(new Event('scroll')); 110 + }); 111 + } 112 + }, [init]); 113 + 114 + // return { 115 + // scrollDirection, 116 + // reachStart, 117 + // reachEnd, 118 + // nearReachStart, 119 + // nearReachEnd, 120 + // init: () => { 121 + // if (scrollableRef.current) { 122 + // scrollableRef.current.dispatchEvent(new Event('scroll')); 123 + // } 124 + // }, 125 + // }; 126 + }
+1 -1
src/utils/useTitle.js
··· 4 4 5 5 import states from './states'; 6 6 7 - const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env; 7 + const { PHANPY_CLIENT_NAME: CLIENT_NAME } = import.meta.env; 8 8 9 9 export default function useTitle(title, path) { 10 10 function setTitle() {
+17 -6
vite.config.js
··· 2 2 import { execSync } from 'child_process'; 3 3 import fs from 'fs'; 4 4 import { resolve } from 'path'; 5 + import { uid } from 'uid/single'; 5 6 import { defineConfig, loadEnv, splitVendorChunkPlugin } from 'vite'; 6 7 import generateFile from 'vite-plugin-generate-file'; 7 8 import htmlPlugin from 'vite-plugin-html-config'; ··· 10 11 11 12 const { NODE_ENV } = process.env; 12 13 const { 13 - VITE_CLIENT_NAME: CLIENT_NAME, 14 - VITE_CLIENT_ID: CLIENT_ID, 15 - VITE_APP_ERROR_LOGGING: ERROR_LOGGING, 14 + PHANPY_CLIENT_NAME: CLIENT_NAME, 15 + PHANPY_APP_ERROR_LOGGING: ERROR_LOGGING, 16 16 } = loadEnv('production', process.cwd()); 17 17 18 18 const now = new Date(); 19 - const commitHash = execSync('git rev-parse --short HEAD').toString().trim(); 19 + let commitHash; 20 + let fakeCommitHash = false; 21 + try { 22 + commitHash = execSync('git rev-parse --short HEAD').toString().trim(); 23 + } catch (error) { 24 + // If error, means git is not installed or not a git repo (could be downloaded instead of git cloned) 25 + // Fallback to random hash which should be different on every build run 🤞 26 + commitHash = uid(); 27 + fakeCommitHash = true; 28 + } 20 29 21 30 const rollbarCode = fs.readFileSync( 22 31 resolve(__dirname, './rollbar.js'), ··· 26 35 // https://vitejs.dev/config/ 27 36 export default defineConfig({ 28 37 base: './', 38 + envPrefix: ['VITE_', 'PHANPY_'], 29 39 mode: NODE_ENV, 30 40 define: { 31 41 __BUILD_TIME__: JSON.stringify(now), 32 42 __COMMIT_HASH__: JSON.stringify(commitHash), 43 + __FAKE_COMMIT_HASH__: fakeCommitHash, 33 44 }, 34 45 server: { 35 46 host: true, ··· 55 66 ]), 56 67 VitePWA({ 57 68 manifest: { 58 - id: CLIENT_ID, 59 69 name: CLIENT_NAME, 60 70 short_name: CLIENT_NAME, 61 71 description: 'Minimalistic opinionated Mastodon web client', 62 - theme_color: '#ffffff', 72 + // https://github.com/cheeaun/phanpy/issues/231 73 + // theme_color: '#ffffff', 63 74 icons: [ 64 75 { 65 76 src: 'logo-192.png',