commit
4277992773
16
package-lock.json
generated
16
package-lock.json
generated
|
@ -18,6 +18,7 @@
|
||||||
"mem": "~9.0.2",
|
"mem": "~9.0.2",
|
||||||
"preact": "~10.11.3",
|
"preact": "~10.11.3",
|
||||||
"preact-router": "~4.1.0",
|
"preact-router": "~4.1.0",
|
||||||
|
"react-hotkeys-hook": "~4.3.2",
|
||||||
"react-intersection-observer": "~9.4.1",
|
"react-intersection-observer": "~9.4.1",
|
||||||
"string-length": "~5.0.1",
|
"string-length": "~5.0.1",
|
||||||
"swiped-events": "~1.1.7",
|
"swiped-events": "~1.1.7",
|
||||||
|
@ -4615,6 +4616,15 @@
|
||||||
"react": "^18.2.0"
|
"react": "^18.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hotkeys-hook": {
|
||||||
|
"version": "4.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.3.2.tgz",
|
||||||
|
"integrity": "sha512-ZA/li3kBDHuRTtJIf7Td41UU87bPtnt9xV4r+PlEzpnFoYRDVspk3B+mlaX75zowyQygMVmoaWnM4B88lkyExQ==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.1",
|
||||||
|
"react-dom": ">=16.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-intersection-observer": {
|
"node_modules/react-intersection-observer": {
|
||||||
"version": "9.4.1",
|
"version": "9.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.4.1.tgz",
|
||||||
|
@ -9091,6 +9101,12 @@
|
||||||
"scheduler": "^0.23.0"
|
"scheduler": "^0.23.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"react-hotkeys-hook": {
|
||||||
|
"version": "4.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.3.2.tgz",
|
||||||
|
"integrity": "sha512-ZA/li3kBDHuRTtJIf7Td41UU87bPtnt9xV4r+PlEzpnFoYRDVspk3B+mlaX75zowyQygMVmoaWnM4B88lkyExQ==",
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
"react-intersection-observer": {
|
"react-intersection-observer": {
|
||||||
"version": "9.4.1",
|
"version": "9.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.4.1.tgz",
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
"mem": "~9.0.2",
|
"mem": "~9.0.2",
|
||||||
"preact": "~10.11.3",
|
"preact": "~10.11.3",
|
||||||
"preact-router": "~4.1.0",
|
"preact-router": "~4.1.0",
|
||||||
|
"react-hotkeys-hook": "~4.3.2",
|
||||||
"react-intersection-observer": "~9.4.1",
|
"react-intersection-observer": "~9.4.1",
|
||||||
"string-length": "~5.0.1",
|
"string-length": "~5.0.1",
|
||||||
"swiped-events": "~1.1.7",
|
"swiped-events": "~1.1.7",
|
||||||
|
|
63
src/app.css
63
src/app.css
|
@ -41,6 +41,7 @@ a.mention span {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
transition: opacity 0.1s ease-in-out;
|
transition: opacity 0.1s ease-in-out;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
.deck-container[hidden] {
|
.deck-container[hidden] {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -119,6 +120,11 @@ a.mention span {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
.timeline.grow {
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding-bottom: calc(env(safe-area-inset-bottom) + 16px);
|
||||||
|
}
|
||||||
.timeline > li {
|
.timeline > li {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
@ -333,6 +339,10 @@ a.mention span {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-insignificant-color);
|
color: var(--text-insignificant-color);
|
||||||
}
|
}
|
||||||
|
.status-error {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
|
|
||||||
.status-link {
|
.status-link {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -341,8 +351,13 @@ a.mention span {
|
||||||
transition: background-color 0.2s ease-out;
|
transition: background-color 0.2s ease-out;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
.status-link:hover {
|
.status-link:is(:hover, :focus) {
|
||||||
background-color: var(--link-bg-hover-color);
|
background-color: var(--link-bg-hover-color);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
.status-link:active {
|
||||||
|
filter: brightness(0.95);
|
||||||
|
transform: translateY(0.5px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-state {
|
.ui-state {
|
||||||
|
@ -393,7 +408,7 @@ a.mention span {
|
||||||
.deck-close {
|
.deck-close {
|
||||||
color: var(--text-insignificant-color) !important;
|
color: var(--text-insignificant-color) !important;
|
||||||
}
|
}
|
||||||
.deck-close:hover {
|
.deck-close:is(:hover, :focus) {
|
||||||
color: var(--text-color) !important;
|
color: var(--text-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -415,7 +430,7 @@ a.mention span {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: translate(-50%, 0);
|
transform: translate(-50%, 150%);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -423,7 +438,7 @@ a.mention span {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
animation: fade-from-top 2s ease-out;
|
animation: fade-from-top 2s ease-out;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, 0);
|
transform: translate(-50%, 150%);
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
|
@ -510,12 +525,12 @@ a.mention span {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.carousel-button,
|
:is(.button, button).carousel-button,
|
||||||
button.carousel-dot {
|
button.carousel-dot {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
button.carousel-button[hidden] {
|
:is(.button, button).carousel-button[hidden] {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
@ -534,8 +549,7 @@ button.carousel-dot {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
backdrop-filter: none !important;
|
backdrop-filter: none !important;
|
||||||
}
|
}
|
||||||
button.carousel-dot:hover,
|
button.carousel-dot:is(:hover, :focus) button.carousel-dot.active,
|
||||||
button.carousel-dot.active,
|
|
||||||
button.carousel-dot[disabled].active {
|
button.carousel-dot[disabled].active {
|
||||||
color: var(--link-color) !important;
|
color: var(--link-color) !important;
|
||||||
}
|
}
|
||||||
|
@ -581,24 +595,27 @@ button.carousel-dot[disabled].active {
|
||||||
right: 16px;
|
right: 16px;
|
||||||
right: max(16px, env(safe-area-inset-right));
|
right: max(16px, env(safe-area-inset-right));
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
box-shadow: 0 0 32px var(--bg-color);
|
background-color: var(--button-bg-blur-color);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
border: 1px solid var(--bg-color);
|
box-shadow: 0 3px 8px -1px var(--bg-faded-blur-color),
|
||||||
opacity: 0.75;
|
0 10px 36px -4px var(--button-bg-blur-color);
|
||||||
|
transition: background-color 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
#compose-button:is(:hover, :focus) {
|
||||||
|
background-color: var(--button-bg-color);
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
#compose-button:active {
|
||||||
|
filter: brightness(0.75);
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
#compose-button .icon {
|
||||||
|
filter: drop-shadow(0 1px 2px var(--button-bg-color));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SHEET */
|
/* SHEET */
|
||||||
|
|
||||||
@keyframes slide-up {
|
|
||||||
0% {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet {
|
.sheet {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -611,7 +628,7 @@ button.carousel-dot[disabled].active {
|
||||||
max-width: calc(40em - 50px - 16px);
|
max-width: calc(40em - 50px - 16px);
|
||||||
border-radius: 16px 16px 0 0;
|
border-radius: 16px 16px 0 0;
|
||||||
box-shadow: 0 -1px 32px var(--divider-color);
|
box-shadow: 0 -1px 32px var(--divider-color);
|
||||||
animation: slide-up 0.2s var(--timing-function);
|
animation: slide-up 0.3s var(--timing-function);
|
||||||
border: 1px solid var(--outline-color);
|
border: 1px solid var(--outline-color);
|
||||||
}
|
}
|
||||||
.sheet header {
|
.sheet header {
|
||||||
|
@ -696,7 +713,7 @@ button.carousel-dot[disabled].active {
|
||||||
color: var(--text-color) !important;
|
color: var(--text-color) !important;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
.menu-container menu button:hover {
|
.menu-container menu button:is(:hover, :focus) {
|
||||||
color: var(--bg-color) !important;
|
color: var(--bg-color) !important;
|
||||||
background-color: var(--link-color);
|
background-color: var(--link-color);
|
||||||
}
|
}
|
||||||
|
|
376
src/app.jsx
376
src/app.jsx
|
@ -2,6 +2,7 @@ import './app.css';
|
||||||
import 'toastify-js/src/toastify.css';
|
import 'toastify-js/src/toastify.css';
|
||||||
|
|
||||||
import { createHashHistory } from 'history';
|
import { createHashHistory } from 'history';
|
||||||
|
import debounce from 'just-debounce-it';
|
||||||
import { login } from 'masto';
|
import { login } from 'masto';
|
||||||
import Router, { route } from 'preact-router';
|
import Router, { route } from 'preact-router';
|
||||||
import { useEffect, useLayoutEffect, useState } from 'preact/hooks';
|
import { useEffect, useLayoutEffect, useState } from 'preact/hooks';
|
||||||
|
@ -28,174 +29,7 @@ const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
|
||||||
|
|
||||||
window.__STATES__ = states;
|
window.__STATES__ = states;
|
||||||
|
|
||||||
async function startStream() {
|
function App() {
|
||||||
const stream = await masto.v1.stream.streamUser();
|
|
||||||
console.log('STREAM START', { stream });
|
|
||||||
stream.on('update', (status) => {
|
|
||||||
console.log('UPDATE', status);
|
|
||||||
|
|
||||||
const inHomeNew = states.homeNew.find((s) => s.id === status.id);
|
|
||||||
const inHome = states.home.find((s) => s.id === status.id);
|
|
||||||
if (!inHomeNew && !inHome) {
|
|
||||||
states.homeNew.unshift({
|
|
||||||
id: status.id,
|
|
||||||
reblog: status.reblog?.id,
|
|
||||||
reply: !!status.inReplyToAccountId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
states.statuses.set(status.id, status);
|
|
||||||
if (status.reblog) {
|
|
||||||
states.statuses.set(status.reblog.id, status.reblog);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
stream.on('status.update', (status) => {
|
|
||||||
console.log('STATUS.UPDATE', status);
|
|
||||||
states.statuses.set(status.id, status);
|
|
||||||
if (status.reblog) {
|
|
||||||
states.statuses.set(status.reblog.id, status.reblog);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
stream.on('delete', (statusID) => {
|
|
||||||
console.log('DELETE', statusID);
|
|
||||||
// states.statuses.delete(statusID);
|
|
||||||
const s = states.statuses.get(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 = states.notifications.find(
|
|
||||||
(n) => n.id === notification.id,
|
|
||||||
);
|
|
||||||
if (!inNotificationsNew && !inNotifications) {
|
|
||||||
states.notificationsNew.unshift(notification);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notification.status && !states.statuses.has(notification.status.id)) {
|
|
||||||
states.statuses.set(notification.status.id, notification.status);
|
|
||||||
if (
|
|
||||||
notification.status.reblog &&
|
|
||||||
!states.statuses.has(notification.status.reblog.id)
|
|
||||||
) {
|
|
||||||
states.statuses.set(
|
|
||||||
notification.status.reblog.id,
|
|
||||||
notification.status.reblog,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.ws.onclose = () => {
|
|
||||||
console.log('STREAM CLOSED!');
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
startStream();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
stream,
|
|
||||||
stopStream: () => {
|
|
||||||
stream.ws.close();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function startVisibility() {
|
|
||||||
const handleVisibilityChange = () => {
|
|
||||||
if (document.visibilityState === 'hidden') {
|
|
||||||
const timestamp = Date.now();
|
|
||||||
store.session.set('lastHidden', timestamp);
|
|
||||||
} else {
|
|
||||||
const timestamp = Date.now();
|
|
||||||
const lastHidden = store.session.get('lastHidden');
|
|
||||||
const diff = timestamp - lastHidden;
|
|
||||||
const diffMins = Math.round(diff / 1000 / 60);
|
|
||||||
if (diffMins > 1) {
|
|
||||||
console.log('visible', { lastHidden, diffMins });
|
|
||||||
setTimeout(() => {
|
|
||||||
// Buffer for WS reconnect
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const fetchHome = masto.v1.timelines.listHome({
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
const fetchNotifications = masto.v1.notifications.list({
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const newStatuses = await fetchHome;
|
|
||||||
if (
|
|
||||||
newStatuses.length &&
|
|
||||||
newStatuses[0].id !== states.home[0].id
|
|
||||||
) {
|
|
||||||
states.homeNew = newStatuses.map((status) => {
|
|
||||||
states.statuses.set(status.id, status);
|
|
||||||
if (status.reblog) {
|
|
||||||
states.statuses.set(status.reblog.id, status.reblog);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: status.id,
|
|
||||||
reblog: status.reblog?.id,
|
|
||||||
reply: !!status.inReplyToAccountId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const newNotifications = await fetchNotifications;
|
|
||||||
if (newNotifications.length) {
|
|
||||||
const notification = newNotifications[0];
|
|
||||||
const inNotificationsNew = states.notificationsNew.find(
|
|
||||||
(n) => n.id === notification.id,
|
|
||||||
);
|
|
||||||
const inNotifications = states.notifications.find(
|
|
||||||
(n) => n.id === notification.id,
|
|
||||||
);
|
|
||||||
if (!inNotificationsNew && !inNotifications) {
|
|
||||||
states.notificationsNew.unshift(notification);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
notification.status &&
|
|
||||||
!states.statuses.has(notification.status.id)
|
|
||||||
) {
|
|
||||||
states.statuses.set(
|
|
||||||
notification.status.id,
|
|
||||||
notification.status,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
notification.status.reblog &&
|
|
||||||
!states.statuses.has(notification.status.reblog.id)
|
|
||||||
) {
|
|
||||||
states.statuses.set(
|
|
||||||
notification.status.reblog.id,
|
|
||||||
notification.status.reblog,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Silently fail
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
return {
|
|
||||||
stop: () => {
|
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function App() {
|
|
||||||
const snapStates = useSnapshot(states);
|
const snapStates = useSnapshot(states);
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
const [uiState, setUIState] = useState('loading');
|
const [uiState, setUIState] = useState('loading');
|
||||||
|
@ -294,6 +128,28 @@ export function App() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [currentDeck, setCurrentDeck] = useState('home');
|
const [currentDeck, setCurrentDeck] = useState('home');
|
||||||
|
const [currentModal, setCurrentModal] = useState(null);
|
||||||
|
const focusDeck = () => {
|
||||||
|
if (currentModal) return;
|
||||||
|
let timer = setTimeout(() => {
|
||||||
|
const page = document.getElementById(`${currentDeck}-page`);
|
||||||
|
console.log('focus', currentDeck, page);
|
||||||
|
if (page) {
|
||||||
|
page.focus();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
};
|
||||||
|
useEffect(focusDeck, [currentDeck, currentModal]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!snapStates.showCompose &&
|
||||||
|
!snapStates.showSettings &&
|
||||||
|
!snapStates.showAccount
|
||||||
|
) {
|
||||||
|
focusDeck();
|
||||||
|
}
|
||||||
|
}, [snapStates.showCompose, snapStates.showSettings, snapStates.showAccount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// HACK: prevent this from running again due to HMR
|
// HACK: prevent this from running again due to HMR
|
||||||
|
@ -306,7 +162,7 @@ export function App() {
|
||||||
|
|
||||||
// Collect instance info
|
// Collect instance info
|
||||||
(async () => {
|
(async () => {
|
||||||
const info = await masto.v2.instance.fetch();
|
const info = await masto.v1.instances.fetch();
|
||||||
console.log(info);
|
console.log(info);
|
||||||
const { uri, domain } = info;
|
const { uri, domain } = info;
|
||||||
const instances = store.local.getJSON('instances') || {};
|
const instances = store.local.getJSON('instances') || {};
|
||||||
|
@ -351,14 +207,20 @@ export function App() {
|
||||||
<Router
|
<Router
|
||||||
history={createHashHistory()}
|
history={createHashHistory()}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
console.log('router onChange', e);
|
||||||
// Special handling for Home and Notifications
|
// Special handling for Home and Notifications
|
||||||
const { url } = e;
|
const { url } = e;
|
||||||
if (/notifications/i.test(url)) {
|
if (/notifications/i.test(url)) {
|
||||||
setCurrentDeck('notifications');
|
setCurrentDeck('notifications');
|
||||||
|
setCurrentModal(null);
|
||||||
} else if (url === '/') {
|
} else if (url === '/') {
|
||||||
setCurrentDeck('home');
|
setCurrentDeck('home');
|
||||||
document.title = `Home / ${CLIENT_NAME}`;
|
document.title = `Home / ${CLIENT_NAME}`;
|
||||||
} else if (url === '/login' || url === '/welcome') {
|
setCurrentModal(null);
|
||||||
|
} else if (/^\/s\//i.test(url)) {
|
||||||
|
setCurrentModal('status');
|
||||||
|
} else {
|
||||||
|
setCurrentModal(null);
|
||||||
setCurrentDeck(null);
|
setCurrentDeck(null);
|
||||||
}
|
}
|
||||||
states.history.push(url);
|
states.history.push(url);
|
||||||
|
@ -433,3 +295,177 @@ export function App() {
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startStream() {
|
||||||
|
const stream = await masto.v1.stream.streamUser();
|
||||||
|
console.log('STREAM START', { stream });
|
||||||
|
const handleNewStatus = debounce((status) => {
|
||||||
|
console.log('UPDATE', status);
|
||||||
|
|
||||||
|
const inHomeNew = states.homeNew.find((s) => s.id === status.id);
|
||||||
|
const inHome = states.home.find((s) => s.id === status.id);
|
||||||
|
if (!inHomeNew && !inHome) {
|
||||||
|
states.homeNew.unshift({
|
||||||
|
id: status.id,
|
||||||
|
reblog: status.reblog?.id,
|
||||||
|
reply: !!status.inReplyToAccountId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
states.statuses.set(status.id, status);
|
||||||
|
if (status.reblog) {
|
||||||
|
states.statuses.set(status.reblog.id, status.reblog);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
stream.on('update', handleNewStatus);
|
||||||
|
stream.on('status.update', (status) => {
|
||||||
|
console.log('STATUS.UPDATE', status);
|
||||||
|
states.statuses.set(status.id, status);
|
||||||
|
if (status.reblog) {
|
||||||
|
states.statuses.set(status.reblog.id, status.reblog);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stream.on('delete', (statusID) => {
|
||||||
|
console.log('DELETE', statusID);
|
||||||
|
// states.statuses.delete(statusID);
|
||||||
|
const s = states.statuses.get(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 = states.notifications.find(
|
||||||
|
(n) => n.id === notification.id,
|
||||||
|
);
|
||||||
|
if (!inNotificationsNew && !inNotifications) {
|
||||||
|
states.notificationsNew.unshift(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notification.status && !states.statuses.has(notification.status.id)) {
|
||||||
|
states.statuses.set(notification.status.id, notification.status);
|
||||||
|
if (
|
||||||
|
notification.status.reblog &&
|
||||||
|
!states.statuses.has(notification.status.reblog.id)
|
||||||
|
) {
|
||||||
|
states.statuses.set(
|
||||||
|
notification.status.reblog.id,
|
||||||
|
notification.status.reblog,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.ws.onclose = () => {
|
||||||
|
console.log('STREAM CLOSED!');
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
startStream();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
stream,
|
||||||
|
stopStream: () => {
|
||||||
|
stream.ws.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function startVisibility() {
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
store.session.set('lastHidden', timestamp);
|
||||||
|
} else {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const lastHidden = store.session.get('lastHidden');
|
||||||
|
const diff = timestamp - lastHidden;
|
||||||
|
const diffMins = Math.round(diff / 1000 / 60);
|
||||||
|
if (diffMins > 1) {
|
||||||
|
console.log('visible', { lastHidden, diffMins });
|
||||||
|
setTimeout(() => {
|
||||||
|
// Buffer for WS reconnect
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const firstStatusID = states.home[0]?.id;
|
||||||
|
const firstNotificationID = states.notifications[0]?.id;
|
||||||
|
const fetchHome = masto.v1.timelines.listHome({
|
||||||
|
limit: 1,
|
||||||
|
...(firstStatusID && { sinceId: firstStatusID }),
|
||||||
|
});
|
||||||
|
const fetchNotifications = masto.v1.notifications.list({
|
||||||
|
limit: 1,
|
||||||
|
...(firstNotificationID && { sinceId: firstNotificationID }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const newStatuses = await fetchHome;
|
||||||
|
if (
|
||||||
|
newStatuses.length &&
|
||||||
|
newStatuses[0].id !== states.home[0].id
|
||||||
|
) {
|
||||||
|
states.homeNew = newStatuses.map((status) => {
|
||||||
|
states.statuses.set(status.id, status);
|
||||||
|
if (status.reblog) {
|
||||||
|
states.statuses.set(status.reblog.id, status.reblog);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: status.id,
|
||||||
|
reblog: status.reblog?.id,
|
||||||
|
reply: !!status.inReplyToAccountId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newNotifications = await fetchNotifications;
|
||||||
|
if (newNotifications.length) {
|
||||||
|
const notification = newNotifications[0];
|
||||||
|
const inNotificationsNew = states.notificationsNew.find(
|
||||||
|
(n) => n.id === notification.id,
|
||||||
|
);
|
||||||
|
const inNotifications = states.notifications.find(
|
||||||
|
(n) => n.id === notification.id,
|
||||||
|
);
|
||||||
|
if (!inNotificationsNew && !inNotifications) {
|
||||||
|
states.notificationsNew.unshift(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
notification.status &&
|
||||||
|
!states.statuses.has(notification.status.id)
|
||||||
|
) {
|
||||||
|
states.statuses.set(
|
||||||
|
notification.status.id,
|
||||||
|
notification.status,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
notification.status.reblog &&
|
||||||
|
!states.statuses.has(notification.status.reblog.id)
|
||||||
|
) {
|
||||||
|
states.statuses.set(
|
||||||
|
notification.status.reblog.id,
|
||||||
|
notification.status.reblog,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silently fail
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
return {
|
||||||
|
stop: () => {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { App };
|
||||||
|
|
|
@ -128,7 +128,7 @@ function Account({ account }) {
|
||||||
<Avatar url={avatar} size="xxxl" />
|
<Avatar url={avatar} size="xxxl" />
|
||||||
<NameText account={info} showAcct external />
|
<NameText account={info} showAcct external />
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main tabIndex="-1">
|
||||||
{bot && (
|
{bot && (
|
||||||
<>
|
<>
|
||||||
<span class="tag">
|
<span class="tag">
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
background-color: var(--bg-faded-color);
|
background-color: var(--bg-faded-color);
|
||||||
box-shadow: 0 0 0 1px var(--bg-blur-color);
|
box-shadow: 0 0 0 1px var(--bg-blur-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar img {
|
.avatar img {
|
||||||
|
|
|
@ -150,9 +150,6 @@
|
||||||
background-color: var(--bg-faded-color);
|
background-color: var(--bg-faded-color);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
#compose-container .toolbar-button:has([disabled]) > * {
|
|
||||||
/* filter: opacity(0.5); */
|
|
||||||
}
|
|
||||||
#compose-container
|
#compose-container
|
||||||
.toolbar-button:not(.show-field)
|
.toolbar-button:not(.show-field)
|
||||||
:is(input[type='checkbox'], select, input[type='file']) {
|
:is(input[type='checkbox'], select, input[type='file']) {
|
||||||
|
@ -175,10 +172,17 @@
|
||||||
right: 0;
|
right: 0;
|
||||||
left: auto !important;
|
left: auto !important;
|
||||||
}
|
}
|
||||||
#compose-container .toolbar-button:not(:disabled):hover {
|
#compose-container
|
||||||
|
.toolbar-button:not(:disabled):is(
|
||||||
|
:hover,
|
||||||
|
:focus,
|
||||||
|
:focus-within,
|
||||||
|
:focus-visible
|
||||||
|
) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
filter: none;
|
filter: none;
|
||||||
border-color: var(--divider-color);
|
border-color: var(--divider-color);
|
||||||
|
outline: 0;
|
||||||
}
|
}
|
||||||
#compose-container .toolbar-button:not(:disabled):active {
|
#compose-container .toolbar-button:not(:disabled):active {
|
||||||
filter: brightness(0.8);
|
filter: brightness(0.8);
|
||||||
|
@ -231,7 +235,7 @@
|
||||||
width: 2.2em;
|
width: 2.2em;
|
||||||
height: 2.2em;
|
height: 2.2em;
|
||||||
}
|
}
|
||||||
#compose-container .text-expander-menu li:hover {
|
#compose-container .text-expander-menu li:is(:hover, :focus) {
|
||||||
color: var(--bg-color);
|
color: var(--bg-color);
|
||||||
background-color: var(--link-color);
|
background-color: var(--link-color);
|
||||||
}
|
}
|
||||||
|
@ -294,7 +298,7 @@
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
color: var(--text-insignificant-color);
|
color: var(--text-insignificant-color);
|
||||||
}
|
}
|
||||||
#compose-container .media-aside .close-button:hover {
|
#compose-container .media-aside .close-button:is(:hover, :focus) {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
#compose-container .media-aside .uploaded {
|
#compose-container .media-aside .uploaded {
|
||||||
|
|
|
@ -131,8 +131,9 @@ function Compose({
|
||||||
};
|
};
|
||||||
const focusTextarea = () => {
|
const focusTextarea = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
console.log('focusing');
|
||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
}, 100);
|
}, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -597,6 +598,13 @@ function Compose({
|
||||||
pointerEvents: uiState === 'loading' ? 'none' : 'auto',
|
pointerEvents: uiState === 'loading' ? 'none' : 'auto',
|
||||||
opacity: uiState === 'loading' ? 0.5 : 1,
|
opacity: uiState === 'loading' ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
formRef.current.dispatchEvent(
|
||||||
|
new Event('submit', { cancelable: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ const ICONS = {
|
||||||
'arrow-left': 'mingcute:arrow-left-line',
|
'arrow-left': 'mingcute:arrow-left-line',
|
||||||
'arrow-right': 'mingcute:arrow-right-line',
|
'arrow-right': 'mingcute:arrow-right-line',
|
||||||
'arrow-up': 'mingcute:arrow-up-line',
|
'arrow-up': 'mingcute:arrow-up-line',
|
||||||
|
'arrow-down': 'mingcute:arrow-down-line',
|
||||||
earth: 'mingcute:earth-line',
|
earth: 'mingcute:earth-line',
|
||||||
lock: 'mingcute:lock-line',
|
lock: 'mingcute:lock-line',
|
||||||
unlock: 'mingcute:unlock-line',
|
unlock: 'mingcute:unlock-line',
|
||||||
|
|
|
@ -1,14 +1,26 @@
|
||||||
import './modal.css';
|
import './modal.css';
|
||||||
|
|
||||||
import { createPortal } from 'preact/compat';
|
import { createPortal } from 'preact/compat';
|
||||||
|
import { useEffect, useRef } from 'preact/hooks';
|
||||||
|
|
||||||
const $modalContainer = document.getElementById('modal-container');
|
const $modalContainer = document.getElementById('modal-container');
|
||||||
|
|
||||||
function Modal({ children, onClick, class: className }) {
|
function Modal({ children, onClick, class: className }) {
|
||||||
if (!children) return null;
|
if (!children) return null;
|
||||||
|
|
||||||
|
const modalRef = useRef();
|
||||||
|
useEffect(() => {
|
||||||
|
let timer = setTimeout(() => {
|
||||||
|
const focusElement = modalRef.current?.querySelector('[tabindex="-1"]');
|
||||||
|
if (focusElement) {
|
||||||
|
focusElement.focus();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const Modal = (
|
const Modal = (
|
||||||
<div className={className} onClick={onClick}>
|
<div ref={modalRef} className={className} onClick={onClick}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,8 +3,8 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
a.name-text:hover b,
|
a.name-text:is(:hover, :focus) b,
|
||||||
a.name-text.short:hover i {
|
a.name-text.short:is(:hover, :focus) i {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-decoration-color: var(--text-insignificant-color);
|
text-decoration-color: var(--text-insignificant-color);
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,6 +81,8 @@
|
||||||
}
|
}
|
||||||
.status.skeleton {
|
.status.skeleton {
|
||||||
color: var(--outline-color);
|
color: var(--outline-color);
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status.skeleton > .avatar {
|
.status.skeleton > .avatar {
|
||||||
|
@ -122,7 +124,7 @@
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.status > .container > .meta a.time:hover {
|
.status > .container > .meta a.time:is(:hover, :focus) {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
.status > .container > .meta .reply-to {
|
.status > .container > .meta .reply-to {
|
||||||
|
@ -206,9 +208,6 @@
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status .content {
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
.timeline-deck .status .content {
|
.timeline-deck .status .content {
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
max-height: 50dvh;
|
max-height: 50dvh;
|
||||||
|
@ -251,6 +250,9 @@
|
||||||
color: var(--link-color);
|
color: var(--link-color);
|
||||||
transform: translateX(-50%) translateY(-2px) scale(1.01);
|
transform: translateX(-50%) translateY(-2px) scale(1.01);
|
||||||
}
|
}
|
||||||
|
.timeline-deck .status .content.truncated ~ .card {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.status .content p {
|
.status .content p {
|
||||||
margin-block: 0.75em;
|
margin-block: 0.75em;
|
||||||
}
|
}
|
||||||
|
@ -291,7 +293,8 @@
|
||||||
}
|
}
|
||||||
.status.large :is(.media-container, .media-container.media-gt2) {
|
.status.large :is(.media-container, .media-container.media-gt2) {
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 80vh;
|
min-height: 160px;
|
||||||
|
max-height: 50vh;
|
||||||
}
|
}
|
||||||
.status .media {
|
.status .media {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
@ -319,7 +322,7 @@
|
||||||
background-color: var(--bg-faded-blur-color);
|
background-color: var(--bg-faded-blur-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status .media:hover {
|
.status .media:is(:hover, :focus) {
|
||||||
border-color: var(--outline-hover-color);
|
border-color: var(--outline-hover-color);
|
||||||
}
|
}
|
||||||
.status .media :is(img, video) {
|
.status .media :is(img, video) {
|
||||||
|
@ -342,7 +345,7 @@
|
||||||
object-position: 50% 50%;
|
object-position: 50% 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.status .media img:hover {
|
.status:not(.large) .media img:hover {
|
||||||
animation: position-object 5s ease-in-out 1s 5;
|
animation: position-object 5s ease-in-out 1s 5;
|
||||||
}
|
}
|
||||||
.status .media video {
|
.status .media video {
|
||||||
|
@ -410,9 +413,14 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
background: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
max-height: 160px;
|
max-height: 160px;
|
||||||
}
|
}
|
||||||
|
.status.large .card.link.large {
|
||||||
|
border-radius: 16px;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
.card .image {
|
.card .image {
|
||||||
width: 35%;
|
width: 35%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
@ -421,7 +429,13 @@
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 1 / 1;
|
||||||
}
|
}
|
||||||
.card:hover .image {
|
.status.large .card.link.large .image {
|
||||||
|
aspect-ratio: 1.91 / 1;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 50vh;
|
||||||
|
border-inline-end: 0;
|
||||||
|
}
|
||||||
|
.card:is(:hover, :focus) .image {
|
||||||
animation: position-object 5s ease-in-out 1s 5;
|
animation: position-object 5s ease-in-out 1s 5;
|
||||||
}
|
}
|
||||||
.card p {
|
.card p {
|
||||||
|
@ -470,8 +484,9 @@ a.card {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: opacity 0.2s ease-in-out;
|
transition: opacity 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
a.card:hover {
|
a.card:is(:hover, :focus) {
|
||||||
border: 1px solid var(--outline-hover-color);
|
border: 1px solid var(--link-color);
|
||||||
|
box-shadow: 0 0 0 2px var(--link-faded-color);
|
||||||
}
|
}
|
||||||
.card.video {
|
.card.video {
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
|
@ -550,10 +565,10 @@ a.card:hover {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.status .extra-meta a:hover {
|
.status .extra-meta a:is(:hover, :focus) {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
.status .extra-meta .edited:hover {
|
.status .extra-meta .edited:is(:hover, :focus) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
@ -589,11 +604,11 @@ a.card:hover {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border: 1.5px solid transparent;
|
border: 1.5px solid transparent;
|
||||||
}
|
}
|
||||||
.status .action > button.plain:hover {
|
.status .action > button.plain:is(:hover, :focus) {
|
||||||
color: var(--link-color);
|
color: var(--link-color);
|
||||||
background-color: var(--button-plain-bg-hover-color);
|
background-color: var(--button-plain-bg-hover-color);
|
||||||
}
|
}
|
||||||
.status .action > button.plain.reblog-button:hover {
|
.status .action > button.plain.reblog-button:is(:hover, :focus) {
|
||||||
color: var(--reblog-color);
|
color: var(--reblog-color);
|
||||||
}
|
}
|
||||||
.status .action > button.plain.reblog-button.checked {
|
.status .action > button.plain.reblog-button.checked {
|
||||||
|
@ -618,7 +633,7 @@ a.card:hover {
|
||||||
.status .action > button.plain.reblog-button.checked .icon {
|
.status .action > button.plain.reblog-button.checked .icon {
|
||||||
animation: reblogged 1s ease-in-out;
|
animation: reblogged 1s ease-in-out;
|
||||||
}
|
}
|
||||||
.status .action > button.plain.favourite-button:hover {
|
.status .action > button.plain.favourite-button:is(:hover, :focus) {
|
||||||
color: var(--favourite-color);
|
color: var(--favourite-color);
|
||||||
}
|
}
|
||||||
.status .action > button.plain.favourite-button.checked {
|
.status .action > button.plain.favourite-button.checked {
|
||||||
|
|
|
@ -9,6 +9,7 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'preact/hooks';
|
} from 'preact/hooks';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { InView } from 'react-intersection-observer';
|
import { InView } from 'react-intersection-observer';
|
||||||
import 'swiped-events';
|
import 'swiped-events';
|
||||||
import useResizeObserver from 'use-resize-observer';
|
import useResizeObserver from 'use-resize-observer';
|
||||||
|
@ -186,8 +187,12 @@ function Status({
|
||||||
});
|
});
|
||||||
const readMoreText = 'Read more →';
|
const readMoreText = 'Read more →';
|
||||||
|
|
||||||
|
const statusRef = useRef(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<article
|
||||||
|
ref={statusRef}
|
||||||
|
tabindex="-1"
|
||||||
class={`status ${
|
class={`status ${
|
||||||
!withinContext && inReplyToAccount ? 'status-reply-to' : ''
|
!withinContext && inReplyToAccount ? 'status-reply-to' : ''
|
||||||
} visibility-${visibility} ${
|
} visibility-${visibility} ${
|
||||||
|
@ -209,6 +214,7 @@ function Status({
|
||||||
{size !== 's' && (
|
{size !== 's' && (
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
|
tabindex="-1"
|
||||||
// target="_blank"
|
// target="_blank"
|
||||||
title={`@${acct}`}
|
title={`@${acct}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
@ -436,7 +442,10 @@ function Status({
|
||||||
{!!card &&
|
{!!card &&
|
||||||
(size === 'l' ||
|
(size === 'l' ||
|
||||||
(size === 'm' && !poll && !mediaAttachments.length)) && (
|
(size === 'm' && !poll && !mediaAttachments.length)) && (
|
||||||
<Card card={card} />
|
<Card
|
||||||
|
card={card}
|
||||||
|
size={!poll && !mediaAttachments.length ? 'l' : 'm'}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{size === 'l' && (
|
{size === 'l' && (
|
||||||
|
@ -649,6 +658,7 @@ function Status({
|
||||||
index={showMediaModal}
|
index={showMediaModal}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowMediaModal(false);
|
setShowMediaModal(false);
|
||||||
|
statusRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@ -658,6 +668,7 @@ function Status({
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.target === e.currentTarget) {
|
if (e.target === e.currentTarget) {
|
||||||
setShowEdited(false);
|
setShowEdited(false);
|
||||||
|
statusRef.current?.focus();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -665,11 +676,12 @@ function Status({
|
||||||
statusID={showEdited}
|
statusID={showEdited}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowEdited(false);
|
setShowEdited(false);
|
||||||
|
statusRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
</div>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -834,7 +846,7 @@ function Media({ media, showOriginal, onClick = () => {} }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Card({ card }) {
|
function Card({ card, size }) {
|
||||||
const {
|
const {
|
||||||
blurhash,
|
blurhash,
|
||||||
title,
|
title,
|
||||||
|
@ -858,6 +870,8 @@ function Card({ card }) {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const hasText = title || providerName || authorName;
|
const hasText = title || providerName || authorName;
|
||||||
|
const isLandscape = width / height >= 1.2;
|
||||||
|
size = size === 'l' && isLandscape ? 'large' : '';
|
||||||
|
|
||||||
if (hasText && image) {
|
if (hasText && image) {
|
||||||
const domain = new URL(url).hostname.replace(/^www\./, '');
|
const domain = new URL(url).hostname.replace(/^www\./, '');
|
||||||
|
@ -866,7 +880,7 @@ function Card({ card }) {
|
||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="nofollow noopener noreferrer"
|
rel="nofollow noopener noreferrer"
|
||||||
class="card link"
|
class={`card link ${size}`}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
class="image"
|
class="image"
|
||||||
|
@ -1160,7 +1174,7 @@ function EditedAtModal({ statusID, onClose = () => {} }) {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main tabIndex="-1">
|
||||||
{editHistory.length > 0 && (
|
{editHistory.length > 0 && (
|
||||||
<ol>
|
<ol>
|
||||||
{editHistory.map((status) => {
|
{editHistory.map((status) => {
|
||||||
|
@ -1285,10 +1299,13 @@ function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useHotkeys('esc', onClose, [onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
ref={carouselRef}
|
ref={carouselRef}
|
||||||
|
tabIndex="-1"
|
||||||
data-swipe-threshold="44"
|
data-swipe-threshold="44"
|
||||||
class="carousel"
|
class="carousel"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
@ -1299,7 +1316,6 @@ function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
{mediaAttachments?.map((media, i) => {
|
{mediaAttachments?.map((media, i) => {
|
||||||
const { blurhash } = media;
|
const { blurhash } = media;
|
||||||
|
@ -1332,13 +1348,26 @@ function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) {
|
||||||
</div>
|
</div>
|
||||||
<div class="carousel-top-controls" hidden={!showControls}>
|
<div class="carousel-top-controls" hidden={!showControls}>
|
||||||
<span />
|
<span />
|
||||||
<button
|
<span>
|
||||||
type="button"
|
<a
|
||||||
class="carousel-button plain2"
|
href={
|
||||||
onClick={() => onClose()}
|
mediaAttachments[currentIndex]?.remoteUrl ||
|
||||||
>
|
mediaAttachments[currentIndex]?.url
|
||||||
<Icon icon="x" />
|
}
|
||||||
</button>
|
target="_blank"
|
||||||
|
class="button carousel-button plain2"
|
||||||
|
title="Open original media in new window"
|
||||||
|
>
|
||||||
|
<Icon icon="popout" alt="Open original media in new window" />
|
||||||
|
</a>{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="carousel-button plain2"
|
||||||
|
onClick={() => onClose()}
|
||||||
|
>
|
||||||
|
<Icon icon="x" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{mediaAttachments?.length > 1 && (
|
{mediaAttachments?.length > 1 && (
|
||||||
<div class="carousel-controls" hidden={!showControls}>
|
<div class="carousel-controls" hidden={!showControls}>
|
||||||
|
|
|
@ -14,8 +14,9 @@
|
||||||
--text-insignificant-color: #1c1e2199;
|
--text-insignificant-color: #1c1e2199;
|
||||||
--link-color: var(--blue-color);
|
--link-color: var(--blue-color);
|
||||||
--link-light-color: #4169e199;
|
--link-light-color: #4169e199;
|
||||||
--link-faded-color: #4169e133;
|
--link-faded-color: #4169e155;
|
||||||
--link-bg-hover-color: #f0f2f599;
|
--link-bg-hover-color: #f0f2f599;
|
||||||
|
--focus-ring-color: var(--link-color);
|
||||||
--button-bg-color: var(--blue-color);
|
--button-bg-color: var(--blue-color);
|
||||||
--button-bg-blur-color: #4169e1aa;
|
--button-bg-blur-color: #4169e1aa;
|
||||||
--button-text-color: white;
|
--button-text-color: white;
|
||||||
|
@ -49,7 +50,7 @@
|
||||||
--text-color: #f0f2f5;
|
--text-color: #f0f2f5;
|
||||||
--text-insignificant-color: #f0f2f599;
|
--text-insignificant-color: #f0f2f599;
|
||||||
--link-light-color: #6494ed99;
|
--link-light-color: #6494ed99;
|
||||||
--link-faded-color: #6494ed55;
|
--link-faded-color: #6494ed88;
|
||||||
--link-bg-hover-color: #34353799;
|
--link-bg-hover-color: #34353799;
|
||||||
--divider-color: rgba(255, 255, 255, 0.1);
|
--divider-color: rgba(255, 255, 255, 0.1);
|
||||||
--bg-blur-color: #24252699;
|
--bg-blur-color: #24252699;
|
||||||
|
@ -80,7 +81,7 @@ a {
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
a:hover {
|
a:is(:hover, :focus) {
|
||||||
text-decoration-color: var(--link-color);
|
text-decoration-color: var(--link-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -127,7 +128,7 @@ button,
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
:is(button, .button):not(:disabled, .disabled):hover {
|
:is(button, .button):not(:disabled, .disabled):is(:hover, :focus) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
filter: brightness(1.2);
|
filter: brightness(1.2);
|
||||||
}
|
}
|
||||||
|
@ -186,14 +187,14 @@ button,
|
||||||
:is(button, .button).swap > *:nth-child(2) {
|
:is(button, .button).swap > *:nth-child(2) {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
:is(button, .button).swap:hover > *:nth-child(2) {
|
:is(button, .button).swap:is(:hover, :focus) > *:nth-child(2) {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
:is(button, .button).swap[data-swap-state='danger']:hover {
|
:is(button, .button).swap[data-swap-state='danger']:is(:hover, :focus) {
|
||||||
color: var(--red-color);
|
color: var(--red-color);
|
||||||
border-color: var(--red-color);
|
border-color: var(--red-color);
|
||||||
}
|
}
|
||||||
:is(button, .button).swap:hover > *:nth-child(1) {
|
:is(button, .button).swap:is(:hover, :focus) > *:nth-child(1) {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -237,20 +238,19 @@ code {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
/* img,
|
|
||||||
video {
|
|
||||||
filter: brightness(0.7);
|
|
||||||
transition: filter 0.3s ease-out;
|
|
||||||
}
|
|
||||||
img:hover,
|
|
||||||
video:hover {
|
|
||||||
filter: brightness(1);
|
|
||||||
} */
|
|
||||||
:is(button, .button).plain2 {
|
:is(button, .button).plain2 {
|
||||||
backdrop-filter: blur(12px) brightness(0.5);
|
backdrop-filter: blur(12px) brightness(0.5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[tabindex='-1'] {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:not([tabindex='-1']):focus-visible {
|
||||||
|
outline: 2px solid var(--focus-ring-color);
|
||||||
|
}
|
||||||
|
|
||||||
/* UTILS */
|
/* UTILS */
|
||||||
|
|
||||||
.ib {
|
.ib {
|
||||||
|
@ -286,3 +286,12 @@ code {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
0% {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Link } from 'preact-router/match';
|
import { Link } from 'preact-router/match';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { InView } from 'react-intersection-observer';
|
import { InView } from 'react-intersection-observer';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
@ -71,8 +72,96 @@ function Home({ hidden }) {
|
||||||
|
|
||||||
const scrollableRef = useRef();
|
const scrollableRef = useRef();
|
||||||
|
|
||||||
|
useHotkeys('j', () => {
|
||||||
|
// focus on next status after active status
|
||||||
|
// Traverses .timeline li .status-link, focus on .status-link
|
||||||
|
const activeStatus = document.activeElement.closest('.status-link');
|
||||||
|
const activeStatusRect = activeStatus?.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
activeStatus &&
|
||||||
|
activeStatusRect.top < scrollableRef.current.clientHeight &&
|
||||||
|
activeStatusRect.bottom > 0
|
||||||
|
) {
|
||||||
|
const nextStatus = activeStatus.parentElement.nextElementSibling;
|
||||||
|
if (nextStatus) {
|
||||||
|
const statusLink = nextStatus.querySelector('.status-link');
|
||||||
|
if (statusLink) {
|
||||||
|
statusLink.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If active status is not in viewport, get the topmost status-link in viewport
|
||||||
|
const statusLinks = document.querySelectorAll(
|
||||||
|
'.timeline li .status-link',
|
||||||
|
);
|
||||||
|
let topmostStatusLink;
|
||||||
|
for (const statusLink of statusLinks) {
|
||||||
|
const statusLinkRect = statusLink.getBoundingClientRect();
|
||||||
|
if (statusLinkRect.top >= 44) {
|
||||||
|
// 44 is the magic number for header height, not real
|
||||||
|
topmostStatusLink = statusLink;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (topmostStatusLink) {
|
||||||
|
topmostStatusLink.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useHotkeys('k', () => {
|
||||||
|
// focus on previous status after active status
|
||||||
|
// Traverses .timeline li .status-link, focus on .status-link
|
||||||
|
const activeStatus = document.activeElement.closest('.status-link');
|
||||||
|
const activeStatusRect = activeStatus?.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
activeStatus &&
|
||||||
|
activeStatusRect.top < scrollableRef.current.clientHeight &&
|
||||||
|
activeStatusRect.bottom > 0
|
||||||
|
) {
|
||||||
|
const prevStatus = activeStatus.parentElement.previousElementSibling;
|
||||||
|
if (prevStatus) {
|
||||||
|
const statusLink = prevStatus.querySelector('.status-link');
|
||||||
|
if (statusLink) {
|
||||||
|
statusLink.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If active status is not in viewport, get the topmost status-link in viewport
|
||||||
|
const statusLinks = document.querySelectorAll(
|
||||||
|
'.timeline li .status-link',
|
||||||
|
);
|
||||||
|
let topmostStatusLink;
|
||||||
|
for (const statusLink of statusLinks) {
|
||||||
|
const statusLinkRect = statusLink.getBoundingClientRect();
|
||||||
|
if (statusLinkRect.top >= 44) {
|
||||||
|
// 44 is the magic number for header height, not real
|
||||||
|
topmostStatusLink = statusLink;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (topmostStatusLink) {
|
||||||
|
topmostStatusLink.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useHotkeys(['enter', 'o'], () => {
|
||||||
|
// open active status
|
||||||
|
const activeStatus = document.activeElement.closest('.status-link');
|
||||||
|
if (activeStatus) {
|
||||||
|
activeStatus.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="deck-container" hidden={hidden} ref={scrollableRef}>
|
<div
|
||||||
|
id="home-page"
|
||||||
|
class="deck-container"
|
||||||
|
hidden={hidden}
|
||||||
|
ref={scrollableRef}
|
||||||
|
tabIndex="-1"
|
||||||
|
>
|
||||||
<div class="timeline-deck deck">
|
<div class="timeline-deck deck">
|
||||||
<header
|
<header
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
@ -159,6 +248,8 @@ function Home({ hidden }) {
|
||||||
onChange={(inView) => {
|
onChange={(inView) => {
|
||||||
if (inView) loadStatuses();
|
if (inView) loadStatuses();
|
||||||
}}
|
}}
|
||||||
|
root={scrollableRef.current}
|
||||||
|
rootMargin="100px 0px"
|
||||||
>
|
>
|
||||||
<Status skeleton />
|
<Status skeleton />
|
||||||
</InView>
|
</InView>
|
||||||
|
@ -185,7 +276,19 @@ function Home({ hidden }) {
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{uiState === 'error' && (
|
{uiState === 'error' && (
|
||||||
<p class="ui-state">Error loading statuses</p>
|
<p class="ui-state">
|
||||||
|
Unable to load statuses
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
loadStatuses(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -50,7 +50,7 @@
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
margin-top: calc(-16px - 1px);
|
margin-top: calc(-16px - 1px);
|
||||||
}
|
}
|
||||||
.notification .status-link:hover {
|
.notification .status-link:is(:hover, :focus) {
|
||||||
background-color: var(--bg-blur-color);
|
background-color: var(--bg-blur-color);
|
||||||
filter: saturate(1);
|
filter: saturate(1);
|
||||||
border-color: var(--outline-hover-color);
|
border-color: var(--outline-hover-color);
|
||||||
|
|
|
@ -195,7 +195,7 @@ function NotificationsList({ notifications, emptyCopy }) {
|
||||||
{cleanNotifications.map((notification, i) => {
|
{cleanNotifications.map((notification, i) => {
|
||||||
const { id, type } = notification;
|
const { id, type } = notification;
|
||||||
return (
|
return (
|
||||||
<li key={id} class={`notification ${type}`}>
|
<li key={id} class={`notification ${type}`} tabIndex="0">
|
||||||
<Notification notification={notification} />
|
<Notification notification={notification} />
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
@ -290,7 +290,12 @@ function Notifications() {
|
||||||
);
|
);
|
||||||
// console.log(groupedNotifications);
|
// console.log(groupedNotifications);
|
||||||
return (
|
return (
|
||||||
<div class="deck-container" ref={scrollableRef}>
|
<div
|
||||||
|
id="notifications-page"
|
||||||
|
class="deck-container"
|
||||||
|
ref={scrollableRef}
|
||||||
|
tabIndex="-1"
|
||||||
|
>
|
||||||
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
|
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
|
||||||
<header
|
<header
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
@ -397,7 +402,14 @@ function Notifications() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{uiState === 'error' && (
|
{uiState === 'error' && (
|
||||||
<p class="ui-state">Error loading notifications</p>
|
<p class="ui-state">
|
||||||
|
Unable to load notifications
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<button type="button" onClick={() => loadNotifications(true)}>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -28,8 +28,7 @@
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
#settings-container ul li .current.is-current + .avatar {
|
#settings-container ul li .current.is-current + .avatar {
|
||||||
border-color: var(--green-color);
|
box-shadow: 0 0 0 1.5px var(--green-color);
|
||||||
border-width: 2px;
|
|
||||||
}
|
}
|
||||||
#settings-container ul li > div {
|
#settings-container ul li > div {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
@ -72,7 +71,7 @@
|
||||||
color: var(--link-color);
|
color: var(--link-color);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
#settings-container .radio-group label:hover {
|
#settings-container .radio-group label:is(:hover, :focus) {
|
||||||
color: var(--button-bg-color);
|
color: var(--button-bg-color);
|
||||||
}
|
}
|
||||||
#settings-container .radio-group label:has(input:checked) {
|
#settings-container .radio-group label:has(input:checked) {
|
||||||
|
|
|
@ -24,7 +24,7 @@ function Settings({ onClose }) {
|
||||||
const [currentDefault, setCurrentDefault] = useState(0);
|
const [currentDefault, setCurrentDefault] = useState(0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="settings-container" class="sheet">
|
<div id="settings-container" class="sheet" tabIndex="-1">
|
||||||
<main>
|
<main>
|
||||||
{/* <button type="button" class="close-button plain large" onClick={onClose}>
|
{/* <button type="button" class="close-button plain large" onClick={onClose}>
|
||||||
<Icon icon="x" alt="Close" />
|
<Icon icon="x" alt="Close" />
|
||||||
|
|
|
@ -24,6 +24,10 @@
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 0.25em;
|
margin-bottom: 0.25em;
|
||||||
}
|
}
|
||||||
|
.hero-heading .icon {
|
||||||
|
vertical-align: middle;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
}
|
||||||
.hero-heading .insignificant {
|
.hero-heading .insignificant {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import './status.css';
|
import './status.css';
|
||||||
|
|
||||||
import debounce from 'just-debounce-it';
|
import debounce from 'just-debounce-it';
|
||||||
|
import { route } from 'preact-router';
|
||||||
import { Link } from 'preact-router/match';
|
import { Link } from 'preact-router/match';
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
|
@ -9,6 +10,7 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'preact/hooks';
|
} from 'preact/hooks';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { InView } from 'react-intersection-observer';
|
import { InView } from 'react-intersection-observer';
|
||||||
import { useSnapshot } from 'valtio';
|
import { useSnapshot } from 'valtio';
|
||||||
|
|
||||||
|
@ -33,6 +35,9 @@ function StatusPage({ id }) {
|
||||||
const heroStatusRef = useRef();
|
const heroStatusRef = useRef();
|
||||||
|
|
||||||
const scrollableRef = useRef();
|
const scrollableRef = useRef();
|
||||||
|
useEffect(() => {
|
||||||
|
scrollableRef.current?.focus();
|
||||||
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = debounce(() => {
|
const onScroll = debounce(() => {
|
||||||
// console.log('onScroll');
|
// console.log('onScroll');
|
||||||
|
@ -81,15 +86,16 @@ function StatusPage({ id }) {
|
||||||
let heroStatus = snapStates.statuses.get(id);
|
let heroStatus = snapStates.statuses.get(id);
|
||||||
if (hasStatus) {
|
if (hasStatus) {
|
||||||
console.log('Hero status is cached');
|
console.log('Hero status is cached');
|
||||||
heroTimer = setTimeout(async () => {
|
// NOTE: This might conflict if the user interacts with the status before the fetch is done, e.g. favouriting it
|
||||||
try {
|
// heroTimer = setTimeout(async () => {
|
||||||
heroStatus = await heroFetch();
|
// try {
|
||||||
states.statuses.set(id, heroStatus);
|
// heroStatus = await heroFetch();
|
||||||
} catch (e) {
|
// states.statuses.set(id, heroStatus);
|
||||||
// Silent fail if status is cached
|
// } catch (e) {
|
||||||
console.error(e);
|
// // Silent fail if status is cached
|
||||||
}
|
// console.error(e);
|
||||||
}, 1000);
|
// }
|
||||||
|
// }, 1000);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
heroStatus = await heroFetch();
|
heroStatus = await heroFetch();
|
||||||
|
@ -97,7 +103,6 @@ function StatusPage({ id }) {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setUIState('error');
|
setUIState('error');
|
||||||
alert('Error fetching status');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -267,11 +272,22 @@ function StatusPage({ id }) {
|
||||||
|
|
||||||
const [heroInView, setHeroInView] = useState(true);
|
const [heroInView, setHeroInView] = useState(true);
|
||||||
const onView = useDebouncedCallback(setHeroInView, 100);
|
const onView = useDebouncedCallback(setHeroInView, 100);
|
||||||
|
const heroPointer = useMemo(() => {
|
||||||
|
// get top offset of heroStatus
|
||||||
|
if (!heroStatusRef.current || heroInView) return null;
|
||||||
|
const { top } = heroStatusRef.current.getBoundingClientRect();
|
||||||
|
return top > 0 ? 'down' : 'up';
|
||||||
|
}, [heroInView]);
|
||||||
|
|
||||||
|
useHotkeys(['esc', 'backspace'], () => {
|
||||||
|
route(closeLink);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="deck-backdrop">
|
<div class="deck-backdrop">
|
||||||
<Link href={closeLink}></Link>
|
<Link href={closeLink}></Link>
|
||||||
<div
|
<div
|
||||||
|
tabIndex="-1"
|
||||||
ref={scrollableRef}
|
ref={scrollableRef}
|
||||||
class={`status-deck deck contained ${
|
class={`status-deck deck contained ${
|
||||||
statuses.length > 1 ? 'padded-bottom' : ''
|
statuses.length > 1 ? 'padded-bottom' : ''
|
||||||
|
@ -297,8 +313,15 @@ function StatusPage({ id }) {
|
||||||
</Link>
|
</Link>
|
||||||
</div> */}
|
</div> */}
|
||||||
<h1>
|
<h1>
|
||||||
{!heroInView && heroStatus ? (
|
{!heroInView && heroStatus && uiState !== 'loading' ? (
|
||||||
<span class="hero-heading">
|
<span class="hero-heading">
|
||||||
|
{!!heroPointer && (
|
||||||
|
<>
|
||||||
|
<Icon
|
||||||
|
icon={heroPointer === 'down' ? 'arrow-down' : 'arrow-up'}
|
||||||
|
/>{' '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<NameText showAvatar account={heroStatus.account} short />{' '}
|
<NameText showAvatar account={heroStatus.account} short />{' '}
|
||||||
<span class="insignificant">
|
<span class="insignificant">
|
||||||
•{' '}
|
•{' '}
|
||||||
|
@ -321,96 +344,141 @@ function StatusPage({ id }) {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<ul
|
{!!statuses.length && heroStatus ? (
|
||||||
class={`timeline flat contextual ${
|
<ul
|
||||||
uiState === 'loading' ? 'loading' : ''
|
class={`timeline flat contextual grow ${
|
||||||
}`}
|
uiState === 'loading' ? 'loading' : ''
|
||||||
>
|
}`}
|
||||||
{statuses.slice(0, limit).map((status) => {
|
>
|
||||||
const {
|
{statuses.slice(0, limit).map((status) => {
|
||||||
id: statusID,
|
const {
|
||||||
ancestor,
|
id: statusID,
|
||||||
descendant,
|
ancestor,
|
||||||
thread,
|
descendant,
|
||||||
replies,
|
thread,
|
||||||
} = status;
|
replies,
|
||||||
const isHero = statusID === id;
|
} = status;
|
||||||
return (
|
const isHero = statusID === id;
|
||||||
<li
|
return (
|
||||||
key={statusID}
|
<li
|
||||||
ref={isHero ? heroStatusRef : null}
|
key={statusID}
|
||||||
class={`${ancestor ? 'ancestor' : ''} ${
|
ref={isHero ? heroStatusRef : null}
|
||||||
descendant ? 'descendant' : ''
|
class={`${ancestor ? 'ancestor' : ''} ${
|
||||||
} ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
|
descendant ? 'descendant' : ''
|
||||||
>
|
} ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
|
||||||
{isHero ? (
|
>
|
||||||
<InView threshold={0.5} onChange={onView}>
|
{isHero ? (
|
||||||
<Status statusID={statusID} withinContext size="l" />
|
<InView threshold={0.1} onChange={onView}>
|
||||||
</InView>
|
<Status statusID={statusID} withinContext size="l" />
|
||||||
) : (
|
</InView>
|
||||||
<Link
|
) : (
|
||||||
class="
|
<Link
|
||||||
|
class="
|
||||||
status-link
|
status-link
|
||||||
"
|
"
|
||||||
href={`#/s/${statusID}`}
|
href={`#/s/${statusID}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
userInitiated.current = true;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Status
|
|
||||||
statusID={statusID}
|
|
||||||
withinContext
|
|
||||||
size={thread || ancestor ? 'm' : 's'}
|
|
||||||
/>
|
|
||||||
{replies?.length > LIMIT && (
|
|
||||||
<div class="replies-link">
|
|
||||||
<Icon icon="comment" />{' '}
|
|
||||||
<span title={replies.length}>
|
|
||||||
{shortenNumber(replies.length)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{descendant &&
|
|
||||||
replies?.length > 0 &&
|
|
||||||
replies?.length <= LIMIT && (
|
|
||||||
<SubComments
|
|
||||||
hasManyStatuses={hasManyStatuses}
|
|
||||||
replies={replies}
|
|
||||||
onStatusLinkClick={() => {
|
|
||||||
userInitiated.current = true;
|
userInitiated.current = true;
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
)}
|
<Status
|
||||||
{uiState === 'loading' &&
|
statusID={statusID}
|
||||||
isHero &&
|
withinContext
|
||||||
!!heroStatus?.repliesCount &&
|
size={thread || ancestor ? 'm' : 's'}
|
||||||
!hasDescendants && (
|
/>
|
||||||
<div class="status-loading">
|
{replies?.length > LIMIT && (
|
||||||
<Loader />
|
<div class="replies-link">
|
||||||
</div>
|
<Icon icon="comment" />{' '}
|
||||||
|
<span title={replies.length}>
|
||||||
|
{shortenNumber(replies.length)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{descendant &&
|
||||||
|
replies?.length > 0 &&
|
||||||
|
replies?.length <= LIMIT && (
|
||||||
|
<SubComments
|
||||||
|
hasManyStatuses={hasManyStatuses}
|
||||||
|
replies={replies}
|
||||||
|
onStatusLinkClick={() => {
|
||||||
|
userInitiated.current = true;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{uiState === 'loading' &&
|
||||||
|
isHero &&
|
||||||
|
!!heroStatus?.repliesCount &&
|
||||||
|
!hasDescendants && (
|
||||||
|
<div class="status-loading">
|
||||||
|
<Loader />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{uiState === 'error' &&
|
||||||
|
isHero &&
|
||||||
|
!!heroStatus?.repliesCount &&
|
||||||
|
!hasDescendants && (
|
||||||
|
<div class="status-error">
|
||||||
|
Unable to load replies.
|
||||||
|
<br />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain"
|
||||||
|
onClick={() => {
|
||||||
|
states.reloadStatusPage++;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{showMore > 0 && (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="plain block"
|
||||||
|
disabled={uiState === 'loading'}
|
||||||
|
onClick={() => setLimit((l) => l + LIMIT)}
|
||||||
|
style={{ marginBlockEnd: '6em' }}
|
||||||
|
>
|
||||||
|
Show more…{' '}
|
||||||
|
<span class="tag">
|
||||||
|
{showMore > LIMIT ? `${LIMIT}+` : showMore}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
)}
|
||||||
})}
|
</ul>
|
||||||
{showMore > 0 && (
|
) : (
|
||||||
<li>
|
<>
|
||||||
<button
|
{uiState === 'loading' && (
|
||||||
type="button"
|
<ul class="timeline flat contextual grow loading">
|
||||||
class="plain block"
|
<li>
|
||||||
disabled={uiState === 'loading'}
|
<Status skeleton size="l" />
|
||||||
onClick={() => setLimit((l) => l + LIMIT)}
|
</li>
|
||||||
style={{ marginBlockEnd: '6em' }}
|
</ul>
|
||||||
>
|
)}
|
||||||
Show more…{' '}
|
{uiState === 'error' && (
|
||||||
<span class="tag">
|
<p class="ui-state">
|
||||||
{showMore > LIMIT ? `${LIMIT}+` : showMore}
|
Unable to load status
|
||||||
</span>
|
<br />
|
||||||
</button>
|
<br />
|
||||||
</li>
|
<button
|
||||||
)}
|
type="button"
|
||||||
</ul>
|
onClick={() => {
|
||||||
|
states.reloadStatusPage++;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -23,7 +23,12 @@
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
/* gradiented text */
|
/* gradiented text */
|
||||||
background: linear-gradient(45deg, var(--purple-color), var(--red-color));
|
background: linear-gradient(
|
||||||
|
45deg,
|
||||||
|
var(--blue-color) 30%,
|
||||||
|
var(--purple-color),
|
||||||
|
var(--red-color) 70%
|
||||||
|
);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue