Merge pull request #39 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2023-01-01 19:06:33 +08:00 committed by GitHub
commit 4277992773
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 696 additions and 356 deletions

16
package-lock.json generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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">
&bull;{' '} &bull;{' '}
@ -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&hellip;{' '}
<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&hellip;{' '} {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>
); );

View file

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