Merge pull request #79 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2023-03-15 20:49:59 +08:00 committed by GitHub
commit 5e916559b3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 3118 additions and 748 deletions

View file

@ -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
View file

@ -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": {

View file

@ -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",

View 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');
});

View file

@ -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 */

View file

@ -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 };

View 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

View 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

View 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

View file

@ -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);
states.showAccount = {
account,
instance,
};
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 && (
<>
{' '}
&middot; Last posted:{' '}
{niceDateTime(lastStatusAt, {
hideTime: true,
})}
</>
)}
</small>
</>
)}
</span>
</a>
);

View 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));
}
}

View file

@ -1,7 +1,6 @@
import './account.css';
import './account-info.css';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { api } from '../utils/api';
import emojifyText from '../utils/emojify-text';
@ -17,49 +16,36 @@ import Avatar from './avatar';
import Icon from './icon';
import Link from './link';
function Account({ account, instance: propInstance, onClose }) {
const { masto, instance, authenticated } = api({ instance: propInstance });
function AccountInfo({
account,
fetchAccount = () => {},
standalone,
instance,
authenticated,
}) {
const [uiState, setUIState] = useState('default');
const isString = typeof account === 'string';
const [info, setInfo] = useState(isString ? null : account);
useEffect(() => {
if (isString) {
setUIState('loading');
(async () => {
try {
const info = await masto.v1.accounts.lookup({
acct: account,
skip_webfinger: false,
});
setInfo(info);
setUIState('default');
} catch (e) {
try {
const result = await masto.v2.search({
q: account,
type: 'accounts',
limit: 1,
resolve: authenticated,
});
if (result.accounts.length) {
setInfo(result.accounts[0]);
setUIState('default');
return;
}
setInfo(null);
setUIState('error');
} catch (err) {
console.error(err);
setInfo(null);
setUIState('error');
}
}
})();
} else {
if (!isString) {
setInfo(account);
return;
}
}, [account]);
setUIState('loading');
(async () => {
try {
const info = await fetchAccount();
states.accounts[`${info.id}@${instance}`] = info;
setInfo(info);
setUIState('default');
} catch (e) {
console.error(e);
setInfo(null);
setUIState('error');
}
})();
}, [isString, account, fetchAccount]);
const {
acct,
@ -73,8 +59,8 @@ function Account({ account, instance: propInstance, onClose }) {
followersCount,
followingCount,
group,
header,
headerStatic,
// header,
// headerStatic,
id,
lastStatusAt,
locked,
@ -83,14 +69,29 @@ function Account({ account, instance: propInstance, onClose }) {
url,
username,
} = info || {};
let headerIsAvatar = false;
let { header, headerStatic } = info || {};
if (!header || /missing\.png$/.test(header)) {
if (avatar && !/missing\.png$/.test(avatar)) {
header = avatar;
headerIsAvatar = true;
if (avatarStatic && !/missing\.png$/.test(avatarStatic)) {
headerStatic = avatarStatic;
}
}
}
const escRef = useHotkeys('esc', onClose, [onClose]);
const [headerCornerColors, setHeaderCornerColors] = useState([]);
return (
<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,18 +283,28 @@ function Account({ account, instance: propInstance, onClose }) {
</div>
)}
<p class="stats">
<Link
to={instance ? `/${instance}/a/${id}` : `/a/${id}`}
onClick={() => {
hideAllModals();
}}
>
Posts
<br />
<b title={statusesCount}>
{shortenNumber(statusesCount)}
</b>{' '}
</Link>
{standalone ? (
<span>
Posts
<br />
<b title={statusesCount}>
{shortenNumber(statusesCount)}
</b>{' '}
</span>
) : (
<Link
to={instance ? `/${instance}/a/${id}` : `/a/${id}`}
onClick={() => {
hideAllModals();
}}
>
Posts
<br />
<b title={statusesCount}>
{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;

View 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;

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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>
);

View file

@ -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;

View file

@ -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} />

View file

@ -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 && (

View file

@ -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;

View file

@ -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;

View file

@ -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,13 +170,18 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
muted
/>
) : (
<img
src={previewUrl}
alt={description}
width={width}
height={height}
loading="lazy"
/>
<>
<img
src={previewUrl}
alt={description}
width={width}
height={height}
loading="lazy"
/>
<div class="media-play">
<Icon icon="play" size="xxl" />
</div>
</>
)}
</div>
);

View file

@ -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&hellip;</span>
</MenuItem>
<MenuItem
onClick={() => {
states.showShortcutsSettings = true;

View file

@ -52,7 +52,7 @@ function NameText({
>
{showAvatar && (
<>
<Avatar url={avatar} />{' '}
<Avatar url={avatarStatic || avatar} />{' '}
</>
)}
{displayName && !short ? (

View file

@ -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 {

View file

@ -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&nbsp;as:
<select
Specify a list of shortcuts that'll appear&nbsp;as:
<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>

View file

@ -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;

View file

@ -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);
}

View file

@ -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,51 +458,69 @@ 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>
<MenuItem
onClick={() => {
// Copy url to clipboard
try {
navigator.clipboard.writeText(url);
showToast('Link copied');
} catch (e) {
console.error(e);
showToast('Unable to copy link');
}
}}
>
<Icon icon="link" />
<span>Copy link to post</span>
</MenuItem>
{navigator?.share &&
navigator?.canShare?.({
url,
}) && (
<MenuItem
onClick={() => {
try {
navigator.share({
url,
});
} catch (e) {
console.error(e);
alert("Sharing doesn't seem to work.");
}
}}
>
<Icon icon="share" />
<span>Share</span>
</MenuItem>
)}
<div class="menu-horizontal">
<MenuItem
onClick={() => {
// Copy url to clipboard
try {
navigator.clipboard.writeText(url);
showToast('Link copied');
} catch (e) {
console.error(e);
showToast('Unable to copy link');
}
}}
>
<Icon icon="link" />
<span>Copy</span>
</MenuItem>
{navigator?.share &&
navigator?.canShare?.({
url,
}) && (
<MenuItem
onClick={() => {
try {
navigator.share({
url,
});
} catch (e) {
console.error(e);
alert("Sharing doesn't seem to work.");
}
}}
>
<Icon icon="share" />
<span>Share</span>
</MenuItem>
)}
</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);

View file

@ -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} />

View 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);
}

View 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;

View 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"
}
]

View 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"
}
]

