commit
5e916559b3
|
@ -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)
|
||||
|
||||
|
|
61
package-lock.json
generated
61
package-lock.json
generated
|
@ -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": {
|
||||
|
|
|
@ -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",
|
||||
|
|
18
scripts/fetch-lingva-languages.js
Normal file
18
scripts/fetch-lingva-languages.js
Normal file
|
@ -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');
|
||||
});
|
73
src/app.css
73
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 */
|
||||
|
||||
|
|
224
src/app.jsx
224
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() {
|
|||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showAccounts && (
|
||||
<Modal
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
states.showAccounts = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Accounts
|
||||
onClose={() => {
|
||||
states.showAccounts = false;
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showAccount && (
|
||||
<Modal
|
||||
class="light"
|
||||
|
@ -383,11 +416,14 @@ function App() {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<Account
|
||||
<AccountSheet
|
||||
account={snapStates.showAccount?.account || snapStates.showAccount}
|
||||
instance={snapStates.showAccount?.instance}
|
||||
onClose={() => {
|
||||
onClose={({ destination }) => {
|
||||
states.showAccount = false;
|
||||
if (destination) {
|
||||
states.showAccounts = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
@ -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 };
|
||||
|
|
5
src/assets/floating-button.svg
Normal file
5
src/assets/floating-button.svg
Normal file
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 84 62">
|
||||
<rect width="64" height="48" x="18" y="2" fill="#fff" stroke="#999" stroke-width="3" rx="4"/>
|
||||
<rect width="32" height="48" x="2" y="12" fill="#fff" stroke="#999" stroke-width="3" rx="4"/>
|
||||
<path fill="#4169E1" d="M14 52a4 4 0 1 1-8 0 4 4 0 0 1 8 0Zm64-42a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 371 B |
6
src/assets/multi-column.svg
Normal file
6
src/assets/multi-column.svg
Normal file
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 82 62">
|
||||
<rect width="78" height="58" x="2" y="2" fill="#999" fill-opacity=".3" stroke="#999" stroke-width="3" rx="4"/>
|
||||
<rect width="18" height="46" x="8" y="8" fill="#fff" stroke="#999" stroke-width="2" rx="1"/>
|
||||
<rect width="18" height="46" x="32" y="8" fill="#fff" stroke="#999" stroke-width="2" rx="1"/>
|
||||
<rect width="18" height="46" x="56" y="8" fill="#fff" stroke="#999" stroke-width="2" rx="1"/>
|
||||
</svg>
|
After Width: | Height: | Size: 479 B |
10
src/assets/tab-menu-bar.svg
Normal file
10
src/assets/tab-menu-bar.svg
Normal file
|
@ -0,0 +1,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 84 62">
|
||||
<rect width="64" height="48" x="18" y="2" fill="#fff" stroke="#999" stroke-width="3" rx="4"/>
|
||||
<path fill="#999" fill-opacity=".3" d="M19 3h62v10H19z"/>
|
||||
<path stroke="#4169E1" stroke-width="2" d="M43 8a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
|
||||
<path stroke="#999" stroke-width="2" d="M52 8a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm9 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
|
||||
<rect width="32" height="48" x="2" y="12" fill="#fff" stroke="#999" stroke-width="3" rx="4"/>
|
||||
<path fill="#999" fill-opacity=".3" d="M3 49h30v10H3z"/>
|
||||
<path stroke="#4169E1" stroke-width="2" d="M11 54a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
|
||||
<path stroke="#999" stroke-width="2" d="M20 54a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm9 0a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 784 B |
|
@ -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({
|
|||
<span>
|
||||
<b>████████</b>
|
||||
<br />
|
||||
@██████
|
||||
<span class="account-block-acct">@██████</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<a
|
||||
|
@ -40,10 +58,14 @@ function AccountBlock({
|
|||
if (external) return;
|
||||
e.preventDefault();
|
||||
if (onClick) return onClick(e);
|
||||
if (internal) {
|
||||
navigate(`/${instance}/a/${id}`);
|
||||
} else {
|
||||
states.showAccount = {
|
||||
account,
|
||||
instance,
|
||||
};
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Avatar url={avatar} size={avatarSize} />
|
||||
|
@ -57,7 +79,29 @@ function AccountBlock({
|
|||
) : (
|
||||
<b>{username}</b>
|
||||
)}
|
||||
<br />@{acct}
|
||||
<br />
|
||||
<span class="account-block-acct">
|
||||
@{acct1}
|
||||
<wbr />
|
||||
{acct2}
|
||||
</span>
|
||||
{showActivity && (
|
||||
<>
|
||||
<br />
|
||||
<small class="last-status-at insignificant">
|
||||
Posts: {statusesCount}
|
||||
{!!lastStatusAt && (
|
||||
<>
|
||||
{' '}
|
||||
· Last posted:{' '}
|
||||
{niceDateTime(lastStatusAt, {
|
||||
hideTime: true,
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</small>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
|
|
290
src/components/account-info.css
Normal file
290
src/components/account-info.css
Normal file
|
@ -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 <p> 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));
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
if (!isString) {
|
||||
setInfo(account);
|
||||
return;
|
||||
}
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const info = await masto.v1.accounts.lookup({
|
||||
acct: account,
|
||||
skip_webfinger: false,
|
||||
});
|
||||
const info = await fetchAccount();
|
||||
states.accounts[`${info.id}@${instance}`] = info;
|
||||
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;
|
||||
}
|
||||
console.error(e);
|
||||
setInfo(null);
|
||||
setUIState('error');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setInfo(null);
|
||||
setUIState('error');
|
||||
}
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
setInfo(account);
|
||||
}
|
||||
}, [account]);
|
||||
}, [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 (
|
||||
<div
|
||||
ref={escRef}
|
||||
id="account-container"
|
||||
class={`sheet ${uiState === 'loading' ? 'skeleton' : ''}`}
|
||||
class={`account-container ${uiState === 'loading' ? 'skeleton' : ''}`}
|
||||
style={{
|
||||
'--header-color-1': headerCornerColors[0],
|
||||
'--header-color-2': headerCornerColors[1],
|
||||
'--header-color-3': headerCornerColors[2],
|
||||
'--header-color-4': headerCornerColors[3],
|
||||
}}
|
||||
>
|
||||
{uiState === 'error' && (
|
||||
<div class="ui-state">
|
||||
|
@ -113,21 +114,129 @@ function Account({ account, instance: propInstance, onClose }) {
|
|||
<p>███████████████ ███████████████</p>
|
||||
</div>
|
||||
<p class="stats">
|
||||
<span>██ Posts</span>
|
||||
<span>██ Following</span>
|
||||
<span>██ Followers</span>
|
||||
<span>
|
||||
Posts
|
||||
<br />
|
||||
██
|
||||
</span>
|
||||
<span>
|
||||
Following
|
||||
<br />
|
||||
██
|
||||
</span>
|
||||
<span>
|
||||
Followers
|
||||
<br />
|
||||
██
|
||||
</span>
|
||||
</p>
|
||||
</main>
|
||||
</>
|
||||
) : (
|
||||
info && (
|
||||
<>
|
||||
{header && !/missing\.png$/.test(header) && (
|
||||
<img
|
||||
src={header}
|
||||
alt=""
|
||||
class={`header-banner ${
|
||||
headerIsAvatar ? 'header-is-avatar' : ''
|
||||
}`}
|
||||
onError={(e) => {
|
||||
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
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<header>
|
||||
<AccountBlock
|
||||
account={info}
|
||||
instance={instance}
|
||||
avatarSize="xxxl"
|
||||
external
|
||||
external={standalone}
|
||||
internal={!standalone}
|
||||
/>
|
||||
</header>
|
||||
<main tabIndex="-1">
|
||||
|
@ -174,6 +283,15 @@ function Account({ account, instance: propInstance, onClose }) {
|
|||
</div>
|
||||
)}
|
||||
<p class="stats">
|
||||
{standalone ? (
|
||||
<span>
|
||||
Posts
|
||||
<br />
|
||||
<b title={statusesCount}>
|
||||
{shortenNumber(statusesCount)}
|
||||
</b>{' '}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
to={instance ? `/${instance}/a/${id}` : `/a/${id}`}
|
||||
onClick={() => {
|
||||
|
@ -186,6 +304,7 @@ function Account({ account, instance: propInstance, onClose }) {
|
|||
{shortenNumber(statusesCount)}
|
||||
</b>{' '}
|
||||
</Link>
|
||||
)}
|
||||
<span>
|
||||
Following
|
||||
<br />
|
||||
|
@ -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;
|
66
src/components/account-sheet.jsx
Normal file
66
src/components/account-sheet.jsx
Normal file
|
@ -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 (
|
||||
<div
|
||||
ref={escRef}
|
||||
class="sheet"
|
||||
onClick={(e) => {
|
||||
const accountBlock = e.target.closest('.account-block');
|
||||
if (accountBlock) {
|
||||
onClose({
|
||||
destination: 'account-statuses',
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AccountInfo
|
||||
instance={instance}
|
||||
authenticated={authenticated}
|
||||
account={account}
|
||||
fetchAccount={async () => {
|
||||
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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountSheet;
|
|
@ -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 <p> 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);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<span
|
||||
class="avatar"
|
||||
ref={avatarRef}
|
||||
class={`avatar ${alphaCache[url] ? 'has-alpha' : ''}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
|
@ -22,7 +28,50 @@ function Avatar({ url, size, alt = '', ...props }) {
|
|||
{...props}
|
||||
>
|
||||
{!!url && (
|
||||
<img src={url} width={size} height={size} alt={alt} loading="lazy" />
|
||||
<img
|
||||
src={url}
|
||||
width={size}
|
||||
height={size}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
crossOrigin={alphaCache[url] === undefined ? 'anonymous' : undefined}
|
||||
onError={(e) => {
|
||||
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
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 && (
|
||||
<Avatar
|
||||
url={currentAccountInfo.avatarStatic}
|
||||
size="l"
|
||||
size="xl"
|
||||
alt={currentAccountInfo.username}
|
||||
/>
|
||||
)}
|
||||
|
@ -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({
|
|||
}}
|
||||
/>
|
||||
<label
|
||||
class="toolbar-button"
|
||||
class={`toolbar-button ${sensitive ? 'highlight' : ''}`}
|
||||
title="Content warning or sensitive media"
|
||||
>
|
||||
<input
|
||||
|
@ -842,7 +865,7 @@ function Compose({
|
|||
<label
|
||||
class={`toolbar-button ${
|
||||
visibility !== 'public' && !sensitive ? 'show-field' : ''
|
||||
}`}
|
||||
} ${visibility !== 'public' ? 'highlight' : ''}`}
|
||||
title={`Visibility: ${visibility}`}
|
||||
>
|
||||
<Icon icon={visibilityIconsMap[visibility]} alt={visibility} />
|
||||
|
|
|
@ -63,11 +63,20 @@ const ICONS = {
|
|||
share: 'mingcute:share-2-line',
|
||||
sparkles: 'mingcute:sparkles-line',
|
||||
exit: 'mingcute:exit-line',
|
||||
translate: 'mingcute:translate-line',
|
||||
play: 'mingcute:play-fill',
|
||||
};
|
||||
|
||||
const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js');
|
||||
|
||||
function Icon({ icon, size = 'm', alt, title, class: className = '' }) {
|
||||
function Icon({
|
||||
icon,
|
||||
size = 'm',
|
||||
alt,
|
||||
title,
|
||||
class: className = '',
|
||||
style = {},
|
||||
}) {
|
||||
if (!icon) return null;
|
||||
|
||||
const iconSize = SIZES[size];
|
||||
|
@ -96,6 +105,7 @@ function Icon({ icon, size = 'm', alt, title, class: className = '' }) {
|
|||
display: 'inline-block',
|
||||
overflow: 'hidden',
|
||||
lineHeight: 0,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{iconData && (
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
animation: appear 0.3s ease-in-out 1s both;
|
||||
vertical-align: middle;
|
||||
margin: 8px;
|
||||
vertical-align: baseline !important;
|
||||
}
|
||||
.loader-container.abrupt {
|
||||
animation: none;
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { Menu, MenuItem } from '@szhsin/react-menu';
|
||||
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
@ -6,6 +7,7 @@ import Icon from './icon';
|
|||
import Link from './link';
|
||||
import Media from './media';
|
||||
import Modal from './modal';
|
||||
import TranslationBlock from './translation-block';
|
||||
|
||||
function MediaModal({
|
||||
mediaAttachments,
|
||||
|
@ -234,49 +236,54 @@ function MediaModal({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<div class="sheet">
|
||||
<header>
|
||||
<h2>Media description</h2>
|
||||
</header>
|
||||
<main>
|
||||
<p
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{showMediaAlt}
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
{!!showMediaAlt && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowMediaAlt(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="sheet">
|
||||
<header>
|
||||
<h2>Media description</h2>
|
||||
</header>
|
||||
<main>
|
||||
<p
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{showMediaAlt}
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
<MediaAltModal alt={showMediaAlt} />
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaAltModal({ alt }) {
|
||||
const [forceTranslate, setForceTranslate] = useState(false);
|
||||
return (
|
||||
<div class="sheet">
|
||||
<header class="header-grid">
|
||||
<h2>Media description</h2>
|
||||
<div class="header-side">
|
||||
<Menu
|
||||
align="end"
|
||||
menuButton={
|
||||
<button type="button" class="plain4">
|
||||
<Icon icon="more" alt="More" size="xl" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<MenuItem
|
||||
disabled={forceTranslate}
|
||||
onClick={() => {
|
||||
setForceTranslate(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="translate" />
|
||||
<span>Translate</span>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<p
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{alt}
|
||||
</p>
|
||||
{forceTranslate && (
|
||||
<TranslationBlock forceTranslate={forceTranslate} text={alt} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MediaModal;
|
||||
|
|
|
@ -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';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -161,6 +170,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
muted
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={description}
|
||||
|
@ -168,6 +178,10 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
height={height}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="media-play">
|
||||
<Icon icon="play" size="xxl" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -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 && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
{currentAccount?.info?.id && (
|
||||
<MenuLink to={`/${instance}/a/${currentAccount.info.id}`}>
|
||||
<Icon icon="user" size="l" /> <span>Profile</span>
|
||||
</MenuLink>
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showAccounts = true;
|
||||
}}
|
||||
>
|
||||
<Icon icon="group" size="l" /> <span>Accounts…</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showShortcutsSettings = true;
|
||||
|
|
|
@ -52,7 +52,7 @@ function NameText({
|
|||
>
|
||||
{showAvatar && (
|
||||
<>
|
||||
<Avatar url={avatar} />{' '}
|
||||
<Avatar url={avatarStatic || avatar} />{' '}
|
||||
</>
|
||||
)}
|
||||
{displayName && !short ? (
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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() {
|
|||
</header>
|
||||
<main>
|
||||
<p>
|
||||
<label class="shortcuts-view-mode">
|
||||
Specify a list of shortcuts that'll appear as:
|
||||
<select
|
||||
<div class="shortcuts-view-mode">
|
||||
{[
|
||||
{
|
||||
value: 'float-button',
|
||||
label: 'Floating button',
|
||||
imgURL: floatingButtonUrl,
|
||||
},
|
||||
{
|
||||
value: 'tab-menu-bar',
|
||||
label: 'Tab/Menu bar',
|
||||
imgURL: tabMenuBarUrl,
|
||||
},
|
||||
{
|
||||
value: 'multi-column',
|
||||
label: 'Multi-column',
|
||||
imgURL: multiColumnUrl,
|
||||
},
|
||||
].map(({ value, label, imgURL }) => (
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="shortcuts-view-mode"
|
||||
value={value}
|
||||
checked={snapStates.settings.shortcutsViewMode === value}
|
||||
onChange={(e) => {
|
||||
states.settings.shortcutsViewMode = e.target.value;
|
||||
}}
|
||||
/>{' '}
|
||||
<img src={imgURL} alt="" /> <span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{/* <select
|
||||
value={snapStates.settings.shortcutsViewMode || 'float-button'}
|
||||
onChange={(e) => {
|
||||
states.settings.shortcutsViewMode = e.target.value;
|
||||
|
@ -219,8 +253,7 @@ function ShortcutsSettings() {
|
|||
<option value="float-button">Floating button</option>
|
||||
<option value="multi-column">Multi-column</option>
|
||||
<option value="tab-menu-bar">Tab/Menu bar </option>
|
||||
</select>
|
||||
</label>
|
||||
</select> */}
|
||||
</p>
|
||||
{/* <p>
|
||||
<details>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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({
|
|||
</MenuHeader>
|
||||
<MenuLink to={instance ? `/${instance}/s/${id}` : `/s/${id}`}>
|
||||
<Icon icon="arrow-right" />
|
||||
View post and replies
|
||||
<span>View post by @{username || acct}</span>
|
||||
</MenuLink>
|
||||
</>
|
||||
)}
|
||||
|
@ -381,7 +407,7 @@ function Status({
|
|||
</MenuItem>
|
||||
)}
|
||||
{(!isSizeLarge || !!editedAt) && <MenuDivider />}
|
||||
{!isSizeLarge && (
|
||||
{!isSizeLarge && sameInstance && (
|
||||
<>
|
||||
<MenuItem onClick={replyStatus}>
|
||||
<Icon icon="reply" />
|
||||
|
@ -397,7 +423,12 @@ function Status({
|
|||
} catch (e) {}
|
||||
}}
|
||||
>
|
||||
<Icon icon="rocket" />
|
||||
<Icon
|
||||
icon="rocket"
|
||||
style={{
|
||||
color: reblogged && 'var(--reblog-color)',
|
||||
}}
|
||||
/>
|
||||
<span>{reblogged ? 'Unboost' : 'Boost…'}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
@ -410,7 +441,12 @@ function Status({
|
|||
} catch (e) {}
|
||||
}}
|
||||
>
|
||||
<Icon icon="heart" />
|
||||
<Icon
|
||||
icon="heart"
|
||||
style={{
|
||||
color: favourited && 'var(--favourite-color)',
|
||||
}}
|
||||
/>
|
||||
<span>{favourited ? 'Unfavourite' : 'Favourite'}</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
|
@ -422,16 +458,33 @@ function Status({
|
|||
} catch (e) {}
|
||||
}}
|
||||
>
|
||||
<Icon icon="bookmark" />
|
||||
<Icon
|
||||
icon="bookmark"
|
||||
style={{
|
||||
color: bookmarked && 'var(--favourite-color)',
|
||||
}}
|
||||
/>
|
||||
<span>{bookmarked ? 'Unbookmark' : 'Bookmark'}</span>
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
</>
|
||||
)}
|
||||
{enableTranslate && (
|
||||
<MenuItem
|
||||
disabled={forceTranslate}
|
||||
onClick={() => {
|
||||
setForceTranslate(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="translate" />
|
||||
<span>Translate</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{((!isSizeLarge && sameInstance) || enableTranslate) && <MenuDivider />}
|
||||
<MenuItem href={url} target="_blank">
|
||||
<Icon icon="external" />
|
||||
<span>Open link to post</span>
|
||||
<small class="menu-double-lines">{nicePostURL(url)}</small>
|
||||
</MenuItem>
|
||||
<div class="menu-horizontal">
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
// Copy url to clipboard
|
||||
|
@ -445,7 +498,7 @@ function Status({
|
|||
}}
|
||||
>
|
||||
<Icon icon="link" />
|
||||
<span>Copy link to post</span>
|
||||
<span>Copy</span>
|
||||
</MenuItem>
|
||||
{navigator?.share &&
|
||||
navigator?.canShare?.({
|
||||
|
@ -467,6 +520,7 @@ function Status({
|
|||
<span>Share…</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
</div>
|
||||
{isSelf && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
|
@ -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 (
|
||||
<article
|
||||
|
@ -508,6 +578,9 @@ function Status({
|
|||
onContextMenu={(e) => {
|
||||
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' && (
|
||||
<ControlledMenu
|
||||
ref={contextMenuRef}
|
||||
state={isContextMenuOpen ? 'open' : undefined}
|
||||
anchorPoint={contextMenuAnchorPoint}
|
||||
direction="right"
|
||||
|
@ -530,9 +605,12 @@ function Status({
|
|||
// Higher than the backdrop
|
||||
zIndex: 1001,
|
||||
},
|
||||
onClick: () => {
|
||||
contextMenuRef.current?.closeMenu?.();
|
||||
},
|
||||
}}
|
||||
overflow="auto"
|
||||
boundingBoxPadding="8 8 8 8"
|
||||
boundingBoxPadding={safeBoundingBoxPadding()}
|
||||
unmountOnClose
|
||||
>
|
||||
{StatusMenuItems}
|
||||
|
@ -561,7 +639,7 @@ function Status({
|
|||
};
|
||||
}}
|
||||
>
|
||||
<Avatar url={avatarStatic} size="xxl" />
|
||||
<Avatar url={avatarStatic || avatar} size="xxl" />
|
||||
</a>
|
||||
)}
|
||||
<div class="container">
|
||||
|
@ -767,6 +845,25 @@ function Status({
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{((enableTranslate &&
|
||||
!!content.trim() &&
|
||||
language &&
|
||||
language !== targetLanguage) ||
|
||||
forceTranslate) && (
|
||||
<TranslationBlock
|
||||
forceTranslate={forceTranslate}
|
||||
sourceLanguage={language}
|
||||
text={
|
||||
(spoilerText ? `${spoilerText}\n\n` : '') +
|
||||
getHTMLText(content) +
|
||||
(poll?.options?.length
|
||||
? `\n\nPoll:\n${poll.options
|
||||
.map((option) => `- ${option.title}`)
|
||||
.join('\n')}`
|
||||
: '')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!spoilerText && sensitive && !!mediaAttachments.length && (
|
||||
<button
|
||||
class={`plain spoiler ${showSpoiler ? 'spoiling' : ''}`}
|
||||
|
@ -1295,7 +1392,14 @@ function EditedAtModal({
|
|||
return (
|
||||
<li key={createdAt} class="history-item">
|
||||
<h3>
|
||||
<time>{niceDateTime(createdAtDate)}</time>
|
||||
<time>
|
||||
{niceDateTime(createdAtDate, {
|
||||
formatOpts: {
|
||||
weekday: 'short',
|
||||
second: 'numeric',
|
||||
},
|
||||
})}
|
||||
</time>
|
||||
</h3>
|
||||
<Status
|
||||
status={status}
|
||||
|
@ -1468,6 +1572,62 @@ function _unfurlMastodonLink(instance, url) {
|
|||
return Promise.any([remoteInstanceFetch, mastoSearchFetch]);
|
||||
}
|
||||
|
||||
function nicePostURL(url) {
|
||||
if (!url) return;
|
||||
const urlObj = new URL(url);
|
||||
const { host, pathname } = urlObj;
|
||||
const path = pathname.replace(/\/$/, '');
|
||||
// split only first slash
|
||||
const [_, username, restPath] = path.match(/\/(@[^\/]+)\/(.*)/) || [];
|
||||
return (
|
||||
<>
|
||||
{host}
|
||||
{username ? (
|
||||
<>
|
||||
/{username}
|
||||
<wbr />
|
||||
<span class="more-insignificant">/{restPath}</span>
|
||||
</>
|
||||
) : (
|
||||
<span class="more-insignificant">{path}</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const unfurlMastodonLink = throttle(_unfurlMastodonLink);
|
||||
|
||||
const div = document.createElement('div');
|
||||
function getHTMLText(html) {
|
||||
if (!html) return 0;
|
||||
div.innerHTML = html
|
||||
.replace(/<\/p>/g, '</p>\n\n')
|
||||
.replace(/<\/li>/g, '</li>\n');
|
||||
div.querySelectorAll('br').forEach((br) => {
|
||||
br.replaceWith('\n');
|
||||
});
|
||||
return div.innerText.replace(/[\r\n]{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
const defaultBoundingBoxPadding = 8;
|
||||
function safeBoundingBoxPadding() {
|
||||
// Get safe area inset variables from root
|
||||
const style = getComputedStyle(root);
|
||||
const safeAreaInsetTop = style.getPropertyValue('--sai-top');
|
||||
const safeAreaInsetRight = style.getPropertyValue('--sai-right');
|
||||
const safeAreaInsetBottom = style.getPropertyValue('--sai-bottom');
|
||||
const safeAreaInsetLeft = style.getPropertyValue('--sai-left');
|
||||
const str = [
|
||||
safeAreaInsetTop,
|
||||
safeAreaInsetRight,
|
||||
safeAreaInsetBottom,
|
||||
safeAreaInsetLeft,
|
||||
]
|
||||
.map((v) => parseInt(v, 10) || defaultBoundingBoxPadding)
|
||||
.join(' ');
|
||||
// console.log(str);
|
||||
return str;
|
||||
}
|
||||
|
||||
export default memo(Status);
|
||||
|
|
|
@ -27,6 +27,7 @@ function Timeline({
|
|||
checkForUpdatesInterval = 60_000, // 1 minute
|
||||
headerStart,
|
||||
headerEnd,
|
||||
timelineStart,
|
||||
}) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [uiState, setUIState] = useState('default');
|
||||
|
@ -292,11 +293,12 @@ function Timeline({
|
|||
</button>
|
||||
)}
|
||||
</header>
|
||||
{!!timelineStart && <div class="timeline-start">{timelineStart}</div>}
|
||||
{!!items.length ? (
|
||||
<>
|
||||
<ul class="timeline">
|
||||
{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 (
|
||||
<li key={`timeline-${statusID}`}>
|
||||
<li key={`timeline-${statusID + _pinned}`}>
|
||||
<Link class="status-link timeline-item" to={url}>
|
||||
{useItemID ? (
|
||||
<Status statusID={statusID} instance={instance} />
|
||||
|
|
86
src/components/translation-block.css
Normal file
86
src/components/translation-block.css
Normal file
|
@ -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);
|
||||
}
|
154
src/components/translation-block.jsx
Normal file
154
src/components/translation-block.jsx
Normal file
|
@ -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 (
|
||||
<div class="status-translation-block">
|
||||
<details ref={detailsRef}>
|
||||
<summary>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
detailsRef.current.open = !detailsRef.current.open;
|
||||
if (uiState === 'loading') return;
|
||||
if (!translatedContent) translate();
|
||||
}}
|
||||
>
|
||||
<Icon icon="translate" />{' '}
|
||||
<span>
|
||||
{uiState === 'loading'
|
||||
? 'Translating…'
|
||||
: sourceLanguage && !detectedLang
|
||||
? `Translate from ${sourceLangText}`
|
||||
: `Translate`}
|
||||
</span>
|
||||
</button>
|
||||
</summary>
|
||||
<div class="translated-block">
|
||||
<div class="translation-info insignificant">
|
||||
<select
|
||||
class="translated-source-select"
|
||||
disabled={uiState === 'loading'}
|
||||
onChange={(e) => {
|
||||
apiSourceLang.current = e.target.value;
|
||||
translate();
|
||||
}}
|
||||
>
|
||||
{sourceLanguages.map((l) => (
|
||||
<option value={l.code}>
|
||||
{l.code === 'auto' ? `Auto (${detectedLang ?? '…'})` : l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>{' '}
|
||||
<span>→ {targetLangText}</span>
|
||||
<Loader abrupt hidden={uiState !== 'loading'} />
|
||||
</div>
|
||||
{uiState === 'error' ? (
|
||||
<p class="ui-state">Failed to translate</p>
|
||||
) : (
|
||||
!!translatedContent && (
|
||||
<>
|
||||
{!!pronunciationContent && (
|
||||
<output class="translated-pronunciation-content">
|
||||
{pronunciationContent}
|
||||
</output>
|
||||
)}
|
||||
<output class="translated-content" lang={targetLang}>
|
||||
{translatedContent}
|
||||
</output>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TranslationBlock;
|
534
src/data/lingva-source-languages.json
Normal file
534
src/data/lingva-source-languages.json
Normal file
|
@ -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"
|
||||
}
|
||||
]
|
534
src/data/lingva-target-languages.json
Normal file
534
src/data/lingva-target-languages.json
Normal file
|
@ -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"
|
||||
}
|
||||
]
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<AccountInfo
|
||||
instance={instance}
|
||||
account={cachedAccount || id}
|
||||
fetchAccount={() => masto.v1.accounts.fetch(id)}
|
||||
authenticated={authenticated}
|
||||
standalone
|
||||
/>
|
||||
);
|
||||
}, [id, instance, authenticated]);
|
||||
|
||||
return (
|
||||
<Timeline
|
||||
|
@ -103,6 +119,7 @@ function AccountStatuses() {
|
|||
errorText="Unable to load statuses"
|
||||
fetchItems={fetchAccountStatuses}
|
||||
boostsCarousel={snapStates.settings.boostsCarousel}
|
||||
timelineStart={TimelineStart}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
152
src/pages/accounts.jsx
Normal file
152
src/pages/accounts.jsx
Normal file
|
@ -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 (
|
||||
<div id="settings-container" class="sheet" tabIndex="-1">
|
||||
<header class="header-grid">
|
||||
<h2>Accounts</h2>
|
||||
<div class="header-side">
|
||||
<Link to="/login" class="button plain" onClick={onClose}>
|
||||
<Icon icon="plus" /> <span>Account</span>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<section>
|
||||
<ul class="accounts-list">
|
||||
{accounts.map((account, i) => {
|
||||
const isCurrent = account.info.id === currentAccount;
|
||||
const isDefault = i === (currentDefault || 0);
|
||||
return (
|
||||
<li key={i + account.id}>
|
||||
<div>
|
||||
{moreThanOneAccount && (
|
||||
<span class={`current ${isCurrent ? 'is-current' : ''}`}>
|
||||
<Icon icon="check-circle" alt="Current" />
|
||||
</span>
|
||||
)}
|
||||
<Avatar
|
||||
url={account.info.avatarStatic}
|
||||
size="xxl"
|
||||
onDblClick={async () => {
|
||||
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) {}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<NameText
|
||||
account={account.info}
|
||||
showAcct
|
||||
onClick={() => {
|
||||
states.showAccount = `${account.info.username}@${account.instanceURL}`;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{isDefault && moreThanOneAccount && (
|
||||
<>
|
||||
<span class="tag">Default</span>{' '}
|
||||
</>
|
||||
)}
|
||||
{!isCurrent && (
|
||||
<button
|
||||
type="button"
|
||||
class="light"
|
||||
onClick={() => {
|
||||
store.session.set('currentAccount', account.info.id);
|
||||
location.reload();
|
||||
}}
|
||||
>
|
||||
<Icon icon="transfer" /> Switch
|
||||
</button>
|
||||
)}
|
||||
<Menu
|
||||
align="end"
|
||||
menuButton={
|
||||
<button
|
||||
type="button"
|
||||
title="More"
|
||||
class="plain more-button"
|
||||
>
|
||||
<Icon icon="more" size="l" alt="More" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{moreThanOneAccount && (
|
||||
<MenuItem
|
||||
disabled={isDefault}
|
||||
onClick={() => {
|
||||
// Move account to the top of the list
|
||||
accounts.splice(i, 1);
|
||||
accounts.unshift(account);
|
||||
store.local.setJSON('accounts', accounts);
|
||||
setCurrentDefault(i);
|
||||
}}
|
||||
>
|
||||
<Icon icon="check-circle" />
|
||||
<span>Set as default</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
disabled={!isCurrent}
|
||||
onClick={() => {
|
||||
const yes = confirm('Log out?');
|
||||
if (!yes) return;
|
||||
accounts.splice(i, 1);
|
||||
store.local.setJSON('accounts', accounts);
|
||||
// location.reload();
|
||||
location.href = '/';
|
||||
}}
|
||||
>
|
||||
<Icon icon="exit" />
|
||||
<span>Log out</span>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{moreThanOneAccount && (
|
||||
<p>
|
||||
<small>
|
||||
Note: <i>Default</i> account will always be used for first load.
|
||||
Switched accounts will persist during the session.
|
||||
</small>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Accounts;
|
|
@ -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}`
|
||||
}
|
||||
>
|
||||
<Status status={status} size="s" />
|
||||
<Status statusID={actualStatusID} size="s" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<div id="settings-container" class="sheet" tabIndex="-1">
|
||||
<main>
|
||||
{/* <button type="button" class="close-button plain large" onClick={onClose}>
|
||||
<Icon icon="x" alt="Close" />
|
||||
</button> */}
|
||||
<h2>Accounts</h2>
|
||||
<section>
|
||||
<ul class="accounts-list">
|
||||
{accounts.map((account, i) => {
|
||||
const isCurrent = account.info.id === currentAccount;
|
||||
const isDefault = i === (currentDefault || 0);
|
||||
return (
|
||||
<li key={i + account.id}>
|
||||
<div>
|
||||
{moreThanOneAccount && (
|
||||
<span class={`current ${isCurrent ? 'is-current' : ''}`}>
|
||||
<Icon icon="check-circle" alt="Current" />
|
||||
</span>
|
||||
)}
|
||||
<Avatar
|
||||
url={account.info.avatarStatic}
|
||||
size="xxl"
|
||||
onDblClick={async () => {
|
||||
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) {}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<NameText
|
||||
account={account.info}
|
||||
showAcct
|
||||
onClick={() => {
|
||||
states.showAccount = `${account.info.username}@${account.instanceURL}`;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{isDefault && moreThanOneAccount && (
|
||||
<>
|
||||
<span class="tag">Default</span>{' '}
|
||||
</>
|
||||
)}
|
||||
{!isCurrent && (
|
||||
<button
|
||||
type="button"
|
||||
class="light"
|
||||
onClick={() => {
|
||||
store.session.set('currentAccount', account.info.id);
|
||||
location.reload();
|
||||
}}
|
||||
>
|
||||
<Icon icon="transfer" /> Switch
|
||||
</button>
|
||||
)}
|
||||
<Menu
|
||||
align="end"
|
||||
menuButton={
|
||||
<button
|
||||
type="button"
|
||||
title="More"
|
||||
class="plain more-button"
|
||||
>
|
||||
<Icon icon="more" size="l" alt="More" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{moreThanOneAccount && (
|
||||
<MenuItem
|
||||
disabled={isDefault}
|
||||
onClick={() => {
|
||||
// Move account to the top of the list
|
||||
accounts.splice(i, 1);
|
||||
accounts.unshift(account);
|
||||
store.local.setJSON('accounts', accounts);
|
||||
setCurrentDefault(i);
|
||||
}}
|
||||
>
|
||||
<Icon icon="check-circle" />
|
||||
<span>Set as default</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
disabled={!isCurrent}
|
||||
onClick={() => {
|
||||
const yes = confirm('Log out?');
|
||||
if (!yes) return;
|
||||
accounts.splice(i, 1);
|
||||
store.local.setJSON('accounts', accounts);
|
||||
// location.reload();
|
||||
location.href = '/';
|
||||
}}
|
||||
>
|
||||
<Icon icon="exit" />
|
||||
<span>Log out</span>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{moreThanOneAccount && (
|
||||
<p>
|
||||
<small>
|
||||
Note: <i>Default</i> account will always be used for first load.
|
||||
Switched accounts will persist during the session.
|
||||
</small>
|
||||
</p>
|
||||
)}
|
||||
<p style={{ textAlign: 'end' }}>
|
||||
<Link to="/login" class="button" onClick={onClose}>
|
||||
Add new account
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
<header>
|
||||
<h2>Settings</h2>
|
||||
</header>
|
||||
<main>
|
||||
<section>
|
||||
<ul>
|
||||
<li>
|
||||
|
@ -228,6 +100,47 @@ function Settings({ onClose }) {
|
|||
</form>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div>
|
||||
<label>Text size</label>
|
||||
</div>
|
||||
<div class="range-group">
|
||||
<span style={{ fontSize: TEXT_SIZES[0] }}>A</span>{' '}
|
||||
<input
|
||||
type="range"
|
||||
min={TEXT_SIZES[0]}
|
||||
max={TEXT_SIZES[TEXT_SIZES.length - 1]}
|
||||
step="1"
|
||||
value={currentTextSize}
|
||||
list="sizes"
|
||||
onChange={(e) => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>{' '}
|
||||
<span style={{ fontSize: TEXT_SIZES[TEXT_SIZES.length - 1] }}>
|
||||
A
|
||||
</span>
|
||||
<datalist id="sizes">
|
||||
{TEXT_SIZES.map((size) => (
|
||||
<option value={size} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<h3>Experiments</h3>
|
||||
<section>
|
||||
<ul>
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
|
@ -237,14 +150,66 @@ function Settings({ onClose }) {
|
|||
states.settings.boostsCarousel = e.target.checked;
|
||||
}}
|
||||
/>{' '}
|
||||
Boosts carousel (experimental)
|
||||
Boosts carousel
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<h2>Hidden features</h2>
|
||||
<section>
|
||||
<div>
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={snapStates.settings.contentTranslation}
|
||||
onChange={(e) => {
|
||||
const { checked } = e.target;
|
||||
states.settings.contentTranslation = checked;
|
||||
if (!checked) {
|
||||
states.settings.contentTranslationTargetLanguage = null;
|
||||
}
|
||||
}}
|
||||
/>{' '}
|
||||
Post translation
|
||||
</label>
|
||||
<div
|
||||
class={`sub-section ${
|
||||
!snapStates.settings.contentTranslation
|
||||
? 'more-insignificant'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<label>
|
||||
Translate to{' '}
|
||||
<select
|
||||
value={targetLanguage || ''}
|
||||
disabled={!snapStates.settings.contentTranslation}
|
||||
onChange={(e) => {
|
||||
states.settings.contentTranslationTargetLanguage =
|
||||
e.target.value || null;
|
||||
}}
|
||||
>
|
||||
<option value="">
|
||||
System language ({systemTargetLanguageText})
|
||||
</option>
|
||||
<option disabled>──────────</option>
|
||||
{targetLanguages.map((lang) => (
|
||||
<option value={lang.code}>{lang.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p>
|
||||
<small>
|
||||
Note: This feature uses an external API to translate,
|
||||
powered by{' '}
|
||||
<a
|
||||
href="https://github.com/thedaviddelta/lingva-translate"
|
||||
target="_blank"
|
||||
>
|
||||
Lingva Translate
|
||||
</a>
|
||||
.
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="light"
|
||||
|
@ -255,21 +220,34 @@ function Settings({ onClose }) {
|
|||
>
|
||||
Unsent drafts
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<h2>About</h2>
|
||||
<h3>About</h3>
|
||||
<section>
|
||||
<p>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
lineHeight: 1.25,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={logo}
|
||||
alt=""
|
||||
width="20"
|
||||
height="20"
|
||||
width="64"
|
||||
height="64"
|
||||
style={{
|
||||
aspectRatio: '1/1',
|
||||
verticalAlign: 'middle',
|
||||
background: '#b7cdf9',
|
||||
borderRadius: 12,
|
||||
}}
|
||||
/>{' '}
|
||||
/>
|
||||
<div>
|
||||
<b>Phanpy</b>{' '}
|
||||
<a
|
||||
href="https://hachyderm.io/@phanpy"
|
||||
// target="_blank"
|
||||
|
@ -280,9 +258,7 @@ function Settings({ onClose }) {
|
|||
>
|
||||
@phanpy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
<br />
|
||||
<a href="https://github.com/cheeaun/phanpy" target="_blank">
|
||||
Built
|
||||
</a>{' '}
|
||||
|
@ -297,18 +273,20 @@ function Settings({ onClose }) {
|
|||
>
|
||||
@cheeaun
|
||||
</a>
|
||||
.{' '}
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
<a
|
||||
href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"
|
||||
target="_blank"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
{__BUILD_TIME__ && (
|
||||
<p>
|
||||
Last build: <RelativeTime datetime={new Date(__BUILD_TIME__)} />{' '}
|
||||
<span class="insignificant">Last build:</span>{' '}
|
||||
<RelativeTime datetime={new Date(__BUILD_TIME__)} />{' '}
|
||||
{__COMMIT_HASH__ && (
|
||||
<>
|
||||
(
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
}
|
||||
|
||||
.hero-heading {
|
||||
font-size: 16px;
|
||||
font-size: var(--text-size);
|
||||
display: inline-block;
|
||||
}
|
||||
.hero-heading .icon {
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
</InView>
|
||||
{uiState !== 'loading' && !authenticated ? (
|
||||
|
@ -700,6 +703,7 @@ function StatusPage() {
|
|||
instance={instance}
|
||||
withinContext
|
||||
size={thread || ancestor ? 'm' : 's'}
|
||||
enableTranslate
|
||||
/>
|
||||
{/* {replies?.length > LIMIT && (
|
||||
<div class="replies-link">
|
||||
|
@ -880,6 +884,7 @@ function SubComments({ hasManyStatuses, replies, instance, hasParentThread }) {
|
|||
instance={instance}
|
||||
withinContext
|
||||
size="s"
|
||||
enableTranslate
|
||||
/>
|
||||
{!r.replies?.length && r.repliesCount > 0 && (
|
||||
<div class="replies-link">
|
||||
|
|
24
src/utils/get-translate-target-language.jsx
Normal file
24
src/utils/get-translate-target-language.jsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { match } from '@formatjs/intl-localematcher';
|
||||
|
||||
import translationTargetLanguages from '../data/lingva-target-languages';
|
||||
|
||||
import states from './states';
|
||||
|
||||
function getTranslateTargetLanguage(fromSettings = false) {
|
||||
if (fromSettings) {
|
||||
const { contentTranslationTargetLanguage } = states.settings;
|
||||
if (contentTranslationTargetLanguage) {
|
||||
return contentTranslationTargetLanguage;
|
||||
}
|
||||
}
|
||||
return match(
|
||||
[
|
||||
new Intl.DateTimeFormat().resolvedOptions().locale,
|
||||
...navigator.languages,
|
||||
],
|
||||
translationTargetLanguages.map((l) => l.code.replace('_', '-')), // The underscore will fail Intl.Locale inside `match`
|
||||
'en',
|
||||
);
|
||||
}
|
||||
|
||||
export default getTranslateTargetLanguage;
|
|
@ -45,6 +45,9 @@ function handleContentLinks(opts) {
|
|||
} else if (states.unfurledLinks[target.href]?.url) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
states.prevLocation = {
|
||||
pathname: location.hash.replace(/^#/, ''),
|
||||
};
|
||||
location.hash = `#${states.unfurledLinks[target.href].url}`;
|
||||
}
|
||||
};
|
||||
|
|
5
src/utils/localeCode2Text.jsx
Normal file
5
src/utils/localeCode2Text.jsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
export default function localeCode2Text(code) {
|
||||
return new Intl.DisplayNames(navigator.languages, {
|
||||
type: 'language',
|
||||
}).of(code);
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
function niceDateTime(date, { hideTime } = {}) {
|
||||
function niceDateTime(date, { hideTime, formatOpts } = {}) {
|
||||
if (!(date instanceof Date)) {
|
||||
date = new Date(date);
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ function niceDateTime(date, { hideTime } = {}) {
|
|||
// Hide time if requested
|
||||
hour: hideTime ? undefined : 'numeric',
|
||||
minute: hideTime ? undefined : 'numeric',
|
||||
...formatOpts,
|
||||
}).format(date);
|
||||
return dateText;
|
||||
}
|
||||
|
|
|
@ -4,14 +4,14 @@ function showToast(props) {
|
|||
if (typeof props === 'string') {
|
||||
props = { text: props };
|
||||
}
|
||||
const { onClick = () => {}, delay, ...rest } = props;
|
||||
const { onClick, delay, ...rest } = props;
|
||||
const toast = Toastify({
|
||||
className: 'shiny-pill',
|
||||
className: `${onClick || props.destination ? 'shiny-pill' : ''}`,
|
||||
gravity: 'bottom',
|
||||
position: 'center',
|
||||
...rest,
|
||||
onClick: () => {
|
||||
onClick(toast); // Pass in the object itself!
|
||||
onClick?.(toast); // Pass in the object itself!
|
||||
},
|
||||
});
|
||||
if (delay) {
|
||||
|
|
|
@ -27,10 +27,12 @@ const states = proxy({
|
|||
spoilers: {},
|
||||
scrollPositions: {},
|
||||
unfurledLinks: {},
|
||||
accounts: {},
|
||||
// Modals
|
||||
showCompose: false,
|
||||
showSettings: false,
|
||||
showAccount: false,
|
||||
showAccounts: false,
|
||||
showDrafts: false,
|
||||
showMediaModal: false,
|
||||
showShortcutsSettings: false,
|
||||
|
@ -42,6 +44,10 @@ const states = proxy({
|
|||
shortcutsColumnsMode:
|
||||
store.account.get('settings-shortcutsColumnsMode') ?? false,
|
||||
boostsCarousel: store.account.get('settings-boostsCarousel') ?? true,
|
||||
contentTranslation:
|
||||
store.account.get('settings-contentTranslation') ?? true,
|
||||
contentTranslationTargetLanguage:
|
||||
store.account.get('settings-contentTranslationTargetLanguage') || null,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -51,9 +57,9 @@ subscribeKey(states, 'notificationsLast', (v) => {
|
|||
console.log('CHANGE', v);
|
||||
store.account.set('notificationsLast', states.notificationsLast);
|
||||
});
|
||||
subscribe(states, (v) => {
|
||||
console.debug('STATES change', v);
|
||||
const [action, path, value, prevValue] = v[0];
|
||||
subscribe(states, (changes) => {
|
||||
console.debug('STATES change', changes);
|
||||
for (const [action, path, value, prevValue] of changes) {
|
||||
if (path.join('.') === 'settings.boostsCarousel') {
|
||||
store.account.set('settings-boostsCarousel', !!value);
|
||||
}
|
||||
|
@ -63,15 +69,24 @@ subscribe(states, (v) => {
|
|||
if (path.join('.') === 'settings.shortcutsViewMode') {
|
||||
store.account.set('settings-shortcutsViewMode', value);
|
||||
}
|
||||
if (path.join('.') === 'settings.contentTranslation') {
|
||||
store.account.set('settings-contentTranslation', !!value);
|
||||
}
|
||||
if (path.join('.') === 'settings.contentTranslationTargetLanguage') {
|
||||
console.log('SET', value);
|
||||
store.account.set('settings-contentTranslationTargetLanguage', value);
|
||||
}
|
||||
if (path?.[0] === 'shortcuts') {
|
||||
store.account.set('shortcuts', states.shortcuts);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function hideAllModals() {
|
||||
states.showCompose = false;
|
||||
states.showSettings = false;
|
||||
states.showAccount = false;
|
||||
states.showAccounts = false;
|
||||
states.showDrafts = false;
|
||||
states.showMediaModal = false;
|
||||
states.showShortcutsSettings = false;
|
||||
|
|
Loading…
Reference in a new issue