Merge pull request #272 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2023-10-26 20:26:20 +08:00 committed by GitHub
commit 87f1d17ce3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 1614 additions and 552 deletions

124
package-lock.json generated
View file

@ -9,11 +9,12 @@
"version": "0.1.0",
"dependencies": {
"@formatjs/intl-localematcher": "~0.4.2",
"@formkit/auto-animate": "~0.8.0",
"@github/text-expander-element": "~2.5.0",
"@iconify-icons/mingcute": "~1.2.8",
"@iconify-icons/mingcute": "~1.2.9",
"@justinribeiro/lite-youtube": "~1.5.0",
"@szhsin/react-menu": "~4.1.0",
"@uidotdev/usehooks": "~2.4.0",
"@uidotdev/usehooks": "~2.4.1",
"dayjs": "~1.11.10",
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
@ -21,7 +22,7 @@
"idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0",
"lz-string": "~1.5.0",
"masto": "~6.3.1",
"masto": "~6.3.3",
"moize": "~6.1.6",
"p-retry": "~6.1.0",
"p-throttle": "~5.1.0",
@ -41,12 +42,12 @@
},
"devDependencies": {
"@preact/preset-vite": "~2.6.0",
"@trivago/prettier-plugin-sort-imports": "~4.2.0",
"@trivago/prettier-plugin-sort-imports": "~4.2.1",
"postcss": "~8.4.31",
"postcss-dark-theme-class": "~1.0.0",
"postcss-preset-env": "~9.2.0",
"twitter-text": "~3.1.0",
"vite": "~4.4.11",
"vite": "~4.5.0",
"vite-plugin-generate-file": "~0.0.4",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.16.5",
@ -3043,6 +3044,11 @@
"tslib": "^2.4.0"
}
},
"node_modules/@formkit/auto-animate": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.8.0.tgz",
"integrity": "sha512-G8f7489ka0mWyi+1IEZT+xgIwcpWtRMmE2x+IrVoQ+KM1cP6VDj/TbujZjwxdb0P8w8b16/qBfViRmydbYHwMw=="
},
"node_modules/@github/combobox-nav": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.1.5.tgz",
@ -3057,9 +3063,9 @@
}
},
"node_modules/@iconify-icons/mingcute": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@iconify-icons/mingcute/-/mingcute-1.2.8.tgz",
"integrity": "sha512-9mH0dn/rtsKvaR/P57LgTB8IGoN3ePxCiap3EQfmNSu1x+w2ib478HHxUnXdg1WpyRFbX81aFtUDvq7yuSOyeg==",
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@iconify-icons/mingcute/-/mingcute-1.2.9.tgz",
"integrity": "sha512-u+hX7mh7amKlWFHOTi52tnJ52NWQVAFevjDcQkZvK4Zj2UyVVKZ45yKBsFHo4OTJDzBkIafJh4C4fkPJsvCtOA==",
"dependencies": {
"@iconify/types": "*"
}
@ -3298,14 +3304,14 @@
}
},
"node_modules/@trivago/prettier-plugin-sort-imports": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.2.0.tgz",
"integrity": "sha512-YBepjbt+ZNBVmN3ev1amQH3lWCmHyt5qTbLCp/syXJRu/Kw2koXh44qayB1gMRxcL/gV8egmjN5xWSrYyfUtyw==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.2.1.tgz",
"integrity": "sha512-iuy2MPVURGdxILTchHr15VAioItuYBejKfcTmQFlxIuqA7jeaT6ngr5aUIG6S6U096d6a6lJCgaOwlRrPLlOPg==",
"dev": true,
"dependencies": {
"@babel/generator": "7.17.7",
"@babel/parser": "^7.20.5",
"@babel/traverse": "7.17.3",
"@babel/traverse": "7.23.2",
"@babel/types": "7.17.0",
"javascript-natural-sort": "0.7.1",
"lodash": "^4.17.21"
@ -3334,27 +3340,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@trivago/prettier-plugin-sort-imports/node_modules/@babel/traverse": {
"version": "7.17.3",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.3.tgz",
"integrity": "sha512-5irClVky7TxRWIRtxlh2WPUUOLhcPN06AGgaQSB8AEwuyEBgJVuJ5imdHm5zxk8w0QS5T+tDfnDxAlhWjpb7cw==",
"dev": true,
"dependencies": {
"@babel/code-frame": "^7.16.7",
"@babel/generator": "^7.17.3",
"@babel/helper-environment-visitor": "^7.16.7",
"@babel/helper-function-name": "^7.16.7",
"@babel/helper-hoist-variables": "^7.16.7",
"@babel/helper-split-export-declaration": "^7.16.7",
"@babel/parser": "^7.17.3",
"@babel/types": "^7.17.0",
"debug": "^4.1.0",
"globals": "^11.1.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@trivago/prettier-plugin-sort-imports/node_modules/@babel/types": {
"version": "7.17.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz",
@ -3410,9 +3395,9 @@
"dev": true
},
"node_modules/@uidotdev/usehooks": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.0.tgz",
"integrity": "sha512-NrpTsZUGsawYxFbEXrd8+FPpfziC4M01GSQgYWOnGa84UiavqVCzCL5bSRe6rfQc4QsHS2rGAA0h63ya/j+p6A==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.1.tgz",
"integrity": "sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==",
"engines": {
"node": ">=16"
},
@ -5272,9 +5257,9 @@
}
},
"node_modules/masto": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/masto/-/masto-6.3.1.tgz",
"integrity": "sha512-Os3MlbGFNL6KHxlKldYY+d/1exO6oBjtF4vx8d6cmXRmeeeW3mKQeunTZz+yY5qWksPg2eVdk+FOhaEnOeclVw==",
"version": "6.3.3",
"resolved": "https://registry.npmjs.org/masto/-/masto-6.3.3.tgz",
"integrity": "sha512-hmDsiscImeZfpkS+5oEWk3w5mkbxERFKN/UpuaoKZpVWWoGWCNnO7iPfQHygs/phP7PQqS6pVHlE5ylqSylf6A==",
"dependencies": {
"change-case": "^4.1.2",
"events-to-async": "^2.0.0",
@ -7260,9 +7245,9 @@
}
},
"node_modules/vite": {
"version": "4.4.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.4.11.tgz",
"integrity": "sha512-ksNZJlkcU9b0lBwAGZGGaZHCMqHsc8OpgtoYhsQ4/I2v5cnpmmmqe5pM4nv/4Hn6G/2GhTdj0DhZh2e+Er1q5A==",
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz",
"integrity": "sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==",
"dev": true,
"dependencies": {
"esbuild": "^0.18.10",
@ -9558,6 +9543,11 @@
"tslib": "^2.4.0"
}
},
"@formkit/auto-animate": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-0.8.0.tgz",
"integrity": "sha512-G8f7489ka0mWyi+1IEZT+xgIwcpWtRMmE2x+IrVoQ+KM1cP6VDj/TbujZjwxdb0P8w8b16/qBfViRmydbYHwMw=="
},
"@github/combobox-nav": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.1.5.tgz",
@ -9572,9 +9562,9 @@
}
},
"@iconify-icons/mingcute": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@iconify-icons/mingcute/-/mingcute-1.2.8.tgz",
"integrity": "sha512-9mH0dn/rtsKvaR/P57LgTB8IGoN3ePxCiap3EQfmNSu1x+w2ib478HHxUnXdg1WpyRFbX81aFtUDvq7yuSOyeg==",
"version": "1.2.9",
"resolved": "https://registry.npmjs.org/@iconify-icons/mingcute/-/mingcute-1.2.9.tgz",
"integrity": "sha512-u+hX7mh7amKlWFHOTi52tnJ52NWQVAFevjDcQkZvK4Zj2UyVVKZ45yKBsFHo4OTJDzBkIafJh4C4fkPJsvCtOA==",
"requires": {
"@iconify/types": "*"
}
@ -9771,14 +9761,14 @@
}
},
"@trivago/prettier-plugin-sort-imports": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.2.0.tgz",
"integrity": "sha512-YBepjbt+ZNBVmN3ev1amQH3lWCmHyt5qTbLCp/syXJRu/Kw2koXh44qayB1gMRxcL/gV8egmjN5xWSrYyfUtyw==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.2.1.tgz",
"integrity": "sha512-iuy2MPVURGdxILTchHr15VAioItuYBejKfcTmQFlxIuqA7jeaT6ngr5aUIG6S6U096d6a6lJCgaOwlRrPLlOPg==",
"dev": true,
"requires": {
"@babel/generator": "7.17.7",
"@babel/parser": "^7.20.5",
"@babel/traverse": "7.17.3",
"@babel/traverse": "7.23.2",
"@babel/types": "7.17.0",
"javascript-natural-sort": "0.7.1",
"lodash": "^4.17.21"
@ -9795,24 +9785,6 @@
"source-map": "^0.5.0"
}
},
"@babel/traverse": {
"version": "7.17.3",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.3.tgz",
"integrity": "sha512-5irClVky7TxRWIRtxlh2WPUUOLhcPN06AGgaQSB8AEwuyEBgJVuJ5imdHm5zxk8w0QS5T+tDfnDxAlhWjpb7cw==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.16.7",
"@babel/generator": "^7.17.3",
"@babel/helper-environment-visitor": "^7.16.7",
"@babel/helper-function-name": "^7.16.7",
"@babel/helper-hoist-variables": "^7.16.7",
"@babel/helper-split-export-declaration": "^7.16.7",
"@babel/parser": "^7.17.3",
"@babel/types": "^7.17.0",
"debug": "^4.1.0",
"globals": "^11.1.0"
}
},
"@babel/types": {
"version": "7.17.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz",
@ -9864,9 +9836,9 @@
"dev": true
},
"@uidotdev/usehooks": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.0.tgz",
"integrity": "sha512-NrpTsZUGsawYxFbEXrd8+FPpfziC4M01GSQgYWOnGa84UiavqVCzCL5bSRe6rfQc4QsHS2rGAA0h63ya/j+p6A==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.1.tgz",
"integrity": "sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==",
"requires": {}
},
"@vue/compiler-core": {
@ -11216,9 +11188,9 @@
}
},
"masto": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/masto/-/masto-6.3.1.tgz",
"integrity": "sha512-Os3MlbGFNL6KHxlKldYY+d/1exO6oBjtF4vx8d6cmXRmeeeW3mKQeunTZz+yY5qWksPg2eVdk+FOhaEnOeclVw==",
"version": "6.3.3",
"resolved": "https://registry.npmjs.org/masto/-/masto-6.3.3.tgz",
"integrity": "sha512-hmDsiscImeZfpkS+5oEWk3w5mkbxERFKN/UpuaoKZpVWWoGWCNnO7iPfQHygs/phP7PQqS6pVHlE5ylqSylf6A==",
"requires": {
"change-case": "^4.1.2",
"events-to-async": "^2.0.0",
@ -12453,9 +12425,9 @@
}
},
"vite": {
"version": "4.4.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.4.11.tgz",
"integrity": "sha512-ksNZJlkcU9b0lBwAGZGGaZHCMqHsc8OpgtoYhsQ4/I2v5cnpmmmqe5pM4nv/4Hn6G/2GhTdj0DhZh2e+Er1q5A==",
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz",
"integrity": "sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==",
"dev": true,
"requires": {
"esbuild": "^0.18.10",

View file

@ -11,11 +11,12 @@
},
"dependencies": {
"@formatjs/intl-localematcher": "~0.4.2",
"@formkit/auto-animate": "~0.8.0",
"@github/text-expander-element": "~2.5.0",
"@iconify-icons/mingcute": "~1.2.8",
"@iconify-icons/mingcute": "~1.2.9",
"@justinribeiro/lite-youtube": "~1.5.0",
"@szhsin/react-menu": "~4.1.0",
"@uidotdev/usehooks": "~2.4.0",
"@uidotdev/usehooks": "~2.4.1",
"dayjs": "~1.11.10",
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
@ -23,7 +24,7 @@
"idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0",
"lz-string": "~1.5.0",
"masto": "~6.3.1",
"masto": "~6.3.3",
"moize": "~6.1.6",
"p-retry": "~6.1.0",
"p-throttle": "~5.1.0",
@ -43,12 +44,12 @@
},
"devDependencies": {
"@preact/preset-vite": "~2.6.0",
"@trivago/prettier-plugin-sort-imports": "~4.2.0",
"@trivago/prettier-plugin-sort-imports": "~4.2.1",
"postcss": "~8.4.31",
"postcss-dark-theme-class": "~1.0.0",
"postcss-preset-env": "~9.2.0",
"twitter-text": "~3.1.0",
"vite": "~4.4.11",
"vite": "~4.5.0",
"vite-plugin-generate-file": "~0.0.4",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.16.5",

View file

@ -86,6 +86,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
width: var(--main-width);
max-width: 100%;
background-color: var(--bg-color);
overflow-anchor: auto;
}
.deck.contained {
overflow: auto;
@ -273,6 +274,11 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
background-color: var(--bg-color);
box-shadow: inset 0 -3px var(--comment-line-color),
inset 0 3px var(--comment-line-color);
overscroll-behavior-x: contain;
.status-link {
width: fit-content;
}
}
.timeline.contextual .replies[data-comments-level='4'] {
overflow-x: auto;
@ -1051,13 +1057,13 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
width: 100%;
height: 100vh;
height: 100dvh;
background-color: var(--average-color-alpha);
background-image: radial-gradient(
background-color: var(--accent-alpha-color);
/* background-image: radial-gradient(
closest-side,
var(--average-color) 10%,
var(--average-color-alpha) 40%,
var(--accent-color) 10%,
var(--accent-alpha-color) 40%,
transparent 100%
);
); */
}
.carousel .carousel-item :is(img, video) {
width: auto;
@ -1372,7 +1378,8 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
margin-top: -44px;
background-image: radial-gradient(
circle,
var(--bg-faded-color) 0px 14px,
var(--bg-faded-color) 0px 13px,
var(--outline-color) 13px 14px,
transparent 14px
);
}
@ -1792,6 +1799,7 @@ meter.donut[hidden] {
background-image: none;
box-shadow: 0 3px 8px -1px var(--drop-shadow-color),
0 10px 36px -4px var(--button-bg-blur-color);
text-align: center;
}
.toastify-bottom {
margin-bottom: env(safe-area-inset-bottom);
@ -2127,6 +2135,72 @@ ul.link-list li a .icon {
pointer-events: none;
opacity: 0.5;
}
.filter-field {
flex-shrink: 0;
padding: 8px 16px;
border-radius: 999px;
color: var(--text-color);
background-color: var(--bg-color);
background-image: none;
border: 2px solid transparent;
margin: 0;
/* appearance: none; */
line-height: 1;
font-size: 90%;
display: flex;
gap: 8px;
> .icon {
color: var(--link-color);
}
&:placeholder-shown {
color: var(--text-insignificant-color);
}
&:is(:hover, :focus-visible) {
border-color: var(--link-light-color);
}
&:focus {
outline-color: var(--link-light-color);
}
&.is-active {
border-color: var(--link-color);
box-shadow: inset 0 0 8px var(--link-faded-color);
}
:is(input, select) {
background-color: transparent;
background-image: none;
border: 0;
padding: 0;
margin: 0;
color: inherit;
font-size: inherit;
line-height: inherit;
appearance: none;
border-radius: 0;
box-shadow: none;
outline: none;
}
input[type='month'] {
min-width: 6em;
&::-webkit-calendar-picker-indicator {
/* replace icon with triangle */
opacity: 0.5;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none"><path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>');
}
@media (prefers-color-scheme: dark) {
&::-webkit-calendar-picker-indicator {
filter: invert(1);
}
}
}
}
}
.filter-bar.centered {
justify-content: center;
@ -2229,6 +2303,10 @@ ul.link-list li a .icon {
}
.deck > header {
text-shadow: 0 1px var(--bg-color);
form {
text-shadow: none;
}
}
.deck > header h1 {
font-size: 1.5em;

View file

@ -1,5 +1,6 @@
import './app.css';
import debounce from 'just-debounce-it';
import {
useEffect,
useLayoutEffect,
@ -9,7 +10,7 @@ import {
} from 'preact/hooks';
import { matchPath, Route, Routes, useLocation } from 'react-router-dom';
import 'swiped-events';
import { useSnapshot } from 'valtio';
import { subscribe, useSnapshot } from 'valtio';
import BackgroundService from './components/background-service';
import ComposeButton from './components/compose-button';
@ -68,8 +69,64 @@ setTimeout(() => {
}
}, 5000);
(() => {
window.__IDLE__ = false;
const nonIdleEvents = [
'mousemove',
'mousedown',
'resize',
'keydown',
'touchstart',
'pointerdown',
'pointermove',
'wheel',
];
const IDLE_TIME = 5_000; // 5 seconds
const setIdle = debounce(() => {
window.__IDLE__ = true;
}, IDLE_TIME);
const onNonIdle = () => {
window.__IDLE__ = false;
setIdle();
};
nonIdleEvents.forEach((event) => {
window.addEventListener(event, onNonIdle, {
passive: true,
capture: true,
});
});
// document.addEventListener(
// 'visibilitychange',
// () => {
// if (document.visibilityState === 'visible') {
// onNonIdle();
// }
// },
// {
// passive: true,
// },
// );
})();
subscribe(states, (changes) => {
for (const [action, path, value, prevValue] of changes) {
// Change #app dataset based on settings.shortcutsViewMode
if (path.join('.') === 'settings.shortcutsViewMode') {
const $app = document.getElementById('app');
if ($app) {
$app.dataset.shortcutsViewMode = states.shortcuts?.length ? value : '';
}
}
// Add/Remove cloak class to body
if (path.join('.') === 'settings.cloakMode') {
const $body = document.body;
$body.classList.toggle('cloak', value);
}
}
});
function App() {
const snapStates = useSnapshot(states);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [uiState, setUIState] = useState('loading');
@ -153,106 +210,27 @@ function App() {
let location = useLocation();
states.currentLocation = location.pathname;
// useLayoutEffect(() => {
// states.currentLocation = location.pathname;
// }, [location.pathname]);
useEffect(focusDeck, [location, isLoggedIn]);
const prevLocation = snapStates.prevLocation;
const backgroundLocation = useRef(prevLocation || null);
const isModalPage = useMemo(() => {
return (
matchPath('/:instance/s/:id', location.pathname) ||
matchPath('/s/:id', location.pathname)
);
}, [location.pathname, matchPath]);
if (isModalPage) {
if (!backgroundLocation.current) backgroundLocation.current = prevLocation;
} else {
backgroundLocation.current = null;
}
console.debug({
backgroundLocation: backgroundLocation.current,
location,
});
if (/\/https?:/.test(location.pathname)) {
return <HttpRoute />;
}
const nonRootLocation = useMemo(() => {
const { pathname } = location;
return !/^\/(login|welcome)/.test(pathname);
}, [location]);
// Change #app dataset based on snapStates.settings.shortcutsViewMode
useEffect(() => {
const $app = document.getElementById('app');
if ($app) {
$app.dataset.shortcutsViewMode = snapStates.shortcuts?.length
? snapStates.settings.shortcutsViewMode
: '';
}
}, [snapStates.shortcuts, snapStates.settings.shortcutsViewMode]);
// Add/Remove cloak class to body
useEffect(() => {
const $body = document.body;
$body.classList.toggle('cloak', snapStates.settings.cloakMode);
}, [snapStates.settings.cloakMode]);
return (
<>
<Routes location={nonRootLocation || location}>
<Route
path="/"
element={
isLoggedIn ? (
<Home />
) : uiState === 'loading' ? (
<Loader id="loader-root" />
) : (
<Welcome />
)
}
/>
<Route path="/login" element={<Login />} />
<Route path="/welcome" element={<Welcome />} />
</Routes>
<Routes location={backgroundLocation.current || location}>
{isLoggedIn && (
<Route path="/notifications" element={<Notifications />} />
)}
{isLoggedIn && <Route path="/mentions" element={<Mentions />} />}
{isLoggedIn && <Route path="/following" element={<Following />} />}
{isLoggedIn && <Route path="/b" element={<Bookmarks />} />}
{isLoggedIn && <Route path="/f" element={<Favourites />} />}
{isLoggedIn && (
<Route path="/l">
<Route index element={<Lists />} />
<Route path=":id" element={<List />} />
</Route>
)}
{isLoggedIn && <Route path="/ft" element={<FollowedHashtags />} />}
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} />
<Route path="/:instance?/a/:id" element={<AccountStatuses />} />
<Route path="/:instance?/p">
<Route index element={<Public />} />
<Route path="l" element={<Public local />} />
</Route>
<Route path="/:instance?/trending" element={<Trending />} />
<Route path="/:instance?/search" element={<Search />} />
{/* <Route path="/:anything" element={<NotFound />} /> */}
</Routes>
<PrimaryRoutes isLoggedIn={isLoggedIn} loading={uiState === 'loading'} />
<SecondaryRoutes isLoggedIn={isLoggedIn} />
{uiState === 'default' && (
<Routes>
<Route path="/:instance?/s/:id" element={<StatusRoute />} />
</Routes>
)}
{isLoggedIn && <ComposeButton />}
{isLoggedIn &&
!snapStates.settings.shortcutsColumnsMode &&
snapStates.settings.shortcutsViewMode !== 'multi-column' && (
<Shortcuts />
)}
{isLoggedIn && <Shortcuts />}
<Modals />
{isLoggedIn && <NotificationService />}
<BackgroundService isLoggedIn={isLoggedIn} />
@ -262,4 +240,86 @@ function App() {
);
}
function PrimaryRoutes({ isLoggedIn, loading }) {
const location = useLocation();
const nonRootLocation = useMemo(() => {
const { pathname } = location;
return !/^\/(login|welcome)/.test(pathname);
}, [location]);
return (
<Routes location={nonRootLocation || location}>
<Route
path="/"
element={
isLoggedIn ? (
<Home />
) : loading ? (
<Loader id="loader-root" />
) : (
<Welcome />
)
}
/>
<Route path="/login" element={<Login />} />
<Route path="/welcome" element={<Welcome />} />
</Routes>
);
}
function getPrevLocation() {
return states.prevLocation || null;
}
function SecondaryRoutes({ isLoggedIn }) {
// const snapStates = useSnapshot(states);
const location = useLocation();
// const prevLocation = snapStates.prevLocation;
const backgroundLocation = useRef(getPrevLocation());
const isModalPage = useMemo(() => {
return (
matchPath('/:instance/s/:id', location.pathname) ||
matchPath('/s/:id', location.pathname)
);
}, [location.pathname, matchPath]);
if (isModalPage) {
if (!backgroundLocation.current)
backgroundLocation.current = getPrevLocation();
} else {
backgroundLocation.current = null;
}
console.debug({
backgroundLocation: backgroundLocation.current,
location,
});
return (
<Routes location={backgroundLocation.current || location}>
{isLoggedIn && (
<>
<Route path="/notifications" element={<Notifications />} />
<Route path="/mentions" element={<Mentions />} />
<Route path="/following" element={<Following />} />
<Route path="/b" element={<Bookmarks />} />
<Route path="/f" element={<Favourites />} />
<Route path="/l">
<Route index element={<Lists />} />
<Route path=":id" element={<List />} />
</Route>
<Route path="/ft" element={<FollowedHashtags />} />
</>
)}
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} />
<Route path="/:instance?/a/:id" element={<AccountStatuses />} />
<Route path="/:instance?/p">
<Route index element={<Public />} />
<Route path="l" element={<Public local />} />
</Route>
<Route path="/:instance?/trending" element={<Trending />} />
<Route path="/:instance?/search" element={<Search />} />
{/* <Route path="/:anything" element={<NotFound />} /> */}
</Routes>
);
}
export { App };

View file

@ -1,8 +1,10 @@
.account-container {
display: flex;
flex-direction: column;
overflow: hidden;
/* display: flex; */
/* flex-direction: column; */
/* overflow: hidden; */
overflow-y: auto;
max-width: 100%;
--banner-overlap: 44px;
}
.account-container.skeleton {
@ -51,6 +53,7 @@
.account-container .header-banner {
/* pointer-events: none; */
vertical-align: top;
aspect-ratio: 6 / 1;
width: 100%;
height: auto;
@ -75,7 +78,7 @@
hsla(0, 0%, 0%, 0.013) 95.3%,
hsla(0, 0%, 0%, 0) 100%
);
margin-bottom: -44px;
margin-bottom: calc(-1 * var(--banner-overlap));
user-select: none;
-webkit-user-drag: none;
opacity: 0;
@ -116,18 +119,26 @@
}
.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;
& + header {
background-image: none;
}
& + header .avatar + * {
transition: opacity 0.3s ease-in-out;
opacity: 0 !important;
}
&,
& + header .avatar {
transition: filter 0.3s ease-in-out;
filter: none !important;
}
& + header .avatar img {
transition: border-radius 0.3s ease-in-out;
border-radius: 8px;
}
}
@media (min-height: 480px) {
@ -165,6 +176,10 @@
animation: fade-in 0.3s both ease-in-out 0.2s;
}
.account-container .account-block .account-block-acct {
opacity: 0.7;
}
.private-note-tag {
z-index: 1;
appearance: none;
@ -290,8 +305,11 @@
color: inherit;
}
.account-container footer {
padding: 0 16px 16px;
}
.account-container .actions {
margin-block: 8px;
/* margin-block: 8px; */
display: flex;
gap: 8px;
justify-content: space-between;
@ -396,13 +414,64 @@
animation: none;
}
.timeline-start .account-container main {
padding: 1px 16px 1px;
padding: 1px 16px 16px;
}
.timeline-start .account-container main > * {
animation: none;
}
.timeline-start .account-container .account-block .account-block-acct {
opacity: 0.5;
.faux-header-bg {
display: none;
}
.sheet .account-container {
border-radius: 16px 16px 0 0;
overflow-x: hidden;
max-height: 75vh;
overscroll-behavior: none;
header {
padding-bottom: 16px;
position: sticky;
top: 0;
z-index: 2;
/* --bg-color: red; */
background-image: linear-gradient(
to bottom,
transparent 30%,
var(--bg-color) var(--banner-overlap),
var(--bg-color) calc(100% - 8px),
transparent
);
}
.faux-header-bg {
display: block;
height: var(--banner-overlap);
position: sticky;
top: 0;
z-index: 1;
background-color: var(--bg-color);
margin-top: calc(-1 * var(--banner-overlap));
}
main {
margin-top: -8px;
padding-top: 1px;
}
footer {
min-height: calc(40px + 16px);
animation: slide-up 0.3s ease-out 0.3s both;
position: sticky;
bottom: 0;
background-color: var(--bg-faded-blur-color);
backdrop-filter: blur(16px) saturate(3);
padding: 8px 16px;
border-top: var(--hairline-width) solid var(--outline-color);
padding-bottom: max(8px, env(safe-area-inset-bottom));
box-shadow: 0 -8px 16px -8px var(--drop-shadow-color);
}
}
@keyframes swoosh-bg-image {
@ -609,6 +678,7 @@
@media (min-width: 40em) {
.timeline-start .account-container {
--banner-overlap: 77px;
--item-radius: 16px;
border: 1px solid var(--divider-color);
margin: 16px 0;
@ -625,9 +695,9 @@
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 {
/* .timeline-start .account-container .header-banner {
margin-bottom: -77px;
}
} */
.timeline-start .account-container header .account-block {
font-size: 175%;
/* margin-bottom: -8px; */

View file

@ -314,6 +314,7 @@ function AccountInfo({
return (
<div
tabIndex="-1"
class={`account-container ${uiState === 'loading' ? 'skeleton' : ''}`}
style={{
'--header-color-1': headerCornerColors[0],
@ -343,20 +344,39 @@ function AccountInfo({
</header>
<main>
<div class="note">
<p> </p>
<p> </p>
<p> </p>
<p> </p>
</div>
<div class="stats">
<div>
<span></span> Followers
<div class="account-metadata-box">
<div class="profile-metadata">
<div class="profile-field">
<b class="more-insignificant"></b>
<p></p>
</div>
<div class="profile-field">
<b class="more-insignificant"></b>
<p></p>
</div>
</div>
<div>
<span></span> Following
<div class="stats">
<div>
<span></span> Followers
</div>
<div>
<span></span> Following
</div>
<div>
<span></span> Posts
</div>
</div>
<div>
<span></span> Posts
</div>
<div>Joined </div>
</div>
<div class="actions">
<span />
<span class="buttons">
<button type="button" title="More" class="plain" disabled>
<Icon icon="more" size="l" alt="More" />
</button>
</span>
</div>
</main>
</>
@ -379,7 +399,7 @@ function AccountInfo({
/>
</div>
)}
{header && !/missing\.png$/.test(header) && (
{!!header && !/missing\.png$/.test(header) && (
<img
src={header}
alt=""
@ -486,7 +506,8 @@ function AccountInfo({
internal={!standalone}
/>
</header>
<main tabIndex="-1">
<div class="faux-header-bg" aria-hidden="true" />
<main>
{!!memorial && <span class="tag">In Memoriam</span>}
{!!bot && (
<span class="tag">
@ -729,13 +750,15 @@ function AccountInfo({
</div>
</div>
</div>
</main>
<footer>
<RelatedActions
info={info}
instance={instance}
authenticated={authenticated}
onRelationshipChange={onRelationshipChange}
/>
</main>
</footer>
</>
)
)}
@ -1366,6 +1389,7 @@ function AddRemoveListsSheet({ accountID, onClose }) {
(async () => {
try {
const lists = await masto.v1.lists.list();
lists.sort((a, b) => a.title.localeCompare(b.title));
const listsContainingAccount = await masto.v1.accounts
.$select(accountID)
.lists.list();

View file

@ -2,6 +2,8 @@ import './avatar.css';
import { useRef } from 'preact/hooks';
import mem from '../utils/mem';
const SIZES = {
s: 16,
m: 20,
@ -90,4 +92,4 @@ function Avatar({ url, size, alt = '', squircle, ...props }) {
);
}
export default Avatar;
export default mem(Avatar);

View file

@ -1,5 +1,6 @@
import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useDebouncedCallback } from 'use-debounce';
import { api } from '../utils/api';
import states, { saveStatus } from '../utils/states';
@ -11,57 +12,62 @@ export default memo(function BackgroundService({ isLoggedIn }) {
// - WebSocket to receive notifications when page is visible
const [visible, setVisible] = useState(true);
usePageVisibility(setVisible);
useEffect(() => {
let sub;
if (isLoggedIn && visible) {
const { masto, streaming, instance } = api();
(async () => {
// 1. Get the latest notification
if (states.notificationsLast) {
const notificationsIterator = masto.v1.notifications.list({
limit: 1,
since_id: states.notificationsLast.id,
});
const { value: notifications } = await notificationsIterator.next();
if (notifications?.length) {
let lastReadId;
try {
const markers = await masto.v1.markers.fetch({
timeline: 'notifications',
});
lastReadId = markers?.notifications?.lastReadId;
} catch (e) {}
if (lastReadId) {
if (notifications[0].id !== lastReadId) {
states.notificationsShowNew = true;
}
} else {
states.notificationsShowNew = true;
}
}
}
// 2. Start streaming
if (streaming) {
sub = streaming.user.notification.subscribe();
console.log('🎏 Streaming notification', sub);
for await (const entry of sub) {
if (!sub) break;
console.log('🔔🔔 Notification entry', entry);
if (entry.event === 'notification') {
console.log('🔔🔔 Notification', entry);
saveStatus(entry.payload, instance, {
skipThreading: true,
});
}
const subRef = useRef();
const debouncedStartNotifications = useDebouncedCallback(() => {
const { masto, streaming, instance } = api();
(async () => {
// 1. Get the latest notification
if (states.notificationsLast) {
const notificationsIterator = masto.v1.notifications.list({
limit: 1,
since_id: states.notificationsLast.id,
});
const { value: notifications } = await notificationsIterator.next();
if (notifications?.length) {
let lastReadId;
try {
const markers = await masto.v1.markers.fetch({
timeline: 'notifications',
});
lastReadId = markers?.notifications?.lastReadId;
} catch (e) {}
if (lastReadId) {
states.notificationsShowNew = notifications[0].id !== lastReadId;
} else {
states.notificationsShowNew = true;
}
}
})();
}
// 2. Start streaming
if (streaming) {
let sub = (subRef.current = streaming.user.notification.subscribe());
console.log('🎏 Streaming notification', sub);
for await (const entry of sub) {
if (!sub) break;
console.log('🔔🔔 Notification entry', entry);
if (entry.event === 'notification') {
console.log('🔔🔔 Notification', entry);
saveStatus(entry.payload, instance, {
skipThreading: true,
});
}
states.notificationsShowNew = true;
}
}
})();
}, 3000);
useEffect(() => {
// let sub;
if (isLoggedIn && visible) {
debouncedStartNotifications();
}
return () => {
sub?.unsubscribe?.();
sub = null;
// sub?.unsubscribe?.();
// sub = null;
debouncedStartNotifications?.cancel?.();
subRef.current?.unsubscribe?.();
subRef.current = null;
};
}, [visible, isLoggedIn]);

View file

@ -56,8 +56,8 @@
@media (min-width: 40em) {
#compose-container textarea {
font-size: 150%;
font-size: calc(100% + 50% / var(--text-weight));
/* font-size: 150%;
font-size: calc(100% + 50% / var(--text-weight)); */
max-height: 65vh;
}
}

View file

@ -23,6 +23,7 @@ import {
getCurrentAccount,
getCurrentAccountNS,
getCurrentInstance,
getCurrentInstanceConfiguration,
} from '../utils/store-utils';
import supports from '../utils/supports';
import useInterval from '../utils/useInterval';
@ -119,21 +120,30 @@ function Compose({
const currentAccount = getCurrentAccount();
const currentAccountInfo = currentAccount.info;
const { configuration } = getCurrentInstance();
const configuration = getCurrentInstanceConfiguration();
console.log('⚙️ Configuration', configuration);
const {
statuses: { maxCharacters, maxMediaAttachments, charactersReservedPerUrl },
statuses: {
maxCharacters,
maxMediaAttachments,
charactersReservedPerUrl,
} = {},
mediaAttachments: {
supportedMimeTypes,
supportedMimeTypes = [],
imageSizeLimit,
imageMatrixLimit,
videoSizeLimit,
videoMatrixLimit,
videoFrameRateLimit,
},
polls: { maxOptions, maxCharactersPerOption, maxExpiration, minExpiration },
} = configuration;
} = {},
polls: {
maxOptions,
maxCharactersPerOption,
maxExpiration,
minExpiration,
} = {},
} = configuration || {};
const textareaRef = useRef();
const spoilerTextRef = useRef();
@ -377,6 +387,14 @@ function Compose({
enableOnFormTags: true,
// Use keyup because Esc keydown will close the confirm dialog on Safari
keyup: true,
ignoreEventWhen: (e) => {
const modals = document.querySelectorAll('#modal-container > *');
const hasModal = !!modals;
const hasOnlyComposer =
modals.length === 1 && modals[0].querySelector('#compose-container');
console.log('hasModal', hasModal, 'hasOnlyComposer', hasOnlyComposer);
return hasModal && !hasOnlyComposer;
},
},
);
@ -1200,7 +1218,7 @@ const Textarea = forwardRef((props, ref) => {
const [text, setText] = useState(ref.current?.value || '');
const { maxCharacters, performSearch = () => {}, ...textareaProps } = props;
const snapStates = useSnapshot(states);
const charCount = snapStates.composerCharacterCount;
// const charCount = snapStates.composerCharacterCount;
const customEmojis = useRef();
useEffect(() => {
@ -1432,7 +1450,7 @@ const Textarea = forwardRef((props, ref) => {
style={{
width: '100%',
height: '4em',
'--text-weight': (1 + charCount / 140).toFixed(1) || 1,
// '--text-weight': (1 + charCount / 140).toFixed(1) || 1,
}}
/>
</text-expander>

View file

@ -101,6 +101,7 @@ export const ICONS = {
'account-warning': () => import('@iconify-icons/mingcute/user-warning-line'),
keyboard: () => import('@iconify-icons/mingcute/keyboard-line'),
cloud: () => import('@iconify-icons/mingcute/cloud-line'),
month: () => import('@iconify-icons/mingcute/calendar-month-line'),
};
function Icon({

View file

@ -135,8 +135,12 @@ export default memo(function KeyboardShortcutsHelp() {
keys: <kbd>r</kbd>,
},
{
action: 'Favourite',
keys: <kbd>f</kbd>,
action: 'Like (favourite)',
keys: (
<>
<kbd>l</kbd> or <kbd>f</kbd>
</>
),
},
{
action: 'Boost',

View file

@ -24,7 +24,7 @@ export default function MediaAltModal({ alt, lang, onClose }) {
);
return (
<div class="sheet">
<div class="sheet" tabindex="-1">
{!!onClose && (
<button type="button" class="sheet-close outer" onClick={onClose}>
<Icon icon="x" />

View file

@ -3,12 +3,13 @@ import { getBlurHashAverageColor } from 'fast-blurhash';
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { oklab2rgb, rgb2oklab } from '../utils/color-utils';
import states from '../utils/states';
import Icon from './icon';
import Link from './link';
import Media from './media';
import MediaAltModal from './media-alt-modal';
import MenuLink from './menu-link';
import Modal from './modal';
function MediaModal({
mediaAttachments,
@ -64,9 +65,17 @@ function MediaModal({
};
}, []);
useHotkeys('esc', onClose, [onClose]);
const [showMediaAlt, setShowMediaAlt] = useState(false);
useHotkeys(
'esc',
onClose,
{
ignoreEventWhen: (e) => {
const hasModal = !!document.querySelector('#modal-container > *');
return hasModal;
},
},
[onClose],
);
useEffect(() => {
let handleScroll = () => {
@ -112,17 +121,22 @@ function MediaModal({
>
{mediaAttachments?.map((media, i) => {
const { blurhash } = media;
const rgbAverageColor = blurhash
? getBlurHashAverageColor(blurhash)
: null;
let accentColor;
if (blurhash) {
const averageColor = getBlurHashAverageColor(blurhash);
const labAverageColor = rgb2oklab(averageColor);
accentColor = oklab2rgb([
0.6,
labAverageColor[1],
labAverageColor[2],
]);
}
return (
<div
class="carousel-item"
style={{
'--average-color': `rgb(${rgbAverageColor?.join(',')})`,
'--average-color-alpha': `rgba(${rgbAverageColor?.join(
',',
)}, .5)`,
'--accent-color': `rgb(${accentColor?.join(',')})`,
'--accent-alpha-color': `rgba(${accentColor?.join(',')}, 0.4)`,
}}
tabindex="0"
key={media.id}
@ -139,10 +153,10 @@ function MediaModal({
class="media-alt"
hidden={!showControls}
onClick={() => {
setShowMediaAlt({
states.showMediaAlt = {
alt: media.description,
lang,
});
};
}}
>
<span class="alt-badge">ALT</span>
@ -274,23 +288,6 @@ function MediaModal({
</button>
</div>
)}
{!!showMediaAlt && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowMediaAlt(false);
carouselRef.current.focus();
}
}}
>
<MediaAltModal
alt={showMediaAlt.alt || showMediaAlt}
lang={showMediaAlt?.lang}
onClose={() => setShowMediaAlt(false)}
/>
</Modal>
)}
</div>
);
}

View file

@ -170,15 +170,18 @@ function Media({
const maxAspectHeight =
window.innerHeight * (orientation === 'portrait' ? 0.45 : 0.33);
const maxHeight = orientation === 'portrait' ? 0 : 160;
const mediaStyles = {
'--width': `${width}px`,
'--height': `${height}px`,
// Calculate '--aspectWidth' based on aspect ratio calculated from '--width' and '--height', max height has to be 160px
'--aspectWidth': `${
(width / height) * Math.max(maxHeight, maxAspectHeight)
}px`,
aspectRatio: `${width} / ${height}`,
};
const mediaStyles =
width && height
? {
'--width': `${width}px`,
'--height': `${height}px`,
// Calculate '--aspectWidth' based on aspect ratio calculated from '--width' and '--height', max height has to be 160px
'--aspectWidth': `${
(width / height) * Math.max(maxHeight, maxAspectHeight)
}px`,
aspectRatio: `${width} / ${height}`,
}
: {};
const longDesc = isMediaCaptionLong(description);
const showInlineDesc =

View file

@ -20,9 +20,22 @@ function Modal({ children, onClose, onClick, class: className }) {
return () => clearTimeout(timer);
}, []);
const escRef = useHotkeys('esc', onClose, [onClose], {
enabled: !!onClose,
});
const escRef = useHotkeys(
'esc',
() => {
setTimeout(() => {
onClose?.();
}, 0);
},
{
enabled: !!onClose,
// Using keyup and setTimeout above
// This will run "later" to prevent clash with esc handlers from other components
keydown: false,
keyup: true,
},
[onClose],
);
const Modal = (
<div

View file

@ -183,10 +183,8 @@ export default function Modals() {
{!!snapStates.showMediaAlt && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showMediaAlt = false;
}
onClose={(e) => {
states.showMediaAlt = false;
}}
>
<MediaAltModal

View file

@ -192,7 +192,7 @@ function NavMenu(props) {
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
</MenuLink>
<MenuLink to="/f">
<Icon icon="heart" size="l" /> <span>Favourites</span>
<Icon icon="heart" size="l" /> <span>Likes</span>
</MenuLink>
</>
)}

View file

@ -77,11 +77,7 @@ export default memo(function NotificationService() {
}
}
} else {
console.warn(
'🛎️ Notification not found',
notificationID,
notificationAccessToken,
);
console.warn('🛎️ Notification not found', id);
}
})();
}, [id, accessToken]);

View file

@ -1,3 +1,5 @@
import { memo } from 'preact/compat';
import shortenNumber from '../utils/shorten-number';
import states from '../utils/states';
import store from '../utils/store';
@ -47,24 +49,24 @@ const contentText = {
reblog_reply: 'boosted your reply.',
follow: 'followed you.',
follow_request: 'requested to follow you.',
favourite: 'favourited your post.',
'favourite+account': (count) => `favourited ${count} of your posts.`,
favourite_reply: 'favourited your reply.',
favourite: 'liked your post.',
'favourite+account': (count) => `liked ${count} of your posts.`,
favourite_reply: 'liked your reply.',
poll: 'A poll you have voted in or created has ended.',
'poll-self': 'A poll you have created has ended.',
'poll-voted': 'A poll you have voted in has ended.',
update: 'A post you interacted with has been edited.',
'favourite+reblog': 'boosted & favourited your post.',
'favourite+reblog': 'boosted & liked your post.',
'favourite+reblog+account': (count) =>
`boosted & favourited ${count} of your posts.`,
'favourite+reblog_reply': 'boosted & favourited your reply.',
`boosted & liked ${count} of your posts.`,
'favourite+reblog_reply': 'boosted & liked your reply.',
'admin.sign_up': 'signed up.',
'admin.report': (targetAccount) => <>reported {targetAccount}</>,
};
const AVATARS_LIMIT = 50;
function Notification({ notification, instance, reload, isStatic }) {
function Notification({ notification, instance, isStatic }) {
const { id, status, account, report, _accounts, _statuses } = notification;
let { type } = notification;
@ -140,8 +142,8 @@ function Notification({ notification, instance, reload, isStatic }) {
const genericAccountsHeading =
{
'favourite+reblog': 'Boosted/Favourited by…',
favourite: 'Favourited by…',
'favourite+reblog': 'Boosted/Liked by…',
favourite: 'Liked by…',
reblog: 'Boosted by…',
follow: 'Followed by…',
}[type] || 'Accounts';
@ -153,8 +155,14 @@ function Notification({ notification, instance, reload, isStatic }) {
};
};
console.debug('RENDER Notification', notification.id);
return (
<div class={`notification notification-${type}`} tabIndex="0">
<div
class={`notification notification-${type}`}
data-notification-id={id}
tabIndex="0"
>
<div
class={`notification-type notification-${type}`}
title={formattedCreatedAt}
@ -207,12 +215,7 @@ function Notification({ notification, instance, reload, isStatic }) {
)}
</p>
{type === 'follow_request' && (
<FollowRequestButtons
accountID={account.id}
onChange={() => {
// reload();
}}
/>
<FollowRequestButtons accountID={account.id} />
)}
</>
)}
@ -327,4 +330,4 @@ function TruncatedLink(props) {
return <Link {...props} data-read-more="Read more →" ref={ref} />;
}
export default Notification;
export default memo(Notification);

View file

@ -85,7 +85,7 @@
transform: scale(0.975);
transition: all 0.2s ease-out;
}
#shortcuts-settings-container .shortcuts-view-mode label:has(input:checked) {
#shortcuts-settings-container .shortcuts-view-mode label.checked {
box-shadow: inset 0 0 0 3px var(--link-color);
}
#shortcuts-settings-container

View file

@ -1,5 +1,6 @@
import './shortcuts-settings.css';
import { useAutoAnimate } from '@formkit/auto-animate/preact';
import {
compressToEncodedURIComponent,
decompressFromEncodedURIComponent,
@ -45,7 +46,7 @@ const TYPE_TEXT = {
search: 'Search',
'account-statuses': 'Account',
bookmarks: 'Bookmarks',
favourites: 'Favourites',
favourites: 'Likes',
hashtag: 'Hashtag',
trending: 'Trending',
mentions: 'Mentions',
@ -177,7 +178,7 @@ export const SHORTCUTS_META = {
},
favourites: {
id: 'favourites',
title: 'Favourites',
title: 'Likes',
path: '/f',
icon: 'heart',
},
@ -201,10 +202,13 @@ function ShortcutsSettings({ onClose }) {
const [showForm, setShowForm] = useState(false);
const [showImportExport, setShowImportExport] = useState(false);
const [shortcutsListParent] = useAutoAnimate();
useEffect(() => {
(async () => {
try {
const lists = await masto.v1.lists.list();
lists.sort((a, b) => a.title.localeCompare(b.title));
setLists(lists);
} catch (e) {
console.error(e);
@ -267,25 +271,27 @@ function ShortcutsSettings({ onClose }) {
label: 'Multi-column',
imgURL: multiColumnUrl,
},
].map(({ value, label, imgURL }) => (
<label>
<input
type="radio"
name="shortcuts-view-mode"
value={value}
checked={
snapStates.settings.shortcutsViewMode === value ||
(value === 'float-button' &&
!snapStates.settings.shortcutsViewMode)
}
onChange={(e) => {
states.settings.shortcutsViewMode = e.target.value;
}}
/>{' '}
<img src={imgURL} alt="" width="80" height="58" />{' '}
<span>{label}</span>
</label>
))}
].map(({ value, label, imgURL }) => {
const checked =
snapStates.settings.shortcutsViewMode === value ||
(value === 'float-button' &&
!snapStates.settings.shortcutsViewMode);
return (
<label key={value} class={checked ? 'checked' : ''}>
<input
type="radio"
name="shortcuts-view-mode"
value={value}
checked={checked}
onChange={(e) => {
states.settings.shortcutsViewMode = e.target.value;
}}
/>{' '}
<img src={imgURL} alt="" width="80" height="58" />{' '}
<span>{label}</span>
</label>
);
})}
</div>
{/* <select
value={snapStates.settings.shortcutsViewMode || 'float-button'}
@ -315,9 +321,10 @@ function ShortcutsSettings({ onClose }) {
</details>
</p> */}
{shortcuts.length > 0 ? (
<ol class="shortcuts-list">
<ol class="shortcuts-list" ref={shortcutsListParent}>
{shortcuts.filter(Boolean).map((shortcut, i) => {
const key = i + Object.values(shortcut);
// const key = i + Object.values(shortcut);
const key = Object.values(shortcut).join('-');
const { type } = shortcut;
if (!SHORTCUTS_META[type]) return null;
let { icon, title, subtitle } = SHORTCUTS_META[type];

View file

@ -1,6 +1,7 @@
import './shortcuts.css';
import { Menu, MenuItem } from '@szhsin/react-menu';
import { memo } from 'preact/compat';
import { useMemo, useRef } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router-dom';
@ -18,11 +19,17 @@ import MenuLink from './menu-link';
function Shortcuts() {
const { instance } = api();
const snapStates = useSnapshot(states);
const { shortcuts } = snapStates;
const { shortcuts, settings } = snapStates;
if (!shortcuts.length) {
return null;
}
if (
settings.shortcutsColumnsMode ||
settings.shortcutsViewMode === 'multi-column'
) {
return null;
}
const menuRef = useRef();
@ -180,4 +187,4 @@ function Shortcuts() {
);
}
export default Shortcuts;
export default memo(Shortcuts);

View file

@ -512,7 +512,7 @@ function Status({
)}{' '}
{favouritesCount > 0 && (
<span>
<Icon icon="heart" alt="Favourites" size="s" />{' '}
<Icon icon="heart" alt="Likes" size="s" />{' '}
<span>{shortenNumber(favouritesCount)}</span>
</span>
)}
@ -550,7 +550,7 @@ function Status({
<MenuItem onClick={() => setShowReactions(true)}>
<Icon icon="react" />
<span>
Boosted/Favourited by<span class="more-insignificant"></span>
Boosted/Liked by<span class="more-insignificant"></span>
</span>
</MenuItem>
)}
@ -603,8 +603,8 @@ function Status({
if (!isSizeLarge) {
showToast(
favourited
? `Unfavourited @${username || acct}'s post`
: `Favourited @${username || acct}'s post`,
? `Unliked @${username || acct}'s post`
: `Liked @${username || acct}'s post`,
);
}
} catch (e) {}
@ -616,7 +616,7 @@ function Status({
color: favourited && 'var(--favourite-color)',
}}
/>
<span>{favourited ? 'Unfavourite' : 'Favourite'}</span>
<span>{favourited ? 'Unlike' : 'Like'}</span>
</MenuItem>
</div>
<div class="menu-horizontal">
@ -794,10 +794,7 @@ function Status({
const contextMenuRef = useRef();
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
const [contextMenuAnchorPoint, setContextMenuAnchorPoint] = useState({
x: 0,
y: 0,
});
const [contextMenuProps, setContextMenuProps] = useState({});
const isIOS =
window.ontouchstart !== undefined &&
/iPad|iPhone|iPod/.test(navigator.userAgent);
@ -814,9 +811,12 @@ function Status({
const link = e.target.closest('a');
if (link && /^https?:\/\//.test(link.getAttribute('href'))) return;
e.preventDefault();
setContextMenuAnchorPoint({
x: clientX,
y: clientY,
setContextMenuProps({
anchorPoint: {
x: clientX,
y: clientY,
},
direction: 'right',
});
setIsContextMenuOpen(true);
}
@ -836,15 +836,15 @@ function Status({
enabled: hotkeysEnabled,
});
const fRef = useHotkeys(
'f',
'f, l',
() => {
try {
favouriteStatus();
if (!isSizeLarge) {
showToast(
favourited
? `Unfavourited @${username || acct}'s post`
: `Favourited @${username || acct}'s post`,
? `Unliked @${username || acct}'s post`
: `Liked @${username || acct}'s post`,
);
}
} catch (e) {}
@ -996,9 +996,12 @@ function Status({
const link = e.target.closest('a');
if (link && /^https?:\/\//.test(link.getAttribute('href'))) return;
e.preventDefault();
setContextMenuAnchorPoint({
x: e.clientX,
y: e.clientY,
setContextMenuProps({
anchorPoint: {
x: e.clientX,
y: e.clientY,
},
direction: 'right',
});
setIsContextMenuOpen(true);
}}
@ -1008,8 +1011,7 @@ function Status({
<ControlledMenu
ref={contextMenuRef}
state={isContextMenuOpen ? 'open' : undefined}
anchorPoint={contextMenuAnchorPoint}
direction="right"
{...contextMenuProps}
onClose={(e) => {
setIsContextMenuOpen(false);
// statusRef.current?.focus?.();
@ -1086,49 +1088,78 @@ function Status({
(_deleted ? (
<span class="status-deleted-tag">Deleted</span>
) : url && !previewMode && !quoted ? (
<Menu
instanceRef={menuInstanceRef}
portal={{
target: document.body,
<Link
to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onStatusLinkClick?.(e, status);
setContextMenuProps({
anchorRef: {
current: e.currentTarget,
},
align: 'end',
direction: 'bottom',
gap: 4,
});
setIsContextMenuOpen(true);
}}
containerProps={{
style: {
// Higher than the backdrop
zIndex: 1001,
},
onClick: (e) => {
if (e.target === e.currentTarget)
menuInstanceRef.current?.closeMenu?.();
},
}}
align="end"
gap={4}
overflow="auto"
viewScroll="close"
boundingBoxPadding="8 8 8 8"
unmountOnClose
menuButton={({ open }) => (
<Link
to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onStatusLinkClick?.(e, status);
}}
class={`time ${open ? 'is-open' : ''}`}
>
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibilityText[visibility]}
size="s"
/>{' '}
<RelativeTime datetime={createdAtDate} format="micro" />
</Link>
)}
class={`time ${
isContextMenuOpen && contextMenuProps?.anchorRef
? 'is-open'
: ''
}`}
>
{StatusMenuItems}
</Menu>
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibilityText[visibility]}
size="s"
/>{' '}
<RelativeTime datetime={createdAtDate} format="micro" />
</Link>
) : (
// <Menu
// instanceRef={menuInstanceRef}
// portal={{
// target: document.body,
// }}
// containerProps={{
// style: {
// // Higher than the backdrop
// zIndex: 1001,
// },
// onClick: (e) => {
// if (e.target === e.currentTarget)
// menuInstanceRef.current?.closeMenu?.();
// },
// }}
// align="end"
// gap={4}
// overflow="auto"
// viewScroll="close"
// boundingBoxPadding="8 8 8 8"
// unmountOnClose
// menuButton={({ open }) => (
// <Link
// to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
// onClick={(e) => {
// e.preventDefault();
// e.stopPropagation();
// onStatusLinkClick?.(e, status);
// }}
// class={`time ${open ? 'is-open' : ''}`}
// >
// <Icon
// icon={visibilityIconsMap[visibility]}
// alt={visibilityText[visibility]}
// size="s"
// />{' '}
// <RelativeTime datetime={createdAtDate} format="micro" />
// </Link>
// )}
// >
// {StatusMenuItems}
// </Menu>
<span class="time">
<Icon
icon={visibilityIconsMap[visibility]}
@ -1497,8 +1528,8 @@ function Status({
<div class="action has-count">
<StatusButton
checked={favourited}
title={['Favourite', 'Unfavourite']}
alt={['Favourite', 'Favourited']}
title={['Like', 'Unlike']}
alt={['Like', 'Liked']}
class="favourite-button"
icon="heart"
count={favouritesCount}
@ -1931,7 +1962,7 @@ function ReactionsModal({ statusID, instance, onClose }) {
</button>
)}
<header>
<h2>Boosted/Favourited by</h2>
<h2>Boosted/Liked by</h2>
</header>
<main>
{accounts.length > 0 ? (

View file

@ -1,4 +1,3 @@
import { useIdle } from '@uidotdev/usehooks';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
@ -211,21 +210,19 @@ function Timeline({
}
}, [nearReachEnd, showMore]);
const idle = useIdle(5000);
console.debug('🧘‍♀️ IDLE', idle);
const loadOrCheckUpdates = useCallback(
async ({ disableHoverCheck = false } = {}) => {
async ({ disableIdleCheck = false } = {}) => {
console.log('✨ Load or check updates', {
autoRefresh: snapStates.settings.autoRefresh,
scrollTop: scrollableRef.current.scrollTop,
disableHoverCheck,
idle,
disableIdleCheck,
idle: window.__IDLE__,
inBackground: inBackground(),
});
if (
snapStates.settings.autoRefresh &&
scrollableRef.current.scrollTop === 0 &&
(disableHoverCheck || idle) &&
(disableIdleCheck || window.__IDLE__) &&
!inBackground()
) {
console.log('✨ Load updates', snapStates.settings.autoRefresh);
@ -239,7 +236,11 @@ function Timeline({
}
}
},
[id, idle, loadItems, checkForUpdates, snapStates.settings.autoRefresh],
[id, loadItems, checkForUpdates, snapStates.settings.autoRefresh],
);
const debouncedLoadOrCheckUpdates = useDebouncedCallback(
loadOrCheckUpdates,
3000,
);
const lastHiddenTime = useRef();
@ -248,12 +249,14 @@ function Timeline({
if (visible) {
const timeDiff = Date.now() - lastHiddenTime.current;
if (!lastHiddenTime.current || timeDiff > 1000 * 60) {
loadOrCheckUpdates({
disableHoverCheck: true,
// 1 minute
debouncedLoadOrCheckUpdates({
disableIdleCheck: true,
});
}
} else {
lastHiddenTime.current = Date.now();
debouncedLoadOrCheckUpdates.cancel();
}
setVisible(visible);
},
@ -609,11 +612,16 @@ function StatusCarousel({ title, class: className, children }) {
function TimelineStatusCompact({ status, instance }) {
const snapStates = useSnapshot(states);
const { id } = status;
const { id, visibility } = status;
const statusPeekText = statusPeek(status);
const sKey = statusKey(id, instance);
return (
<article class="status compact-thread" tabindex="-1">
<article
class={`status compact-thread ${
visibility === 'direct' ? 'visibility-direct' : ''
}`}
tabindex="-1"
>
{!!snapStates.statusThreadNumber[sKey] ? (
<div class="status-thread-badge">
<Icon icon="thread" size="s" />

View file

@ -20,8 +20,8 @@ const throttle = pThrottle({
// Using other API instances instead of lingva.ml because of this bug (slashes don't work):
// https://github.com/thedaviddelta/lingva-translate/issues/68
const LINGVA_INSTANCES = [
'lingva.garudalinux.org',
'lingva.lunar.icu',
'lingva.garudalinux.org',
'translate.plausibility.cloud',
];
let currentLingvaInstance = 0;
@ -35,7 +35,10 @@ function _lingvaTranslate(text, source, target) {
text,
)}`,
)
.then((res) => res.json())
.then((res) => {
if (!res.ok) throw new Error(res.statusText);
return res.json();
})
.then((res) => {
return {
provider: 'lingva',

View file

@ -53,7 +53,7 @@
--divider-color: rgba(0, 0, 0, 0.1);
--backdrop-color: rgba(0, 0, 0, 0.05);
--backdrop-darker-color: rgba(0, 0, 0, 0.25);
--backdrop-solid-color: #ccc;
--backdrop-solid-color: #eee;
--img-bg-color: rgba(128, 128, 128, 0.2);
--loader-color: #1c1e2199;
--comment-line-color: #e5e5e5;
@ -106,7 +106,7 @@
--divider-color: rgba(255, 255, 255, 0.1);
--bg-blur-color: #24252699;
--backdrop-color: rgba(0, 0, 0, 0.5);
--backdrop-solid-color: #333;
--backdrop-solid-color: #111;
--loader-color: #f0f2f599;
--comment-line-color: #565656;
--drop-shadow-color: rgba(0, 0, 0, 0.5);
@ -148,6 +148,16 @@ body {
}
}
p {
/*
white-space is shorthand for two values; white-space-collapse and text-wrap
https://developer.mozilla.org/en-US/docs/Web/CSS/white-space
!important is needed to override higher specificity when elements are styled
with `white-space` and 1 value, which doesn't have "pretty"
*/
text-wrap: pretty !important;
}
a {
color: var(--link-color);
text-decoration-color: var(--link-faded-color);

View file

@ -10,24 +10,139 @@ import Link from '../components/link';
import Menu2 from '../components/menu2';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import pmem from '../utils/pmem';
import showToast from '../utils/show-toast';
import states from '../utils/states';
import { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
const MIN_YEAR = 1983;
const MIN_YEAR_MONTH = `${MIN_YEAR}-01`; // Birth of the Internet
const supportsInputMonth = (() => {
try {
const input = document.createElement('input');
input.setAttribute('type', 'month');
return input.type === 'month';
} catch (e) {
return false;
}
})();
async function _isSearchEnabled(instance) {
const { masto } = api({ instance });
const results = await masto.v2.search.fetch({
q: 'from:me',
type: 'statuses',
limit: 1,
});
return !!results?.statuses?.length;
}
const isSearchEnabled = pmem(_isSearchEnabled);
function AccountStatuses() {
const snapStates = useSnapshot(states);
const { id, ...params } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const month = searchParams.get('month');
const excludeReplies = !searchParams.get('replies');
const excludeBoosts = !!searchParams.get('boosts');
const tagged = searchParams.get('tagged');
const media = !!searchParams.get('media');
const { masto, instance, authenticated } = api({ instance: params.instance });
const accountStatusesIterator = useRef();
const allSearchParams = [month, excludeReplies, excludeBoosts, tagged, media];
const [account, setAccount] = useState();
const searchOffsetRef = useRef(0);
useEffect(() => {
searchOffsetRef.current = 0;
}, allSearchParams);
const sameCurrentInstance = useMemo(
() => instance === api().instance,
[instance],
);
const [searchEnabled, setSearchEnabled] = useState(false);
useEffect(() => {
// Only enable for current logged-in instance
// Most remote instances don't allow unauthenticated searches
if (!sameCurrentInstance) return;
if (!account?.acct) return;
(async () => {
const enabled = await isSearchEnabled(instance);
console.log({ enabled });
setSearchEnabled(enabled);
})();
}, [instance, sameCurrentInstance, account?.acct]);
async function fetchAccountStatuses(firstLoad) {
const isValidMonth = /^\d{4}-[01]\d$/.test(month);
const isValidYear = month?.split?.('-')?.[0] >= MIN_YEAR;
if (isValidMonth && isValidYear) {
if (!account) {
return {
value: [],
done: true,
};
}
const [_year, _month] = month.split('-');
const monthIndex = parseInt(_month, 10) - 1;
// YYYY-MM (no day)
// Search options:
// - from:account
// - after:YYYY-MM-DD (non-inclusive)
// - before:YYYY-MM-DD (non-inclusive)
// Last day of previous month
const after = new Date(_year, monthIndex, 0);
const afterStr = `${after.getFullYear()}-${(after.getMonth() + 1)
.toString()
.padStart(2, '0')}-${after.getDate().toString().padStart(2, '0')}`;
// First day of next month
const before = new Date(_year, monthIndex + 1, 1);
const beforeStr = `${before.getFullYear()}-${(before.getMonth() + 1)
.toString()
.padStart(2, '0')}-${before.getDate().toString().padStart(2, '0')}`;
console.log({
month,
_year,
_month,
monthIndex,
after,
before,
afterStr,
beforeStr,
});
let limit;
if (firstLoad) {
limit = LIMIT + 1;
searchOffsetRef.current = 0;
} else {
limit = LIMIT + searchOffsetRef.current + 1;
searchOffsetRef.current += LIMIT;
}
const searchResults = await masto.v2.search.fetch({
q: `from:${account.acct} after:${afterStr} before:${beforeStr}`,
type: 'statuses',
limit,
offset: searchOffsetRef.current,
});
if (searchResults?.statuses?.length) {
const value = searchResults.statuses.slice(0, LIMIT);
value.forEach((item) => {
saveStatus(item, instance);
});
const done = searchResults.statuses.length <= LIMIT;
return { value, done };
} else {
return { value: [], done: true };
}
}
const results = [];
if (firstLoad) {
const { value: pinnedStatuses } = await masto.v1.accounts
@ -78,7 +193,6 @@ function AccountStatuses() {
};
}
const [account, setAccount] = useState();
const [featuredTags, setFeaturedTags] = useState([]);
useTitle(
`${account?.displayName ? account.displayName + ' ' : ''}@${
@ -98,7 +212,7 @@ function AccountStatuses() {
try {
const featuredTags = await masto.v1.accounts
.$select(id)
.featuredTags.list(id);
.featuredTags.list();
console.log({ featuredTags });
setFeaturedTags(featuredTags);
} catch (e) {
@ -112,7 +226,8 @@ function AccountStatuses() {
const filterBarRef = useRef();
const TimelineStart = useMemo(() => {
const cachedAccount = snapStates.accounts[`${id}@${instance}`];
const filtered = !excludeReplies || excludeBoosts || tagged || media;
const filtered =
!excludeReplies || excludeBoosts || tagged || media || !!month;
return (
<>
<AccountInfo
@ -170,6 +285,7 @@ function AccountStatuses() {
</Link>
{featuredTags.map((tag) => (
<Link
key={tag.id}
to={`/${instance}/a/${id}${
tagged === tag.name
? ''
@ -192,6 +308,50 @@ function AccountStatuses() {
{/* <span class="filter-count">{tag.statusesCount}</span> */}
</Link>
))}
{searchEnabled &&
(supportsInputMonth ? (
<label class={`filter-field ${month ? 'is-active' : ''}`}>
<Icon icon="month" size="l" />
<input
type="month"
disabled={!account?.acct}
value={month || ''}
min={MIN_YEAR_MONTH}
max={new Date().toISOString().slice(0, 7)}
onInput={(e) => {
const { value, validity } = e.currentTarget;
if (!validity.valid) return;
setSearchParams(
value
? {
month: value,
}
: {},
);
}}
/>
</label>
) : (
// Fallback to <select> for month and <input type="number"> for year
<MonthPicker
class={`filter-field ${month ? 'is-active' : ''}`}
disabled={!account?.acct}
value={month || ''}
min={MIN_YEAR_MONTH}
max={new Date().toISOString().slice(0, 7)}
onInput={(e) => {
const { value, validity } = e;
if (!validity.valid) return;
setSearchParams(
value
? {
month: value,
}
: {},
);
}}
/>
))}
</div>
</>
);
@ -199,11 +359,9 @@ function AccountStatuses() {
id,
instance,
authenticated,
excludeReplies,
excludeBoosts,
featuredTags,
tagged,
media,
searchEnabled,
...allSearchParams,
]);
useEffect(() => {
@ -218,7 +376,7 @@ function AccountStatuses() {
(filterBarRef.current.offsetWidth - active.offsetWidth) / 2,
});
}
}, [featuredTags, tagged, media, excludeReplies, excludeBoosts]);
}, [featuredTags, searchEnabled, ...allSearchParams]);
const accountInstance = useMemo(() => {
if (!account?.url) return null;
@ -258,7 +416,13 @@ function AccountStatuses() {
useItemID
boostsCarousel={snapStates.settings.boostsCarousel}
timelineStart={TimelineStart}
refresh={[excludeReplies, excludeBoosts, tagged, media].toString()}
refresh={[
excludeReplies,
excludeBoosts,
tagged,
media,
month + account?.acct,
].toString()}
headerEnd={
<Menu2
portal
@ -303,4 +467,100 @@ function AccountStatuses() {
);
}
function MonthPicker(props) {
const {
class: className,
disabled,
value,
min,
max,
onInput = () => {},
} = props;
const [_year, _month] = value?.split('-') || [];
const monthFieldRef = useRef();
const yearFieldRef = useRef();
const checkValidity = (month, year) => {
const [minYear, minMonth] = min?.split('-') || [];
const [maxYear, maxMonth] = max?.split('-') || [];
if (year < minYear) return false;
if (year > maxYear) return false;
if (year === minYear && month < minMonth) return false;
if (year === maxYear && month > maxMonth) return false;
return true;
};
return (
<div class={className}>
<Icon icon="month" size="l" />
<select
ref={monthFieldRef}
disabled={disabled}
value={_month || ''}
onInput={(e) => {
const { value: month } = e.currentTarget;
const year = yearFieldRef.current.value;
if (!checkValidity(month, year))
return {
value: '',
validity: {
valid: false,
},
};
onInput({
value: month ? `${year}-${month}` : '',
validity: {
valid: true,
},
});
}}
>
<option value="">Month</option>
<option disabled>-----</option>
{Array.from({ length: 12 }, (_, i) => (
<option
value={
// Month is 1-indexed
(i + 1).toString().padStart(2, '0')
}
key={i}
>
{new Date(0, i).toLocaleString('default', {
month: 'long',
})}
</option>
))}
</select>{' '}
<input
ref={yearFieldRef}
type="number"
disabled={disabled}
value={_year || new Date().getFullYear()}
min={min?.slice(0, 4) || MIN_YEAR}
max={max?.slice(0, 4) || new Date().getFullYear()}
onInput={(e) => {
const { value: year, validity } = e.currentTarget;
const month = monthFieldRef.current.value;
if (!validity.valid || !checkValidity(month, year))
return {
value: '',
validity: {
valid: false,
},
};
onInput({
value: year ? `${year}-${month}` : '',
validity: {
valid: true,
},
});
}}
style={{
width: '4.5em',
}}
/>
</div>
);
}
export default AccountStatuses;

View file

@ -1,7 +1,8 @@
import './accounts.css';
import { useAutoAnimate } from '@formkit/auto-animate/preact';
import { Menu, MenuDivider, MenuItem } from '@szhsin/react-menu';
import { useReducer, useState } from 'preact/hooks';
import { useReducer } from 'preact/hooks';
import Avatar from '../components/avatar';
import Icon from '../components/icon';
@ -18,9 +19,9 @@ function Accounts({ onClose }) {
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);
const [accountsListParent] = useAutoAnimate();
return (
<div id="accounts-container" class="sheet" tabIndex="-1">
@ -34,12 +35,12 @@ function Accounts({ onClose }) {
</header>
<main>
<section>
<ul class="accounts-list">
<ul class="accounts-list" ref={accountsListParent}>
{accounts.map((account, i) => {
const isCurrent = account.info.id === currentAccount;
const isDefault = i === (currentDefault || 0);
const isDefault = i === 0; // first account is always default
return (
<li key={i + account.id}>
<li key={account.info.id}>
<div>
{moreThanOneAccount && (
<span class={`current ${isCurrent ? 'is-current' : ''}`}>
@ -120,7 +121,7 @@ function Accounts({ onClose }) {
accounts.splice(i, 1);
accounts.unshift(account);
store.local.setJSON('accounts', accounts);
setCurrentDefault(i);
reload();
}}
>
<Icon icon="check-circle" />

View file

@ -7,7 +7,7 @@ import useTitle from '../utils/useTitle';
const LIMIT = 20;
function Favourites() {
useTitle('Favourites', '/f');
useTitle('Likes', '/f');
const { masto, instance } = api();
const favouritesIterator = useRef();
async function fetchFavourites(firstLoad) {
@ -19,10 +19,10 @@ function Favourites() {
return (
<Timeline
title="Favourites"
title="Likes"
id="favourites"
emptyText="No favourites yet. Go favourite something!"
errorText="Unable to load favourites"
emptyText="No likes yet. Go like something!"
errorText="Unable to load likes"
instance={instance}
fetchItems={fetchFavourites}
/>

View file

@ -23,6 +23,7 @@ function Lists() {
(async () => {
try {
const lists = await masto.v1.lists.list();
lists.sort((a, b) => a.title.localeCompare(b.title));
console.log(lists);
setLists(lists);
setUIState('default');

View file

@ -23,6 +23,7 @@
padding: 0;
height: 40em;
overflow: auto;
overscroll-behavior: contain;
}
.notifications-menu .status {
font-size: inherit;

View file

@ -1,8 +1,9 @@
import './notifications.css';
import { useIdle } from '@uidotdev/usehooks';
import { Fragment } from 'preact';
import { memo } from 'preact/compat';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { InView } from 'react-intersection-observer';
import { useSearchParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
@ -166,14 +167,12 @@ function Notifications({ columnMode }) {
}
}, [reachStart]);
useEffect(() => {
if (nearReachEnd && showMore) {
loadNotifications();
}
}, [nearReachEnd, showMore]);
// useEffect(() => {
// if (nearReachEnd && showMore) {
// loadNotifications();
// }
// }, [nearReachEnd, showMore]);
const idle = useIdle(5000);
console.debug('🧘‍♀️ IDLE', idle);
const loadUpdates = useCallback(() => {
console.log('✨ Load updates', {
autoRefresh: snapStates.settings.autoRefresh,
@ -185,7 +184,7 @@ function Notifications({ columnMode }) {
if (
snapStates.settings.autoRefresh &&
scrollableRef.current?.scrollTop === 0 &&
idle &&
window.__IDLE__ &&
!inBackground() &&
snapStates.notificationsShowNew &&
uiState !== 'loading'
@ -193,7 +192,6 @@ function Notifications({ columnMode }) {
loadNotifications(true);
}
}, [
idle,
snapStates.notificationsShowNew,
snapStates.settings.autoRefresh,
uiState,
@ -220,23 +218,23 @@ function Notifications({ columnMode }) {
}
}, [notificationID, notificationAccessToken]);
useEffect(() => {
if (uiState === 'default') {
(async () => {
try {
const registration = await getRegistration();
if (registration?.getNotifications) {
const notifications = await registration.getNotifications();
console.log('🔔 Push notifications', notifications);
// Close all notifications?
// notifications.forEach((notification) => {
// notification.close();
// });
}
} catch (e) {}
})();
}
}, [uiState]);
// useEffect(() => {
// if (uiState === 'default') {
// (async () => {
// try {
// const registration = await getRegistration();
// if (registration?.getNotifications) {
// const notifications = await registration.getNotifications();
// console.log('🔔 Push notifications', notifications);
// // Close all notifications?
// // notifications.forEach((notification) => {
// // notification.close();
// // });
// }
// } catch (e) {}
// })();
// }
// }, [uiState]);
return (
<div
@ -412,18 +410,14 @@ function Notifications({ columnMode }) {
hideTime: true,
});
return (
<>
<Fragment key={notification.id}>
{differentDay && <h2 class="timeline-header">{heading}</h2>}
<Notification
instance={instance}
notification={notification}
key={notification.id}
reload={() => {
loadNotifications(true);
loadFollowRequests();
}}
/>
</>
</Fragment>
);
})}
</>
@ -458,15 +452,27 @@ function Notifications({ columnMode }) {
</>
)}
{showMore && (
<button
type="button"
class="plain block"
disabled={uiState === 'loading'}
onClick={() => loadNotifications()}
style={{ marginBlockEnd: '6em' }}
<InView
onChange={(inView) => {
if (inView) {
loadNotifications();
}
}}
>
{uiState === 'loading' ? <Loader abrupt /> : <>Show more&hellip;</>}
</button>
<button
type="button"
class="plain block"
disabled={uiState === 'loading'}
onClick={() => loadNotifications()}
style={{ marginBlockEnd: '6em' }}
>
{uiState === 'loading' ? (
<Loader abrupt />
) : (
<>Show more&hellip;</>
)}
</button>
</InView>
)}
</div>
</div>

View file

@ -40,8 +40,14 @@ ul.link-list.hashtag-list li a {
}
@media (min-width: 40em) {
#search-page header input {
background-color: var(--bg-color);
#search-page {
header input {
background-color: var(--bg-color);
}
.filter-bar {
margin-top: 8px;
}
}
}

View file

@ -71,6 +71,11 @@ function Search(props) {
};
function loadResults(firstLoad) {
if (!firstLoad && !authenticated) {
// Search results pagination is only available to authenticated users
return;
}
setUIState('loading');
if (firstLoad && !type) {
setStatusResults(statusResults.slice(0, SHORT_LIMIT));
@ -89,6 +94,7 @@ function Search(props) {
params.type = type;
if (authenticated) params.offset = offsetRef.current;
}
try {
const results = await masto.v2.search.fetch(params);
console.log(results);
@ -141,7 +147,7 @@ function Search(props) {
return (
<div id="search-page" class="deck-container" ref={scrollableRef}>
<div class="timeline-deck deck">
<header>
<header class={uiState === 'loading' ? 'loading' : ''}>
<div class="header-grid">
<div class="header-side">
<NavMenu />
@ -181,7 +187,9 @@ function Search(props) {
return 0;
})
.map((link) => (
<Link to={link.to}>{link.label}</Link>
<Link to={link.to} key={link.type}>
{link.label}
</Link>
))}
</div>
)}

View file

@ -140,3 +140,10 @@
color: var(--link-color);
vertical-align: middle;
}
#settings-container .version-string {
padding: 4px;
font-family: var(--monospace-font);
font-size: 85%;
text-align: center;
}

View file

@ -18,6 +18,7 @@ import {
removeSubscription,
updateSubscription,
} from '../utils/push-notifications';
import showToast from '../utils/show-toast';
import states from '../utils/states';
import store from '../utils/store';
@ -508,21 +509,38 @@ function Settings({ onClose }) {
</p>
{__BUILD_TIME__ && (
<p>
<span class="insignificant">Last build:</span>{' '}
<RelativeTime datetime={new Date(__BUILD_TIME__)} />{' '}
{__COMMIT_HASH__ && (
<>
(
<a
href={`https://github.com/cheeaun/phanpy/commit/${__COMMIT_HASH__}`}
target="_blank"
rel="noopener noreferrer"
>
<code>{__COMMIT_HASH__}</code>
</a>
)
</>
)}
Version:{' '}
<input
type="text"
class="version-string"
readOnly
size="18" // Manually calculated here
value={`${__BUILD_TIME__.slice(0, 10).replace(/-/g, '.')}${
__COMMIT_HASH__ ? `.${__COMMIT_HASH__}` : ''
}`}
onClick={(e) => {
e.target.select();
// Copy to clipboard
try {
navigator.clipboard.writeText(e.target.value);
showToast('Version string copied');
} catch (e) {
console.warn(e);
showToast('Unable to copy version string');
}
}}
/>{' '}
<span class="ib insignificant">
(
<a
href={`https://github.com/cheeaun/phanpy/commit/${__COMMIT_HASH__}`}
target="_blank"
rel="noopener noreferrer"
>
<RelativeTime datetime={new Date(__BUILD_TIME__)} />
</a>
)
</span>
</p>
)}
</section>
@ -709,7 +727,7 @@ function PushNotificationsSection({ onClose }) {
},
{
value: 'favourite',
label: 'Favourites',
label: 'Likes',
},
{
value: 'reblog',

View file

@ -31,7 +31,7 @@
.ancestors-indicator {
font-size: 70% !important;
& > .avatar:not(:first-child) {
& > .avatar ~ .avatar {
margin-left: -4px;
}
}

View file

@ -168,7 +168,10 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
const [searchParams, setSearchParams] = useSearchParams();
const mediaParam = searchParams.get('media');
const showMedia = parseInt(mediaParam, 10) > 0;
const [viewMode, setViewMode] = useState(searchParams.get('view'));
const firstLoad = useRef(!states.prevLocation && history.length === 1);
const [viewMode, setViewMode] = useState(
searchParams.get('view') || firstLoad.current ? 'full' : null,
);
const translate = !!parseInt(searchParams.get('translate'));
const { masto, instance } = api({ instance: propInstance });
const {
@ -534,6 +537,10 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
// If media is open, esc to close media first
// Else close the status page
enabled: !showMedia,
ignoreEventWhen: (e) => {
const hasModal = !!document.querySelector('#modal-container > *');
return hasModal;
},
},
);
// For backspace, will always close both media and status page
@ -839,9 +846,11 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
ref={scrollableRef}
class={`status-deck deck contained ${
statuses.length > 1 ? 'padded-bottom' : ''
} ${initialPageState.current === 'status' ? 'slide-in' : ''} ${
viewMode ? `deck-view-${viewMode}` : ''
}`}
} ${
initialPageState.current === 'status' && !firstLoad.current
? 'slide-in'
: ''
} ${viewMode ? `deck-view-${viewMode}` : ''}`}
onAnimationEnd={(e) => {
// Fix the bounce effect when switching viewMode
// `slide-in` animation kicks in when switching viewMode
@ -959,6 +968,23 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
)}
</h1>
<div class="header-side">
<button
type="button"
class="plain4"
style={{
display: viewMode === 'full' ? '' : 'none',
}}
onClick={() => {
setViewMode(null);
searchParams.delete('media');
searchParams.delete('media-only');
searchParams.delete('view');
setSearchParams(searchParams);
}}
title="Switch to Side Peek view"
>
<Icon icon="layout4" size="l" />
</button>
<Menu
align="end"
portal={{

191
src/pages/trending.css Normal file
View file

@ -0,0 +1,191 @@
.links-bar {
position: relative;
display: flex;
padding: 16px 16px 20px 16px;
gap: 16px;
overflow-x: auto;
background-color: var(--bg-faded-color);
mask-image: linear-gradient(
to right,
transparent,
black 16px,
black calc(100% - 16px),
transparent
);
text-shadow: 0 1px var(--bg-blur-color);
transition: opacity 0.3s ease-out;
&:not(#columns &) {
@media (min-width: 40em) {
width: 95vw;
max-width: calc(320px * 3.3);
transform: translateX(calc(-50% + var(--main-width) / 2));
}
}
& > header {
width: 1.2em;
white-space: nowrap;
position: relative;
flex-shrink: 0;
h3 {
font-size: 90%;
font-style: italic;
margin: 0;
padding: 0;
text-transform: uppercase;
color: var(--text-insignificant-color);
position: absolute;
top: 8px;
left: 0;
transform-origin: top left;
transform: rotate(-90deg) translateX(-100%);
user-select: none;
background-image: linear-gradient(
to left,
var(--text-color),
var(--link-color)
);
background-clip: text;
text-fill-color: transparent;
-webkit-text-fill-color: transparent;
}
}
a {
min-width: 240px;
flex-grow: 1;
max-width: 320px;
text-decoration: none;
color: inherit;
border-radius: 16px;
overflow: hidden;
background-color: var(--accent-alpha-color);
border: 4px solid transparent;
box-shadow: 0 4px 8px -2px var(--drop-shadow-color);
transition: all 0.15s ease-out;
display: flex;
background-image: linear-gradient(
to bottom,
var(--accent-color, var(--link-text-color)) -50%,
transparent
);
background-clip: border-box;
background-origin: border-box;
min-height: 160px;
height: 320px;
max-height: 50vh;
&:not(:active):is(:hover, :focus-visible) {
border-color: var(--accent-color, var(--link-light-color));
box-shadow: 0 4px 8px var(--drop-shadow-color),
0 8px 16px var(--drop-shadow-color);
transform-origin: center bottom;
transform: scale(1.02);
img {
animation: position-object 5s ease-in-out 1s 5;
}
}
&:active {
transition: none;
transform: scale(1.015);
filter: brightness(0.8);
}
article {
display: flex;
flex-direction: column;
justify-content: flex-end;
background-color: var(--bg-color);
background-repeat: no-repeat;
background-image: linear-gradient(
to bottom,
var(--accent-alpha-color) 70%,
var(--bg-color) 100%
);
transition: background-position-y 0.15s ease-out;
&:is(:hover, :focus-visible) {
background-position-y: -40px;
}
figure {
flex-grow: 1;
margin: 0 0 -16px;
padding: 0;
position: relative;
}
img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
vertical-align: top;
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%
);
}
}
.article-body {
padding: 0 8px 8px;
line-height: 1.3;
flex-shrink: 0;
}
.article-meta {
color: var(--text-insignificant-color);
font-size: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover .domain {
color: var(--link-text-color);
}
h1 {
font-weight: normal;
font-size: inherit;
margin: 0;
padding: 0;
text-wrap: balance;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
p {
color: var(--text-insignificant-color);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 90%;
}
}
}

View file

@ -1,4 +1,7 @@
import './trending.css';
import { MenuItem } from '@szhsin/react-menu';
import { getBlurHashAverageColor } from 'fast-blurhash';
import { useMemo, useRef, useState } from 'preact/hooks';
import { useNavigate, useParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
@ -6,15 +9,28 @@ import { useSnapshot } from 'valtio';
import Icon from '../components/icon';
import Link from '../components/link';
import Menu2 from '../components/menu2';
import RelativeTime from '../components/relative-time';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import { oklab2rgb, rgb2oklab } from '../utils/color-utils';
import { filteredItems } from '../utils/filters';
import pmem from '../utils/pmem';
import states from '../utils/states';
import { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
const fetchLinks = pmem(
(masto) => {
return masto.v1.trends.links.list().next();
},
{
// News last much longer
maxAge: 10 * 60 * 1000, // 10 minutes
},
);
function Trending({ columnMode, ...props }) {
const snapStates = useSnapshot(states);
const params = columnMode ? {} : useParams();
@ -27,6 +43,7 @@ function Trending({ columnMode, ...props }) {
const latestItem = useRef();
const [hashtags, setHashtags] = useState([]);
const [links, setLinks] = useState([]);
const trendIterator = useRef();
async function fetchTrend(firstLoad) {
if (firstLoad || !trendIterator.current) {
@ -38,8 +55,24 @@ function Trending({ columnMode, ...props }) {
try {
const iterator = masto.v1.trends.tags.list();
const { value: tags } = await iterator.next();
console.log(tags);
setHashtags(tags);
console.log('tags', tags);
if (tags?.length) {
setHashtags(tags);
}
} catch (e) {
console.error(e);
}
// Get links
try {
const { value } = await fetchLinks(masto);
// 4 types available: link, photo, video, rich
// Only want links for now
const links = value?.filter?.((link) => link.type === 'link');
console.log('links', links);
if (links?.length) {
setLinks(links);
}
} catch (e) {
console.error(e);
}
@ -84,26 +117,124 @@ function Trending({ columnMode, ...props }) {
}
const TimelineStart = useMemo(() => {
if (!hashtags.length) return null;
return (
<div class="filter-bar">
<Icon icon="chart" class="insignificant" size="l" />
{hashtags.map((tag, i) => {
const { name, history } = tag;
const total = history.reduce((acc, cur) => acc + +cur.uses, 0);
return (
<Link to={`/${instance}/t/${name}`}>
<span>
<span class="more-insignificant">#</span>
{name}
</span>
<span class="filter-count">{total.toLocaleString()}</span>
</Link>
);
})}
</div>
<>
{!!hashtags.length && (
<div class="filter-bar">
<Icon icon="chart" class="insignificant" size="l" />
{hashtags.map((tag, i) => {
const { name, history } = tag;
const total = history.reduce((acc, cur) => acc + +cur.uses, 0);
return (
<Link to={`/${instance}/t/${name}`} key={name}>
<span>
<span class="more-insignificant">#</span>
{name}
</span>
<span class="filter-count">{total.toLocaleString()}</span>
</Link>
);
})}
</div>
)}
{!!links.length && (
<div class="links-bar">
<header>
<h3>Trending News</h3>
</header>
{links.map((link) => {
const {
authorName,
authorUrl,
blurhash,
description,
height,
image,
imageDescription,
language,
providerName,
providerUrl,
publishedAt,
title,
url,
width,
} = link;
const domain = new URL(url).hostname
.replace(/^www\./, '')
.replace(/\/$/, '');
let accentColor;
if (blurhash) {
const averageColor = getBlurHashAverageColor(blurhash);
const labAverageColor = rgb2oklab(averageColor);
accentColor = oklab2rgb([
0.6,
labAverageColor[1],
labAverageColor[2],
]);
}
return (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
style={
accentColor
? {
'--accent-color': `rgb(${accentColor.join(',')})`,
'--accent-alpha-color': `rgba(${accentColor.join(
',',
)}, 0.4)`,
}
: {}
}
>
<article>
<figure>
<img
src={image}
alt={imageDescription}
width={width}
height={height}
loading="lazy"
/>
</figure>
<div class="article-body">
<header>
<div class="article-meta">
<span class="domain">{domain}</span>{' '}
{!!publishedAt && <>&middot; </>}
{!!publishedAt && (
<>
<RelativeTime
datetime={publishedAt}
format="micro"
/>
</>
)}
</div>
{!!title && (
<h1 class="title" lang={language} dir="auto">
{title}
</h1>
)}
</header>
{!!description && (
<p class="description" lang={language} dir="auto">
{description}
</p>
)}
</div>
</article>
</a>
);
})}
</div>
)}
</>
);
}, [hashtags]);
}, [hashtags, links]);
return (
<Timeline

52
src/utils/color-utils.js Normal file
View file

@ -0,0 +1,52 @@
// https://gist.github.com/earthbound19/e7fe15fdf8ca3ef814750a61bc75b5ce
function clamp(value, min, max) {
return Math.max(Math.min(value, max), min);
}
const gammaToLinear = (c) =>
c >= 0.04045 ? Math.pow((c + 0.055) / 1.055, 2.4) : c / 12.92;
const linearToGamma = (c) =>
c >= 0.0031308 ? 1.055 * Math.pow(c, 1 / 2.4) - 0.055 : 12.92 * c;
export function rgb2oklab([r, g, b]) {
r = gammaToLinear(r / 255);
g = gammaToLinear(g / 255);
b = gammaToLinear(b / 255);
var l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
var m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
var s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
l = Math.cbrt(l);
m = Math.cbrt(m);
s = Math.cbrt(s);
return [
l * +0.2104542553 + m * +0.793617785 + s * -0.0040720468,
l * +1.9779984951 + m * -2.428592205 + s * +0.4505937099,
l * +0.0259040371 + m * +0.7827717662 + s * -0.808675766,
];
}
export function oklab2rgb([L, a, b]) {
var l = L + a * +0.3963377774 + b * +0.2158037573;
var m = L + a * -0.1055613458 + b * -0.0638541728;
var s = L + a * -0.0894841775 + b * -1.291485548;
// The ** operator here cubes; same as l_*l_*l_ in the C++ example:
l = l ** 3;
m = m ** 3;
s = s ** 3;
var r = l * +4.0767416621 + m * -3.3077115913 + s * +0.2309699292;
var g = l * -1.2684380046 + m * +2.6097574011 + s * -0.3413193965;
var b = l * -0.0041960863 + m * -0.7034186147 + s * +1.707614701;
// Convert linear RGB values returned from oklab math to sRGB for our use before returning them:
r = 255 * linearToGamma(r);
g = 255 * linearToGamma(g);
b = 255 * linearToGamma(b);
// OPTION: clamp r g and b values to the range 0-255; but if you use the values immediately to draw, JavaScript clamps them on use:
r = clamp(r, 0, 255);
g = clamp(g, 0, 255);
b = clamp(b, 0, 255);
// OPTION: round the values. May not be necessary if you use them immediately for rendering in JavaScript, as JavaScript (also) discards decimals on render:
r = Math.round(r);
g = Math.round(g);
b = Math.round(b);
return [r, g, b];
}

View file

@ -6,7 +6,7 @@ function groupNotifications(notifications) {
const cleanNotifications = [];
for (let i = 0, j = 0; i < notifications.length; i++) {
const notification = notifications[i];
const { status, account, type, createdAt } = notification;
const { id, status, account, type, createdAt } = notification;
const date = new Date(createdAt).toLocaleDateString();
let virtualType = type;
if (type === 'favourite' || type === 'reblog') {
@ -23,9 +23,11 @@ function groupNotifications(notifications) {
if (mappedAccount) {
mappedAccount._types.push(type);
mappedAccount._types.sort().reverse();
mappedNotification.id += `-${id}`;
} else {
account._types = [type];
mappedNotification._accounts.push(account);
mappedNotification.id += `-${id}`;
}
} else {
account._types = [type];
@ -47,13 +49,14 @@ function groupNotifications(notifications) {
const cleanNotifications2 = [];
for (let i = 0, j = 0; i < cleanNotifications.length; i++) {
const notification = cleanNotifications[i];
const { account, _accounts, type, createdAt } = notification;
const { id, account, _accounts, type, createdAt } = notification;
const date = new Date(createdAt).toLocaleDateString();
if (type === 'favourite+reblog' && account && _accounts.length === 1) {
const key = `${account?.id}-${type}-${date}`;
const mappedNotification = notificationsMap2[key];
if (mappedNotification) {
mappedNotification._statuses.push(notification.status);
mappedNotification.id += `-${id}`;
} else {
let n = (notificationsMap2[key] = {
...notification,

View file

@ -81,3 +81,42 @@ export function getCurrentInstance() {
return {};
}
}
// Massage these instance configurations to match the Mastodon API
// - Pleroma
function getInstanceConfiguration(instance) {
const {
configuration,
maxMediaAttachments,
maxTootChars,
pleroma,
pollLimits,
} = instance;
const statuses = configuration?.statuses || {};
if (maxMediaAttachments) {
statuses.maxMediaAttachments ??= maxMediaAttachments;
}
if (maxTootChars) {
statuses.maxCharacters ??= maxTootChars;
}
const polls = configuration?.polls || {};
if (pollLimits) {
polls.maxCharactersPerOption ??= pollLimits.maxOptionChars;
polls.maxExpiration ??= pollLimits.maxExpiration;
polls.maxOptions ??= pollLimits.maxOptions;
polls.minExpiration ??= pollLimits.minExpiration;
}
return {
...configuration,
statuses,
polls,
};
}
export function getCurrentInstanceConfiguration() {
const instance = getCurrentInstance();
return getInstanceConfiguration(instance);
}