diff --git a/README.md b/README.md index bcf52dc4..71ef021a 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ And here I am. Building a Mastodon web client. - [Soapbox](https://fe.soapbox.pub/) - [Elk](https://elk.zone/) - [Mastodeck](https://mastodeck.com/) +- [Trunks (alpha)](https://alpha.trunks.social/) - [Tooty](https://github.com/n1k0/tooty) - [More...](https://github.com/hueyy/awesome-mastodon/#clients) diff --git a/package-lock.json b/package-lock.json index 22d02159..d658f20d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@formatjs/intl-localematcher": "~0.2.32", "@github/text-expander-element": "~2.3.0", "@iconify-icons/mingcute": "~1.2.4", - "@szhsin/react-menu": "~3.5.1", + "@szhsin/react-menu": "~3.5.2", "dayjs": "~1.11.7", "dayjs-twitter": "~0.5.0", "fast-blurhash": "~1.1.2", @@ -22,7 +22,7 @@ "mem": "~9.0.2", "p-retry": "~5.1.2", "p-throttle": "~5.0.0", - "preact": "~10.13.0", + "preact": "~10.13.1", "react-hotkeys-hook": "~4.3.7", "react-intersection-observer": "~9.4.3", "react-router-dom": "6.6.2", @@ -31,6 +31,7 @@ "toastify-js": "~1.12.0", "uid": "~2.0.1", "use-debounce": "~9.0.3", + "use-long-press": "~2.0.3", "use-resize-observer": "~9.1.0", "valtio": "1.9.0" }, @@ -46,7 +47,7 @@ "vite-plugin-html-config": "~1.0.11", "vite-plugin-html-env": "~1.2.7", "vite-plugin-pwa": "~0.14.4", - "vite-plugin-remove-console": "~2.0.0", + "vite-plugin-remove-console": "~2.1.0", "workbox-cacheable-response": "~6.5.4", "workbox-expiration": "~6.5.4", "workbox-routing": "~6.5.4", @@ -2821,9 +2822,9 @@ } }, "node_modules/@szhsin/react-menu": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.5.1.tgz", - "integrity": "sha512-bTCfVNBSReG4+mnbN8n2OQWZ3DRPlJgMIBJFepPfDLiRzNSe5lbZ8Z5Kjiv9nuPLHOu3jSaybxgYJj/Dn8n75Q==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.5.2.tgz", + "integrity": "sha512-eR7dzDBrwlt9RSgGmLXjfA1Rd5tYqD5mnqjQgZJysf3Jt3vBPkrbDT1oW21nLpfUCkyUQOuZ38n2IdhWl9KkzQ==", "dependencies": { "prop-types": "^15.7.2", "react-transition-state": "^1.1.5" @@ -5658,9 +5659,9 @@ "dev": true }, "node_modules/preact": { - "version": "10.13.0", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.13.0.tgz", - "integrity": "sha512-ERdIdUpR6doqdaSIh80hvzebHB7O6JxycOhyzAeLEchqOq/4yueslQbfnPwXaNhAYacFTyCclhwkEbOumT0tHw==", + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.13.1.tgz", + "integrity": "sha512-KyoXVDU5OqTpG9LXlB3+y639JAGzl8JSBXLn1J9HTSB3gbKcuInga7bZnXLlxmK94ntTs1EFeZp0lrja2AuBYQ==", "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -6564,6 +6565,18 @@ "react": ">=16.8.0" } }, + "node_modules/use-long-press": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-2.0.3.tgz", + "integrity": "sha512-n3cfv90Y1ldNt+hhXzxnxuLZmgLOOC/+qfLGoeEBgOxmnokPPt39MPF3KmvKriq5VMoJ7uQdVjHejCdHBt9anw==", + "engines": { + "node": ">=10", + "npm": ">=5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/use-resize-observer": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz", @@ -6789,9 +6802,9 @@ } }, "node_modules/vite-plugin-remove-console": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/vite-plugin-remove-console/-/vite-plugin-remove-console-2.0.0.tgz", - "integrity": "sha512-bEsyShSacsunbm0X1zaVliwgmWlsaBPLk7FN4wr2xQMs8zSZPSwpRNTT5UZiF0+cfMEkN4VVnofITawmT3pjgQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-remove-console/-/vite-plugin-remove-console-2.1.0.tgz", + "integrity": "sha512-cil+h4rX3fDnnKMt73fexMGkwRSOV08+lTAzLGTRjGyxs9Ync3fqPWxnGrngJY7LyMMt3kEKf0hNOi+1DQ0j2g==", "dev": true }, "node_modules/webidl-conversions": { @@ -8954,9 +8967,9 @@ } }, "@szhsin/react-menu": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.5.1.tgz", - "integrity": "sha512-bTCfVNBSReG4+mnbN8n2OQWZ3DRPlJgMIBJFepPfDLiRzNSe5lbZ8Z5Kjiv9nuPLHOu3jSaybxgYJj/Dn8n75Q==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.5.2.tgz", + "integrity": "sha512-eR7dzDBrwlt9RSgGmLXjfA1Rd5tYqD5mnqjQgZJysf3Jt3vBPkrbDT1oW21nLpfUCkyUQOuZ38n2IdhWl9KkzQ==", "requires": { "prop-types": "^15.7.2", "react-transition-state": "^1.1.5" @@ -10964,9 +10977,9 @@ "dev": true }, "preact": { - "version": "10.13.0", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.13.0.tgz", - "integrity": "sha512-ERdIdUpR6doqdaSIh80hvzebHB7O6JxycOhyzAeLEchqOq/4yueslQbfnPwXaNhAYacFTyCclhwkEbOumT0tHw==" + "version": "10.13.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.13.1.tgz", + "integrity": "sha512-KyoXVDU5OqTpG9LXlB3+y639JAGzl8JSBXLn1J9HTSB3gbKcuInga7bZnXLlxmK94ntTs1EFeZp0lrja2AuBYQ==" }, "prettier": { "version": "2.8.0", @@ -11609,6 +11622,12 @@ "integrity": "sha512-FhtlbDtDXILJV7Lix5OZj5yX/fW1tzq+VrvK1fnT2bUrPOGruU9Rw8NCEn+UI9wopfERBEZAOQ8lfeCJPllgnw==", "requires": {} }, + "use-long-press": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-2.0.3.tgz", + "integrity": "sha512-n3cfv90Y1ldNt+hhXzxnxuLZmgLOOC/+qfLGoeEBgOxmnokPPt39MPF3KmvKriq5VMoJ7uQdVjHejCdHBt9anw==", + "requires": {} + }, "use-resize-observer": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz", @@ -11744,9 +11763,9 @@ } }, "vite-plugin-remove-console": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/vite-plugin-remove-console/-/vite-plugin-remove-console-2.0.0.tgz", - "integrity": "sha512-bEsyShSacsunbm0X1zaVliwgmWlsaBPLk7FN4wr2xQMs8zSZPSwpRNTT5UZiF0+cfMEkN4VVnofITawmT3pjgQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-remove-console/-/vite-plugin-remove-console-2.1.0.tgz", + "integrity": "sha512-cil+h4rX3fDnnKMt73fexMGkwRSOV08+lTAzLGTRjGyxs9Ync3fqPWxnGrngJY7LyMMt3kEKf0hNOi+1DQ0j2g==", "dev": true }, "webidl-conversions": { diff --git a/package.json b/package.json index 2ee389b7..24370bc5 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@formatjs/intl-localematcher": "~0.2.32", "@github/text-expander-element": "~2.3.0", "@iconify-icons/mingcute": "~1.2.4", - "@szhsin/react-menu": "~3.5.1", + "@szhsin/react-menu": "~3.5.2", "dayjs": "~1.11.7", "dayjs-twitter": "~0.5.0", "fast-blurhash": "~1.1.2", @@ -24,7 +24,7 @@ "mem": "~9.0.2", "p-retry": "~5.1.2", "p-throttle": "~5.0.0", - "preact": "~10.13.0", + "preact": "~10.13.1", "react-hotkeys-hook": "~4.3.7", "react-intersection-observer": "~9.4.3", "react-router-dom": "6.6.2", @@ -33,6 +33,7 @@ "toastify-js": "~1.12.0", "uid": "~2.0.1", "use-debounce": "~9.0.3", + "use-long-press": "~2.0.3", "use-resize-observer": "~9.1.0", "valtio": "1.9.0" }, @@ -48,7 +49,7 @@ "vite-plugin-html-config": "~1.0.11", "vite-plugin-html-env": "~1.2.7", "vite-plugin-pwa": "~0.14.4", - "vite-plugin-remove-console": "~2.0.0", + "vite-plugin-remove-console": "~2.1.0", "workbox-cacheable-response": "~6.5.4", "workbox-expiration": "~6.5.4", "workbox-routing": "~6.5.4", diff --git a/scripts/fetch-lingva-languages.js b/scripts/fetch-lingva-languages.js new file mode 100644 index 00000000..f270cabe --- /dev/null +++ b/scripts/fetch-lingva-languages.js @@ -0,0 +1,18 @@ +// Fetch https://lingva.ml/api/v1/languages/{source|target} +import fs from 'fs'; + +fetch('https://lingva.ml/api/v1/languages/source') + .then((response) => response.json()) + .then((json) => { + const file = './src/data/lingva-source-languages.json'; + console.log(`Writing ${file}...`); + fs.writeFileSync(file, JSON.stringify(json.languages, null, '\t'), 'utf8'); + }); + +fetch('https://lingva.ml/api/v1/languages/target') + .then((response) => response.json()) + .then((json) => { + const file = './src/data/lingva-target-languages.json'; + console.log(`Writing ${file}...`); + fs.writeFileSync(file, JSON.stringify(json.languages, null, '\t'), 'utf8'); + }); diff --git a/src/app.css b/src/app.css index 77c03e4c..e4d1a2ff 100644 --- a/src/app.css +++ b/src/app.css @@ -74,8 +74,6 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { margin: auto; width: var(--main-width); max-width: 100%; - border-left: 1px solid rgba(0, 0, 0, 0.1); - border-right: 1px solid rgba(0, 0, 0, 0.1); background-color: var(--bg-color); } .deck.contained { @@ -537,6 +535,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { transition: background-color 0.2s ease-out; -webkit-tap-highlight-color: transparent; animation: appear 0.2s ease-out; + -webkit-touch-callout: none; } :is(.status-link, .status-focus):is(:focus, .is-active) { background-color: var(--link-bg-hover-color); @@ -987,9 +986,9 @@ body:has(.status-deck) .media-post-link { width: 100%; max-width: calc(var(--main-width) - 50px - 16px); border-radius: 16px 16px 0 0; - box-shadow: 0 -1px 32px var(--divider-color); + box-shadow: 0 -1px 32px var(--drop-shadow-color); animation: slide-up 0.3s var(--timing-function); - border: 1px solid var(--outline-color); + /* border: 1px solid var(--outline-color); */ } .sheet-max { width: 90vw; @@ -1007,6 +1006,12 @@ body:has(.status-deck) .media-post-link { .sheet header :is(h1, h2, h3) { margin: 0; } +.sheet header.header-grid { + display: grid; + grid-template-columns: 1fr auto; + grid-gap: 8px; + align-items: center; +} .sheet main { overflow: auto; overflow-x: hidden; @@ -1045,6 +1050,11 @@ body:has(.status-deck) .media-post-link { /* MENU POPUP */ +.szh-menu-container { + user-select: none; + -webkit-touch-callout: none; + -webkit-user-drag: none; +} .szh-menu-container:has(.szh-menu--state-open) { inset: 0; inset: env(safe-area-inset-top) env(safe-area-inset-right) @@ -1053,7 +1063,7 @@ body:has(.status-deck) .media-post-link { .szh-menu { padding: 8px 0; margin: 0; - font-size: 16px; + font-size: var(--text-size); background-color: var(--bg-color); border: 1px solid var(--outline-color); border-radius: 8px; @@ -1088,10 +1098,16 @@ body:has(.status-deck) .media-post-link { line-height: 1; padding: 8px 16px !important; transition: all 0.1s ease-in-out; + text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - text-decoration: none; +} +.szh-menu .szh-menu__item span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.05; } .szh-menu .szh-menu__item * { vertical-align: middle; @@ -1106,6 +1122,7 @@ body:has(.status-deck) .media-post-link { text-decoration: none; padding: 8px 16px !important; margin: -8px -16px !important; + align-items: center; } .szh-menu .szh-menu__item a.is-active { font-weight: bold; @@ -1129,6 +1146,24 @@ body:has(.status-deck) .media-post-link { text-overflow: ellipsis; overflow: hidden; } +.szh-menu .menu-double-lines { + white-space: normal; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.szh-menu .menu-double-lines span { + white-space: normal; + line-height: inherit; + font-size: inherit; +} +.szh-menu .menu-horizontal { + display: flex; +} +.szh-menu .menu-horizontal .szh-menu__item { + flex: 1; +} .szh-menu .szh-menu__item .menu-shortcut { opacity: 0.5; font-weight: normal; @@ -1219,35 +1254,45 @@ meter.donut:is(.danger, .explode):after { /* SHINY PILL */ -.shiny-pill { +:is(.shiny-pill, :root .toastify.shiny-pill) { + pointer-events: auto; color: var(--button-text-color); text-shadow: 0 calc(var(--hairline-width) * -1) var(--drop-shadow-color); background-color: var(--button-bg-color); background-image: linear-gradient( 160deg, rgba(255, 255, 255, 0.5), - rgba(255, 255, 255, 0) 50% + rgba(0, 0, 0, 0.1) ); box-shadow: 0 3px 8px -1px var(--drop-shadow-color), 0 10px 36px -4px var(--button-bg-blur-color), inset var(--hairline-width) var(--hairline-width) rgba(255, 255, 255, 0.5); + transition: filter 0.3s; +} +:is(.shiny-pill, :root .toastify.shiny-pill):hover { + filter: brightness(1.2); +} +:is(.shiny-pill, :root .toastify.shiny-pill):active { + transition: none; + filter: brightness(0.9); } /* TOAST */ :root .toastify { + user-select: none; padding: 8px 16px; border-radius: 999px; + pointer-events: none; + color: var(--button-text-color); + text-shadow: 0 calc(var(--hairline-width) * -1) var(--drop-shadow-color); + background-color: var(--button-bg-blur-color); + background-image: none; + backdrop-filter: blur(16px); } .toastify-bottom { margin-bottom: env(safe-area-inset-bottom); } -:root .toastify:hover { - filter: brightness(1.2); -} -:root .toastify:active { - filter: brightness(0.8); -} /* AVATARS STACK */ diff --git a/src/app.jsx b/src/app.jsx index ebbce5dd..77d2ce17 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -16,7 +16,7 @@ import { } from 'react-router-dom'; import { useSnapshot } from 'valtio'; -import Account from './components/account'; +import AccountSheet from './components/account-sheet'; import Compose from './components/compose'; import Drafts from './components/drafts'; import Loader from './components/loader'; @@ -26,6 +26,7 @@ import Shortcuts from './components/shortcuts'; import ShortcutsSettings from './components/shortcuts-settings'; import NotFound from './pages/404'; import AccountStatuses from './pages/account-statuses'; +import Accounts from './pages/accounts'; import Bookmarks from './pages/bookmarks'; import Favourites from './pages/favourites'; import FollowedHashtags from './pages/followed-hashtags'; @@ -73,6 +74,13 @@ function App() { .querySelector('meta[name="color-scheme"]') .setAttribute('content', theme === 'auto' ? 'dark light' : theme); } + const textSize = store.local.get('textSize'); + if (textSize) { + document.documentElement.style.setProperty( + '--text-size', + `${textSize}px`, + ); + } }, []); useEffect(() => { @@ -143,6 +151,8 @@ function App() { // Focus first column columns.querySelector('.deck-container')?.focus?.(); } else { + const backDrop = document.querySelector('.deck-backdrop'); + if (backDrop) return; // Focus last deck const pages = document.querySelectorAll('.deck-container'); const page = pages[pages.length - 1]; // last one @@ -163,6 +173,7 @@ function App() { const showModal = snapStates.showCompose || snapStates.showSettings || + snapStates.showAccounts || snapStates.showAccount || snapStates.showDrafts || snapStates.showMediaModal || @@ -171,15 +182,6 @@ function App() { if (!showModal) focusDeck(); }, [showModal]); - // useEffect(() => { - // // HACK: prevent this from running again due to HMR - // if (states.init) return; - // if (isLoggedIn) { - // requestAnimationFrame(startVisibility); - // states.init = true; - // } - // }, [isLoggedIn]); - // Notifications service // - WebSocket to receive notifications when page is visible const [visible, setVisible] = useState(true); @@ -253,7 +255,9 @@ function App() { return !/^\/(login|welcome)/.test(pathname); }, [location]); - useInterval(() => { + const lastCheckDate = useRef(); + const checkForUpdates = () => { + lastCheckDate.current = Date.now(); console.log('✨ Check app update'); fetch('./version.json') .then((r) => r.json()) @@ -263,7 +267,21 @@ function App() { .catch((e) => { console.error(e); }); - }, visible && 1000 * 60 * 60); // 1 hour + }; + useInterval(() => checkForUpdates, visible && 1000 * 60 * 30); // 30 minutes + usePageVisibility((visible) => { + if (visible) { + if (!lastCheckDate.current) { + checkForUpdates(); + } else { + const diff = Date.now() - lastCheckDate.current; + if (diff > 1000 * 60 * 60) { + // 1 hour + checkForUpdates(); + } + } + } + }); return ( <> @@ -374,6 +392,21 @@ function App() { /> )} + {!!snapStates.showAccounts && ( + { + if (e.target === e.currentTarget) { + states.showAccounts = false; + } + }} + > + { + states.showAccounts = false; + }} + /> + + )} {!!snapStates.showAccount && ( - { + onClose={({ destination }) => { states.showAccount = false; + if (destination) { + states.showAccounts = false; + } }} /> @@ -440,164 +476,4 @@ function App() { ); } -// let ws; -// async function startStream() { -// const { masto, instance } = api(); -// if ( -// ws && -// (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) -// ) { -// return; -// } - -// const stream = await masto.v1.stream.streamUser(); -// console.log('STREAM START', { stream }); -// ws = stream.ws; - -// const handleNewStatus = debounce((status) => { -// console.log('UPDATE', status); -// if (document.visibilityState === 'hidden') return; - -// const inHomeNew = states.homeNew.find((s) => s.id === status.id); -// const inHome = status.id === states.homeLast?.id; -// if (!inHomeNew && !inHome) { -// if (states.settings.boostsCarousel && status.reblog) { -// // do nothing -// } else { -// states.homeNew.unshift({ -// id: status.id, -// reblog: status.reblog?.id, -// reply: !!status.inReplyToAccountId, -// }); -// console.log('homeNew 1', [...states.homeNew]); -// } -// } - -// saveStatus(status, instance); -// }, 5000); -// stream.on('update', handleNewStatus); -// stream.on('status.update', (status) => { -// console.log('STATUS.UPDATE', status); -// saveStatus(status, instance); -// }); -// stream.on('delete', (statusID) => { -// console.log('DELETE', statusID); -// // delete states.statuses[statusID]; -// const s = getStatus(statusID); -// if (s) s._deleted = true; -// }); -// stream.on('notification', (notification) => { -// console.log('NOTIFICATION', notification); - -// const inNotificationsNew = states.notificationsNew.find( -// (n) => n.id === notification.id, -// ); -// const inNotifications = notification.id === states.notificationsLast?.id; -// if (!inNotificationsNew && !inNotifications) { -// states.notificationsNew.unshift(notification); -// } - -// saveStatus(notification.status, instance, { override: false }); -// }); - -// stream.ws.onclose = () => { -// console.log('STREAM CLOSED!'); -// if (document.visibilityState !== 'hidden') { -// startStream(); -// } -// }; - -// return { -// stream, -// stopStream: () => { -// stream.ws.close(); -// }, -// }; -// } - -// let lastHidden; -// function startVisibility() { -// const { masto, instance } = api(); -// const handleVisible = (visible) => { -// if (!visible) { -// const timestamp = Date.now(); -// lastHidden = timestamp; -// } else { -// const timestamp = Date.now(); -// const diff = timestamp - lastHidden; -// const diffMins = Math.round(diff / 1000 / 60); -// console.log(`visible: ${visible}`, { lastHidden, diffMins }); -// if (!lastHidden || diffMins > 1) { -// (async () => { -// try { -// const firstStatusID = states.homeLast?.id; -// const firstNotificationID = states.notificationsLast?.id; -// console.log({ states, firstNotificationID, firstStatusID }); -// const fetchHome = masto.v1.timelines.listHome({ -// limit: 5, -// ...(firstStatusID && { sinceId: firstStatusID }), -// }); -// const fetchNotifications = masto.v1.notifications.list({ -// limit: 1, -// ...(firstNotificationID && { sinceId: firstNotificationID }), -// }); - -// const newStatuses = await fetchHome; -// const hasOneAndReblog = -// newStatuses.length === 1 && newStatuses?.[0]?.reblog; -// if (newStatuses.length) { -// if (states.settings.boostsCarousel && hasOneAndReblog) { -// // do nothing -// } else { -// states.homeNew = newStatuses.map((status) => { -// saveStatus(status, instance); -// return { -// id: status.id, -// reblog: status.reblog?.id, -// reply: !!status.inReplyToAccountId, -// }; -// }); -// console.log('homeNew 2', [...states.homeNew]); -// } -// } - -// const newNotifications = await fetchNotifications; -// if (newNotifications.length) { -// const notification = newNotifications[0]; -// const inNotificationsNew = states.notificationsNew.find( -// (n) => n.id === notification.id, -// ); -// const inNotifications = -// notification.id === states.notificationsLast?.id; -// if (!inNotificationsNew && !inNotifications) { -// states.notificationsNew.unshift(notification); -// } - -// saveStatus(notification.status, instance, { override: false }); -// } -// } catch (e) { -// // Silently fail -// console.error(e); -// } finally { -// startStream(); -// } -// })(); -// } -// } -// }; - -// const handleVisibilityChange = () => { -// const hidden = document.visibilityState === 'hidden'; -// handleVisible(!hidden); -// console.log('VISIBILITY: ' + (hidden ? 'hidden' : 'visible')); -// }; -// document.addEventListener('visibilitychange', handleVisibilityChange); -// requestAnimationFrame(handleVisibilityChange); -// return { -// stop: () => { -// document.removeEventListener('visibilitychange', handleVisibilityChange); -// }, -// }; -// } - export { App }; diff --git a/src/assets/floating-button.svg b/src/assets/floating-button.svg new file mode 100644 index 00000000..6a9ad117 --- /dev/null +++ b/src/assets/floating-button.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/assets/multi-column.svg b/src/assets/multi-column.svg new file mode 100644 index 00000000..5e8deff7 --- /dev/null +++ b/src/assets/multi-column.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/assets/tab-menu-bar.svg b/src/assets/tab-menu-bar.svg new file mode 100644 index 00000000..64b48b03 --- /dev/null +++ b/src/assets/tab-menu-bar.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/components/account-block.jsx b/src/components/account-block.jsx index c8c2e54b..b4c40a0a 100644 --- a/src/components/account-block.jsx +++ b/src/components/account-block.jsx @@ -1,6 +1,9 @@ import './account-block.css'; +import { useNavigate } from 'react-router-dom'; + import emojifyText from '../utils/emojify-text'; +import niceDateTime from '../utils/nice-date-time'; import states from '../utils/states'; import Avatar from './avatar'; @@ -11,7 +14,9 @@ function AccountBlock({ avatarSize = 'xl', instance, external, + internal, onClick, + showActivity = false, }) { if (skeleton) { return ( @@ -20,15 +25,28 @@ function AccountBlock({ ████████
- @██████ + @██████
); } - const { acct, avatar, avatarStatic, displayName, username, emojis, url } = - account; + const navigate = useNavigate(); + + const { + id, + acct, + avatar, + avatarStatic, + displayName, + username, + emojis, + url, + statusesCount, + lastStatusAt, + } = account; const displayNameWithEmoji = emojifyText(displayName, emojis); + const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct]; return ( @@ -57,7 +79,29 @@ function AccountBlock({ ) : ( {username} )} -
@{acct} +
+ + {showActivity && ( + <> +
+ + Posts: {statusesCount} + {!!lastStatusAt && ( + <> + {' '} + · Last posted:{' '} + {niceDateTime(lastStatusAt, { + hideTime: true, + })} + + )} + + + )}
); diff --git a/src/components/account-info.css b/src/components/account-info.css new file mode 100644 index 00000000..586fb960 --- /dev/null +++ b/src/components/account-info.css @@ -0,0 +1,290 @@ +.account-container { + display: flex; + flex-direction: column; + overflow: hidden; + max-width: 100%; +} + +.account-container.skeleton { + color: var(--outline-color); +} + +.account-container .header-banner { + /* pointer-events: none; */ + aspect-ratio: 6 / 1; + width: 100%; + height: auto; + object-fit: cover; + /* mask fade out bottom of banner */ + mask-image: linear-gradient( + to bottom, + hsl(0, 0%, 0%) 0%, + hsla(0, 0%, 0%, 0.987) 14%, + hsla(0, 0%, 0%, 0.951) 26.2%, + hsla(0, 0%, 0%, 0.896) 36.8%, + hsla(0, 0%, 0%, 0.825) 45.9%, + hsla(0, 0%, 0%, 0.741) 53.7%, + hsla(0, 0%, 0%, 0.648) 60.4%, + hsla(0, 0%, 0%, 0.55) 66.2%, + hsla(0, 0%, 0%, 0.45) 71.2%, + hsla(0, 0%, 0%, 0.352) 75.6%, + hsla(0, 0%, 0%, 0.259) 79.6%, + hsla(0, 0%, 0%, 0.175) 83.4%, + hsla(0, 0%, 0%, 0.104) 87.2%, + hsla(0, 0%, 0%, 0.049) 91.1%, + hsla(0, 0%, 0%, 0.013) 95.3%, + hsla(0, 0%, 0%, 0) 100% + ); + margin-bottom: -44px; + user-select: none; + -webkit-user-drag: none; +} +.account-container .header-banner.header-is-avatar { + mask-image: linear-gradient( + to bottom, + hsl(0, 0%, 0%) 0%, + hsla(0, 0%, 0%, 0.987) 8.1%, + hsla(0, 0%, 0%, 0.951) 15.5%, + hsla(0, 0%, 0%, 0.896) 22.5%, + hsla(0, 0%, 0%, 0.825) 29%, + hsla(0, 0%, 0%, 0.741) 35.3%, + hsla(0, 0%, 0%, 0.648) 41.2%, + hsla(0, 0%, 0%, 0.55) 47.1%, + hsla(0, 0%, 0%, 0.45) 52.9%, + hsla(0, 0%, 0%, 0.352) 58.8%, + hsla(0, 0%, 0%, 0.259) 64.7%, + hsla(0, 0%, 0%, 0.175) 71%, + hsla(0, 0%, 0%, 0.104) 77.5%, + hsla(0, 0%, 0%, 0.049) 84.5%, + hsla(0, 0%, 0%, 0.013) 91.9%, + hsla(0, 0%, 0%, 0) 100% + ); + filter: blur(32px) saturate(3) opacity(0.5); + pointer-events: none; +} +.account-container .header-banner:hover { + animation: position-object 5s ease-in-out 1s 5; +} +.account-container .header-banner:active { + mask-image: none; +} +.account-container .header-banner:active + header .avatar + * { + transition: opacity 0.3s ease-in-out; + opacity: 0 !important; +} +.account-container .header-banner:active + header .avatar { + transition: filter 0.3s ease-in-out; + filter: none !important; +} +.account-container .header-banner:active + header .avatar img { + transition: border-radius 0.3s ease-in-out; + border-radius: 8px; +} + +@media (min-height: 480px) { + .account-container .header-banner:not(.header-is-avatar) { + aspect-ratio: 3 / 1; + } +} + +.account-container header { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 8px; + text-shadow: -8px 0 12px -6px var(--bg-color), 8px 0 12px -6px var(--bg-color), + -8px 0 24px var(--header-color-3, --bg-color), + 8px 0 24px var(--header-color-4, --bg-color); + animation: fade-in 0.3s both ease-in-out 0.1s; +} +.account-container header .avatar { + /* box-shadow: -8px 0 24px var(--header-color-3, --bg-color), + 8px 0 24px var(--header-color-4, --bg-color); */ + overflow: initial; + filter: drop-shadow(-2px 0 4px var(--header-color-3, --bg-color)) + drop-shadow(2px 0 4px var(--header-color-4, --bg-color)); +} +.account-container header .avatar:not(.has-alpha) img { + border-radius: 50%; +} + +.account-container main > *:first-child { + animation: fade-in 0.3s both ease-in-out 0.15s; +} +.account-container main > *:first-child ~ * { + animation: fade-in 0.3s both ease-in-out 0.2s; +} + +.account-container .note { + font-size: 95%; + line-height: 1.4; +} +.account-container .note:not(:has(p)):not(:empty) { + /* Some notes don't have

tags, so we need to add some padding */ + padding: 1em 0; +} + +.account-container .stats { + display: flex; + flex-wrap: wrap; + justify-content: space-around; + gap: 16px; + opacity: 0.75; + font-size: 90%; + background-color: var(--bg-faded-color); + padding: 12px; + border-radius: 8px; + line-height: 1.25; +} +.account-container .stats > * { + text-align: center; +} +.account-container .stats a { + color: inherit; +} + +.account-container .actions { + display: flex; + gap: 8px; + justify-content: space-between; + min-height: 2.5em; +} +.account-container .actions button { + align-self: flex-end; +} + +.account-container .profile-metadata { + display: flex; + flex-wrap: wrap; + gap: 12px; +} +.account-container .profile-field { + min-width: 0; + flex-grow: 1; + font-size: 90%; + background-color: var(--bg-faded-color); + padding: 12px; + border-radius: 8px; + filter: saturate(0.75); + line-height: 1.25; +} + +.account-container :is(.note, .profile-field) .invisible { + display: none; +} +.account-container :is(.note, .profile-field) .ellipsis::after { + content: '…'; +} + +.account-container .profile-field b { + font-size: 90%; + color: var(--text-insignificant-color); + text-transform: uppercase; +} +.account-container .profile-field b .icon { + color: var(--green-color); +} +.account-container .profile-field p { + margin: 0; +} + +.account-container .common-followers { + border-top: 1px solid var(--outline-color); + border-bottom: 1px solid var(--outline-color); + padding: 8px 0; + font-size: 90%; + line-height: 1.5; + color: var(--text-insignificant-color); +} + +.timeline-start .account-container { + border-bottom: 1px solid var(--outline-color); +} +.timeline-start .account-container header { + padding: 16px 16px 1px; + animation: none; +} +.timeline-start .account-container main { + padding: 1px 16px 1px; +} +.timeline-start .account-container main > * { + animation: none; +} +.timeline-start .account-container .account-block .account-block-acct { + opacity: 0.5; +} + +@keyframes shine { + 0% { + left: -100%; + } + 100% { + left: 100%; + } +} +.timeline-start .account-container { + position: relative; + overflow: hidden; +} +.timeline-start .account-container:before { + content: ''; + position: absolute; + z-index: 2; + width: 100%; + height: 100%; + background-image: linear-gradient( + 100deg, + rgba(255, 255, 255, 0) 30%, + rgba(255, 255, 255, 0.25), + rgba(255, 255, 255, 0) 70% + ); + top: 0; + left: -100%; + pointer-events: none; +} +@media (prefers-color-scheme: dark) { + .timeline-start .account-container:before { + opacity: 0.25; + } +} +.timeline-start .account-container:hover:before { + animation: shine 1s ease-in-out 1s; +} + +@media (min-width: 40em) { + .timeline-start .account-container { + --item-radius: 16px; + border: 1px solid var(--divider-color); + margin: 16px 0; + background-color: var(--bg-color); + border-radius: var(--item-radius); + overflow: hidden; + /* box-shadow: 0px 1px var(--bg-blur-color), 0 0 64px var(--bg-color); */ + --shadow-offset: 16px; + --shadow-blur: 32px; + --shadow-spread: calc(var(--shadow-blur) * -0.75); + box-shadow: calc(var(--shadow-offset) * -1) var(--shadow-offset) + var(--shadow-blur) var(--shadow-spread) + var(--header-color-1, var(--drop-shadow-color)), + var(--shadow-offset) var(--shadow-offset) var(--shadow-blur) + var(--shadow-spread) var(--header-color-2, var(--drop-shadow-color)); + } + .timeline-start .account-container .header-banner { + margin-bottom: -77px; + } + .timeline-start .account-container header .account-block { + font-size: 175%; + margin-bottom: -8px; + line-height: 1.1; + letter-spacing: -0.5px; + mix-blend-mode: multiply; + gap: 12px; + } + .timeline-start .account-container header .account-block .avatar { + width: 112px !important; + height: 112px !important; + filter: drop-shadow(-8px 0 8px var(--header-color-3, --bg-color)) + drop-shadow(8px 0 8px var(--header-color-4, --bg-color)); + } +} diff --git a/src/components/account.jsx b/src/components/account-info.jsx similarity index 60% rename from src/components/account.jsx rename to src/components/account-info.jsx index 76dc18c7..a084db00 100644 --- a/src/components/account.jsx +++ b/src/components/account-info.jsx @@ -1,7 +1,6 @@ -import './account.css'; +import './account-info.css'; import { useEffect, useRef, useState } from 'preact/hooks'; -import { useHotkeys } from 'react-hotkeys-hook'; import { api } from '../utils/api'; import emojifyText from '../utils/emojify-text'; @@ -17,49 +16,36 @@ import Avatar from './avatar'; import Icon from './icon'; import Link from './link'; -function Account({ account, instance: propInstance, onClose }) { - const { masto, instance, authenticated } = api({ instance: propInstance }); +function AccountInfo({ + account, + fetchAccount = () => {}, + standalone, + instance, + authenticated, +}) { const [uiState, setUIState] = useState('default'); const isString = typeof account === 'string'; const [info, setInfo] = useState(isString ? null : account); useEffect(() => { - if (isString) { - setUIState('loading'); - (async () => { - try { - const info = await masto.v1.accounts.lookup({ - acct: account, - skip_webfinger: false, - }); - setInfo(info); - setUIState('default'); - } catch (e) { - try { - const result = await masto.v2.search({ - q: account, - type: 'accounts', - limit: 1, - resolve: authenticated, - }); - if (result.accounts.length) { - setInfo(result.accounts[0]); - setUIState('default'); - return; - } - setInfo(null); - setUIState('error'); - } catch (err) { - console.error(err); - setInfo(null); - setUIState('error'); - } - } - })(); - } else { + if (!isString) { setInfo(account); + return; } - }, [account]); + setUIState('loading'); + (async () => { + try { + const info = await fetchAccount(); + states.accounts[`${info.id}@${instance}`] = info; + setInfo(info); + setUIState('default'); + } catch (e) { + console.error(e); + setInfo(null); + setUIState('error'); + } + })(); + }, [isString, account, fetchAccount]); const { acct, @@ -73,8 +59,8 @@ function Account({ account, instance: propInstance, onClose }) { followersCount, followingCount, group, - header, - headerStatic, + // header, + // headerStatic, id, lastStatusAt, locked, @@ -83,14 +69,29 @@ function Account({ account, instance: propInstance, onClose }) { url, username, } = info || {}; + let headerIsAvatar = false; + let { header, headerStatic } = info || {}; + if (!header || /missing\.png$/.test(header)) { + if (avatar && !/missing\.png$/.test(avatar)) { + header = avatar; + headerIsAvatar = true; + if (avatarStatic && !/missing\.png$/.test(avatarStatic)) { + headerStatic = avatarStatic; + } + } + } - const escRef = useHotkeys('esc', onClose, [onClose]); + const [headerCornerColors, setHeaderCornerColors] = useState([]); return (

{uiState === 'error' && (
@@ -113,21 +114,129 @@ function Account({ account, instance: propInstance, onClose }) {

███████████████ ███████████████

- ██ Posts - ██ Following - ██ Followers + + Posts +
+ ██ +
+ + Following +
+ ██ +
+ + Followers +
+ ██ +

) : ( info && ( <> + {header && !/missing\.png$/.test(header) && ( + { + if (e.target.crossOrigin) { + if (e.target.src !== headerStatic) { + e.target.src = headerStatic; + } else { + e.target.removeAttribute('crossorigin'); + e.target.src = header; + } + } else if (e.target.src !== headerStatic) { + e.target.src = headerStatic; + } else { + e.target.remove(); + } + }} + crossOrigin="anonymous" + onLoad={(e) => { + try { + // Get color from four corners of image + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + canvas.width = e.target.width; + canvas.height = e.target.height; + ctx.drawImage(e.target, 0, 0); + // const colors = [ + // ctx.getImageData(0, 0, 1, 1).data, + // ctx.getImageData(e.target.width - 1, 0, 1, 1).data, + // ctx.getImageData(0, e.target.height - 1, 1, 1).data, + // ctx.getImageData( + // e.target.width - 1, + // e.target.height - 1, + // 1, + // 1, + // ).data, + // ]; + // Get 10x10 pixels from corners, get average color from each + const pixelDimension = 10; + const colors = [ + ctx.getImageData(0, 0, pixelDimension, pixelDimension) + .data, + ctx.getImageData( + e.target.width - pixelDimension, + 0, + pixelDimension, + pixelDimension, + ).data, + ctx.getImageData( + 0, + e.target.height - pixelDimension, + pixelDimension, + pixelDimension, + ).data, + ctx.getImageData( + e.target.width - pixelDimension, + e.target.height - pixelDimension, + pixelDimension, + pixelDimension, + ).data, + ].map((data) => { + let r = 0; + let g = 0; + let b = 0; + let a = 0; + for (let i = 0; i < data.length; i += 4) { + r += data[i]; + g += data[i + 1]; + b += data[i + 2]; + a += data[i + 3]; + } + const dataLength = data.length / 4; + return [ + r / dataLength, + g / dataLength, + b / dataLength, + a / dataLength, + ]; + }); + const rgbColors = colors.map((color) => { + const [r, g, b, a] = lightenRGB(color); + return `rgba(${r}, ${g}, ${b}, ${a})`; + }); + setHeaderCornerColors(rgbColors); + console.log({ colors, rgbColors }); + } catch (e) { + // Silently fail + } + }} + /> + )}
@@ -174,18 +283,28 @@ function Account({ account, instance: propInstance, onClose }) {
)}

- { - hideAllModals(); - }} - > - Posts -
- - {shortenNumber(statusesCount)} - {' '} - + {standalone ? ( + + Posts +
+ + {shortenNumber(statusesCount)} + {' '} +
+ ) : ( + { + hideAllModals(); + }} + > + Posts +
+ + {shortenNumber(statusesCount)} + {' '} + + )} Following
@@ -419,4 +538,20 @@ function RelatedActions({ info, instance, authenticated }) { ); } -export default Account; +// Apply more alpha if high luminence +function lightenRGB([r, g, b]) { + const luminence = 0.2126 * r + 0.7152 * g + 0.0722 * b; + console.log('luminence', luminence); + let alpha; + if (luminence >= 220) { + alpha = 1; + } else if (luminence <= 50) { + alpha = 0.1; + } else { + alpha = luminence / 255; + } + alpha = Math.min(1, alpha); + return [r, g, b, alpha]; +} + +export default AccountInfo; diff --git a/src/components/account-sheet.jsx b/src/components/account-sheet.jsx new file mode 100644 index 00000000..e615b23d --- /dev/null +++ b/src/components/account-sheet.jsx @@ -0,0 +1,66 @@ +import { useEffect } from 'preact/hooks'; +import { useHotkeys } from 'react-hotkeys-hook'; + +import { api } from '../utils/api'; +import states from '../utils/states'; + +import AccountInfo from './account-info'; + +function AccountSheet({ account, instance: propInstance, onClose }) { + const { masto, instance, authenticated } = api({ instance: propInstance }); + const isString = typeof account === 'string'; + + const escRef = useHotkeys('esc', onClose, [onClose]); + + useEffect(() => { + if (!isString) { + states.accounts[`${account.id}@${instance}`] = account; + } + }, [account]); + + return ( +

{ + const accountBlock = e.target.closest('.account-block'); + if (accountBlock) { + onClose({ + destination: 'account-statuses', + }); + } + }} + > + { + if (isString) { + try { + const info = await masto.v1.accounts.lookup({ + acct: account, + skip_webfinger: false, + }); + return info; + } catch (e) { + const result = await masto.v2.search({ + q: account, + type: 'accounts', + limit: 1, + resolve: authenticated, + }); + if (result.accounts.length) { + return result.accounts[0]; + } + } + } else { + return account; + } + }} + /> +
+ ); +} + +export default AccountSheet; diff --git a/src/components/account.css b/src/components/account.css deleted file mode 100644 index 2d66c31b..00000000 --- a/src/components/account.css +++ /dev/null @@ -1,91 +0,0 @@ -#account-container.skeleton { - color: var(--outline-color); -} - -#account-container header { - display: flex; - align-items: center; - gap: 8px; -} - -#account-container .note { - font-size: 95%; - line-height: 1.4; -} -#account-container .note:not(:has(p)):not(:empty) { - /* Some notes don't have

tags, so we need to add some padding */ - padding: 1em 0; -} - -#account-container .stats { - display: flex; - flex-wrap: wrap; - justify-content: space-around; - gap: 16px; - opacity: 0.75; - font-size: 90%; - background-color: var(--bg-faded-color); - padding: 12px; - border-radius: 8px; - line-height: 1.25; -} -#account-container .stats > * { - text-align: center; -} -#account-container .stats a { - color: inherit; -} - -#account-container .actions { - display: flex; - gap: 8px; - justify-content: space-between; - min-height: 2.5em; -} -#account-container .actions button { - align-self: flex-end; -} - -#account-container .profile-metadata { - display: flex; - flex-wrap: wrap; - gap: 12px; -} -#account-container .profile-field { - min-width: 0; - flex-grow: 1; - font-size: 90%; - background-color: var(--bg-faded-color); - padding: 12px; - border-radius: 8px; - filter: saturate(0.75); - line-height: 1.25; -} - -#account-container :is(.note, .profile-field) .invisible { - display: none; -} -#account-container :is(.note, .profile-field) .ellipsis::after { - content: '…'; -} - -#account-container .profile-field b { - font-size: 90%; - color: var(--text-insignificant-color); - text-transform: uppercase; -} -#account-container .profile-field b .icon { - color: var(--green-color); -} -#account-container .profile-field p { - margin: 0; -} - -#account-container .common-followers { - border-top: 1px solid var(--outline-color); - border-bottom: 1px solid var(--outline-color); - padding: 8px 0; - font-size: 90%; - line-height: 1.5; - color: var(--text-insignificant-color); -} diff --git a/src/components/avatar.css b/src/components/avatar.css index 411407a6..444a0cdb 100644 --- a/src/components/avatar.css +++ b/src/components/avatar.css @@ -9,6 +9,9 @@ flex-shrink: 0; vertical-align: middle; } +.avatar.has-alpha { + border-radius: 0; +} .avatar img { width: 100%; @@ -16,3 +19,9 @@ object-fit: cover; background-color: var(--img-bg-color); } + +.avatar[data-loaded], +.avatar[data-loaded] img { + box-shadow: none; + background-color: transparent; +} diff --git a/src/components/avatar.jsx b/src/components/avatar.jsx index 1ceb7fe4..a9c2d9c4 100644 --- a/src/components/avatar.jsx +++ b/src/components/avatar.jsx @@ -1,5 +1,7 @@ import './avatar.css'; +import { useRef } from 'preact/hooks'; + const SIZES = { s: 16, m: 20, @@ -9,11 +11,15 @@ const SIZES = { xxxl: 64, }; +const alphaCache = {}; + function Avatar({ url, size, alt = '', ...props }) { size = SIZES[size] || size || SIZES.m; + const avatarRef = useRef(); return ( {!!url && ( - {alt} + {alt} { + if (e.target.crossOrigin) { + e.target.crossOrigin = null; + e.target.src = url; + } + }} + onLoad={(e) => { + if (avatarRef.current) avatarRef.current.dataset.loaded = true; + try { + // Check if image has alpha channel + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + canvas.width = e.target.width; + canvas.height = e.target.height; + ctx.drawImage(e.target, 0, 0); + const allPixels = ctx.getImageData( + 0, + 0, + canvas.width, + canvas.height, + ); + // At least 10% of pixels have alpha <= 128 + const hasAlpha = + allPixels.data.filter((pixel, i) => i % 4 === 3 && pixel <= 128) + .length / + (allPixels.data.length / 4) > + 0.1; + if (hasAlpha) { + // console.log('hasAlpha', hasAlpha, allPixels.data); + avatarRef.current.classList.add('has-alpha'); + alphaCache[url] = true; + } + } catch (e) { + // Ignore + } + }} + /> )} ); diff --git a/src/components/compose.css b/src/components/compose.css index a56bc469..62469c83 100644 --- a/src/components/compose.css +++ b/src/components/compose.css @@ -31,6 +31,10 @@ max-height: 50vh; resize: vertical; line-height: 1.4; + border-color: transparent; +} +#compose-container textarea:hover { + border-color: var(--divider-color); } @media (min-width: 40em) { @@ -51,7 +55,7 @@ } } #compose-container .status-preview { - border-radius: 8px 8px 0 0; + border-radius: 16px 16px 0 0; max-height: 160px; background-color: var(--bg-color); margin: 0 12px; @@ -59,6 +63,7 @@ border-bottom: 0; animation: appear-up 1s ease-in-out; overflow: auto; + box-shadow: 0 -3px 12px -3px var(--drop-shadow-color); } #compose-container .status-preview :is(.hashtag, .time) { /* Prevent hashtags from being clickable */ @@ -87,7 +92,7 @@ transparent, var(--bg-faded-color) ); */ - border-top: 1px solid var(--outline-color); + border-top: var(--hairline-width) solid var(--outline-color); backdrop-filter: blur(8px); text-shadow: 0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color), @@ -105,14 +110,17 @@ } #compose-container form { - border-radius: 8px; + border-radius: 16px; padding: 4px 12px; - background-image: linear-gradient(var(--bg-color) 75%, transparent); + background-color: var(--bg-blur-color); + /* background-image: linear-gradient(var(--bg-color) 85%, transparent); */ position: relative; - z-index: 1; + z-index: 2; + --drop-shadow: 0 3px 6px -3px var(--drop-shadow-color); + box-shadow: var(--drop-shadow); } #compose-container .status-preview ~ form { - box-shadow: 0 -12px 12px -12px var(--divider-color); + box-shadow: var(--drop-shadow), 0 -3px 6px -3px var(--drop-shadow-color); } #compose-container .toolbar { @@ -131,8 +139,8 @@ } #compose-container .toolbar-button { display: inline-block; - color: var(--text-color); - background-color: var(--bg-faded-color); + color: var(--link-color); + background-color: var(--bg-blur-color); padding: 0 8px; border-radius: 8px; min-height: 2.4em; @@ -150,9 +158,10 @@ cursor: inherit; outline: 0; } -#compose-container .toolbar-button:has([disabled]) { +#compose-container .toolbar-button:has([disabled]), +#compose-container .toolbar-button[disabled] { pointer-events: none; - background-color: var(--bg-faded-color); + background-color: transparent; opacity: 0.5; } #compose-container @@ -186,9 +195,14 @@ ) { cursor: pointer; filter: none; - border-color: var(--divider-color); + background-color: var(--bg-color); + border-color: var(--link-faded-color); outline: 0; } +#compose-container .toolbar-button:not(:disabled).highlight { + border-color: var(--link-color); + box-shadow: inset 0 0 8px var(--link-faded-color); +} #compose-container .toolbar-button:not(:disabled):active { filter: brightness(0.8); } @@ -430,6 +444,12 @@ } } +@media (min-width: 480px) { + #compose-container button[type='submit'] { + padding-inline: 24px; + } +} + #media-sheet main { padding-top: 8px; display: flex; diff --git a/src/components/compose.jsx b/src/components/compose.jsx index 0eafecf0..76f595c6 100644 --- a/src/components/compose.jsx +++ b/src/components/compose.jsx @@ -348,12 +348,24 @@ function Compose({ }; useEffect(updateCharCount, []); + const escDownRef = useRef(false); useHotkeys( 'esc', () => { - if (!standalone && confirmClose()) { + escDownRef.current = true; + // This won't be true if this event is already handled and not propagated 🤞 + }, + { + enableOnFormTags: true, + }, + ); + useHotkeys( + 'esc', + () => { + if (!standalone && escDownRef.current && confirmClose()) { onClose(); } + escDownRef.current = false; }, { enableOnFormTags: true, @@ -490,7 +502,7 @@ function Compose({ {currentAccountInfo?.avatarStatic && ( )} @@ -687,6 +699,17 @@ function Compose({ } // TODO: check for URLs and use `charactersReservedPerUrl` to calculate max characters + if (mediaAttachments.length > 0) { + // If there are media attachments, check if they have no descriptions + const hasNoDescriptions = mediaAttachments.some( + (media) => !media.description?.trim?.(), + ); + if (hasNoDescriptions) { + const yes = confirm('Some media have no descriptions. Continue?'); + if (!yes) return; + } + } + // Post-cleanup spoilerText = (sensitive && spoilerText) || undefined; status = status === '' ? undefined : status; @@ -819,7 +842,7 @@ function Compose({ }} />

-
-

Media description

-
-
-

- {showMediaAlt} -

-
-
- - )} - {!!showMediaAlt && ( - { - if (e.target === e.currentTarget) { - setShowMediaAlt(false); - } - }} - > -
-
-

Media description

-
-
-

- {showMediaAlt} -

-
-
+
)} ); } +function MediaAltModal({ alt }) { + const [forceTranslate, setForceTranslate] = useState(false); + return ( +
+
+

Media description

+
+ + + + } + > + { + setForceTranslate(true); + }} + > + + Translate + + +
+
+
+