View file

@ -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%;
}
}

View file

@ -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
View 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;

View file

@ -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>

View file

@ -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;
}

View file

@ -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,78 +150,143 @@ function Settings({ onClose }) {
states.settings.boostsCarousel = e.target.checked;
}}
/>{' '}
Boosts carousel (experimental)
Boosts carousel
</label>
</li>
<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"
onClick={() => {
states.showDrafts = true;
states.showSettings = false;
}}
>
Unsent drafts
</button>
</li>
</ul>
</section>
<h2>Hidden features</h2>
<h3>About</h3>
<section>
<div>
<button
type="button"
class="light"
onClick={() => {
states.showDrafts = true;
states.showSettings = false;
}}
>
Unsent drafts
</button>
</div>
</section>
<h2>About</h2>
<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,
}}
/>{' '}
<a
href="https://hachyderm.io/@phanpy"
// target="_blank"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'phanpy@hachyderm.io';
}}
>
@phanpy
</a>
.
</p>
/>
<div>
<b>Phanpy</b>{' '}
<a
href="https://hachyderm.io/@phanpy"
// target="_blank"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'phanpy@hachyderm.io';
}}
>
@phanpy
</a>
<br />
<a href="https://github.com/cheeaun/phanpy" target="_blank">
Built
</a>{' '}
by{' '}
<a
href="https://mastodon.social/@cheeaun"
// target="_blank"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'cheeaun@mastodon.social';
}}
>
@cheeaun
</a>
</div>
</div>
<p>
<a href="https://github.com/cheeaun/phanpy" target="_blank">
Built
</a>{' '}
by{' '}
<a
href="https://mastodon.social/@cheeaun"
// target="_blank"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'cheeaun@mastodon.social';
}}
>
@cheeaun
</a>
.{' '}
<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__ && (
<>
(

View file

@ -17,7 +17,7 @@
}
.hero-heading {
font-size: 16px;
font-size: var(--text-size);
display: inline-block;
}
.hero-heading .icon {

View file

@ -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">

View 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;

View file

@ -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}`;
}
};

View file

@ -0,0 +1,5 @@
export default function localeCode2Text(code) {
return new Intl.DisplayNames(navigator.languages, {
type: 'language',
}).of(code);
}

View file

@ -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;
}

View file

@ -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) {

View file

@ -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,20 +57,28 @@ 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];
if (path.join('.') === 'settings.boostsCarousel') {
store.account.set('settings-boostsCarousel', !!value);
}
if (path.join('.') === 'settings.shortcutsColumnsMode') {
store.account.set('settings-shortcutsColumnsMode', !!value);
}
if (path.join('.') === 'settings.shortcutsViewMode') {
store.account.set('settings-shortcutsViewMode', value);
}
if (path?.[0] === 'shortcuts') {
store.account.set('shortcuts', states.shortcuts);
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);
}
if (path.join('.') === 'settings.shortcutsColumnsMode') {
store.account.set('settings-shortcutsColumnsMode', !!value);
}
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);
}
}
});
@ -72,6 +86,7 @@ export function hideAllModals() {
states.showCompose = false;
states.showSettings = false;
states.showAccount = false;
states.showAccounts = false;
states.showDrafts = false;
states.showMediaModal = false;
states.showShortcutsSettings = false;