From a0367f4860f9481eb7fc40a1ab56d641c098e7fb Mon Sep 17 00:00:00 2001 From: Lim Chee Aun Date: Fri, 8 Mar 2024 16:25:23 +0800 Subject: [PATCH] Basic j/k/o/enter shortcuts for Notifications page --- src/pages/notifications.css | 1 + src/pages/notifications.jsx | 82 ++++++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/pages/notifications.css b/src/pages/notifications.css index 63a75693..7a9bf706 100644 --- a/src/pages/notifications.css +++ b/src/pages/notifications.css @@ -143,6 +143,7 @@ border-color: var(--reply-to-color); box-shadow: 0 0 0 3px var(--reply-to-faded-color); } +.notification:focus-visible .status-link, .notification .status-link:is(:hover, :focus) { background-color: var(--bg-blur-color); filter: saturate(1); diff --git a/src/pages/notifications.jsx b/src/pages/notifications.jsx index ed3ce2de..aa08aafd 100644 --- a/src/pages/notifications.jsx +++ b/src/pages/notifications.jsx @@ -3,6 +3,7 @@ import './notifications.css'; import { Fragment } from 'preact'; import { memo } from 'preact/compat'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; +import { useHotkeys } from 'react-hotkeys-hook'; import { InView } from 'react-intersection-observer'; import { useSearchParams } from 'react-router-dom'; import { useSnapshot } from 'valtio'; @@ -31,6 +32,12 @@ import useTitle from '../utils/useTitle'; const LIMIT = 30; // 30 is the maximum limit :( const emptySearchParams = new URLSearchParams(); +const scrollIntoViewOptions = { + block: 'center', + inline: 'center', + behavior: 'smooth', +}; + function Notifications({ columnMode }) { useTitle('Notifications', '/notifications'); const { masto, instance } = api(); @@ -273,11 +280,84 @@ function Notifications({ columnMode }) { // } // }, [uiState]); + const itemsSelector = '.notification'; + const jRef = useHotkeys('j', () => { + const activeItem = document.activeElement.closest(itemsSelector); + const activeItemRect = activeItem?.getBoundingClientRect(); + const allItems = Array.from( + scrollableRef.current.querySelectorAll(itemsSelector), + ); + if ( + activeItem && + activeItemRect.top < scrollableRef.current.clientHeight && + activeItemRect.bottom > 0 + ) { + const activeItemIndex = allItems.indexOf(activeItem); + let nextItem = allItems[activeItemIndex + 1]; + if (nextItem) { + nextItem.focus(); + nextItem.scrollIntoView(scrollIntoViewOptions); + } + } else { + const topmostItem = allItems.find((item) => { + const itemRect = item.getBoundingClientRect(); + return itemRect.top >= 44 && itemRect.left >= 0; + }); + if (topmostItem) { + topmostItem.focus(); + topmostItem.scrollIntoView(scrollIntoViewOptions); + } + } + }); + + const kRef = useHotkeys('k', () => { + // focus on previous status after active item + const activeItem = document.activeElement.closest(itemsSelector); + const activeItemRect = activeItem?.getBoundingClientRect(); + const allItems = Array.from( + scrollableRef.current.querySelectorAll(itemsSelector), + ); + if ( + activeItem && + activeItemRect.top < scrollableRef.current.clientHeight && + activeItemRect.bottom > 0 + ) { + const activeItemIndex = allItems.indexOf(activeItem); + let prevItem = allItems[activeItemIndex - 1]; + if (prevItem) { + prevItem.focus(); + prevItem.scrollIntoView(scrollIntoViewOptions); + } + } else { + const topmostItem = allItems.find((item) => { + const itemRect = item.getBoundingClientRect(); + return itemRect.top >= 44 && itemRect.left >= 0; + }); + if (topmostItem) { + topmostItem.focus(); + topmostItem.scrollIntoView(scrollIntoViewOptions); + } + } + }); + + const oRef = useHotkeys(['enter', 'o'], () => { + const activeItem = document.activeElement.closest(itemsSelector); + const statusLink = activeItem?.querySelector('.status-link'); + if (statusLink) { + statusLink.click(); + } + }); + return (
{ + scrollableRef.current = node; + jRef.current = node; + kRef.current = node; + oRef.current = node; + }} tabIndex="-1" >