+ {alt} +

+ {forceTranslate && ( + + )} +
+
+ ); +} + export default MediaModal; diff --git a/src/components/media.jsx b/src/components/media.jsx index 659d5902..9d3fdcfc 100644 --- a/src/components/media.jsx +++ b/src/components/media.jsx @@ -1,6 +1,7 @@ import { getBlurHashAverageColor } from 'fast-blurhash'; import { useRef } from 'preact/hooks'; +import Icon from './icon'; import { formatDuration } from './status'; /* @@ -74,6 +75,14 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) { backgroundPosition: focalBackgroundPosition || 'center', } } + onDblClick={() => { + // Open original image in new tab + window.open(url, '_blank'); + }} + onLoad={(e) => { + // Hide background image after image loads + e.target.parentElement.style.backgroundImage = 'none'; + }} /> ); @@ -161,13 +170,18 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) { muted /> ) : ( - {description} + <> + {description} +
+ +
+ )} ); diff --git a/src/components/menu.jsx b/src/components/menu.jsx index c7ca39f0..bca5dcb5 100644 --- a/src/components/menu.jsx +++ b/src/components/menu.jsx @@ -3,6 +3,7 @@ import { useSnapshot } from 'valtio'; import { api } from '../utils/api'; import states from '../utils/states'; +import { getCurrentAccount } from '../utils/store-utils'; import Icon from './icon'; import MenuLink from './MenuLink'; @@ -10,6 +11,7 @@ import MenuLink from './MenuLink'; function NavMenu(props) { const snapStates = useSnapshot(states); const { instance, authenticated } = api(); + const currentAccount = getCurrentAccount(); // Home = Following // But when in multi-column mode, Home becomes columns of anything @@ -102,6 +104,18 @@ function NavMenu(props) { {authenticated && ( <> + {currentAccount?.info?.id && ( + + Profile + + )} + { + states.showAccounts = true; + }} + > + Accounts… + { states.showShortcutsSettings = true; diff --git a/src/components/name-text.jsx b/src/components/name-text.jsx index 0137f500..36e8c198 100644 --- a/src/components/name-text.jsx +++ b/src/components/name-text.jsx @@ -52,7 +52,7 @@ function NameText({ > {showAvatar && ( <> - {' '} + {' '} )} {displayName && !short ? ( diff --git a/src/components/shortcuts-settings.css b/src/components/shortcuts-settings.css index 0758f967..7cd8e494 100644 --- a/src/components/shortcuts-settings.css +++ b/src/components/shortcuts-settings.css @@ -33,7 +33,55 @@ #shortcuts-settings-container .shortcuts-view-mode { display: flex; align-items: center; + gap: 2px; + margin: 8px 0 0; +} +#shortcuts-settings-container .shortcuts-view-mode label { + border-radius: 4px; + background-color: var(--bg-faded-color); + padding: 16px; + text-align: center; + cursor: pointer; + display: block; + flex-grow: 1; + display: flex; gap: 8px; + flex-direction: column; + align-items: center; +} +#shortcuts-settings-container .shortcuts-view-mode label:first-child { + border-top-left-radius: 16px; + border-bottom-left-radius: 16px; +} +#shortcuts-settings-container .shortcuts-view-mode label:last-child { + border-top-right-radius: 16px; + border-bottom-right-radius: 16px; +} +#shortcuts-settings-container .shortcuts-view-mode label img { + max-height: 64px; +} +#shortcuts-settings-container .shortcuts-view-mode label span { + text-align: center; + font-size: 80%; +} +#shortcuts-settings-container .shortcuts-view-mode label input { + position: absolute; + opacity: 0; + pointer-events: none; +} +#shortcuts-settings-container .shortcuts-view-mode label input ~ * { + opacity: 0.5; + transition: opacity 0.2s; +} +#shortcuts-settings-container .shortcuts-view-mode label:has(input:checked) { + box-shadow: inset 0 0 0 3px var(--link-color); +} +#shortcuts-settings-container + .shortcuts-view-mode + label + input:is(:hover, :active, :checked) + ~ * { + opacity: 1; } #shortcuts-settings-container summary { diff --git a/src/components/shortcuts-settings.jsx b/src/components/shortcuts-settings.jsx index ecf39c3d..77bdbf0b 100644 --- a/src/components/shortcuts-settings.jsx +++ b/src/components/shortcuts-settings.jsx @@ -4,6 +4,9 @@ import mem from 'mem'; import { useEffect, useState } from 'preact/hooks'; import { useSnapshot } from 'valtio'; +import floatingButtonUrl from '../assets/floating-button.svg'; +import multiColumnUrl from '../assets/multi-column.svg'; +import tabMenuBarUrl from '../assets/tab-menu-bar.svg'; import { api } from '../utils/api'; import states from '../utils/states'; @@ -208,9 +211,40 @@ function ShortcutsSettings() {

- + ))} + + {/* - + */}

