diff --git a/package-lock.json b/package-lock.json
index 904ec235..6abed8d9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"mem": "~9.0.2",
"preact": "~10.11.3",
"preact-router": "~4.1.0",
+ "react-hotkeys-hook": "~4.3.2",
"react-intersection-observer": "~9.4.1",
"string-length": "~5.0.1",
"swiped-events": "~1.1.7",
@@ -4615,6 +4616,15 @@
"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": {
"version": "9.4.1",
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.4.1.tgz",
@@ -9091,6 +9101,12 @@
"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": {
"version": "9.4.1",
"resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.4.1.tgz",
diff --git a/package.json b/package.json
index 6cbdcb8a..3e9a9912 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"mem": "~9.0.2",
"preact": "~10.11.3",
"preact-router": "~4.1.0",
+ "react-hotkeys-hook": "~4.3.2",
"react-intersection-observer": "~9.4.1",
"string-length": "~5.0.1",
"swiped-events": "~1.1.7",
diff --git a/src/app.css b/src/app.css
index 7013621d..ebb37d1e 100644
--- a/src/app.css
+++ b/src/app.css
@@ -41,6 +41,7 @@ a.mention span {
overflow-x: hidden;
transition: opacity 0.1s ease-in-out;
overscroll-behavior: contain;
+ scroll-behavior: smooth;
}
.deck-container[hidden] {
display: block;
@@ -119,6 +120,11 @@ a.mention span {
margin: 0 auto;
padding: 0;
}
+.timeline.grow {
+ min-height: 100vh;
+ min-height: 100dvh;
+ padding-bottom: calc(env(safe-area-inset-bottom) + 16px);
+}
.timeline > li {
list-style: none;
margin: 0;
@@ -333,6 +339,10 @@ a.mention span {
text-align: center;
color: var(--text-insignificant-color);
}
+.status-error {
+ text-align: center;
+ color: var(--text-insignificant-color);
+}
.status-link {
display: block;
@@ -341,8 +351,13 @@ a.mention span {
transition: background-color 0.2s ease-out;
-webkit-tap-highlight-color: transparent;
}
-.status-link:hover {
+.status-link:is(:hover, :focus) {
background-color: var(--link-bg-hover-color);
+ outline-offset: -2px;
+}
+.status-link:active {
+ filter: brightness(0.95);
+ transform: translateY(0.5px);
}
.ui-state {
@@ -393,7 +408,7 @@ a.mention span {
.deck-close {
color: var(--text-insignificant-color) !important;
}
-.deck-close:hover {
+.deck-close:is(:hover, :focus) {
color: var(--text-color) !important;
}
@@ -415,7 +430,7 @@ a.mention span {
opacity: 0;
}
100% {
- transform: translate(-50%, 0);
+ transform: translate(-50%, 150%);
opacity: 1;
}
}
@@ -423,7 +438,7 @@ a.mention span {
position: absolute;
animation: fade-from-top 2s ease-out;
left: 50%;
- transform: translate(-50%, 0);
+ transform: translate(-50%, 150%);
font-size: 90%;
background: linear-gradient(
to bottom,
@@ -510,12 +525,12 @@ a.mention span {
opacity: 0;
}
-button.carousel-button,
+:is(.button, button).carousel-button,
button.carousel-dot {
pointer-events: auto;
font-weight: bold;
}
-button.carousel-button[hidden] {
+:is(.button, button).carousel-button[hidden] {
display: inline-block;
opacity: 0;
pointer-events: none;
@@ -534,8 +549,7 @@ button.carousel-dot {
font-weight: bold;
backdrop-filter: none !important;
}
-button.carousel-dot:hover,
-button.carousel-dot.active,
+button.carousel-dot:is(:hover, :focus) button.carousel-dot.active,
button.carousel-dot[disabled].active {
color: var(--link-color) !important;
}
@@ -581,24 +595,27 @@ button.carousel-dot[disabled].active {
right: 16px;
right: max(16px, env(safe-area-inset-right));
padding: 16px;
- box-shadow: 0 0 32px var(--bg-color);
+ background-color: var(--button-bg-blur-color);
+ backdrop-filter: blur(16px);
z-index: 1;
- border: 1px solid var(--bg-color);
- opacity: 0.75;
+ box-shadow: 0 3px 8px -1px var(--bg-faded-blur-color),
+ 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 */
-@keyframes slide-up {
- 0% {
- transform: translateY(100%);
- opacity: 0;
- }
- 100% {
- transform: translateY(0);
- opacity: 1;
- }
-}
.sheet {
align-self: flex-end;
display: flex;
@@ -611,7 +628,7 @@ button.carousel-dot[disabled].active {
max-width: calc(40em - 50px - 16px);
border-radius: 16px 16px 0 0;
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);
}
.sheet header {
@@ -696,7 +713,7 @@ button.carousel-dot[disabled].active {
color: var(--text-color) !important;
border-radius: 0;
}
-.menu-container menu button:hover {
+.menu-container menu button:is(:hover, :focus) {
color: var(--bg-color) !important;
background-color: var(--link-color);
}
diff --git a/src/app.jsx b/src/app.jsx
index adf5578f..c8cccc91 100644
--- a/src/app.jsx
+++ b/src/app.jsx
@@ -2,6 +2,7 @@ import './app.css';
import 'toastify-js/src/toastify.css';
import { createHashHistory } from 'history';
+import debounce from 'just-debounce-it';
import { login } from 'masto';
import Router, { route } from 'preact-router';
import { useEffect, useLayoutEffect, useState } from 'preact/hooks';
@@ -28,174 +29,7 @@ const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
window.__STATES__ = states;
-async function startStream() {
- 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() {
+function App() {
const snapStates = useSnapshot(states);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [uiState, setUIState] = useState('loading');
@@ -294,6 +128,28 @@ export function App() {
}, []);
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(() => {
// HACK: prevent this from running again due to HMR
@@ -306,7 +162,7 @@ export function App() {
// Collect instance info
(async () => {
- const info = await masto.v2.instance.fetch();
+ const info = await masto.v1.instances.fetch();
console.log(info);
const { uri, domain } = info;
const instances = store.local.getJSON('instances') || {};
@@ -351,14 +207,20 @@ export function App() {
{
+ console.log('router onChange', e);
// Special handling for Home and Notifications
const { url } = e;
if (/notifications/i.test(url)) {
setCurrentDeck('notifications');
+ setCurrentModal(null);
} else if (url === '/') {
setCurrentDeck('home');
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);
}
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 };
diff --git a/src/components/account.jsx b/src/components/account.jsx
index 123d2b6e..831e93c2 100644
--- a/src/components/account.jsx
+++ b/src/components/account.jsx
@@ -128,7 +128,7 @@ function Account({ account }) {
-
+
{bot && (
<>
diff --git a/src/components/avatar.css b/src/components/avatar.css
index 72859e51..411407a6 100644
--- a/src/components/avatar.css
+++ b/src/components/avatar.css
@@ -7,6 +7,7 @@
background-color: var(--bg-faded-color);
box-shadow: 0 0 0 1px var(--bg-blur-color);
flex-shrink: 0;
+ vertical-align: middle;
}
.avatar img {
diff --git a/src/components/compose.css b/src/components/compose.css
index ac20ea4b..28930557 100644
--- a/src/components/compose.css
+++ b/src/components/compose.css
@@ -150,9 +150,6 @@
background-color: var(--bg-faded-color);
opacity: 0.5;
}
-#compose-container .toolbar-button:has([disabled]) > * {
- /* filter: opacity(0.5); */
-}
#compose-container
.toolbar-button:not(.show-field)
:is(input[type='checkbox'], select, input[type='file']) {
@@ -175,10 +172,17 @@
right: 0;
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;
filter: none;
border-color: var(--divider-color);
+ outline: 0;
}
#compose-container .toolbar-button:not(:disabled):active {
filter: brightness(0.8);
@@ -231,7 +235,7 @@
width: 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);
background-color: var(--link-color);
}
@@ -294,7 +298,7 @@
align-self: flex-start;
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);
}
#compose-container .media-aside .uploaded {
diff --git a/src/components/compose.jsx b/src/components/compose.jsx
index 67b679d4..e2c408ba 100644
--- a/src/components/compose.jsx
+++ b/src/components/compose.jsx
@@ -131,8 +131,9 @@ function Compose({
};
const focusTextarea = () => {
setTimeout(() => {
+ console.log('focusing');
textareaRef.current?.focus();
- }, 100);
+ }, 300);
};
useEffect(() => {
@@ -597,6 +598,13 @@ function Compose({
pointerEvents: uiState === 'loading' ? 'none' : 'auto',
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) => {
e.preventDefault();
diff --git a/src/components/icon.jsx b/src/components/icon.jsx
index 5301fe18..d9ab5008 100644
--- a/src/components/icon.jsx
+++ b/src/components/icon.jsx
@@ -18,6 +18,7 @@ const ICONS = {
'arrow-left': 'mingcute:arrow-left-line',
'arrow-right': 'mingcute:arrow-right-line',
'arrow-up': 'mingcute:arrow-up-line',
+ 'arrow-down': 'mingcute:arrow-down-line',
earth: 'mingcute:earth-line',
lock: 'mingcute:lock-line',
unlock: 'mingcute:unlock-line',
diff --git a/src/components/modal.jsx b/src/components/modal.jsx
index 327cd404..a70aad57 100644
--- a/src/components/modal.jsx
+++ b/src/components/modal.jsx
@@ -1,14 +1,26 @@
import './modal.css';
import { createPortal } from 'preact/compat';
+import { useEffect, useRef } from 'preact/hooks';
const $modalContainer = document.getElementById('modal-container');
function Modal({ children, onClick, class: className }) {
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 = (
-
+
{children}
);
diff --git a/src/components/name-text.css b/src/components/name-text.css
index e5d412ef..1db35970 100644
--- a/src/components/name-text.css
+++ b/src/components/name-text.css
@@ -3,8 +3,8 @@
text-decoration: none;
display: inline-block;
}
-a.name-text:hover b,
-a.name-text.short:hover i {
+a.name-text:is(:hover, :focus) b,
+a.name-text.short:is(:hover, :focus) i {
text-decoration: underline;
text-decoration-color: var(--text-insignificant-color);
}
diff --git a/src/components/status.css b/src/components/status.css
index 0989ddcb..ae65237a 100644
--- a/src/components/status.css
+++ b/src/components/status.css
@@ -81,6 +81,8 @@
}
.status.skeleton {
color: var(--outline-color);
+ user-select: none;
+ pointer-events: none;
}
.status.skeleton > .avatar {
@@ -122,7 +124,7 @@
margin-left: 4px;
white-space: nowrap;
}
-.status > .container > .meta a.time:hover {
+.status > .container > .meta a.time:is(:hover, :focus) {
text-decoration: underline;
}
.status > .container > .meta .reply-to {
@@ -206,9 +208,6 @@
opacity: 1;
}
-.status .content {
- margin-top: 2px;
-}
.timeline-deck .status .content {
max-height: 50vh;
max-height: 50dvh;
@@ -251,6 +250,9 @@
color: var(--link-color);
transform: translateX(-50%) translateY(-2px) scale(1.01);
}
+.timeline-deck .status .content.truncated ~ .card {
+ display: none;
+}
.status .content p {
margin-block: 0.75em;
}
@@ -291,7 +293,8 @@
}
.status.large :is(.media-container, .media-container.media-gt2) {
height: auto;
- max-height: 80vh;
+ min-height: 160px;
+ max-height: 50vh;
}
.status .media {
border-radius: 8px;
@@ -319,7 +322,7 @@
background-color: var(--bg-faded-blur-color);
}
-.status .media:hover {
+.status .media:is(:hover, :focus) {
border-color: var(--outline-hover-color);
}
.status .media :is(img, video) {
@@ -342,7 +345,7 @@
object-position: 50% 50%;
}
}
-.status .media img:hover {
+.status:not(.large) .media img:hover {
animation: position-object 5s ease-in-out 1s 5;
}
.status .media video {
@@ -410,9 +413,14 @@
overflow: hidden;
color: inherit;
align-items: stretch;
- background: var(--bg-color);
+ background-color: var(--bg-color);
max-height: 160px;
}
+.status.large .card.link.large {
+ border-radius: 16px;
+ flex-direction: column;
+ max-height: none;
+}
.card .image {
width: 35%;
height: auto;
@@ -421,7 +429,13 @@
object-fit: cover;
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;
}
.card p {
@@ -470,8 +484,9 @@ a.card {
text-decoration: none;
transition: opacity 0.2s ease-in-out;
}
-a.card:hover {
- border: 1px solid var(--outline-hover-color);
+a.card:is(:hover, :focus) {
+ border: 1px solid var(--link-color);
+ box-shadow: 0 0 0 2px var(--link-faded-color);
}
.card.video {
max-width: 320px;
@@ -550,10 +565,10 @@ a.card:hover {
color: inherit;
text-decoration: none;
}
-.status .extra-meta a:hover {
+.status .extra-meta a:is(:hover, :focus) {
text-decoration: underline;
}
-.status .extra-meta .edited:hover {
+.status .extra-meta .edited:is(:hover, :focus) {
cursor: pointer;
color: var(--text-color);
}
@@ -589,11 +604,11 @@ a.card:hover {
color: inherit;
border: 1.5px solid transparent;
}
-.status .action > button.plain:hover {
+.status .action > button.plain:is(:hover, :focus) {
color: var(--link-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);
}
.status .action > button.plain.reblog-button.checked {
@@ -618,7 +633,7 @@ a.card:hover {
.status .action > button.plain.reblog-button.checked .icon {
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);
}
.status .action > button.plain.favourite-button.checked {
diff --git a/src/components/status.jsx b/src/components/status.jsx
index d9a4043f..5f8df8af 100644
--- a/src/components/status.jsx
+++ b/src/components/status.jsx
@@ -9,6 +9,7 @@ import {
useRef,
useState,
} from 'preact/hooks';
+import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import 'swiped-events';
import useResizeObserver from 'use-resize-observer';
@@ -186,8 +187,12 @@ function Status({
});
const readMoreText = 'Read more →';
+ const statusRef = useRef(null);
+
return (
-
{
@@ -436,7 +442,10 @@ function Status({
{!!card &&
(size === 'l' ||
(size === 'm' && !poll && !mediaAttachments.length)) && (
-
+
)}
{size === 'l' && (
@@ -649,6 +658,7 @@ function Status({
index={showMediaModal}
onClose={() => {
setShowMediaModal(false);
+ statusRef.current?.focus();
}}
/>
@@ -658,6 +668,7 @@ function Status({
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowEdited(false);
+ statusRef.current?.focus();
}
}}
>
@@ -665,11 +676,12 @@ function Status({
statusID={showEdited}
onClose={() => {
setShowEdited(false);
+ statusRef.current?.focus();
}}
/>
)}
-
+
);
}
@@ -834,7 +846,7 @@ function Media({ media, showOriginal, onClick = () => {} }) {
}
}
-function Card({ card }) {
+function Card({ card, size }) {
const {
blurhash,
title,
@@ -858,6 +870,8 @@ function Card({ card }) {
*/
const hasText = title || providerName || authorName;
+ const isLandscape = width / height >= 1.2;
+ size = size === 'l' && isLandscape ? 'large' : '';
if (hasText && image) {
const domain = new URL(url).hostname.replace(/^www\./, '');
@@ -866,7 +880,7 @@ function Card({ card }) {
href={url}
target="_blank"
rel="nofollow noopener noreferrer"
- class="card link"
+ class={`card link ${size}`}
>
{} }) {
)}
-
+
{editHistory.length > 0 && (
{editHistory.map((status) => {
@@ -1285,10 +1299,13 @@ function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) {
};
}, []);
+ useHotkeys('esc', onClose, [onClose]);
+
return (
<>
{
@@ -1299,7 +1316,6 @@ function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) {
onClose();
}
}}
- tabindex="0"
>
{mediaAttachments?.map((media, i) => {
const { blurhash } = media;
@@ -1332,13 +1348,26 @@ function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) {
-
+
+
+
+ {' '}
+
+
{mediaAttachments?.length > 1 && (
diff --git a/src/index.css b/src/index.css
index 8c01e487..e4887fba 100644
--- a/src/index.css
+++ b/src/index.css
@@ -14,8 +14,9 @@
--text-insignificant-color: #1c1e2199;
--link-color: var(--blue-color);
--link-light-color: #4169e199;
- --link-faded-color: #4169e133;
+ --link-faded-color: #4169e155;
--link-bg-hover-color: #f0f2f599;
+ --focus-ring-color: var(--link-color);
--button-bg-color: var(--blue-color);
--button-bg-blur-color: #4169e1aa;
--button-text-color: white;
@@ -49,7 +50,7 @@
--text-color: #f0f2f5;
--text-insignificant-color: #f0f2f599;
--link-light-color: #6494ed99;
- --link-faded-color: #6494ed55;
+ --link-faded-color: #6494ed88;
--link-bg-hover-color: #34353799;
--divider-color: rgba(255, 255, 255, 0.1);
--bg-blur-color: #24252699;
@@ -80,7 +81,7 @@ a {
text-underline-offset: 2px;
overflow-wrap: anywhere;
}
-a:hover {
+a:is(:hover, :focus) {
text-decoration-color: var(--link-color);
}
@@ -127,7 +128,7 @@ button,
vertical-align: middle;
pointer-events: none;
}
-:is(button, .button):not(:disabled, .disabled):hover {
+:is(button, .button):not(:disabled, .disabled):is(:hover, :focus) {
cursor: pointer;
filter: brightness(1.2);
}
@@ -186,14 +187,14 @@ button,
:is(button, .button).swap > *:nth-child(2) {
opacity: 0;
}
-:is(button, .button).swap:hover > *:nth-child(2) {
+:is(button, .button).swap:is(:hover, :focus) > *:nth-child(2) {
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);
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;
}
@@ -237,20 +238,19 @@ code {
}
@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 {
backdrop-filter: blur(12px) brightness(0.5);
}
}
+[tabindex='-1'] {
+ outline: 0;
+}
+
+:not([tabindex='-1']):focus-visible {
+ outline: 2px solid var(--focus-ring-color);
+}
+
/* UTILS */
.ib {
@@ -286,3 +286,12 @@ code {
transform: translateY(0);
}
}
+
+@keyframes slide-up {
+ 0% {
+ transform: translateY(100%);
+ }
+ 100% {
+ transform: translateY(0);
+ }
+}
diff --git a/src/pages/home.jsx b/src/pages/home.jsx
index 57aaec13..f6c2d96d 100644
--- a/src/pages/home.jsx
+++ b/src/pages/home.jsx
@@ -1,5 +1,6 @@
import { Link } from 'preact-router/match';
import { useEffect, useRef, useState } from 'preact/hooks';
+import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { useSnapshot } from 'valtio';
@@ -71,8 +72,96 @@ function Home({ hidden }) {
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 (
-