diff --git a/ui/src/components/entry/entry-actions.tsx b/ui/src/components/entry/entry-actions.tsx index 6369b4b7..aced6941 100644 --- a/ui/src/components/entry/entry-actions.tsx +++ b/ui/src/components/entry/entry-actions.tsx @@ -7,6 +7,7 @@ import { HiSolidBookmark, } from 'solid-icons/hi'; import type { Component } from 'solid-js'; +import { useEntries } from '~/contexts/entries-context'; import { useEntryRead } from '~/hooks/queries/use-entry-read'; import { useEntrySaved } from '~/hooks/queries/use-entry-saved'; import type { Entry } from '~/types/bindings'; @@ -17,6 +18,7 @@ type EntryActionsProps = { }; export const EntryActions: Component = props => { + const entries = useEntries(); const entryRead = useEntryRead(); const entrySaved = useEntrySaved(); @@ -30,6 +32,14 @@ export const EntryActions: Component = props => { action(props.entry.uuid, props.entry.feed_uuid); }; + const handleNavigateBack = () => { + entries.nav.maybeNavigate('back'); + }; + + const handleNavigateNext = () => { + entries.nav.maybeNavigate('next'); + }; + return (
= props => { )} > - - + + +
); }; diff --git a/ui/src/components/entry/entry-list.tsx b/ui/src/components/entry/entry-list.tsx index 2603b15b..9913b598 100644 --- a/ui/src/components/entry/entry-list.tsx +++ b/ui/src/components/entry/entry-list.tsx @@ -1,11 +1,8 @@ -import { createActiveElement } from '@solid-primitives/active-element'; import { type NullableBounds, createElementBounds } from '@solid-primitives/bounds'; import { HiOutlineInbox } from 'solid-icons/hi'; import { type Component, For, Match, Show, Switch, createEffect, createSignal } from 'solid-js'; -import { IDS } from '~/constants/elements'; +import { useEntries } from '~/contexts/entries-context'; import { useFeeds } from '~/hooks/queries/use-feeds'; -import { useInfiniteEntries } from '~/hooks/queries/use-infinite-entries'; -import { useListNav } from '~/hooks/use-list-nav'; import { Empty } from '../ui/empty'; import { Spinner } from '../ui/spinner'; import { EntryItem } from './entry-item'; @@ -18,18 +15,8 @@ export const EntryList: Component = props => { const [bottomOfList, setBottomOfList] = createSignal(); const listBounds = createElementBounds(bottomOfList); - const { feeds } = useFeeds(); - const entries = useInfiniteEntries(); - - // If the user is focused within the entry content, don't respond to arrow keys - const activeElement = createActiveElement(); - const isFocusedWithinEntry = () => !!document.getElementById(IDS.ARTICLE)?.contains(activeElement()); - - // Handle arrow navigation - useListNav(() => ({ - enabled: !isFocusedWithinEntry(), - entries: entries.allEntries(), - })); + const feeds = useFeeds(); + const entries = useEntries(); createEffect(() => { if (!listBounds.bottom || !props.containerBounds?.bottom) return; @@ -37,12 +24,12 @@ export const EntryList: Component = props => { const bottomOfListVisible = listBounds.bottom * 0.9 <= props.containerBounds.bottom; if (!bottomOfListVisible) return; - entries.fetchMore(); + entries.data.fetchMore(); }); return ( - +
@@ -50,13 +37,13 @@ export const EntryList: Component = props => {
- -

Error: {entries.query.error?.message}

+ +

Error: {entries.data.query.error?.message}

- + @@ -64,7 +51,7 @@ export const EntryList: Component = props => { } >
- + {(entry, index) => ( = props => {
- +
diff --git a/ui/src/components/feed/feed-item.tsx b/ui/src/components/feed/feed-item.tsx index c74d68eb..51793609 100644 --- a/ui/src/components/feed/feed-item.tsx +++ b/ui/src/components/feed/feed-item.tsx @@ -19,12 +19,12 @@ export const FeedItem: Component = props => { const state = useQueryState(); const location = useLocation(); - const { stats } = useFeedsStats(); + const stats = useFeedsStats(); const notifications = useNotifications(); const getPath = createMemo(() => `/feeds/${props.feed.uuid}`); const isActive = createMemo(() => location.pathname.startsWith(getPath())); - const getStats = createMemo(() => stats.data?.find(item => item.uuid === props.feed.uuid)); + const getStats = createMemo(() => stats.query.data?.find(item => item.uuid === props.feed.uuid)); const getFaviconSrc = () => props.feed.favicon_b64 || props.feed.favicon_url; diff --git a/ui/src/components/feed/feed-list.tsx b/ui/src/components/feed/feed-list.tsx index 719bbcc8..19d42653 100644 --- a/ui/src/components/feed/feed-list.tsx +++ b/ui/src/components/feed/feed-list.tsx @@ -11,8 +11,8 @@ export const FeedList = () => { const state = useQueryState(); const location = useLocation(); - const { feeds } = useFeeds(); - const { totalStats } = useFeedsStats(); + const feeds = useFeeds(); + const stats = useFeedsStats(); return (
@@ -21,7 +21,7 @@ export const FeedList = () => { title="All feeds" icon={() => } active={location.pathname === '/'} - unread_count={totalStats()?.count_unread} + unread_count={stats.total()?.count_unread} />
@@ -31,13 +31,13 @@ export const FeedList = () => { - -

Error: {feeds.error?.message}

+ +

Error: {feeds.query.error?.message}

- - No feeds.
}> - {feed => } + + No feeds.
}> + {feed => }
diff --git a/ui/src/constants/ui/button.ts b/ui/src/constants/ui/button.ts index 84dc4bec..67c08e1f 100644 --- a/ui/src/constants/ui/button.ts +++ b/ui/src/constants/ui/button.ts @@ -56,6 +56,7 @@ export const action = cva( 'hover:bg-gray-200', 'focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-opacity-30', 'dark:hover:bg-gray-700 dark:text-gray-400 dark:focus:ring-gray-600', + 'disabled:pointer-events-none disabled:opacity-25', ], { variants: { diff --git a/ui/src/contexts/entries-context.ts b/ui/src/contexts/entries-context.ts new file mode 100644 index 00000000..6f825fc7 --- /dev/null +++ b/ui/src/contexts/entries-context.ts @@ -0,0 +1,34 @@ +import { createActiveElement } from '@solid-primitives/active-element'; +import { createContext, useContext } from 'solid-js'; +import { IDS } from '~/constants/elements'; +import { useInfiniteEntries } from '~/hooks/queries/use-infinite-entries'; +import { useListNav } from '~/hooks/use-list-nav'; + +type EntriesContext = ReturnType; +export const EntriesContext = createContext(); + +export const useEntries = () => { + const state = useContext(EntriesContext); + if (!state) throw new Error('EntriesContext has not been initialized.'); + return state; +}; + +export const makeEntriesContext = () => { + const data = useInfiniteEntries(); + + // If the user is focused within the entry content, don't respond to arrow keys + const activeElement = createActiveElement(); + const isFocusedWithinEntry = () => !!document.getElementById(IDS.ARTICLE)?.contains(activeElement()); + + // Handle arrow navigation + const nav = useListNav(() => ({ + enabled: !isFocusedWithinEntry(), + entryUuids: data.allEntries().map(entry => entry.uuid), + fetchMore: data.fetchMore, + })); + + return { + data, + nav, + }; +}; diff --git a/ui/src/hooks/queries/use-feeds-stats.ts b/ui/src/hooks/queries/use-feeds-stats.ts index 6daac394..1762cb4f 100644 --- a/ui/src/hooks/queries/use-feeds-stats.ts +++ b/ui/src/hooks/queries/use-feeds-stats.ts @@ -3,15 +3,15 @@ import { getFeedStats } from '~/api/feeds'; import { QUERY_KEYS } from '~/constants/query'; export const useFeedsStats = () => { - const stats = createQuery(() => ({ + const query = createQuery(() => ({ queryKey: [QUERY_KEYS.FEEDS_STATS], queryFn: getFeedStats, refetchOnWindowFocus: false, refetchOnMount: false, })); - const totalStats = () => - stats.data?.reduce( + const total = () => + query.data?.reduce( (acc, stat) => ({ count_total: acc.count_total + stat.count_total, count_unread: acc.count_unread + stat.count_unread, @@ -21,7 +21,7 @@ export const useFeedsStats = () => { ); return { - stats, - totalStats, + query, + total, }; }; diff --git a/ui/src/hooks/queries/use-feeds.ts b/ui/src/hooks/queries/use-feeds.ts index 844942e0..aab72d0c 100644 --- a/ui/src/hooks/queries/use-feeds.ts +++ b/ui/src/hooks/queries/use-feeds.ts @@ -3,17 +3,17 @@ import { getFeeds } from '~/api/feeds'; import { QUERY_KEYS } from '~/constants/query'; export const useFeeds = () => { - const feeds = createQuery(() => ({ + const query = createQuery(() => ({ queryKey: [QUERY_KEYS.FEEDS], queryFn: getFeeds, refetchOnWindowFocus: false, refetchOnMount: false, })); - const findFeed = (feed_uuid: string) => feeds.data?.find(feed => feed.uuid === feed_uuid); + const findFeed = (feed_uuid: string) => query.data?.find(feed => feed.uuid === feed_uuid); return { - feeds, + query, findFeed, }; }; diff --git a/ui/src/hooks/use-list-nav.ts b/ui/src/hooks/use-list-nav.ts index c44e3670..94c7f7d7 100644 --- a/ui/src/hooks/use-list-nav.ts +++ b/ui/src/hooks/use-list-nav.ts @@ -4,14 +4,14 @@ import { useNavigate } from '@solidjs/router'; import { useQueryClient } from '@tanstack/solid-query'; import { createEffect } from 'solid-js'; import { QUERY_KEYS } from '~/constants/query'; -import type { Entry } from '~/types/bindings'; import { findEntryItem } from '~/utils/entries'; import { useQueryState } from '../contexts/query-state-context'; import { useViewport } from '../contexts/viewport-context'; type UseListNavParams = { enabled: boolean; - entries: Entry[]; + entryUuids: string[]; + fetchMore: () => void; }; export const useListNav = (params: () => UseListNavParams) => { @@ -23,7 +23,7 @@ export const useListNav = (params: () => UseListNavParams) => { const queryClient = useQueryClient(); createEffect(() => { - if (!params().entries.length || viewport.lteBreakpoint('md') || !params().enabled) return; + if (!params().enabled || !params().entryUuids.length || viewport.lteBreakpoint('md')) return; const e = keyDownEvent(); if (!e) return; @@ -41,23 +41,33 @@ export const useListNav = (params: () => UseListNavParams) => { } }); + const getCurrentIndex = () => params().entryUuids.findIndex(uuid => uuid === state.params.entry_uuid); + + const canGoBack = () => getCurrentIndex() > 0; + const canGoForward = () => getCurrentIndex() < params().entryUuids.length; + const maybeNavigate = debounce((direction: 'next' | 'back') => { - const currentIndex = params().entries.findIndex(entry => entry.uuid === state.params.entry_uuid); + const currentIndex = getCurrentIndex(); - const offset = direction === 'next' ? -1 : 1; - const entry = params().entries[currentIndex + offset]; - if (!entry) return; + const offset = direction === 'back' ? -1 : 1; + const entry_uuid = params().entryUuids[currentIndex + offset]; + if (!entry_uuid) return; - const activeItem = findEntryItem(entry.uuid); + const activeItem = findEntryItem(entry_uuid); if (activeItem) activeItem.focus(); // Cancel the current entry view request queryClient.cancelQueries({ queryKey: [QUERY_KEYS.ENTRIES_VIEW, state.params.entry_uuid] }); - navigate(state.getEntryUrl(entry.uuid)); + // Maybe fetch more if we're nearing the end of the list + if (currentIndex > params().entryUuids.length - 3) params().fetchMore(); + + navigate(state.getEntryUrl(entry_uuid)); }, 30); return { + canGoBack, + canGoForward, maybeNavigate, }; }; diff --git a/ui/src/routes/feed.tsx b/ui/src/routes/feed.tsx index bd6bb342..59514478 100644 --- a/ui/src/routes/feed.tsx +++ b/ui/src/routes/feed.tsx @@ -2,6 +2,7 @@ import { CreateFeedModal } from '~/components/modals/create-feed-modal'; import { EntryPanel } from '~/components/panels/entry-panel'; import { ListPanel } from '~/components/panels/list-panel'; import { Sidebar } from '~/components/ui/layout/sidebar'; +import { EntriesContext, makeEntriesContext } from '~/contexts/entries-context'; import { NotificationContext, makeNotificationContext } from '~/contexts/notification-context'; import { QueryStateContext, makeQueryStateContext } from '~/contexts/query-state-context'; import { useShortcuts } from '~/hooks/use-shortcuts'; @@ -17,20 +18,23 @@ export default () => { }; const Inner = () => { + const entries = makeEntriesContext(); const notifications = makeNotificationContext(); useShortcuts(); return ( - - + ); };