{/*

diff --git a/src/components/shortcuts.css b/src/components/shortcuts.css index 5c57a920..1c1040cf 100644 --- a/src/components/shortcuts.css +++ b/src/components/shortcuts.css @@ -15,7 +15,9 @@ #shortcuts-button .icon { transform: translateY(2px); /* Balance the icon's vertical alignment */ } -#app:has(header[hidden]) #shortcuts-button, +#app:has(#home-page):not(:has(#home-page ~ .deck-container)):has(header[hidden]) + #shortcuts-button, +#app:has(#home-page ~ .deck-container header[hidden]) #shortcuts-button, #shortcuts-button[hidden] { transform: translateY(200%); pointer-events: none; @@ -39,7 +41,11 @@ top: max(16px, env(safe-area-inset-top)); bottom: auto; } - #app:has(header[hidden]) #shortcuts-button, + #app:has(#home-page):not(:has(#home-page ~ .deck-container)):has( + header[hidden] + ) + #shortcuts-button, + #app:has(#home-page ~ .deck-container header[hidden]) #shortcuts-button, #shortcuts-button[hidden] { transform: translateY(-200%); } @@ -114,7 +120,10 @@ transparent ); } -#app:has(header[hidden]) #shortcuts .tab-bar, +#app:has(#home-page):not(:has(#home-page ~ .deck-container)):has(header[hidden]) + #shortcuts + .tab-bar, +#app:has(#home-page ~ .deck-container header[hidden]) #shortcuts .tab-bar, shortcuts .tab-bar[hidden] { transform: translateY(200%); pointer-events: none; @@ -163,7 +172,12 @@ shortcuts .tab-bar[hidden] { height: 44px; gap: 4px; } - #app:has(header[hidden]) #shortcuts .tab-bar, + #app:has(#home-page):not(:has(#home-page ~ .deck-container)):has( + header[hidden] + ) + #shortcuts + .tab-bar, + #app:has(#home-page ~ .deck-container header[hidden]) #shortcuts .tab-bar, shortcuts .tab-bar[hidden] { transform: translateY(-150%); pointer-events: none; diff --git a/src/components/status.css b/src/components/status.css index 424c0f87..e78a6c69 100644 --- a/src/components/status.css +++ b/src/components/status.css @@ -251,6 +251,9 @@ filter: none; image-rendering: auto; } +.status .content a:not(.mention):not(:has(span)) { + color: inherit; +} .timeline-deck .status .content { max-height: 50vh; @@ -317,7 +320,7 @@ } .status.large .content { font-size: 150%; - font-size: calc(100% + 50% / var(--content-text-weight)); + font-size: min(calc(100% + 50% / var(--content-text-weight)), 150%); } .status.large .poll, .status.large .actions { @@ -426,20 +429,6 @@ .status .media { cursor: pointer; } -@keyframes position-object { - 0% { - object-position: 50% 50%; - } - 25% { - object-position: 0% 0%; - } - 75% { - object-position: 100% 100%; - } - 100% { - object-position: 50% 50%; - } -} .status .media img:is(:hover, :focus), a:focus-visible .status .media img { animation: position-object 5s ease-in-out 1s 5; @@ -456,14 +445,11 @@ body:has(#modal-container .carousel) .status .media img:hover { position: relative; background-clip: padding-box; } -.status :is(.media-video, .media-audio)[data-formatted-duration]:before { +.status :is(.media-video, .media-audio)[data-formatted-duration] .media-play { pointer-events: none; - content: '⏵'; width: 70px; height: 70px; - font-size: 50px; position: absolute; - text-indent: 3px; left: 50%; top: 50%; transform: translate(-50%, -50%); @@ -476,7 +462,9 @@ body:has(#modal-container .carousel) .status .media img:hover { border-radius: 70px; transition: all 0.2s ease-in-out; } -.status :is(.media-video, .media-audio)[data-formatted-duration]:hover:before { +.status + :is(.media-video, .media-audio)[data-formatted-duration]:hover + .media-play { color: var(--text-color); background-color: var(--bg-blur-color); } diff --git a/src/components/status.jsx b/src/components/status.jsx index ab57b354..8494538b 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -12,6 +12,7 @@ import pThrottle from 'p-throttle'; import { memo } from 'preact/compat'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import 'swiped-events'; +import { useLongPress } from 'use-long-press'; import useResizeObserver from 'use-resize-observer'; import { useSnapshot } from 'valtio'; @@ -20,6 +21,7 @@ import Modal from '../components/modal'; import NameText from '../components/name-text'; import { api } from '../utils/api'; import enhanceContent from '../utils/enhance-content'; +import getTranslateTargetLanguage from '../utils/get-translate-target-language'; import handleContentLinks from '../utils/handle-content-links'; import htmlContentLength from '../utils/html-content-length'; import niceDateTime from '../utils/nice-date-time'; @@ -35,6 +37,7 @@ import Link from './link'; import Media from './media'; import MenuLink from './MenuLink'; import RelativeTime from './relative-time'; +import TranslationBlock from './translation-block'; const throttle = pThrottle({ limit: 1, @@ -66,6 +69,7 @@ function Status({ skeleton, readOnly, contentTextWeight, + enableTranslate, }) { if (skeleton) { return ( @@ -194,6 +198,10 @@ function Status({ ); } + const [forceTranslate, setForceTranslate] = useState(false); + const targetLanguage = getTranslateTargetLanguage(true); + if (!snapStates.settings.contentTranslation) enableTranslate = false; + const [showEdited, setShowEdited] = useState(false); const spoilerContentRef = useRef(null); @@ -229,14 +237,24 @@ function Status({ const unauthInteractionErrorMessage = `Sorry, your current logged-in instance can't interact with this status from another instance.`; const textWeight = () => - Math.round((spoilerText.length + htmlContentLength(content)) / 140) || 1; + Math.max( + Math.round((spoilerText.length + htmlContentLength(content)) / 140) || 1, + 1, + ); const createdDateText = niceDateTime(createdAtDate); const editedDateText = editedAt && niceDateTime(editedAtDate); const isSizeLarge = size === 'l'; - // TODO: if visibility = private, only can boost own statuses - const canBoost = authenticated && visibility !== 'direct'; + // Can boost if: + // - authenticated AND + // - visibility != direct OR + // - visibility = private AND isSelf + let canBoost = + authenticated && visibility !== 'direct' && visibility !== 'private'; + if (visibility === 'private' && isSelf) { + canBoost = true; + } const replyStatus = () => { if (!sameInstance || !authenticated) { @@ -253,7 +271,15 @@ function Status({ } try { if (!reblogged) { - const yes = confirm('Boost this post?'); + // Check if media has no descriptions + const hasNoDescriptions = mediaAttachments.some( + (attachment) => !attachment.description?.trim?.(), + ); + let confirmText = 'Boost this post?'; + if (hasNoDescriptions) { + confirmText += '\n\n⚠️ Some media have no descriptions.'; + } + const yes = confirm(confirmText); if (!yes) { return; } @@ -362,7 +388,7 @@ function Status({ - View post and replies + View post by @{username || acct} )} @@ -381,7 +407,7 @@ function Status({ )} {(!isSizeLarge || !!editedAt) && } - {!isSizeLarge && ( + {!isSizeLarge && sameInstance && ( <> @@ -397,7 +423,12 @@ function Status({ } catch (e) {} }} > - + {reblogged ? 'Unboost' : 'Boost…'} )} @@ -410,7 +441,12 @@ function Status({ } catch (e) {} }} > - + {favourited ? 'Unfavourite' : 'Favourite'} - + {bookmarked ? 'Unbookmark' : 'Bookmark'} - )} + {enableTranslate && ( + { + setForceTranslate(true); + }} + > + + Translate + + )} + {((!isSizeLarge && sameInstance) || enableTranslate) && } - Open link to post + {nicePostURL(url)} - { - // Copy url to clipboard - try { - navigator.clipboard.writeText(url); - showToast('Link copied'); - } catch (e) { - console.error(e); - showToast('Unable to copy link'); - } - }} - > - - Copy link to post - - {navigator?.share && - navigator?.canShare?.({ - url, - }) && ( - { - try { - navigator.share({ - url, - }); - } catch (e) { - console.error(e); - alert("Sharing doesn't seem to work."); - } - }} - > - - Share… - - )} + {isSelf && ( <> @@ -485,11 +539,27 @@ function Status({ ); + const contextMenuRef = useRef(); const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); const [contextMenuAnchorPoint, setContextMenuAnchorPoint] = useState({ x: 0, y: 0, }); + const bindLongPress = useLongPress( + (e) => { + const { clientX, clientY } = e.touches?.[0] || e; + setContextMenuAnchorPoint({ + x: clientX, + y: clientY, + }); + setIsContextMenuOpen(true); + }, + { + captureEvent: true, + detect: 'touch', + cancelOnMovement: true, + }, + ); return (
{ if (size === 'l') return; if (e.metaKey) return; + // console.log('context menu', e); + const link = e.target.closest('a'); + if (link && /^https?:\/\//.test(link.getAttribute('href'))) return; e.preventDefault(); setContextMenuAnchorPoint({ x: e.clientX, @@ -515,9 +588,11 @@ function Status({ }); setIsContextMenuOpen(true); }} + {...bindLongPress()} > {size !== 'l' && ( { + contextMenuRef.current?.closeMenu?.(); + }, }} overflow="auto" - boundingBoxPadding="8 8 8 8" + boundingBoxPadding={safeBoundingBoxPadding()} unmountOnClose > {StatusMenuItems} @@ -561,7 +639,7 @@ function Status({ }; }} > - + )}
@@ -767,6 +845,25 @@ function Status({ }} /> )} + {((enableTranslate && + !!content.trim() && + language && + language !== targetLanguage) || + forceTranslate) && ( + `- ${option.title}`) + .join('\n')}` + : '') + } + /> + )} {!spoilerText && sensitive && !!mediaAttachments.length && ( )} + {!!timelineStart &&
{timelineStart}
} {!!items.length ? ( <>
    {items.map((status) => { - const { id: statusID, reblog, items, type } = status; + const { id: statusID, reblog, items, type, _pinned } = status; const actualStatusID = reblog?.id || statusID; const url = instance ? `/${instance}/s/${actualStatusID}` @@ -347,7 +349,7 @@ function Timeline({ ); } return ( -
  • +
  • {useItemID ? ( diff --git a/src/components/translation-block.css b/src/components/translation-block.css new file mode 100644 index 00000000..4e3b0d3d --- /dev/null +++ b/src/components/translation-block.css @@ -0,0 +1,86 @@ +.status-translation-block { + margin: 8px 0 0; + padding: 0; + font-size: 90%; + border-radius: 8px; +} +.status-translation-block summary { + list-style: none; + display: inline-block; +} +.status-translation-block summary::-webkit-details-marker { + display: none; +} +.status-translation-block summary button { + border-radius: 8px; + border: 1px solid var(--outline-color); + padding: 8px; + background-color: var(--bg-color); + font-size: 12px; + color: var(--text-insignificant-color); +} +.status-translation-block summary button:is(:hover, :focus) { + color: var(--text-color); + filter: none !important; +} +.status-translation-block details:not([open]) .detected { + display: none; +} +/* .status-translation-block details summary button:active, */ +.status-translation-block details[open] summary button { + /* color: var(--text-color); */ + /* background-color: var(--bg-faded-color); */ + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom: 0; + margin-bottom: -1px; + background-image: linear-gradient( + to top left, + var(--bg-color) 50%, + var(--bg-faded-blur-color) + ); + box-shadow: inset 0 0 0 1px var(--bg-color); +} +.status-translation-block .translated-block { + border: 1px solid var(--outline-color); + line-height: 1.3; + border-radius: 0 8px 8px 8px; + margin: 0; + padding: 8px; + background-color: var(--bg-color); + background-image: linear-gradient( + to bottom right, + var(--bg-color), + var(--bg-faded-blur-color) + ); + white-space: pre-wrap; + box-shadow: inset 0 0 0 1px var(--bg-color), + 0 1px 5px -2px var(--drop-shadow-color); + text-shadow: 0 1px var(--bg-color); +} +.status-translation-block .translated-block .translation-info * { + vertical-align: middle; +} +.status-translation-block .translated-source-select { + appearance: none; + display: inline-block; + margin: 0; + padding: 4px 8px; + border: 0; + border-radius: 8px; + background-color: var(--bg-faded-color); + color: inherit; + width: min-content; +} +.status-translation-block .translated-block output { + display: block; + margin-top: 1em; +} +.status-translation-block + .translated-block + output.translated-pronunciation-content { + opacity: 0.75; + padding-bottom: 1em; + border-top: var(--hairline-width) solid var(--bg-color); + border-bottom: var(--hairline-width) solid var(--outline-color); +} diff --git a/src/components/translation-block.jsx b/src/components/translation-block.jsx new file mode 100644 index 00000000..aa7b4479 --- /dev/null +++ b/src/components/translation-block.jsx @@ -0,0 +1,154 @@ +import './translation-block.css'; + +import { useEffect, useRef, useState } from 'preact/hooks'; + +import sourceLanguages from '../data/lingva-source-languages'; +import getTranslateTargetLanguage from '../utils/get-translate-target-language'; +import localeCode2Text from '../utils/localeCode2Text'; + +import Icon from './icon'; +import Loader from './loader'; + +function TranslationBlock({ + forceTranslate, + sourceLanguage, + onTranslate, + text = '', +}) { + const targetLang = getTranslateTargetLanguage(true); + const [uiState, setUIState] = useState('default'); + const [pronunciationContent, setPronunciationContent] = useState(null); + const [translatedContent, setTranslatedContent] = useState(null); + const [detectedLang, setDetectedLang] = useState(null); + const detailsRef = useRef(); + + const sourceLangText = sourceLanguage + ? localeCode2Text(sourceLanguage) + : null; + const targetLangText = localeCode2Text(targetLang); + const apiSourceLang = useRef('auto'); + + if (!onTranslate) + onTranslate = (source, target) => { + console.log('TRANSLATE', source, target, text); + // Using another API instance instead of lingva.ml because of this bug (slashes don't work): + // https://github.com/thedaviddelta/lingva-translate/issues/68 + return fetch( + `https://lingva.garudalinux.org/api/v1/${source}/${target}/${encodeURIComponent( + text, + )}`, + ) + .then((res) => res.json()) + .then((res) => { + return { + provider: 'lingva', + content: res.translation, + detectedSourceLanguage: res.info.detectedSource, + info: res.info, + }; + }); + // return masto.v1.statuses.translate(id, { + // lang: DEFAULT_LANG, + // }); + }; + + const translate = async () => { + setUIState('loading'); + const { content, detectedSourceLanguage, provider, ...props } = + await onTranslate(apiSourceLang.current, targetLang); + if (content) { + if (detectedSourceLanguage) { + const detectedLangText = localeCode2Text(detectedSourceLanguage); + setDetectedLang(detectedLangText); + } + if (provider === 'lingva') { + const pronunciation = props?.info?.pronunciation?.query; + if (pronunciation) { + setPronunciationContent(pronunciation); + } + } + setTranslatedContent(content); + setUIState('default'); + detailsRef.current.open = true; + detailsRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + } else { + console.error(result); + setUIState('error'); + } + }; + + useEffect(() => { + if (forceTranslate) { + translate(); + } + }, [forceTranslate]); + + return ( +
    +
    + + + +
    +
    + {' '} + → {targetLangText} +
    + {uiState === 'error' ? ( +

    Failed to translate

    + ) : ( + !!translatedContent && ( + <> + {!!pronunciationContent && ( + + {pronunciationContent} + + )} + + {translatedContent} + + + ) + )} +
    +
    +
    + ); +} + +export default TranslationBlock; diff --git a/src/data/lingva-source-languages.json b/src/data/lingva-source-languages.json new file mode 100644 index 00000000..bcde98d2 --- /dev/null +++ b/src/data/lingva-source-languages.json @@ -0,0 +1,534 @@ +[ + { + "code": "auto", + "name": "Detect" + }, + { + "code": "af", + "name": "Afrikaans" + }, + { + "code": "sq", + "name": "Albanian" + }, + { + "code": "am", + "name": "Amharic" + }, + { + "code": "ar", + "name": "Arabic" + }, + { + "code": "hy", + "name": "Armenian" + }, + { + "code": "as", + "name": "Assamese" + }, + { + "code": "ay", + "name": "Aymara" + }, + { + "code": "az", + "name": "Azerbaijani" + }, + { + "code": "bm", + "name": "Bambara" + }, + { + "code": "eu", + "name": "Basque" + }, + { + "code": "be", + "name": "Belarusian" + }, + { + "code": "bn", + "name": "Bengali" + }, + { + "code": "bho", + "name": "Bhojpuri" + }, + { + "code": "bs", + "name": "Bosnian" + }, + { + "code": "bg", + "name": "Bulgarian" + }, + { + "code": "ca", + "name": "Catalan" + }, + { + "code": "ceb", + "name": "Cebuano" + }, + { + "code": "ny", + "name": "Chichewa" + }, + { + "code": "zh", + "name": "Chinese" + }, + { + "code": "co", + "name": "Corsican" + }, + { + "code": "hr", + "name": "Croatian" + }, + { + "code": "cs", + "name": "Czech" + }, + { + "code": "da", + "name": "Danish" + }, + { + "code": "dv", + "name": "Dhivehi" + }, + { + "code": "doi", + "name": "Dogri" + }, + { + "code": "nl", + "name": "Dutch" + }, + { + "code": "en", + "name": "English" + }, + { + "code": "eo", + "name": "Esperanto" + }, + { + "code": "et", + "name": "Estonian" + }, + { + "code": "ee", + "name": "Ewe" + }, + { + "code": "tl", + "name": "Filipino" + }, + { + "code": "fi", + "name": "Finnish" + }, + { + "code": "fr", + "name": "French" + }, + { + "code": "fy", + "name": "Frisian" + }, + { + "code": "gl", + "name": "Galician" + }, + { + "code": "ka", + "name": "Georgian" + }, + { + "code": "de", + "name": "German" + }, + { + "code": "el", + "name": "Greek" + }, + { + "code": "gn", + "name": "Guarani" + }, + { + "code": "gu", + "name": "Gujarati" + }, + { + "code": "ht", + "name": "Haitian Creole" + }, + { + "code": "ha", + "name": "Hausa" + }, + { + "code": "haw", + "name": "Hawaiian" + }, + { + "code": "iw", + "name": "Hebrew" + }, + { + "code": "hi", + "name": "Hindi" + }, + { + "code": "hmn", + "name": "Hmong" + }, + { + "code": "hu", + "name": "Hungarian" + }, + { + "code": "is", + "name": "Icelandic" + }, + { + "code": "ig", + "name": "Igbo" + }, + { + "code": "ilo", + "name": "Ilocano" + }, + { + "code": "id", + "name": "Indonesian" + }, + { + "code": "ga", + "name": "Irish" + }, + { + "code": "it", + "name": "Italian" + }, + { + "code": "ja", + "name": "Japanese" + }, + { + "code": "jw", + "name": "Javanese" + }, + { + "code": "kn", + "name": "Kannada" + }, + { + "code": "kk", + "name": "Kazakh" + }, + { + "code": "km", + "name": "Khmer" + }, + { + "code": "rw", + "name": "Kinyarwanda" + }, + { + "code": "gom", + "name": "Konkani" + }, + { + "code": "ko", + "name": "Korean" + }, + { + "code": "kri", + "name": "Krio" + }, + { + "code": "ku", + "name": "Kurdish (Kurmanji)" + }, + { + "code": "ckb", + "name": "Kurdish (Sorani)" + }, + { + "code": "ky", + "name": "Kyrgyz" + }, + { + "code": "lo", + "name": "Lao" + }, + { + "code": "la", + "name": "Latin" + }, + { + "code": "lv", + "name": "Latvian" + }, + { + "code": "ln", + "name": "Lingala" + }, + { + "code": "lt", + "name": "Lithuanian" + }, + { + "code": "lg", + "name": "Luganda" + }, + { + "code": "lb", + "name": "Luxembourgish" + }, + { + "code": "mk", + "name": "Macedonian" + }, + { + "code": "mai", + "name": "Maithili" + }, + { + "code": "mg", + "name": "Malagasy" + }, + { + "code": "ms", + "name": "Malay" + }, + { + "code": "ml", + "name": "Malayalam" + }, + { + "code": "mt", + "name": "Maltese" + }, + { + "code": "mi", + "name": "Maori" + }, + { + "code": "mr", + "name": "Marathi" + }, + { + "code": "mni-Mtei", + "name": "Meiteilon (Manipuri)" + }, + { + "code": "lus", + "name": "Mizo" + }, + { + "code": "mn", + "name": "Mongolian" + }, + { + "code": "my", + "name": "Myanmar (Burmese)" + }, + { + "code": "ne", + "name": "Nepali" + }, + { + "code": "no", + "name": "Norwegian" + }, + { + "code": "or", + "name": "Odia (Oriya)" + }, + { + "code": "om", + "name": "Oromo" + }, + { + "code": "ps", + "name": "Pashto" + }, + { + "code": "fa", + "name": "Persian" + }, + { + "code": "pl", + "name": "Polish" + }, + { + "code": "pt", + "name": "Portuguese" + }, + { + "code": "pa", + "name": "Punjabi" + }, + { + "code": "qu", + "name": "Quechua" + }, + { + "code": "ro", + "name": "Romanian" + }, + { + "code": "ru", + "name": "Russian" + }, + { + "code": "sm", + "name": "Samoan" + }, + { + "code": "sa", + "name": "Sanskrit" + }, + { + "code": "gd", + "name": "Scots Gaelic" + }, + { + "code": "nso", + "name": "Sepedi" + }, + { + "code": "sr", + "name": "Serbian" + }, + { + "code": "st", + "name": "Sesotho" + }, + { + "code": "sn", + "name": "Shona" + }, + { + "code": "sd", + "name": "Sindhi" + }, + { + "code": "si", + "name": "Sinhala" + }, + { + "code": "sk", + "name": "Slovak" + }, + { + "code": "sl", + "name": "Slovenian" + }, + { + "code": "so", + "name": "Somali" + }, + { + "code": "es", + "name": "Spanish" + }, + { + "code": "su", + "name": "Sundanese" + }, + { + "code": "sw", + "name": "Swahili" + }, + { + "code": "sv", + "name": "Swedish" + }, + { + "code": "tg", + "name": "Tajik" + }, + { + "code": "ta", + "name": "Tamil" + }, + { + "code": "tt", + "name": "Tatar" + }, + { + "code": "te", + "name": "Telugu" + }, + { + "code": "th", + "name": "Thai" + }, + { + "code": "ti", + "name": "Tigrinya" + }, + { + "code": "ts", + "name": "Tsonga" + }, + { + "code": "tr", + "name": "Turkish" + }, + { + "code": "tk", + "name": "Turkmen" + }, + { + "code": "ak", + "name": "Twi" + }, + { + "code": "uk", + "name": "Ukrainian" + }, + { + "code": "ur", + "name": "Urdu" + }, + { + "code": "ug", + "name": "Uyghur" + }, + { + "code": "uz", + "name": "Uzbek" + }, + { + "code": "vi", + "name": "Vietnamese" + }, + { + "code": "cy", + "name": "Welsh" + }, + { + "code": "xh", + "name": "Xhosa" + }, + { + "code": "yi", + "name": "Yiddish" + }, + { + "code": "yo", + "name": "Yoruba" + }, + { + "code": "zu", + "name": "Zulu" + } +] \ No newline at end of file diff --git a/src/data/lingva-target-languages.json b/src/data/lingva-target-languages.json new file mode 100644 index 00000000..b8c760de --- /dev/null +++ b/src/data/lingva-target-languages.json @@ -0,0 +1,534 @@ +[ + { + "code": "af", + "name": "Afrikaans" + }, + { + "code": "sq", + "name": "Albanian" + }, + { + "code": "am", + "name": "Amharic" + }, + { + "code": "ar", + "name": "Arabic" + }, + { + "code": "hy", + "name": "Armenian" + }, + { + "code": "as", + "name": "Assamese" + }, + { + "code": "ay", + "name": "Aymara" + }, + { + "code": "az", + "name": "Azerbaijani" + }, + { + "code": "bm", + "name": "Bambara" + }, + { + "code": "eu", + "name": "Basque" + }, + { + "code": "be", + "name": "Belarusian" + }, + { + "code": "bn", + "name": "Bengali" + }, + { + "code": "bho", + "name": "Bhojpuri" + }, + { + "code": "bs", + "name": "Bosnian" + }, + { + "code": "bg", + "name": "Bulgarian" + }, + { + "code": "ca", + "name": "Catalan" + }, + { + "code": "ceb", + "name": "Cebuano" + }, + { + "code": "ny", + "name": "Chichewa" + }, + { + "code": "zh", + "name": "Chinese" + }, + { + "code": "zh_HANT", + "name": "Chinese (Traditional)" + }, + { + "code": "co", + "name": "Corsican" + }, + { + "code": "hr", + "name": "Croatian" + }, + { + "code": "cs", + "name": "Czech" + }, + { + "code": "da", + "name": "Danish" + }, + { + "code": "dv", + "name": "Dhivehi" + }, + { + "code": "doi", + "name": "Dogri" + }, + { + "code": "nl", + "name": "Dutch" + }, + { + "code": "en", + "name": "English" + }, + { + "code": "eo", + "name": "Esperanto" + }, + { + "code": "et", + "name": "Estonian" + }, + { + "code": "ee", + "name": "Ewe" + }, + { + "code": "tl", + "name": "Filipino" + }, + { + "code": "fi", + "name": "Finnish" + }, + { + "code": "fr", + "name": "French" + }, + { + "code": "fy", + "name": "Frisian" + }, + { + "code": "gl", + "name": "Galician" + }, + { + "code": "ka", + "name": "Georgian" + }, + { + "code": "de", + "name": "German" + }, + { + "code": "el", + "name": "Greek" + }, + { + "code": "gn", + "name": "Guarani" + }, + { + "code": "gu", + "name": "Gujarati" + }, + { + "code": "ht", + "name": "Haitian Creole" + }, + { + "code": "ha", + "name": "Hausa" + }, + { + "code": "haw", + "name": "Hawaiian" + }, + { + "code": "iw", + "name": "Hebrew" + }, + { + "code": "hi", + "name": "Hindi" + }, + { + "code": "hmn", + "name": "Hmong" + }, + { + "code": "hu", + "name": "Hungarian" + }, + { + "code": "is", + "name": "Icelandic" + }, + { + "code": "ig", + "name": "Igbo" + }, + { + "code": "ilo", + "name": "Ilocano" + }, + { + "code": "id", + "name": "Indonesian" + }, + { + "code": "ga", + "name": "Irish" + }, + { + "code": "it", + "name": "Italian" + }, + { + "code": "ja", + "name": "Japanese" + }, + { + "code": "jw", + "name": "Javanese" + }, + { + "code": "kn", + "name": "Kannada" + }, + { + "code": "kk", + "name": "Kazakh" + }, + { + "code": "km", + "name": "Khmer" + }, + { + "code": "rw", + "name": "Kinyarwanda" + }, + { + "code": "gom", + "name": "Konkani" + }, + { + "code": "ko", + "name": "Korean" + }, + { + "code": "kri", + "name": "Krio" + }, + { + "code": "ku", + "name": "Kurdish (Kurmanji)" + }, + { + "code": "ckb", + "name": "Kurdish (Sorani)" + }, + { + "code": "ky", + "name": "Kyrgyz" + }, + { + "code": "lo", + "name": "Lao" + }, + { + "code": "la", + "name": "Latin" + }, + { + "code": "lv", + "name": "Latvian" + }, + { + "code": "ln", + "name": "Lingala" + }, + { + "code": "lt", + "name": "Lithuanian" + }, + { + "code": "lg", + "name": "Luganda" + }, + { + "code": "lb", + "name": "Luxembourgish" + }, + { + "code": "mk", + "name": "Macedonian" + }, + { + "code": "mai", + "name": "Maithili" + }, + { + "code": "mg", + "name": "Malagasy" + }, + { + "code": "ms", + "name": "Malay" + }, + { + "code": "ml", + "name": "Malayalam" + }, + { + "code": "mt", + "name": "Maltese" + }, + { + "code": "mi", + "name": "Maori" + }, + { + "code": "mr", + "name": "Marathi" + }, + { + "code": "mni-Mtei", + "name": "Meiteilon (Manipuri)" + }, + { + "code": "lus", + "name": "Mizo" + }, + { + "code": "mn", + "name": "Mongolian" + }, + { + "code": "my", + "name": "Myanmar (Burmese)" + }, + { + "code": "ne", + "name": "Nepali" + }, + { + "code": "no", + "name": "Norwegian" + }, + { + "code": "or", + "name": "Odia (Oriya)" + }, + { + "code": "om", + "name": "Oromo" + }, + { + "code": "ps", + "name": "Pashto" + }, + { + "code": "fa", + "name": "Persian" + }, + { + "code": "pl", + "name": "Polish" + }, + { + "code": "pt", + "name": "Portuguese" + }, + { + "code": "pa", + "name": "Punjabi" + }, + { + "code": "qu", + "name": "Quechua" + }, + { + "code": "ro", + "name": "Romanian" + }, + { + "code": "ru", + "name": "Russian" + }, + { + "code": "sm", + "name": "Samoan" + }, + { + "code": "sa", + "name": "Sanskrit" + }, + { + "code": "gd", + "name": "Scots Gaelic" + }, + { + "code": "nso", + "name": "Sepedi" + }, + { + "code": "sr", + "name": "Serbian" + }, + { + "code": "st", + "name": "Sesotho" + }, + { + "code": "sn", + "name": "Shona" + }, + { + "code": "sd", + "name": "Sindhi" + }, + { + "code": "si", + "name": "Sinhala" + }, + { + "code": "sk", + "name": "Slovak" + }, + { + "code": "sl", + "name": "Slovenian" + }, + { + "code": "so", + "name": "Somali" + }, + { + "code": "es", + "name": "Spanish" + }, + { + "code": "su", + "name": "Sundanese" + }, + { + "code": "sw", + "name": "Swahili" + }, + { + "code": "sv", + "name": "Swedish" + }, + { + "code": "tg", + "name": "Tajik" + }, + { + "code": "ta", + "name": "Tamil" + }, + { + "code": "tt", + "name": "Tatar" + }, + { + "code": "te", + "name": "Telugu" + }, + { + "code": "th", + "name": "Thai" + }, + { + "code": "ti", + "name": "Tigrinya" + }, + { + "code": "ts", + "name": "Tsonga" + }, + { + "code": "tr", + "name": "Turkish" + }, + { + "code": "tk", + "name": "Turkmen" + }, + { + "code": "ak", + "name": "Twi" + }, + { + "code": "uk", + "name": "Ukrainian" + }, + { + "code": "ur", + "name": "Urdu" + }, + { + "code": "ug", + "name": "Uyghur" + }, + { + "code": "uz", + "name": "Uzbek" + }, + { + "code": "vi", + "name": "Vietnamese" + }, + { + "code": "cy", + "name": "Welsh" + }, + { + "code": "xh", + "name": "Xhosa" + }, + { + "code": "yi", + "name": "Yiddish" + }, + { + "code": "yo", + "name": "Yoruba" + }, + { + "code": "zu", + "name": "Zulu" + } +] \ No newline at end of file diff --git a/src/index.css b/src/index.css index 796ec971..339df71f 100644 --- a/src/index.css +++ b/src/index.css @@ -2,6 +2,12 @@ @custom-media --viewport-medium (min-width: 40em); :root { + --sai-top: env(safe-area-inset-top); + --sai-right: env(safe-area-inset-right); + --sai-bottom: env(safe-area-inset-bottom); + --sai-left: env(safe-area-inset-left); + + --text-size: 16px; --main-width: 40em; text-size-adjust: none; --hairline-width: 1px; @@ -90,7 +96,7 @@ html { body { font-family: ui-rounded, system-ui; - font-size: 16px; + font-size: var(--text-size); word-wrap: break-word; overflow-wrap: break-word; } @@ -353,3 +359,18 @@ code { transform: translateY(0); } } + +@keyframes position-object { + 0% { + object-position: 50% 50%; + } + 25% { + object-position: 0% 0%; + } + 75% { + object-position: 100% 100%; + } + 100% { + object-position: 50% 50%; + } +} diff --git a/src/pages/account-statuses.jsx b/src/pages/account-statuses.jsx index 5561849f..def1a03e 100644 --- a/src/pages/account-statuses.jsx +++ b/src/pages/account-statuses.jsx @@ -1,7 +1,8 @@ -import { useEffect, useRef, useState } from 'preact/hooks'; +import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useParams } from 'react-router-dom'; import { useSnapshot } from 'valtio'; +import AccountInfo from '../components/account-info'; import Timeline from '../components/timeline'; import { api } from '../utils/api'; import emojifyText from '../utils/emojify-text'; @@ -13,7 +14,7 @@ const LIMIT = 20; function AccountStatuses() { const snapStates = useSnapshot(states); const { id, ...params } = useParams(); - const { masto, instance } = api({ instance: params.instance }); + const { masto, instance, authenticated } = api({ instance: params.instance }); const accountStatusesIterator = useRef(); async function fetchAccountStatuses(firstLoad) { const results = []; @@ -27,7 +28,7 @@ function AccountStatuses() { pinnedStatuses.forEach((status) => { status._pinned = true; }); - if (pinnedStatuses.length > 1) { + if (pinnedStatuses.length >= 3) { const pinnedStatusesIds = pinnedStatuses.map((status) => status.id); results.push({ id: pinnedStatusesIds, @@ -54,9 +55,11 @@ function AccountStatuses() { }; } - const [account, setAccount] = useState({}); + const [account, setAccount] = useState(); useTitle( - `${account?.acct ? '@' + account.acct : 'Posts'}`, + `${account?.displayName ? account.displayName + ' ' : ''}@${ + account?.acct ? account.acct : 'Account posts' + }`, '/:instance?/a/:id', ); useEffect(() => { @@ -71,7 +74,20 @@ function AccountStatuses() { })(); }, [id]); - const { displayName, acct, emojis } = account; + const { displayName, acct, emojis } = account || {}; + + const TimelineStart = useMemo(() => { + const cachedAccount = snapStates.accounts[`${id}@${instance}`]; + return ( + masto.v1.accounts.fetch(id)} + authenticated={authenticated} + standalone + /> + ); + }, [id, instance, authenticated]); return ( ); } diff --git a/src/pages/accounts.jsx b/src/pages/accounts.jsx new file mode 100644 index 00000000..0e617d9a --- /dev/null +++ b/src/pages/accounts.jsx @@ -0,0 +1,152 @@ +import './settings.css'; + +import { Menu, MenuItem } from '@szhsin/react-menu'; +import { useReducer, useState } from 'preact/hooks'; + +import Avatar from '../components/avatar'; +import Icon from '../components/icon'; +import Link from '../components/link'; +import NameText from '../components/name-text'; +import { api } from '../utils/api'; +import states from '../utils/states'; +import store from '../utils/store'; + +function Accounts({ onClose }) { + const { masto } = api(); + // Accounts + const accounts = store.local.getJSON('accounts'); + const currentAccount = store.session.get('currentAccount'); + const moreThanOneAccount = accounts.length > 1; + const [currentDefault, setCurrentDefault] = useState(0); + + const [_, reload] = useReducer((x) => x + 1, 0); + + return ( +
    +
    +

    Accounts

    +
    + + Account + +
    +
    +
    +
    +
      + {accounts.map((account, i) => { + const isCurrent = account.info.id === currentAccount; + const isDefault = i === (currentDefault || 0); + return ( +
    • +
      + {moreThanOneAccount && ( + + + + )} + { + if (isCurrent) { + try { + const info = await masto.v1.accounts.fetch( + account.info.id, + ); + console.log('fetched account info', info); + account.info = info; + store.local.setJSON('accounts', accounts); + reload(); + } catch (e) {} + } + }} + /> + { + states.showAccount = `${account.info.username}@${account.instanceURL}`; + }} + /> +
      +
      + {isDefault && moreThanOneAccount && ( + <> + Default{' '} + + )} + {!isCurrent && ( + + )} + + + + } + > + {moreThanOneAccount && ( + { + // Move account to the top of the list + accounts.splice(i, 1); + accounts.unshift(account); + store.local.setJSON('accounts', accounts); + setCurrentDefault(i); + }} + > + + Set as default + + )} + { + const yes = confirm('Log out?'); + if (!yes) return; + accounts.splice(i, 1); + store.local.setJSON('accounts', accounts); + // location.reload(); + location.href = '/'; + }} + > + + Log out + + +
      +
    • + ); + })} +
    + {moreThanOneAccount && ( +

    + + Note: Default account will always be used for first load. + Switched accounts will persist during the session. + +

    + )} +
    +
    +
    + ); +} + +export default Accounts; diff --git a/src/pages/notifications.jsx b/src/pages/notifications.jsx index 58c19ad6..739ea1f9 100644 --- a/src/pages/notifications.jsx +++ b/src/pages/notifications.jsx @@ -303,7 +303,8 @@ function Notification({ notification, instance }) { for (const account of _accounts) { if (account._types?.includes('favourite')) { favsCount++; - } else if (account._types?.includes('reblog')) { + } + if (account._types?.includes('reblog')) { reblogsCount++; } } @@ -428,7 +429,7 @@ function Notification({ notification, instance }) { : `/s/${actualStatusID}` } > - + )}
diff --git a/src/pages/settings.css b/src/pages/settings.css index 8ff32ff8..8b3d7ac5 100644 --- a/src/pages/settings.css +++ b/src/pages/settings.css @@ -2,19 +2,16 @@ background-color: var(--bg-faded-color); } -#settings-container h2 { +#settings-container main h3 { font-size: 85%; text-transform: uppercase; color: var(--text-insignificant-color); font-weight: normal; } -#settings-container h2 ~ h2 { - margin-top: 2em; -} #settings-container section { background-color: var(--bg-color); - margin: 0; + margin: 8px 0 0; padding: 8px 16px; border-top: var(--hairline-width) solid var(--outline-color); border-bottom: var(--hairline-width) solid var(--outline-color); @@ -30,7 +27,7 @@ list-style: none; } #settings-container section > ul > li { - padding: 8px 0 16px; + padding: 8px 0; display: flex; justify-content: space-between; align-items: center; @@ -48,6 +45,9 @@ #settings-container section > ul > li .current.is-current + .avatar { box-shadow: 0 0 0 1.5px var(--green-color), 0 0 8px var(--green-color); } +#settings-container section > ul > li .avatar + .name-text { + vertical-align: middle; +} #settings-container section > ul > li > div { flex-grow: 1; max-width: 100%; @@ -59,14 +59,28 @@ #settings-container section > ul > li > div:last-child { text-align: right; } -#settings-container div, -#settings-container div > * { +#settings-container section > ul > li .sub-section { + text-align: left !important; + margin-top: 8px; + margin-left: 24px; +} +#settings-container section > ul > li .sub-section p { + margin-block: 0.5em; +} +#settings-container section > ul > li .sub-section p:last-child { + margin-block-end: 0; +} +#settings-container div { vertical-align: middle; } #settings-container .avatar { margin-right: 8px; } +#settings-container section select { + padding: 4px; +} + #settings-container .radio-group { display: inline-flex; align-items: center; @@ -100,3 +114,12 @@ #settings-container .radio-group label:has(input:checked) input:checked + span { color: inherit; } + +#settings-container .range-group { + display: flex; + align-items: center; + gap: 8px; +} +#settings-container .range-group input[type='range'] { + flex-grow: 1; +} diff --git a/src/pages/settings.jsx b/src/pages/settings.jsx index ce12ec71..b8be2c6d 100644 --- a/src/pages/settings.jsx +++ b/src/pages/settings.jsx @@ -1,163 +1,35 @@ import './settings.css'; -import { Menu, MenuItem } from '@szhsin/react-menu'; -import { useReducer, useRef, useState } from 'preact/hooks'; +import { useRef } from 'preact/hooks'; import { useSnapshot } from 'valtio'; import logo from '../assets/logo.svg'; -import Avatar from '../components/avatar'; -import Icon from '../components/icon'; -import Link from '../components/link'; -import NameText from '../components/name-text'; import RelativeTime from '../components/relative-time'; -import { api } from '../utils/api'; +import targetLanguages from '../data/lingva-target-languages'; +import getTranslateTargetLanguage from '../utils/get-translate-target-language'; +import localeCode2Text from '../utils/localeCode2Text'; import states from '../utils/states'; import store from '../utils/store'; -/* - Settings component that shows these settings: - - Accounts list for switching - - Dark/light/auto theme switch (done with adding/removing 'is-light' or 'is-dark' class on the body) -*/ +const DEFAULT_TEXT_SIZE = 16; +const TEXT_SIZES = [16, 17, 18, 19, 20]; function Settings({ onClose }) { - const { masto } = api(); const snapStates = useSnapshot(states); - // Accounts - const accounts = store.local.getJSON('accounts'); - const currentAccount = store.session.get('currentAccount'); const currentTheme = store.local.get('theme') || 'auto'; const themeFormRef = useRef(); - const moreThanOneAccount = accounts.length > 1; - const [currentDefault, setCurrentDefault] = useState(0); - - const [_, reload] = useReducer((x) => x + 1, 0); + const targetLanguage = + snapStates.settings.contentTranslationTargetLanguage || null; + const systemTargetLanguage = getTranslateTargetLanguage(); + const systemTargetLanguageText = localeCode2Text(systemTargetLanguage); + const currentTextSize = store.local.get('textSize') || DEFAULT_TEXT_SIZE; return (
-
- {/* */} -

