diff --git a/README.md b/README.md
index bcf52dc4..71ef021a 100644
--- a/README.md
+++ b/README.md
@@ -103,6 +103,7 @@ And here I am. Building a Mastodon web client.
- [Soapbox](https://fe.soapbox.pub/)
- [Elk](https://elk.zone/)
- [Mastodeck](https://mastodeck.com/)
+- [Trunks (alpha)](https://alpha.trunks.social/)
- [Tooty](https://github.com/n1k0/tooty)
- [More...](https://github.com/hueyy/awesome-mastodon/#clients)
diff --git a/package-lock.json b/package-lock.json
index 22d02159..d658f20d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,7 +11,7 @@
"@formatjs/intl-localematcher": "~0.2.32",
"@github/text-expander-element": "~2.3.0",
"@iconify-icons/mingcute": "~1.2.4",
- "@szhsin/react-menu": "~3.5.1",
+ "@szhsin/react-menu": "~3.5.2",
"dayjs": "~1.11.7",
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
@@ -22,7 +22,7 @@
"mem": "~9.0.2",
"p-retry": "~5.1.2",
"p-throttle": "~5.0.0",
- "preact": "~10.13.0",
+ "preact": "~10.13.1",
"react-hotkeys-hook": "~4.3.7",
"react-intersection-observer": "~9.4.3",
"react-router-dom": "6.6.2",
@@ -31,6 +31,7 @@
"toastify-js": "~1.12.0",
"uid": "~2.0.1",
"use-debounce": "~9.0.3",
+ "use-long-press": "~2.0.3",
"use-resize-observer": "~9.1.0",
"valtio": "1.9.0"
},
@@ -46,7 +47,7 @@
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-html-env": "~1.2.7",
"vite-plugin-pwa": "~0.14.4",
- "vite-plugin-remove-console": "~2.0.0",
+ "vite-plugin-remove-console": "~2.1.0",
"workbox-cacheable-response": "~6.5.4",
"workbox-expiration": "~6.5.4",
"workbox-routing": "~6.5.4",
@@ -2821,9 +2822,9 @@
}
},
"node_modules/@szhsin/react-menu": {
- "version": "3.5.1",
- "resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.5.1.tgz",
- "integrity": "sha512-bTCfVNBSReG4+mnbN8n2OQWZ3DRPlJgMIBJFepPfDLiRzNSe5lbZ8Z5Kjiv9nuPLHOu3jSaybxgYJj/Dn8n75Q==",
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.5.2.tgz",
+ "integrity": "sha512-eR7dzDBrwlt9RSgGmLXjfA1Rd5tYqD5mnqjQgZJysf3Jt3vBPkrbDT1oW21nLpfUCkyUQOuZ38n2IdhWl9KkzQ==",
"dependencies": {
"prop-types": "^15.7.2",
"react-transition-state": "^1.1.5"
@@ -5658,9 +5659,9 @@
"dev": true
},
"node_modules/preact": {
- "version": "10.13.0",
- "resolved": "https://registry.npmjs.org/preact/-/preact-10.13.0.tgz",
- "integrity": "sha512-ERdIdUpR6doqdaSIh80hvzebHB7O6JxycOhyzAeLEchqOq/4yueslQbfnPwXaNhAYacFTyCclhwkEbOumT0tHw==",
+ "version": "10.13.1",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.13.1.tgz",
+ "integrity": "sha512-KyoXVDU5OqTpG9LXlB3+y639JAGzl8JSBXLn1J9HTSB3gbKcuInga7bZnXLlxmK94ntTs1EFeZp0lrja2AuBYQ==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -6564,6 +6565,18 @@
"react": ">=16.8.0"
}
},
+ "node_modules/use-long-press": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-2.0.3.tgz",
+ "integrity": "sha512-n3cfv90Y1ldNt+hhXzxnxuLZmgLOOC/+qfLGoeEBgOxmnokPPt39MPF3KmvKriq5VMoJ7uQdVjHejCdHBt9anw==",
+ "engines": {
+ "node": ">=10",
+ "npm": ">=5"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/use-resize-observer": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz",
@@ -6789,9 +6802,9 @@
}
},
"node_modules/vite-plugin-remove-console": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/vite-plugin-remove-console/-/vite-plugin-remove-console-2.0.0.tgz",
- "integrity": "sha512-bEsyShSacsunbm0X1zaVliwgmWlsaBPLk7FN4wr2xQMs8zSZPSwpRNTT5UZiF0+cfMEkN4VVnofITawmT3pjgQ==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/vite-plugin-remove-console/-/vite-plugin-remove-console-2.1.0.tgz",
+ "integrity": "sha512-cil+h4rX3fDnnKMt73fexMGkwRSOV08+lTAzLGTRjGyxs9Ync3fqPWxnGrngJY7LyMMt3kEKf0hNOi+1DQ0j2g==",
"dev": true
},
"node_modules/webidl-conversions": {
@@ -8954,9 +8967,9 @@
}
},
"@szhsin/react-menu": {
- "version": "3.5.1",
- "resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.5.1.tgz",
- "integrity": "sha512-bTCfVNBSReG4+mnbN8n2OQWZ3DRPlJgMIBJFepPfDLiRzNSe5lbZ8Z5Kjiv9nuPLHOu3jSaybxgYJj/Dn8n75Q==",
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@szhsin/react-menu/-/react-menu-3.5.2.tgz",
+ "integrity": "sha512-eR7dzDBrwlt9RSgGmLXjfA1Rd5tYqD5mnqjQgZJysf3Jt3vBPkrbDT1oW21nLpfUCkyUQOuZ38n2IdhWl9KkzQ==",
"requires": {
"prop-types": "^15.7.2",
"react-transition-state": "^1.1.5"
@@ -10964,9 +10977,9 @@
"dev": true
},
"preact": {
- "version": "10.13.0",
- "resolved": "https://registry.npmjs.org/preact/-/preact-10.13.0.tgz",
- "integrity": "sha512-ERdIdUpR6doqdaSIh80hvzebHB7O6JxycOhyzAeLEchqOq/4yueslQbfnPwXaNhAYacFTyCclhwkEbOumT0tHw=="
+ "version": "10.13.1",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.13.1.tgz",
+ "integrity": "sha512-KyoXVDU5OqTpG9LXlB3+y639JAGzl8JSBXLn1J9HTSB3gbKcuInga7bZnXLlxmK94ntTs1EFeZp0lrja2AuBYQ=="
},
"prettier": {
"version": "2.8.0",
@@ -11609,6 +11622,12 @@
"integrity": "sha512-FhtlbDtDXILJV7Lix5OZj5yX/fW1tzq+VrvK1fnT2bUrPOGruU9Rw8NCEn+UI9wopfERBEZAOQ8lfeCJPllgnw==",
"requires": {}
},
+ "use-long-press": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-2.0.3.tgz",
+ "integrity": "sha512-n3cfv90Y1ldNt+hhXzxnxuLZmgLOOC/+qfLGoeEBgOxmnokPPt39MPF3KmvKriq5VMoJ7uQdVjHejCdHBt9anw==",
+ "requires": {}
+ },
"use-resize-observer": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz",
@@ -11744,9 +11763,9 @@
}
},
"vite-plugin-remove-console": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/vite-plugin-remove-console/-/vite-plugin-remove-console-2.0.0.tgz",
- "integrity": "sha512-bEsyShSacsunbm0X1zaVliwgmWlsaBPLk7FN4wr2xQMs8zSZPSwpRNTT5UZiF0+cfMEkN4VVnofITawmT3pjgQ==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/vite-plugin-remove-console/-/vite-plugin-remove-console-2.1.0.tgz",
+ "integrity": "sha512-cil+h4rX3fDnnKMt73fexMGkwRSOV08+lTAzLGTRjGyxs9Ync3fqPWxnGrngJY7LyMMt3kEKf0hNOi+1DQ0j2g==",
"dev": true
},
"webidl-conversions": {
diff --git a/package.json b/package.json
index 2ee389b7..24370bc5 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,7 @@
"@formatjs/intl-localematcher": "~0.2.32",
"@github/text-expander-element": "~2.3.0",
"@iconify-icons/mingcute": "~1.2.4",
- "@szhsin/react-menu": "~3.5.1",
+ "@szhsin/react-menu": "~3.5.2",
"dayjs": "~1.11.7",
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
@@ -24,7 +24,7 @@
"mem": "~9.0.2",
"p-retry": "~5.1.2",
"p-throttle": "~5.0.0",
- "preact": "~10.13.0",
+ "preact": "~10.13.1",
"react-hotkeys-hook": "~4.3.7",
"react-intersection-observer": "~9.4.3",
"react-router-dom": "6.6.2",
@@ -33,6 +33,7 @@
"toastify-js": "~1.12.0",
"uid": "~2.0.1",
"use-debounce": "~9.0.3",
+ "use-long-press": "~2.0.3",
"use-resize-observer": "~9.1.0",
"valtio": "1.9.0"
},
@@ -48,7 +49,7 @@
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-html-env": "~1.2.7",
"vite-plugin-pwa": "~0.14.4",
- "vite-plugin-remove-console": "~2.0.0",
+ "vite-plugin-remove-console": "~2.1.0",
"workbox-cacheable-response": "~6.5.4",
"workbox-expiration": "~6.5.4",
"workbox-routing": "~6.5.4",
diff --git a/scripts/fetch-lingva-languages.js b/scripts/fetch-lingva-languages.js
new file mode 100644
index 00000000..f270cabe
--- /dev/null
+++ b/scripts/fetch-lingva-languages.js
@@ -0,0 +1,18 @@
+// Fetch https://lingva.ml/api/v1/languages/{source|target}
+import fs from 'fs';
+
+fetch('https://lingva.ml/api/v1/languages/source')
+ .then((response) => response.json())
+ .then((json) => {
+ const file = './src/data/lingva-source-languages.json';
+ console.log(`Writing ${file}...`);
+ fs.writeFileSync(file, JSON.stringify(json.languages, null, '\t'), 'utf8');
+ });
+
+fetch('https://lingva.ml/api/v1/languages/target')
+ .then((response) => response.json())
+ .then((json) => {
+ const file = './src/data/lingva-target-languages.json';
+ console.log(`Writing ${file}...`);
+ fs.writeFileSync(file, JSON.stringify(json.languages, null, '\t'), 'utf8');
+ });
diff --git a/src/app.css b/src/app.css
index 77c03e4c..e4d1a2ff 100644
--- a/src/app.css
+++ b/src/app.css
@@ -74,8 +74,6 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
margin: auto;
width: var(--main-width);
max-width: 100%;
- border-left: 1px solid rgba(0, 0, 0, 0.1);
- border-right: 1px solid rgba(0, 0, 0, 0.1);
background-color: var(--bg-color);
}
.deck.contained {
@@ -537,6 +535,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
transition: background-color 0.2s ease-out;
-webkit-tap-highlight-color: transparent;
animation: appear 0.2s ease-out;
+ -webkit-touch-callout: none;
}
:is(.status-link, .status-focus):is(:focus, .is-active) {
background-color: var(--link-bg-hover-color);
@@ -987,9 +986,9 @@ body:has(.status-deck) .media-post-link {
width: 100%;
max-width: calc(var(--main-width) - 50px - 16px);
border-radius: 16px 16px 0 0;
- box-shadow: 0 -1px 32px var(--divider-color);
+ box-shadow: 0 -1px 32px var(--drop-shadow-color);
animation: slide-up 0.3s var(--timing-function);
- border: 1px solid var(--outline-color);
+ /* border: 1px solid var(--outline-color); */
}
.sheet-max {
width: 90vw;
@@ -1007,6 +1006,12 @@ body:has(.status-deck) .media-post-link {
.sheet header :is(h1, h2, h3) {
margin: 0;
}
+.sheet header.header-grid {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ grid-gap: 8px;
+ align-items: center;
+}
.sheet main {
overflow: auto;
overflow-x: hidden;
@@ -1045,6 +1050,11 @@ body:has(.status-deck) .media-post-link {
/* MENU POPUP */
+.szh-menu-container {
+ user-select: none;
+ -webkit-touch-callout: none;
+ -webkit-user-drag: none;
+}
.szh-menu-container:has(.szh-menu--state-open) {
inset: 0;
inset: env(safe-area-inset-top) env(safe-area-inset-right)
@@ -1053,7 +1063,7 @@ body:has(.status-deck) .media-post-link {
.szh-menu {
padding: 8px 0;
margin: 0;
- font-size: 16px;
+ font-size: var(--text-size);
background-color: var(--bg-color);
border: 1px solid var(--outline-color);
border-radius: 8px;
@@ -1088,10 +1098,16 @@ body:has(.status-deck) .media-post-link {
line-height: 1;
padding: 8px 16px !important;
transition: all 0.1s ease-in-out;
+ text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
- text-decoration: none;
+}
+.szh-menu .szh-menu__item span {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.05;
}
.szh-menu .szh-menu__item * {
vertical-align: middle;
@@ -1106,6 +1122,7 @@ body:has(.status-deck) .media-post-link {
text-decoration: none;
padding: 8px 16px !important;
margin: -8px -16px !important;
+ align-items: center;
}
.szh-menu .szh-menu__item a.is-active {
font-weight: bold;
@@ -1129,6 +1146,24 @@ body:has(.status-deck) .media-post-link {
text-overflow: ellipsis;
overflow: hidden;
}
+.szh-menu .menu-double-lines {
+ white-space: normal;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.szh-menu .menu-double-lines span {
+ white-space: normal;
+ line-height: inherit;
+ font-size: inherit;
+}
+.szh-menu .menu-horizontal {
+ display: flex;
+}
+.szh-menu .menu-horizontal .szh-menu__item {
+ flex: 1;
+}
.szh-menu .szh-menu__item .menu-shortcut {
opacity: 0.5;
font-weight: normal;
@@ -1219,35 +1254,45 @@ meter.donut:is(.danger, .explode):after {
/* SHINY PILL */
-.shiny-pill {
+:is(.shiny-pill, :root .toastify.shiny-pill) {
+ pointer-events: auto;
color: var(--button-text-color);
text-shadow: 0 calc(var(--hairline-width) * -1) var(--drop-shadow-color);
background-color: var(--button-bg-color);
background-image: linear-gradient(
160deg,
rgba(255, 255, 255, 0.5),
- rgba(255, 255, 255, 0) 50%
+ rgba(0, 0, 0, 0.1)
);
box-shadow: 0 3px 8px -1px var(--drop-shadow-color),
0 10px 36px -4px var(--button-bg-blur-color),
inset var(--hairline-width) var(--hairline-width) rgba(255, 255, 255, 0.5);
+ transition: filter 0.3s;
+}
+:is(.shiny-pill, :root .toastify.shiny-pill):hover {
+ filter: brightness(1.2);
+}
+:is(.shiny-pill, :root .toastify.shiny-pill):active {
+ transition: none;
+ filter: brightness(0.9);
}
/* TOAST */
:root .toastify {
+ user-select: none;
padding: 8px 16px;
border-radius: 999px;
+ pointer-events: none;
+ color: var(--button-text-color);
+ text-shadow: 0 calc(var(--hairline-width) * -1) var(--drop-shadow-color);
+ background-color: var(--button-bg-blur-color);
+ background-image: none;
+ backdrop-filter: blur(16px);
}
.toastify-bottom {
margin-bottom: env(safe-area-inset-bottom);
}
-:root .toastify:hover {
- filter: brightness(1.2);
-}
-:root .toastify:active {
- filter: brightness(0.8);
-}
/* AVATARS STACK */
diff --git a/src/app.jsx b/src/app.jsx
index ebbce5dd..77d2ce17 100644
--- a/src/app.jsx
+++ b/src/app.jsx
@@ -16,7 +16,7 @@ import {
} from 'react-router-dom';
import { useSnapshot } from 'valtio';
-import Account from './components/account';
+import AccountSheet from './components/account-sheet';
import Compose from './components/compose';
import Drafts from './components/drafts';
import Loader from './components/loader';
@@ -26,6 +26,7 @@ import Shortcuts from './components/shortcuts';
import ShortcutsSettings from './components/shortcuts-settings';
import NotFound from './pages/404';
import AccountStatuses from './pages/account-statuses';
+import Accounts from './pages/accounts';
import Bookmarks from './pages/bookmarks';
import Favourites from './pages/favourites';
import FollowedHashtags from './pages/followed-hashtags';
@@ -73,6 +74,13 @@ function App() {
.querySelector('meta[name="color-scheme"]')
.setAttribute('content', theme === 'auto' ? 'dark light' : theme);
}
+ const textSize = store.local.get('textSize');
+ if (textSize) {
+ document.documentElement.style.setProperty(
+ '--text-size',
+ `${textSize}px`,
+ );
+ }
}, []);
useEffect(() => {
@@ -143,6 +151,8 @@ function App() {
// Focus first column
columns.querySelector('.deck-container')?.focus?.();
} else {
+ const backDrop = document.querySelector('.deck-backdrop');
+ if (backDrop) return;
// Focus last deck
const pages = document.querySelectorAll('.deck-container');
const page = pages[pages.length - 1]; // last one
@@ -163,6 +173,7 @@ function App() {
const showModal =
snapStates.showCompose ||
snapStates.showSettings ||
+ snapStates.showAccounts ||
snapStates.showAccount ||
snapStates.showDrafts ||
snapStates.showMediaModal ||
@@ -171,15 +182,6 @@ function App() {
if (!showModal) focusDeck();
}, [showModal]);
- // useEffect(() => {
- // // HACK: prevent this from running again due to HMR
- // if (states.init) return;
- // if (isLoggedIn) {
- // requestAnimationFrame(startVisibility);
- // states.init = true;
- // }
- // }, [isLoggedIn]);
-
// Notifications service
// - WebSocket to receive notifications when page is visible
const [visible, setVisible] = useState(true);
@@ -253,7 +255,9 @@ function App() {
return !/^\/(login|welcome)/.test(pathname);
}, [location]);
- useInterval(() => {
+ const lastCheckDate = useRef();
+ const checkForUpdates = () => {
+ lastCheckDate.current = Date.now();
console.log('✨ Check app update');
fetch('./version.json')
.then((r) => r.json())
@@ -263,7 +267,21 @@ function App() {
.catch((e) => {
console.error(e);
});
- }, visible && 1000 * 60 * 60); // 1 hour
+ };
+ useInterval(() => checkForUpdates, visible && 1000 * 60 * 30); // 30 minutes
+ usePageVisibility((visible) => {
+ if (visible) {
+ if (!lastCheckDate.current) {
+ checkForUpdates();
+ } else {
+ const diff = Date.now() - lastCheckDate.current;
+ if (diff > 1000 * 60 * 60) {
+ // 1 hour
+ checkForUpdates();
+ }
+ }
+ }
+ });
return (
<>
@@ -374,6 +392,21 @@ function App() {
/>
)}
+ {!!snapStates.showAccounts && (
+ {
+ if (e.target === e.currentTarget) {
+ states.showAccounts = false;
+ }
+ }}
+ >
+ {
+ states.showAccounts = false;
+ }}
+ />
+
+ )}
{!!snapStates.showAccount && (
- {
+ onClose={({ destination }) => {
states.showAccount = false;
+ if (destination) {
+ states.showAccounts = false;
+ }
}}
/>
@@ -440,164 +476,4 @@ function App() {
);
}
-// let ws;
-// async function startStream() {
-// const { masto, instance } = api();
-// if (
-// ws &&
-// (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)
-// ) {
-// return;
-// }
-
-// const stream = await masto.v1.stream.streamUser();
-// console.log('STREAM START', { stream });
-// ws = stream.ws;
-
-// const handleNewStatus = debounce((status) => {
-// console.log('UPDATE', status);
-// if (document.visibilityState === 'hidden') return;
-
-// const inHomeNew = states.homeNew.find((s) => s.id === status.id);
-// const inHome = status.id === states.homeLast?.id;
-// if (!inHomeNew && !inHome) {
-// if (states.settings.boostsCarousel && status.reblog) {
-// // do nothing
-// } else {
-// states.homeNew.unshift({
-// id: status.id,
-// reblog: status.reblog?.id,
-// reply: !!status.inReplyToAccountId,
-// });
-// console.log('homeNew 1', [...states.homeNew]);
-// }
-// }
-
-// saveStatus(status, instance);
-// }, 5000);
-// stream.on('update', handleNewStatus);
-// stream.on('status.update', (status) => {
-// console.log('STATUS.UPDATE', status);
-// saveStatus(status, instance);
-// });
-// stream.on('delete', (statusID) => {
-// console.log('DELETE', statusID);
-// // delete states.statuses[statusID];
-// const s = getStatus(statusID);
-// if (s) s._deleted = true;
-// });
-// stream.on('notification', (notification) => {
-// console.log('NOTIFICATION', notification);
-
-// const inNotificationsNew = states.notificationsNew.find(
-// (n) => n.id === notification.id,
-// );
-// const inNotifications = notification.id === states.notificationsLast?.id;
-// if (!inNotificationsNew && !inNotifications) {
-// states.notificationsNew.unshift(notification);
-// }
-
-// saveStatus(notification.status, instance, { override: false });
-// });
-
-// stream.ws.onclose = () => {
-// console.log('STREAM CLOSED!');
-// if (document.visibilityState !== 'hidden') {
-// startStream();
-// }
-// };
-
-// return {
-// stream,
-// stopStream: () => {
-// stream.ws.close();
-// },
-// };
-// }
-
-// let lastHidden;
-// function startVisibility() {
-// const { masto, instance } = api();
-// const handleVisible = (visible) => {
-// if (!visible) {
-// const timestamp = Date.now();
-// lastHidden = timestamp;
-// } else {
-// const timestamp = Date.now();
-// const diff = timestamp - lastHidden;
-// const diffMins = Math.round(diff / 1000 / 60);
-// console.log(`visible: ${visible}`, { lastHidden, diffMins });
-// if (!lastHidden || diffMins > 1) {
-// (async () => {
-// try {
-// const firstStatusID = states.homeLast?.id;
-// const firstNotificationID = states.notificationsLast?.id;
-// console.log({ states, firstNotificationID, firstStatusID });
-// const fetchHome = masto.v1.timelines.listHome({
-// limit: 5,
-// ...(firstStatusID && { sinceId: firstStatusID }),
-// });
-// const fetchNotifications = masto.v1.notifications.list({
-// limit: 1,
-// ...(firstNotificationID && { sinceId: firstNotificationID }),
-// });
-
-// const newStatuses = await fetchHome;
-// const hasOneAndReblog =
-// newStatuses.length === 1 && newStatuses?.[0]?.reblog;
-// if (newStatuses.length) {
-// if (states.settings.boostsCarousel && hasOneAndReblog) {
-// // do nothing
-// } else {
-// states.homeNew = newStatuses.map((status) => {
-// saveStatus(status, instance);
-// return {
-// id: status.id,
-// reblog: status.reblog?.id,
-// reply: !!status.inReplyToAccountId,
-// };
-// });
-// console.log('homeNew 2', [...states.homeNew]);
-// }
-// }
-
-// const newNotifications = await fetchNotifications;
-// if (newNotifications.length) {
-// const notification = newNotifications[0];
-// const inNotificationsNew = states.notificationsNew.find(
-// (n) => n.id === notification.id,
-// );
-// const inNotifications =
-// notification.id === states.notificationsLast?.id;
-// if (!inNotificationsNew && !inNotifications) {
-// states.notificationsNew.unshift(notification);
-// }
-
-// saveStatus(notification.status, instance, { override: false });
-// }
-// } catch (e) {
-// // Silently fail
-// console.error(e);
-// } finally {
-// startStream();
-// }
-// })();
-// }
-// }
-// };
-
-// const handleVisibilityChange = () => {
-// const hidden = document.visibilityState === 'hidden';
-// handleVisible(!hidden);
-// console.log('VISIBILITY: ' + (hidden ? 'hidden' : 'visible'));
-// };
-// document.addEventListener('visibilitychange', handleVisibilityChange);
-// requestAnimationFrame(handleVisibilityChange);
-// return {
-// stop: () => {
-// document.removeEventListener('visibilitychange', handleVisibilityChange);
-// },
-// };
-// }
-
export { App };
diff --git a/src/assets/floating-button.svg b/src/assets/floating-button.svg
new file mode 100644
index 00000000..6a9ad117
--- /dev/null
+++ b/src/assets/floating-button.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/src/assets/multi-column.svg b/src/assets/multi-column.svg
new file mode 100644
index 00000000..5e8deff7
--- /dev/null
+++ b/src/assets/multi-column.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/src/assets/tab-menu-bar.svg b/src/assets/tab-menu-bar.svg
new file mode 100644
index 00000000..64b48b03
--- /dev/null
+++ b/src/assets/tab-menu-bar.svg
@@ -0,0 +1,10 @@
+
\ No newline at end of file
diff --git a/src/components/account-block.jsx b/src/components/account-block.jsx
index c8c2e54b..b4c40a0a 100644
--- a/src/components/account-block.jsx
+++ b/src/components/account-block.jsx
@@ -1,6 +1,9 @@
import './account-block.css';
+import { useNavigate } from 'react-router-dom';
+
import emojifyText from '../utils/emojify-text';
+import niceDateTime from '../utils/nice-date-time';
import states from '../utils/states';
import Avatar from './avatar';
@@ -11,7 +14,9 @@ function AccountBlock({
avatarSize = 'xl',
instance,
external,
+ internal,
onClick,
+ showActivity = false,
}) {
if (skeleton) {
return (
@@ -20,15 +25,28 @@ function AccountBlock({
████████
- @██████
+ @██████
);
}
- const { acct, avatar, avatarStatic, displayName, username, emojis, url } =
- account;
+ const navigate = useNavigate();
+
+ const {
+ id,
+ acct,
+ avatar,
+ avatarStatic,
+ displayName,
+ username,
+ emojis,
+ url,
+ statusesCount,
+ lastStatusAt,
+ } = account;
const displayNameWithEmoji = emojifyText(displayName, emojis);
+ const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
return (
@@ -57,7 +79,29 @@ function AccountBlock({
) : (
{username}
)}
-
@{acct}
+
+
+ @{acct1}
+
+ {acct2}
+
+ {showActivity && (
+ <>
+
+
+ Posts: {statusesCount}
+ {!!lastStatusAt && (
+ <>
+ {' '}
+ · Last posted:{' '}
+ {niceDateTime(lastStatusAt, {
+ hideTime: true,
+ })}
+ >
+ )}
+
+ >
+ )}
);
diff --git a/src/components/account-info.css b/src/components/account-info.css
new file mode 100644
index 00000000..586fb960
--- /dev/null
+++ b/src/components/account-info.css
@@ -0,0 +1,290 @@
+.account-container {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ max-width: 100%;
+}
+
+.account-container.skeleton {
+ color: var(--outline-color);
+}
+
+.account-container .header-banner {
+ /* pointer-events: none; */
+ aspect-ratio: 6 / 1;
+ width: 100%;
+ height: auto;
+ object-fit: cover;
+ /* mask fade out bottom of banner */
+ mask-image: linear-gradient(
+ to bottom,
+ hsl(0, 0%, 0%) 0%,
+ hsla(0, 0%, 0%, 0.987) 14%,
+ hsla(0, 0%, 0%, 0.951) 26.2%,
+ hsla(0, 0%, 0%, 0.896) 36.8%,
+ hsla(0, 0%, 0%, 0.825) 45.9%,
+ hsla(0, 0%, 0%, 0.741) 53.7%,
+ hsla(0, 0%, 0%, 0.648) 60.4%,
+ hsla(0, 0%, 0%, 0.55) 66.2%,
+ hsla(0, 0%, 0%, 0.45) 71.2%,
+ hsla(0, 0%, 0%, 0.352) 75.6%,
+ hsla(0, 0%, 0%, 0.259) 79.6%,
+ hsla(0, 0%, 0%, 0.175) 83.4%,
+ hsla(0, 0%, 0%, 0.104) 87.2%,
+ hsla(0, 0%, 0%, 0.049) 91.1%,
+ hsla(0, 0%, 0%, 0.013) 95.3%,
+ hsla(0, 0%, 0%, 0) 100%
+ );
+ margin-bottom: -44px;
+ user-select: none;
+ -webkit-user-drag: none;
+}
+.account-container .header-banner.header-is-avatar {
+ mask-image: linear-gradient(
+ to bottom,
+ hsl(0, 0%, 0%) 0%,
+ hsla(0, 0%, 0%, 0.987) 8.1%,
+ hsla(0, 0%, 0%, 0.951) 15.5%,
+ hsla(0, 0%, 0%, 0.896) 22.5%,
+ hsla(0, 0%, 0%, 0.825) 29%,
+ hsla(0, 0%, 0%, 0.741) 35.3%,
+ hsla(0, 0%, 0%, 0.648) 41.2%,
+ hsla(0, 0%, 0%, 0.55) 47.1%,
+ hsla(0, 0%, 0%, 0.45) 52.9%,
+ hsla(0, 0%, 0%, 0.352) 58.8%,
+ hsla(0, 0%, 0%, 0.259) 64.7%,
+ hsla(0, 0%, 0%, 0.175) 71%,
+ hsla(0, 0%, 0%, 0.104) 77.5%,
+ hsla(0, 0%, 0%, 0.049) 84.5%,
+ hsla(0, 0%, 0%, 0.013) 91.9%,
+ hsla(0, 0%, 0%, 0) 100%
+ );
+ filter: blur(32px) saturate(3) opacity(0.5);
+ pointer-events: none;
+}
+.account-container .header-banner:hover {
+ animation: position-object 5s ease-in-out 1s 5;
+}
+.account-container .header-banner:active {
+ mask-image: none;
+}
+.account-container .header-banner:active + header .avatar + * {
+ transition: opacity 0.3s ease-in-out;
+ opacity: 0 !important;
+}
+.account-container .header-banner:active + header .avatar {
+ transition: filter 0.3s ease-in-out;
+ filter: none !important;
+}
+.account-container .header-banner:active + header .avatar img {
+ transition: border-radius 0.3s ease-in-out;
+ border-radius: 8px;
+}
+
+@media (min-height: 480px) {
+ .account-container .header-banner:not(.header-is-avatar) {
+ aspect-ratio: 3 / 1;
+ }
+}
+
+.account-container header {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ text-shadow: -8px 0 12px -6px var(--bg-color), 8px 0 12px -6px var(--bg-color),
+ -8px 0 24px var(--header-color-3, --bg-color),
+ 8px 0 24px var(--header-color-4, --bg-color);
+ animation: fade-in 0.3s both ease-in-out 0.1s;
+}
+.account-container header .avatar {
+ /* box-shadow: -8px 0 24px var(--header-color-3, --bg-color),
+ 8px 0 24px var(--header-color-4, --bg-color); */
+ overflow: initial;
+ filter: drop-shadow(-2px 0 4px var(--header-color-3, --bg-color))
+ drop-shadow(2px 0 4px var(--header-color-4, --bg-color));
+}
+.account-container header .avatar:not(.has-alpha) img {
+ border-radius: 50%;
+}
+
+.account-container main > *:first-child {
+ animation: fade-in 0.3s both ease-in-out 0.15s;
+}
+.account-container main > *:first-child ~ * {
+ animation: fade-in 0.3s both ease-in-out 0.2s;
+}
+
+.account-container .note {
+ font-size: 95%;
+ line-height: 1.4;
+}
+.account-container .note:not(:has(p)):not(:empty) {
+ /* Some notes don't have
tags, so we need to add some padding */
+ padding: 1em 0;
+}
+
+.account-container .stats {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-around;
+ gap: 16px;
+ opacity: 0.75;
+ font-size: 90%;
+ background-color: var(--bg-faded-color);
+ padding: 12px;
+ border-radius: 8px;
+ line-height: 1.25;
+}
+.account-container .stats > * {
+ text-align: center;
+}
+.account-container .stats a {
+ color: inherit;
+}
+
+.account-container .actions {
+ display: flex;
+ gap: 8px;
+ justify-content: space-between;
+ min-height: 2.5em;
+}
+.account-container .actions button {
+ align-self: flex-end;
+}
+
+.account-container .profile-metadata {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+.account-container .profile-field {
+ min-width: 0;
+ flex-grow: 1;
+ font-size: 90%;
+ background-color: var(--bg-faded-color);
+ padding: 12px;
+ border-radius: 8px;
+ filter: saturate(0.75);
+ line-height: 1.25;
+}
+
+.account-container :is(.note, .profile-field) .invisible {
+ display: none;
+}
+.account-container :is(.note, .profile-field) .ellipsis::after {
+ content: '…';
+}
+
+.account-container .profile-field b {
+ font-size: 90%;
+ color: var(--text-insignificant-color);
+ text-transform: uppercase;
+}
+.account-container .profile-field b .icon {
+ color: var(--green-color);
+}
+.account-container .profile-field p {
+ margin: 0;
+}
+
+.account-container .common-followers {
+ border-top: 1px solid var(--outline-color);
+ border-bottom: 1px solid var(--outline-color);
+ padding: 8px 0;
+ font-size: 90%;
+ line-height: 1.5;
+ color: var(--text-insignificant-color);
+}
+
+.timeline-start .account-container {
+ border-bottom: 1px solid var(--outline-color);
+}
+.timeline-start .account-container header {
+ padding: 16px 16px 1px;
+ animation: none;
+}
+.timeline-start .account-container main {
+ padding: 1px 16px 1px;
+}
+.timeline-start .account-container main > * {
+ animation: none;
+}
+.timeline-start .account-container .account-block .account-block-acct {
+ opacity: 0.5;
+}
+
+@keyframes shine {
+ 0% {
+ left: -100%;
+ }
+ 100% {
+ left: 100%;
+ }
+}
+.timeline-start .account-container {
+ position: relative;
+ overflow: hidden;
+}
+.timeline-start .account-container:before {
+ content: '';
+ position: absolute;
+ z-index: 2;
+ width: 100%;
+ height: 100%;
+ background-image: linear-gradient(
+ 100deg,
+ rgba(255, 255, 255, 0) 30%,
+ rgba(255, 255, 255, 0.25),
+ rgba(255, 255, 255, 0) 70%
+ );
+ top: 0;
+ left: -100%;
+ pointer-events: none;
+}
+@media (prefers-color-scheme: dark) {
+ .timeline-start .account-container:before {
+ opacity: 0.25;
+ }
+}
+.timeline-start .account-container:hover:before {
+ animation: shine 1s ease-in-out 1s;
+}
+
+@media (min-width: 40em) {
+ .timeline-start .account-container {
+ --item-radius: 16px;
+ border: 1px solid var(--divider-color);
+ margin: 16px 0;
+ background-color: var(--bg-color);
+ border-radius: var(--item-radius);
+ overflow: hidden;
+ /* box-shadow: 0px 1px var(--bg-blur-color), 0 0 64px var(--bg-color); */
+ --shadow-offset: 16px;
+ --shadow-blur: 32px;
+ --shadow-spread: calc(var(--shadow-blur) * -0.75);
+ box-shadow: calc(var(--shadow-offset) * -1) var(--shadow-offset)
+ var(--shadow-blur) var(--shadow-spread)
+ var(--header-color-1, var(--drop-shadow-color)),
+ var(--shadow-offset) var(--shadow-offset) var(--shadow-blur)
+ var(--shadow-spread) var(--header-color-2, var(--drop-shadow-color));
+ }
+ .timeline-start .account-container .header-banner {
+ margin-bottom: -77px;
+ }
+ .timeline-start .account-container header .account-block {
+ font-size: 175%;
+ margin-bottom: -8px;
+ line-height: 1.1;
+ letter-spacing: -0.5px;
+ mix-blend-mode: multiply;
+ gap: 12px;
+ }
+ .timeline-start .account-container header .account-block .avatar {
+ width: 112px !important;
+ height: 112px !important;
+ filter: drop-shadow(-8px 0 8px var(--header-color-3, --bg-color))
+ drop-shadow(8px 0 8px var(--header-color-4, --bg-color));
+ }
+}
diff --git a/src/components/account.jsx b/src/components/account-info.jsx
similarity index 60%
rename from src/components/account.jsx
rename to src/components/account-info.jsx
index 76dc18c7..a084db00 100644
--- a/src/components/account.jsx
+++ b/src/components/account-info.jsx
@@ -1,7 +1,6 @@
-import './account.css';
+import './account-info.css';
import { useEffect, useRef, useState } from 'preact/hooks';
-import { useHotkeys } from 'react-hotkeys-hook';
import { api } from '../utils/api';
import emojifyText from '../utils/emojify-text';
@@ -17,49 +16,36 @@ import Avatar from './avatar';
import Icon from './icon';
import Link from './link';
-function Account({ account, instance: propInstance, onClose }) {
- const { masto, instance, authenticated } = api({ instance: propInstance });
+function AccountInfo({
+ account,
+ fetchAccount = () => {},
+ standalone,
+ instance,
+ authenticated,
+}) {
const [uiState, setUIState] = useState('default');
const isString = typeof account === 'string';
const [info, setInfo] = useState(isString ? null : account);
useEffect(() => {
- if (isString) {
- setUIState('loading');
- (async () => {
- try {
- const info = await masto.v1.accounts.lookup({
- acct: account,
- skip_webfinger: false,
- });
- setInfo(info);
- setUIState('default');
- } catch (e) {
- try {
- const result = await masto.v2.search({
- q: account,
- type: 'accounts',
- limit: 1,
- resolve: authenticated,
- });
- if (result.accounts.length) {
- setInfo(result.accounts[0]);
- setUIState('default');
- return;
- }
- setInfo(null);
- setUIState('error');
- } catch (err) {
- console.error(err);
- setInfo(null);
- setUIState('error');
- }
- }
- })();
- } else {
+ if (!isString) {
setInfo(account);
+ return;
}
- }, [account]);
+ setUIState('loading');
+ (async () => {
+ try {
+ const info = await fetchAccount();
+ states.accounts[`${info.id}@${instance}`] = info;
+ setInfo(info);
+ setUIState('default');
+ } catch (e) {
+ console.error(e);
+ setInfo(null);
+ setUIState('error');
+ }
+ })();
+ }, [isString, account, fetchAccount]);
const {
acct,
@@ -73,8 +59,8 @@ function Account({ account, instance: propInstance, onClose }) {
followersCount,
followingCount,
group,
- header,
- headerStatic,
+ // header,
+ // headerStatic,
id,
lastStatusAt,
locked,
@@ -83,14 +69,29 @@ function Account({ account, instance: propInstance, onClose }) {
url,
username,
} = info || {};
+ let headerIsAvatar = false;
+ let { header, headerStatic } = info || {};
+ if (!header || /missing\.png$/.test(header)) {
+ if (avatar && !/missing\.png$/.test(avatar)) {
+ header = avatar;
+ headerIsAvatar = true;
+ if (avatarStatic && !/missing\.png$/.test(avatarStatic)) {
+ headerStatic = avatarStatic;
+ }
+ }
+ }
- const escRef = useHotkeys('esc', onClose, [onClose]);
+ const [headerCornerColors, setHeaderCornerColors] = useState([]);
return (
{uiState === 'error' && (
@@ -113,21 +114,129 @@ function Account({ account, instance: propInstance, onClose }) {
███████████████ ███████████████
- ██ Posts
- ██ Following
- ██ Followers
+
+ Posts
+
+ ██
+
+
+ Following
+
+ ██
+
+
+ Followers
+
+ ██
+
>
) : (
info && (
<>
+ {header && !/missing\.png$/.test(header) && (
+ {
+ if (e.target.crossOrigin) {
+ if (e.target.src !== headerStatic) {
+ e.target.src = headerStatic;
+ } else {
+ e.target.removeAttribute('crossorigin');
+ e.target.src = header;
+ }
+ } else if (e.target.src !== headerStatic) {
+ e.target.src = headerStatic;
+ } else {
+ e.target.remove();
+ }
+ }}
+ crossOrigin="anonymous"
+ onLoad={(e) => {
+ try {
+ // Get color from four corners of image
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+ canvas.width = e.target.width;
+ canvas.height = e.target.height;
+ ctx.drawImage(e.target, 0, 0);
+ // const colors = [
+ // ctx.getImageData(0, 0, 1, 1).data,
+ // ctx.getImageData(e.target.width - 1, 0, 1, 1).data,
+ // ctx.getImageData(0, e.target.height - 1, 1, 1).data,
+ // ctx.getImageData(
+ // e.target.width - 1,
+ // e.target.height - 1,
+ // 1,
+ // 1,
+ // ).data,
+ // ];
+ // Get 10x10 pixels from corners, get average color from each
+ const pixelDimension = 10;
+ const colors = [
+ ctx.getImageData(0, 0, pixelDimension, pixelDimension)
+ .data,
+ ctx.getImageData(
+ e.target.width - pixelDimension,
+ 0,
+ pixelDimension,
+ pixelDimension,
+ ).data,
+ ctx.getImageData(
+ 0,
+ e.target.height - pixelDimension,
+ pixelDimension,
+ pixelDimension,
+ ).data,
+ ctx.getImageData(
+ e.target.width - pixelDimension,
+ e.target.height - pixelDimension,
+ pixelDimension,
+ pixelDimension,
+ ).data,
+ ].map((data) => {
+ let r = 0;
+ let g = 0;
+ let b = 0;
+ let a = 0;
+ for (let i = 0; i < data.length; i += 4) {
+ r += data[i];
+ g += data[i + 1];
+ b += data[i + 2];
+ a += data[i + 3];
+ }
+ const dataLength = data.length / 4;
+ return [
+ r / dataLength,
+ g / dataLength,
+ b / dataLength,
+ a / dataLength,
+ ];
+ });
+ const rgbColors = colors.map((color) => {
+ const [r, g, b, a] = lightenRGB(color);
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+ });
+ setHeaderCornerColors(rgbColors);
+ console.log({ colors, rgbColors });
+ } catch (e) {
+ // Silently fail
+ }
+ }}
+ />
+ )}
@@ -174,18 +283,28 @@ function Account({ account, instance: propInstance, onClose }) {
)}
- {
- hideAllModals();
- }}
- >
- Posts
-
-
- {shortenNumber(statusesCount)}
- {' '}
-
+ {standalone ? (
+
+ Posts
+
+
+ {shortenNumber(statusesCount)}
+ {' '}
+
+ ) : (
+ {
+ hideAllModals();
+ }}
+ >
+ Posts
+
+
+ {shortenNumber(statusesCount)}
+ {' '}
+
+ )}
Following
@@ -419,4 +538,20 @@ function RelatedActions({ info, instance, authenticated }) {
);
}
-export default Account;
+// Apply more alpha if high luminence
+function lightenRGB([r, g, b]) {
+ const luminence = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+ console.log('luminence', luminence);
+ let alpha;
+ if (luminence >= 220) {
+ alpha = 1;
+ } else if (luminence <= 50) {
+ alpha = 0.1;
+ } else {
+ alpha = luminence / 255;
+ }
+ alpha = Math.min(1, alpha);
+ return [r, g, b, alpha];
+}
+
+export default AccountInfo;
diff --git a/src/components/account-sheet.jsx b/src/components/account-sheet.jsx
new file mode 100644
index 00000000..e615b23d
--- /dev/null
+++ b/src/components/account-sheet.jsx
@@ -0,0 +1,66 @@
+import { useEffect } from 'preact/hooks';
+import { useHotkeys } from 'react-hotkeys-hook';
+
+import { api } from '../utils/api';
+import states from '../utils/states';
+
+import AccountInfo from './account-info';
+
+function AccountSheet({ account, instance: propInstance, onClose }) {
+ const { masto, instance, authenticated } = api({ instance: propInstance });
+ const isString = typeof account === 'string';
+
+ const escRef = useHotkeys('esc', onClose, [onClose]);
+
+ useEffect(() => {
+ if (!isString) {
+ states.accounts[`${account.id}@${instance}`] = account;
+ }
+ }, [account]);
+
+ return (
+ {
+ const accountBlock = e.target.closest('.account-block');
+ if (accountBlock) {
+ onClose({
+ destination: 'account-statuses',
+ });
+ }
+ }}
+ >
+
{
+ if (isString) {
+ try {
+ const info = await masto.v1.accounts.lookup({
+ acct: account,
+ skip_webfinger: false,
+ });
+ return info;
+ } catch (e) {
+ const result = await masto.v2.search({
+ q: account,
+ type: 'accounts',
+ limit: 1,
+ resolve: authenticated,
+ });
+ if (result.accounts.length) {
+ return result.accounts[0];
+ }
+ }
+ } else {
+ return account;
+ }
+ }}
+ />
+
+ );
+}
+
+export default AccountSheet;
diff --git a/src/components/account.css b/src/components/account.css
deleted file mode 100644
index 2d66c31b..00000000
--- a/src/components/account.css
+++ /dev/null
@@ -1,91 +0,0 @@
-#account-container.skeleton {
- color: var(--outline-color);
-}
-
-#account-container header {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-#account-container .note {
- font-size: 95%;
- line-height: 1.4;
-}
-#account-container .note:not(:has(p)):not(:empty) {
- /* Some notes don't have tags, so we need to add some padding */
- padding: 1em 0;
-}
-
-#account-container .stats {
- display: flex;
- flex-wrap: wrap;
- justify-content: space-around;
- gap: 16px;
- opacity: 0.75;
- font-size: 90%;
- background-color: var(--bg-faded-color);
- padding: 12px;
- border-radius: 8px;
- line-height: 1.25;
-}
-#account-container .stats > * {
- text-align: center;
-}
-#account-container .stats a {
- color: inherit;
-}
-
-#account-container .actions {
- display: flex;
- gap: 8px;
- justify-content: space-between;
- min-height: 2.5em;
-}
-#account-container .actions button {
- align-self: flex-end;
-}
-
-#account-container .profile-metadata {
- display: flex;
- flex-wrap: wrap;
- gap: 12px;
-}
-#account-container .profile-field {
- min-width: 0;
- flex-grow: 1;
- font-size: 90%;
- background-color: var(--bg-faded-color);
- padding: 12px;
- border-radius: 8px;
- filter: saturate(0.75);
- line-height: 1.25;
-}
-
-#account-container :is(.note, .profile-field) .invisible {
- display: none;
-}
-#account-container :is(.note, .profile-field) .ellipsis::after {
- content: '…';
-}
-
-#account-container .profile-field b {
- font-size: 90%;
- color: var(--text-insignificant-color);
- text-transform: uppercase;
-}
-#account-container .profile-field b .icon {
- color: var(--green-color);
-}
-#account-container .profile-field p {
- margin: 0;
-}
-
-#account-container .common-followers {
- border-top: 1px solid var(--outline-color);
- border-bottom: 1px solid var(--outline-color);
- padding: 8px 0;
- font-size: 90%;
- line-height: 1.5;
- color: var(--text-insignificant-color);
-}
diff --git a/src/components/avatar.css b/src/components/avatar.css
index 411407a6..444a0cdb 100644
--- a/src/components/avatar.css
+++ b/src/components/avatar.css
@@ -9,6 +9,9 @@
flex-shrink: 0;
vertical-align: middle;
}
+.avatar.has-alpha {
+ border-radius: 0;
+}
.avatar img {
width: 100%;
@@ -16,3 +19,9 @@
object-fit: cover;
background-color: var(--img-bg-color);
}
+
+.avatar[data-loaded],
+.avatar[data-loaded] img {
+ box-shadow: none;
+ background-color: transparent;
+}
diff --git a/src/components/avatar.jsx b/src/components/avatar.jsx
index 1ceb7fe4..a9c2d9c4 100644
--- a/src/components/avatar.jsx
+++ b/src/components/avatar.jsx
@@ -1,5 +1,7 @@
import './avatar.css';
+import { useRef } from 'preact/hooks';
+
const SIZES = {
s: 16,
m: 20,
@@ -9,11 +11,15 @@ const SIZES = {
xxxl: 64,
};
+const alphaCache = {};
+
function Avatar({ url, size, alt = '', ...props }) {
size = SIZES[size] || size || SIZES.m;
+ const avatarRef = useRef();
return (
{!!url && (
-
+ {
+ if (e.target.crossOrigin) {
+ e.target.crossOrigin = null;
+ e.target.src = url;
+ }
+ }}
+ onLoad={(e) => {
+ if (avatarRef.current) avatarRef.current.dataset.loaded = true;
+ try {
+ // Check if image has alpha channel
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+ canvas.width = e.target.width;
+ canvas.height = e.target.height;
+ ctx.drawImage(e.target, 0, 0);
+ const allPixels = ctx.getImageData(
+ 0,
+ 0,
+ canvas.width,
+ canvas.height,
+ );
+ // At least 10% of pixels have alpha <= 128
+ const hasAlpha =
+ allPixels.data.filter((pixel, i) => i % 4 === 3 && pixel <= 128)
+ .length /
+ (allPixels.data.length / 4) >
+ 0.1;
+ if (hasAlpha) {
+ // console.log('hasAlpha', hasAlpha, allPixels.data);
+ avatarRef.current.classList.add('has-alpha');
+ alphaCache[url] = true;
+ }
+ } catch (e) {
+ // Ignore
+ }
+ }}
+ />
)}
);
diff --git a/src/components/compose.css b/src/components/compose.css
index a56bc469..62469c83 100644
--- a/src/components/compose.css
+++ b/src/components/compose.css
@@ -31,6 +31,10 @@
max-height: 50vh;
resize: vertical;
line-height: 1.4;
+ border-color: transparent;
+}
+#compose-container textarea:hover {
+ border-color: var(--divider-color);
}
@media (min-width: 40em) {
@@ -51,7 +55,7 @@
}
}
#compose-container .status-preview {
- border-radius: 8px 8px 0 0;
+ border-radius: 16px 16px 0 0;
max-height: 160px;
background-color: var(--bg-color);
margin: 0 12px;
@@ -59,6 +63,7 @@
border-bottom: 0;
animation: appear-up 1s ease-in-out;
overflow: auto;
+ box-shadow: 0 -3px 12px -3px var(--drop-shadow-color);
}
#compose-container .status-preview :is(.hashtag, .time) {
/* Prevent hashtags from being clickable */
@@ -87,7 +92,7 @@
transparent,
var(--bg-faded-color)
); */
- border-top: 1px solid var(--outline-color);
+ border-top: var(--hairline-width) solid var(--outline-color);
backdrop-filter: blur(8px);
text-shadow: 0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
@@ -105,14 +110,17 @@
}
#compose-container form {
- border-radius: 8px;
+ border-radius: 16px;
padding: 4px 12px;
- background-image: linear-gradient(var(--bg-color) 75%, transparent);
+ background-color: var(--bg-blur-color);
+ /* background-image: linear-gradient(var(--bg-color) 85%, transparent); */
position: relative;
- z-index: 1;
+ z-index: 2;
+ --drop-shadow: 0 3px 6px -3px var(--drop-shadow-color);
+ box-shadow: var(--drop-shadow);
}
#compose-container .status-preview ~ form {
- box-shadow: 0 -12px 12px -12px var(--divider-color);
+ box-shadow: var(--drop-shadow), 0 -3px 6px -3px var(--drop-shadow-color);
}
#compose-container .toolbar {
@@ -131,8 +139,8 @@
}
#compose-container .toolbar-button {
display: inline-block;
- color: var(--text-color);
- background-color: var(--bg-faded-color);
+ color: var(--link-color);
+ background-color: var(--bg-blur-color);
padding: 0 8px;
border-radius: 8px;
min-height: 2.4em;
@@ -150,9 +158,10 @@
cursor: inherit;
outline: 0;
}
-#compose-container .toolbar-button:has([disabled]) {
+#compose-container .toolbar-button:has([disabled]),
+#compose-container .toolbar-button[disabled] {
pointer-events: none;
- background-color: var(--bg-faded-color);
+ background-color: transparent;
opacity: 0.5;
}
#compose-container
@@ -186,9 +195,14 @@
) {
cursor: pointer;
filter: none;
- border-color: var(--divider-color);
+ background-color: var(--bg-color);
+ border-color: var(--link-faded-color);
outline: 0;
}
+#compose-container .toolbar-button:not(:disabled).highlight {
+ border-color: var(--link-color);
+ box-shadow: inset 0 0 8px var(--link-faded-color);
+}
#compose-container .toolbar-button:not(:disabled):active {
filter: brightness(0.8);
}
@@ -430,6 +444,12 @@
}
}
+@media (min-width: 480px) {
+ #compose-container button[type='submit'] {
+ padding-inline: 24px;
+ }
+}
+
#media-sheet main {
padding-top: 8px;
display: flex;
diff --git a/src/components/compose.jsx b/src/components/compose.jsx
index 0eafecf0..76f595c6 100644
--- a/src/components/compose.jsx
+++ b/src/components/compose.jsx
@@ -348,12 +348,24 @@ function Compose({
};
useEffect(updateCharCount, []);
+ const escDownRef = useRef(false);
useHotkeys(
'esc',
() => {
- if (!standalone && confirmClose()) {
+ escDownRef.current = true;
+ // This won't be true if this event is already handled and not propagated 🤞
+ },
+ {
+ enableOnFormTags: true,
+ },
+ );
+ useHotkeys(
+ 'esc',
+ () => {
+ if (!standalone && escDownRef.current && confirmClose()) {
onClose();
}
+ escDownRef.current = false;
},
{
enableOnFormTags: true,
@@ -490,7 +502,7 @@ function Compose({
{currentAccountInfo?.avatarStatic && (
)}
@@ -687,6 +699,17 @@ function Compose({
}
// TODO: check for URLs and use `charactersReservedPerUrl` to calculate max characters
+ if (mediaAttachments.length > 0) {
+ // If there are media attachments, check if they have no descriptions
+ const hasNoDescriptions = mediaAttachments.some(
+ (media) => !media.description?.trim?.(),
+ );
+ if (hasNoDescriptions) {
+ const yes = confirm('Some media have no descriptions. Continue?');
+ if (!yes) return;
+ }
+ }
+
// Post-cleanup
spoilerText = (sensitive && spoilerText) || undefined;
status = status === '' ? undefined : status;
@@ -819,7 +842,7 @@ function Compose({
}}
/>