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 ( <> {mediaAttachments?.length > 1 && (