Accounts

-
-
    - {accounts.map((account, i) => { - const isCurrent = account.info.id === currentAccount; - const isDefault = i === (currentDefault || 0); - return ( -
  • -
    - {moreThanOneAccount && ( - - - - )} - { - if (isCurrent) { - try { - const info = await masto.v1.accounts.fetch( - account.info.id, - ); - console.log('fetched account info', info); - account.info = info; - store.local.setJSON('accounts', accounts); - reload(); - } catch (e) {} - } - }} - /> - { - states.showAccount = `${account.info.username}@${account.instanceURL}`; - }} - /> -
    -
    - {isDefault && moreThanOneAccount && ( - <> - Default{' '} - - )} - {!isCurrent && ( - - )} - - - - } - > - {moreThanOneAccount && ( - { - // Move account to the top of the list - accounts.splice(i, 1); - accounts.unshift(account); - store.local.setJSON('accounts', accounts); - setCurrentDefault(i); - }} - > - - Set as default - - )} - { - const yes = confirm('Log out?'); - if (!yes) return; - accounts.splice(i, 1); - store.local.setJSON('accounts', accounts); - // location.reload(); - location.href = '/'; - }} - > - - Log out - - -
    -
  • - ); - })} -
- {moreThanOneAccount && ( -

- - Note: Default account will always be used for first load. - Switched accounts will persist during the session. - -

- )} -

