diff --git a/public/locales/en.json b/public/locales/en.json
index 8c190209..db5a3f22 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -1522,5 +1522,13 @@
}
},
"title": "Updates"
+ },
+ "history": {
+ "error": {
+ "label": {
+ "no_history_available": "You have not read any series yet."
+ }
+ },
+ "title": "History"
}
-}
\ No newline at end of file
+}
diff --git a/src/App.tsx b/src/App.tsx
index ee656f53..9b8487f3 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -39,6 +39,7 @@ const { CategorySettings } = loadable(
const { SourceConfigure } = loadable(() => import('@/modules/source/screens/SourceConfigure.tsx'), lazyLoadFallback);
const { SourceMangas } = loadable(() => import('@/modules/source/screens/SourceMangas.tsx'), lazyLoadFallback);
const { Updates } = loadable(() => import('@/modules/updates/screens/Updates.tsx'), lazyLoadFallback);
+const { History } = loadable(() => import('@/modules/history/screens/History.tsx'), lazyLoadFallback);
const { LibrarySettings } = loadable(() => import('@/modules/library/screens/LibrarySettings.tsx'), lazyLoadFallback);
const { DownloadSettings } = loadable(
() => import('@/modules/downloads/screens/DownloadSettings.tsx'),
@@ -158,6 +159,7 @@ const MainApp = () => {
} />
} />
+ } />
} />
} />
diff --git a/src/lib/graphql/fragments/ChapterFragments.ts b/src/lib/graphql/fragments/ChapterFragments.ts
index b6886b3c..73351828 100644
--- a/src/lib/graphql/fragments/ChapterFragments.ts
+++ b/src/lib/graphql/fragments/ChapterFragments.ts
@@ -64,6 +64,7 @@ export const CHAPTER_LIST_FIELDS = gql`
fetchedAt
uploadDate
+ lastReadAt
}
`;
@@ -79,3 +80,16 @@ export const CHAPTER_UPDATE_LIST_FIELDS = gql`
}
}
`;
+
+export const CHAPTER_HISTORY_LIST_FIELDS = gql`
+ ${CHAPTER_LIST_FIELDS}
+ ${MANGA_BASE_FIELDS}
+
+ fragment CHAPTER_HISTORY_LIST_FIELDS on ChapterType {
+ ...CHAPTER_LIST_FIELDS
+
+ manga {
+ ...MANGA_BASE_FIELDS
+ }
+ }
+`;
diff --git a/src/lib/graphql/generated/graphql.ts b/src/lib/graphql/generated/graphql.ts
index 545ad400..0b8b82be 100644
--- a/src/lib/graphql/generated/graphql.ts
+++ b/src/lib/graphql/generated/graphql.ts
@@ -2773,6 +2773,8 @@ export type ChapterListFieldsFragment = { __typename?: 'ChapterType', fetchedAt:
export type ChapterUpdateListFieldsFragment = { __typename?: 'ChapterType', fetchedAt: string, uploadDate: string, id: number, name: string, mangaId: number, scanlator?: string | null, realUrl?: string | null, sourceOrder: number, chapterNumber: number, isRead: boolean, isDownloaded: boolean, isBookmarked: boolean, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, thumbnailUrlLastFetched?: string | null, inLibrary: boolean, initialized: boolean, sourceId: string } };
+export type ChapterHistoryListFieldsFragment = { __typename?: 'ChapterType', lastReadAt: string, lastPageRead: number, pageCount: number, uploadDate: string, id: number, name: string, mangaId: number, scanlator?: string | null, realUrl?: string | null, sourceOrder: number, chapterNumber: number, isRead: boolean, isDownloaded: boolean, isBookmarked: boolean, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, thumbnailUrlLastFetched?: string | null, inLibrary: boolean, initialized: boolean, sourceId: string } };
+
export type DownloadTypeFieldsFragment = { __typename?: 'DownloadType', progress: number, state: DownloadState, tries: number, chapter: { __typename?: 'ChapterType', id: number, name: string, sourceOrder: number, isDownloaded: boolean }, manga: { __typename?: 'MangaType', id: number, title: string, downloadCount: number } };
export type DownloadStatusFieldsFragment = { __typename?: 'DownloadStatus', state: DownloaderState, queue: Array<{ __typename?: 'DownloadType', progress: number, state: DownloadState, tries: number, chapter: { __typename?: 'ChapterType', id: number, name: string, sourceOrder: number, isDownloaded: boolean }, manga: { __typename?: 'MangaType', id: number, title: string, downloadCount: number } }> };
@@ -3376,9 +3378,21 @@ export type GetChaptersUpdatesQueryVariables = Exact<{
order?: InputMaybe | ChapterOrderInput>;
}>;
-
export type GetChaptersUpdatesQuery = { __typename?: 'Query', chapters: { __typename?: 'ChapterNodeList', totalCount: number, nodes: Array<{ __typename?: 'ChapterType', fetchedAt: string, uploadDate: string, id: number, name: string, mangaId: number, scanlator?: string | null, realUrl?: string | null, sourceOrder: number, chapterNumber: number, isRead: boolean, isDownloaded: boolean, isBookmarked: boolean, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, thumbnailUrlLastFetched?: string | null, inLibrary: boolean, initialized: boolean, sourceId: string } }>, pageInfo: { __typename?: 'PageInfo', endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null } } };
+export type GetChaptersHistoryQueryVariables = Exact<{
+ after?: InputMaybe;
+ before?: InputMaybe;
+ condition?: InputMaybe;
+ filter?: InputMaybe;
+ first?: InputMaybe;
+ last?: InputMaybe;
+ offset?: InputMaybe;
+ order?: InputMaybe | ChapterOrderInput>;
+}>;
+
+export type GetChaptersHistoryQuery = { __typename?: 'Query', chapters: { __typename?: 'ChapterNodeList', totalCount: number, nodes: Array<{ __typename?: 'ChapterType', lastReadAt: string, lastPageRead: number, pageCount: number, uploadDate: string, id: number, name: string, mangaId: number, scanlator?: string | null, realUrl?: string | null, sourceOrder: number, chapterNumber: number, isRead: boolean, isDownloaded: boolean, isBookmarked: boolean, manga: { __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, thumbnailUrlLastFetched?: string | null, inLibrary: boolean, initialized: boolean, sourceId: string } }>, pageInfo: { __typename?: 'PageInfo', endCursor?: string | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null } } };
+
export type GetMangasChapterIdsWithStateQueryVariables = Exact<{
mangaIds: Array | Scalars['Int']['input'];
isDownloaded?: InputMaybe;
diff --git a/src/lib/graphql/queries/ChapterQuery.ts b/src/lib/graphql/queries/ChapterQuery.ts
index e04da0a4..1c844a79 100644
--- a/src/lib/graphql/queries/ChapterQuery.ts
+++ b/src/lib/graphql/queries/ChapterQuery.ts
@@ -13,6 +13,7 @@ import {
CHAPTER_READER_FIELDS,
CHAPTER_STATE_FIELDS,
CHAPTER_UPDATE_LIST_FIELDS,
+ CHAPTER_HISTORY_LIST_FIELDS,
} from '@/lib/graphql/fragments/ChapterFragments.ts';
// returns the current chapters from the database
@@ -123,6 +124,42 @@ export const GET_CHAPTERS_UPDATES = gql`
}
`;
+// returns the current chapters from the database
+export const GET_CHAPTERS_HISTORY = gql`
+ ${CHAPTER_HISTORY_LIST_FIELDS}
+ ${PAGE_INFO}
+
+ query GET_CHAPTERS_HISTORY(
+ $after: Cursor
+ $before: Cursor
+ $condition: ChapterConditionInput
+ $filter: ChapterFilterInput
+ $first: Int
+ $last: Int
+ $offset: Int
+ $order: [ChapterOrderInput!]
+ ) {
+ chapters(
+ after: $after
+ before: $before
+ condition: $condition
+ filter: $filter
+ first: $first
+ last: $last
+ offset: $offset
+ order: $order
+ ) {
+ nodes {
+ ...CHAPTER_HISTORY_LIST_FIELDS
+ }
+ pageInfo {
+ ...PAGE_INFO
+ }
+ totalCount
+ }
+ }
+`;
+
export const GET_MANGAS_CHAPTER_IDS_WITH_STATE = gql`
${CHAPTER_STATE_FIELDS}
diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts
index d5947a76..0fb81eb7 100644
--- a/src/lib/requests/RequestManager.ts
+++ b/src/lib/requests/RequestManager.ts
@@ -87,6 +87,8 @@ import {
GetChaptersMangaQueryVariables,
GetChaptersUpdatesQuery,
GetChaptersUpdatesQueryVariables,
+ GetChaptersHistoryQuery,
+ GetChaptersHistoryQueryVariables,
GetDownloadStatusQuery,
GetDownloadStatusQueryVariables,
GetExtensionsFetchMutation,
@@ -267,6 +269,7 @@ import {
STOP_DOWNLOADER,
} from '@/lib/graphql/mutations/DownloaderMutation.ts';
import {
+ GET_CHAPTERS_HISTORY,
GET_CHAPTERS_MANGA,
GET_CHAPTERS_UPDATES,
GET_MANGAS_CHAPTER_IDS_WITH_STATE,
@@ -2886,6 +2889,42 @@ export class RequestManager {
} as typeof result;
}
+ public useGetRecentlyReadChapters(
+ initialPages: number = 1,
+ options?: QueryHookOptions,
+ ): AbortableApolloUseQueryResponse {
+ const PAGE_SIZE = 50;
+ const CACHE_KEY = 'useGetRecentlyReadChapters';
+
+ const offset = this.cache.getResponseFor(CACHE_KEY, undefined) ?? 0;
+ const [lastOffset] = useState(offset);
+
+ const result = this.useGetChapters(
+ GET_CHAPTERS_HISTORY,
+ {
+ filter: { lastReadAt: { isNull: false, notEqualToAll: ['0'] } },
+ order: [
+ { by: ChapterOrderBy.LastReadAt, byType: SortOrder.Desc },
+ { by: ChapterOrderBy.SourceOrder, byType: SortOrder.Desc },
+ ],
+ first: initialPages * PAGE_SIZE + lastOffset,
+ },
+ options,
+ );
+
+ return {
+ ...result,
+ fetchMore: (...args: Parameters<(typeof result)['fetchMore']>) => {
+ const fetchMoreOptions = args[0] ?? {};
+ this.cache.cacheResponse(CACHE_KEY, undefined, fetchMoreOptions.variables?.offset);
+ return result.fetchMore({
+ ...fetchMoreOptions,
+ variables: { first: PAGE_SIZE, ...fetchMoreOptions.variables },
+ });
+ },
+ } as typeof result;
+ }
+
public startGlobalUpdate(
categories?: undefined,
options?: MutationOptions,
diff --git a/src/modules/core/AppRoute.constants.ts b/src/modules/core/AppRoute.constants.ts
index 3964b692..5767d8b1 100644
--- a/src/modules/core/AppRoute.constants.ts
+++ b/src/modules/core/AppRoute.constants.ts
@@ -129,6 +129,14 @@ export const AppRoutes = {
match: 'updates',
path: '/updates',
},
+ history: {
+ match: 'history',
+ path: '/history',
+ },
+ recent: {
+ match: 'recent',
+ path: '/recent',
+ },
browse: {
match: 'browse',
path: '/browse',
diff --git a/src/modules/history/components/ChapterHistoryCard.tsx b/src/modules/history/components/ChapterHistoryCard.tsx
new file mode 100644
index 00000000..2f593dee
--- /dev/null
+++ b/src/modules/history/components/ChapterHistoryCard.tsx
@@ -0,0 +1,148 @@
+/*
+ * Copyright (C) Contributors to the Suwayomi project
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import DownloadIcon from '@mui/icons-material/Download';
+import Box from '@mui/material/Box';
+import CardActionArea from '@mui/material/CardActionArea';
+import Avatar from '@mui/material/Avatar';
+import Card from '@mui/material/Card';
+import CardContent from '@mui/material/CardContent';
+import IconButton from '@mui/material/IconButton';
+import { Link } from 'react-router-dom';
+import Refresh from '@mui/icons-material/Refresh';
+import { useTranslation } from 'react-i18next';
+import { memo } from 'react';
+import { CustomTooltip } from '@/modules/core/components/CustomTooltip.tsx';
+import { DownloadStateIndicator } from '@/modules/core/components/DownloadStateIndicator.tsx';
+import { ChapterHistoryListFieldsFragment, DownloadState } from '@/lib/graphql/generated/graphql.ts';
+import { Mangas } from '@/modules/manga/services/Mangas.ts';
+import { SpinnerImage } from '@/modules/core/components/SpinnerImage.tsx';
+import { TypographyMaxLines } from '@/modules/core/components/TypographyMaxLines.tsx';
+import { AppRoutes } from '@/modules/core/AppRoute.constants.ts';
+import { requestManager } from '@/lib/requests/RequestManager.ts';
+import { makeToast } from '@/modules/core/utils/Toast.ts';
+import { getErrorMessage } from '@/lib/HelperFunctions.ts';
+import { Chapters } from '@/modules/chapter/services/Chapters.ts';
+
+export const ChapterHistoryCard = memo(({ chapter }: { chapter: ChapterHistoryListFieldsFragment }) => {
+ const { manga } = chapter;
+ const download = Chapters.useDownloadStatusFromCache(chapter.id);
+
+ const { t } = useTranslation();
+
+ const handleRetry = async () => {
+ try {
+ await requestManager.addChapterToDownloadQueue(chapter.id).response;
+ } catch (e) {
+ makeToast(t('download.queue.error.label.failed_to_remove'), 'error', getErrorMessage(e));
+ }
+ };
+
+ const downloadChapter = () => {
+ requestManager
+ .addChapterToDownloadQueue(chapter.id)
+ .response.catch((e) =>
+ makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)),
+ );
+ };
+
+ return (
+
+ theme.palette.text[chapter.isRead ? 'disabled' : 'primary'],
+ }}
+ >
+
+
+
+
+
+
+
+
+
+ {manga.title}
+
+
+ {chapter.name}
+
+
+
+
+ {download?.state === DownloadState.Error && (
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ handleRetry();
+ }}
+ size="large"
+ >
+
+
+
+ )}
+ {download == null && !chapter.isDownloaded && (
+
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ downloadChapter();
+ }}
+ size="large"
+ >
+
+
+
+ )}
+
+
+
+ );
+});
diff --git a/src/modules/history/screens/History.tsx b/src/modules/history/screens/History.tsx
new file mode 100644
index 00000000..a0dcb6cd
--- /dev/null
+++ b/src/modules/history/screens/History.tsx
@@ -0,0 +1,123 @@
+/*
+ * Copyright (C) Contributors to the Suwayomi project
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+import Typography from '@mui/material/Typography';
+import React, { useCallback, useLayoutEffect, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { requestManager } from '@/lib/requests/RequestManager.ts';
+import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx';
+import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx';
+import { ChapterType } from '@/lib/graphql/generated/graphql.ts';
+import { StyledGroupedVirtuoso } from '@/modules/core/components/virtuoso/StyledGroupedVirtuoso.tsx';
+import { StyledGroupHeader } from '@/modules/core/components/virtuoso/StyledGroupHeader.tsx';
+import { StyledGroupItemWrapper } from '@/modules/core/components/virtuoso/StyledGroupItemWrapper.tsx';
+import { epochToDate, getDateString } from '@/util/DateHelper.ts';
+import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
+import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx';
+import { getErrorMessage } from '@/lib/HelperFunctions.ts';
+import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx';
+import { ChapterHistoryCard } from '@/modules/history/components/ChapterHistoryCard.tsx';
+
+const groupByDate = (histories: Pick[]): [date: string, items: number][] => {
+ if (!histories.length) {
+ return [];
+ }
+
+ const dateToItemMap = new Map();
+ histories.forEach((item) => {
+ const date = getDateString(epochToDate(Number(item.lastReadAt)));
+ dateToItemMap.set(date, (dateToItemMap.get(date) ?? 0) + 1);
+ });
+
+ return [...dateToItemMap.entries()];
+};
+
+export const History: React.FC = () => {
+ const { t } = useTranslation();
+
+ const { setTitle } = useNavBarContext();
+ const {
+ data: chapterHistoryData,
+ loading: isLoading,
+ error,
+ fetchMore,
+ refetch,
+ } = requestManager.useGetRecentlyReadChapters(undefined, {
+ fetchPolicy: 'cache-and-network',
+ notifyOnNetworkStatusChange: true,
+ });
+ const hasNextPage = !!chapterHistoryData?.chapters.pageInfo.hasNextPage;
+ const endCursor = chapterHistoryData?.chapters.pageInfo.endCursor;
+ const readEntries = chapterHistoryData?.chapters.nodes ?? [];
+ const groupedHistory = useMemo(() => groupByDate(readEntries), [readEntries]);
+ const groupCounts: number[] = useMemo(() => groupedHistory.map((group) => group[1]), [groupedHistory]);
+
+ const computeItemKey = VirtuosoUtil.useCreateGroupedComputeItemKey(
+ groupCounts,
+ useCallback((index) => groupedHistory[index][0], [groupedHistory]),
+ useCallback((index) => readEntries[index].id, [readEntries]),
+ );
+
+ useLayoutEffect(() => {
+ setTitle(t('history.title'));
+
+ return () => {
+ setTitle('');
+ };
+ }, [t]);
+
+ const loadMore = useCallback(() => {
+ if (!hasNextPage) {
+ return;
+ }
+
+ fetchMore({ variables: { offset: readEntries.length } });
+ }, [hasNextPage, endCursor]);
+
+ if (error) {
+ return (
+ refetch().catch(defaultPromiseErrorHandler('History::refetch'))}
+ />
+ );
+ }
+
+ if (!isLoading && readEntries.length === 0) {
+ return ;
+ }
+
+ return (
+ (isLoading ? : null),
+ }}
+ overscan={window.innerHeight * 0.5}
+ endReached={loadMore}
+ groupCounts={groupCounts}
+ groupContent={(index) => (
+
+
+ {groupedHistory[index][0]}
+
+
+ )}
+ computeItemKey={computeItemKey}
+ itemContent={(index) => (
+
+
+
+ )}
+ />
+ );
+};
diff --git a/src/modules/navigation-bar/components/DefaultNavBar.tsx b/src/modules/navigation-bar/components/DefaultNavBar.tsx
index ae7dd84c..f1c3d6d1 100644
--- a/src/modules/navigation-bar/components/DefaultNavBar.tsx
+++ b/src/modules/navigation-bar/components/DefaultNavBar.tsx
@@ -15,6 +15,8 @@ import CollectionsBookmarkIcon from '@mui/icons-material/CollectionsBookmark';
import CollectionsOutlinedBookmarkIcon from '@mui/icons-material/CollectionsBookmarkOutlined';
import NewReleasesIcon from '@mui/icons-material/NewReleases';
import NewReleasesOutlinedIcon from '@mui/icons-material/NewReleasesOutlined';
+import HistoryIcon from '@mui/icons-material/History';
+import HistoryOutlinedIcon from '@mui/icons-material/HistoryOutlined';
import ExploreIcon from '@mui/icons-material/Explore';
import ExploreOutlinedIcon from '@mui/icons-material/ExploreOutlined';
import GetAppIcon from '@mui/icons-material/GetApp';
@@ -51,6 +53,13 @@ const navbarItems: Array = [
IconComponent: NewReleasesOutlinedIcon,
show: 'both',
},
+ {
+ path: AppRoutes.history.path,
+ title: 'history.title',
+ SelectedIconComponent: HistoryIcon,
+ IconComponent: HistoryOutlinedIcon,
+ show: 'both',
+ },
{
path: AppRoutes.browse.path,
title: 'global.label.browse',