- - Add new account - -

-
+

Settings

+
+
  • @@ -228,6 +100,47 @@ function Settings({ onClose }) {
+
  • +
    + +
    +
    + A{' '} + { + const value = parseInt(e.target.value, 10); + const html = document.documentElement; + // set CSS variable + html.style.setProperty('--text-size', `${value}px`); + // save to local storage + if (value === DEFAULT_TEXT_SIZE) { + store.local.del('textSize'); + } else { + store.local.set('textSize', e.target.value); + } + }} + />{' '} + + A + + + {TEXT_SIZES.map((size) => ( + +
    +
  • + + +

    Experiments

    +
    +
    • +
    • + +
      + +

      + + Note: This feature uses an external API to translate, + powered by{' '} + + Lingva Translate + + . + +

      +
      +
    • +
    • + +
    -

    Hidden features

    +

    About

    -
    - -
    -
    -

    About

    -
    -

    +

    - - Built - {' '} - by{' '} - { - e.preventDefault(); - states.showAccount = 'cheeaun@mastodon.social'; - }} - > - @cheeaun - - .{' '} Privacy Policy - .

    {__BUILD_TIME__ && (

    - Last build: {' '} + Last build:{' '} + {' '} {__COMMIT_HASH__ && ( <> ( diff --git a/src/pages/status.css b/src/pages/status.css index 0805628e..5cd41ee5 100644 --- a/src/pages/status.css +++ b/src/pages/status.css @@ -17,7 +17,7 @@ } .hero-heading { - font-size: 16px; + font-size: var(--text-size); display: inline-block; } .hero-heading .icon { diff --git a/src/pages/status.jsx b/src/pages/status.jsx index 273df5f9..9135a10a 100644 --- a/src/pages/status.jsx +++ b/src/pages/status.jsx @@ -78,7 +78,7 @@ function StatusPage() { }, [id, uiState !== 'loading']); const scrollOffsets = useRef(); - const initContext = () => { + const initContext = ({ reloadHero } = {}) => { console.debug('initContext', id); setUIState('loading'); let heroTimer; @@ -114,7 +114,7 @@ function StatusPage() { const hasStatus = !!snapStates.statuses[sKey]; let heroStatus = snapStates.statuses[sKey]; - if (hasStatus) { + if (hasStatus && !reloadHero) { console.debug('Hero status is cached'); } else { try { @@ -277,7 +277,9 @@ function StatusPage() { const apiCache = await caches.open('api'); await apiCache.delete(contextURL, { ignoreVary: true }); - return initContext(); + return initContext({ + reloadHero: true, + }); } catch (e) { console.error(e); } @@ -624,6 +626,7 @@ function StatusPage() { instance={instance} withinContext size="l" + enableTranslate /> {uiState !== 'loading' && !authenticated ? ( @@ -700,6 +703,7 @@ function StatusPage() { instance={instance} withinContext size={thread || ancestor ? 'm' : 's'} + enableTranslate /> {/* {replies?.length > LIMIT && (