Show only first unread chapter per manga per day in updates page
This commit is contained in:
@@ -41,6 +41,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
- (**Category**) Require confirmation before deleting a category
|
- (**Category**) Require confirmation before deleting a category
|
||||||
- (**Download**) Respect manga chapter filters on bulk manga download in the library
|
- (**Download**) Respect manga chapter filters on bulk manga download in the library
|
||||||
- (**History**) Show only the last read chapter per manga
|
- (**History**) Show only the last read chapter per manga
|
||||||
|
- (**Updates**) Show only the first unread chapter per manga per day
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import Box from '@mui/material/Box';
|
|||||||
import CardActionArea from '@mui/material/CardActionArea';
|
import CardActionArea from '@mui/material/CardActionArea';
|
||||||
import Card from '@mui/material/Card';
|
import Card from '@mui/material/Card';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { memo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { DownloadStateIndicator } from '@/base/components/downloads/DownloadStateIndicator.tsx';
|
import { DownloadStateIndicator } from '@/base/components/downloads/DownloadStateIndicator.tsx';
|
||||||
import type { ChapterUpdateListFieldsFragment } from '@/lib/graphql/generated/graphql.ts';
|
import type { ChapterUpdateListFieldsFragment } from '@/lib/graphql/generated/graphql.ts';
|
||||||
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
||||||
@@ -20,10 +20,50 @@ import { ChapterDownloadButton } from '@/features/chapter/components/buttons/Cha
|
|||||||
import { ChapterDownloadRetryButton } from '@/features/chapter/components/buttons/ChapterDownloadRetryButton.tsx';
|
import { ChapterDownloadRetryButton } from '@/features/chapter/components/buttons/ChapterDownloadRetryButton.tsx';
|
||||||
import { Chapters } from '@/features/chapter/services/Chapters.ts';
|
import { Chapters } from '@/features/chapter/services/Chapters.ts';
|
||||||
import { ListCardContent } from '@/base/components/lists/cards/ListCardContent.tsx';
|
import { ListCardContent } from '@/base/components/lists/cards/ListCardContent.tsx';
|
||||||
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { plural } from '@lingui/core/macro';
|
||||||
|
|
||||||
export const ChapterUpdateCard = memo(({ chapter }: { chapter: ChapterUpdateListFieldsFragment }) => {
|
export const ChapterUpdateCard = memo(
|
||||||
|
({
|
||||||
|
chapter,
|
||||||
|
otherChapters,
|
||||||
|
}: {
|
||||||
|
chapter: ChapterUpdateListFieldsFragment;
|
||||||
|
otherChapters: ChapterUpdateListFieldsFragment[];
|
||||||
|
}) => {
|
||||||
const { manga } = chapter;
|
const { manga } = chapter;
|
||||||
|
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const uniqueOtherChapters = useMemo(
|
||||||
|
() => Chapters.removeDuplicates(chapter, otherChapters),
|
||||||
|
[chapter, otherChapters],
|
||||||
|
);
|
||||||
|
const otherChaptersCount = uniqueOtherChapters.length;
|
||||||
|
const firstFewOtherChapters = uniqueOtherChapters.slice(-3);
|
||||||
|
|
||||||
|
const otherChaptersText = (() => {
|
||||||
|
if (!otherChaptersCount) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstFewUpdatesString = firstFewOtherChapters
|
||||||
|
.map((otherChapter) => `#${otherChapter.chapterNumber}`)
|
||||||
|
.toReversed()
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
if (otherChaptersCount > firstFewOtherChapters.length) {
|
||||||
|
const remainingUpdatesCount = otherChaptersCount - firstFewOtherChapters.length;
|
||||||
|
|
||||||
|
return t`Plus chapters ${firstFewUpdatesString} and ${remainingUpdatesCount} more`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return plural(firstFewOtherChapters.length, {
|
||||||
|
one: `Plus chapter ${firstFewUpdatesString}`,
|
||||||
|
other: `Plus chapters ${firstFewUpdatesString}`,
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardActionArea
|
<CardActionArea
|
||||||
@@ -43,7 +83,11 @@ export const ChapterUpdateCard = memo(({ chapter }: { chapter: ChapterUpdateList
|
|||||||
thumbnailUrl={manga.thumbnailUrl}
|
thumbnailUrl={manga.thumbnailUrl}
|
||||||
thumbnailUrlLastFetched={manga.thumbnailUrlLastFetched}
|
thumbnailUrlLastFetched={manga.thumbnailUrlLastFetched}
|
||||||
/>
|
/>
|
||||||
<ChapterCardMetadata title={manga.title} secondaryText={chapter.name} />
|
<ChapterCardMetadata
|
||||||
|
title={manga.title}
|
||||||
|
secondaryText={chapter.name}
|
||||||
|
ternaryText={otherChaptersText}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<DownloadStateIndicator chapterId={chapter.id} />
|
<DownloadStateIndicator chapterId={chapter.id} />
|
||||||
<ChapterDownloadRetryButton chapterId={chapter.id} />
|
<ChapterDownloadRetryButton chapterId={chapter.id} />
|
||||||
@@ -52,4 +96,5 @@ export const ChapterUpdateCard = memo(({ chapter }: { chapter: ChapterUpdateList
|
|||||||
</CardActionArea>
|
</CardActionArea>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||||
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
|
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
|
||||||
@@ -16,7 +16,7 @@ import { UpdateChecker } from '@/features/updates/components/UpdateChecker.tsx';
|
|||||||
import { StyledGroupedVirtuoso } from '@/base/components/virtuoso/StyledGroupedVirtuoso.tsx';
|
import { StyledGroupedVirtuoso } from '@/base/components/virtuoso/StyledGroupedVirtuoso.tsx';
|
||||||
import { StyledGroupHeader } from '@/base/components/virtuoso/StyledGroupHeader.tsx';
|
import { StyledGroupHeader } from '@/base/components/virtuoso/StyledGroupHeader.tsx';
|
||||||
import { StyledGroupItemWrapper } from '@/base/components/virtuoso/StyledGroupItemWrapper.tsx';
|
import { StyledGroupItemWrapper } from '@/base/components/virtuoso/StyledGroupItemWrapper.tsx';
|
||||||
import { dateTimeFormatter } from '@/base/utils/DateHelper.ts';
|
import { dateTimeFormatter, epochToDate, getDateString } from '@/base/utils/DateHelper.ts';
|
||||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||||
import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx';
|
import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx';
|
||||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||||
@@ -26,11 +26,16 @@ import { Chapters } from '@/features/chapter/services/Chapters.ts';
|
|||||||
import { useAppTitleAndAction } from '@/features/navigation-bar/hooks/useAppTitleAndAction.ts';
|
import { useAppTitleAndAction } from '@/features/navigation-bar/hooks/useAppTitleAndAction.ts';
|
||||||
import { GROUPED_VIRTUOSO_Z_INDEX } from '@/lib/virtuoso/Virtuoso.constants.ts';
|
import { GROUPED_VIRTUOSO_Z_INDEX } from '@/lib/virtuoso/Virtuoso.constants.ts';
|
||||||
import { STABLE_EMPTY_ARRAY } from '@/base/Base.constants.ts';
|
import { STABLE_EMPTY_ARRAY } from '@/base/Base.constants.ts';
|
||||||
|
import mapValues from 'lodash/fp/mapValues';
|
||||||
|
import difference from 'lodash/fp/difference';
|
||||||
|
import uniqBy from 'lodash/fp/uniqBy';
|
||||||
|
|
||||||
export const Updates: React.FC = () => {
|
export const Updates: React.FC = () => {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { appBarHeight } = useNavBarContext();
|
const { appBarHeight } = useNavBarContext();
|
||||||
|
|
||||||
|
useAppTitleAndAction(t`Updates`, <UpdateChecker />);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: chapterUpdateData,
|
data: chapterUpdateData,
|
||||||
loading: isLoading,
|
loading: isLoading,
|
||||||
@@ -41,21 +46,73 @@ export const Updates: React.FC = () => {
|
|||||||
fetchPolicy: 'cache-and-network',
|
fetchPolicy: 'cache-and-network',
|
||||||
});
|
});
|
||||||
const hasNextPage = !!chapterUpdateData?.chapters.pageInfo.hasNextPage;
|
const hasNextPage = !!chapterUpdateData?.chapters.pageInfo.hasNextPage;
|
||||||
const endCursor = chapterUpdateData?.chapters.pageInfo.endCursor;
|
const allUpdateEntries = chapterUpdateData?.chapters.nodes ?? STABLE_EMPTY_ARRAY;
|
||||||
const updateEntries = chapterUpdateData?.chapters.nodes ?? STABLE_EMPTY_ARRAY;
|
|
||||||
const groupedUpdates = useMemo(
|
const [prevUpdateEntriesCount, setPrevUpdateEntriesCount] = useState(0);
|
||||||
() => Object.entries(Chapters.groupByDate(updateEntries, 'fetchedAt')),
|
|
||||||
[updateEntries],
|
const [firstUnreadUpdatesByGroup, otherUpdatesByMangaByGroup] = useMemo(() => {
|
||||||
);
|
const groupedEntries = Chapters.groupByDate(allUpdateEntries, 'fetchedAt');
|
||||||
const groupCounts: number[] = useMemo(
|
|
||||||
() => groupedUpdates.map((group) => group[VirtuosoUtil.ITEMS].length),
|
const mangaIdByGroup = mapValues(
|
||||||
[groupedUpdates],
|
(groupEntries) => uniqBy('mangaId', groupEntries).map((entry) => entry.mangaId),
|
||||||
|
groupedEntries,
|
||||||
);
|
);
|
||||||
|
|
||||||
const computeItemKey = VirtuosoUtil.useCreateGroupedComputeItemKey(
|
const entriesByMangaByGroup = mapValues(
|
||||||
groupCounts,
|
(entries) => Object.groupBy(entries!, (entry) => entry.mangaId),
|
||||||
useCallback((index) => groupedUpdates[index][VirtuosoUtil.GROUP], [groupedUpdates]),
|
groupedEntries,
|
||||||
useCallback((index) => updateEntries[index].id, [updateEntries]),
|
);
|
||||||
|
|
||||||
|
const firstUnreadEntryByMangaByGroup = mapValues(
|
||||||
|
(entriesByManga) =>
|
||||||
|
mapValues(
|
||||||
|
(mangaEntries) => [mangaEntries!.findLast((entry) => !entry.isRead) ?? mangaEntries![0]],
|
||||||
|
entriesByManga,
|
||||||
|
),
|
||||||
|
entriesByMangaByGroup,
|
||||||
|
);
|
||||||
|
const firstUnreadEntryByGroup = mapValues(
|
||||||
|
(firstUnreadEntryByManga) =>
|
||||||
|
Object.values(firstUnreadEntryByManga)
|
||||||
|
.flat()
|
||||||
|
.toSorted((a, b) => {
|
||||||
|
const groupMangaIds = mangaIdByGroup[getDateString(epochToDate(Number(a.fetchedAt)))];
|
||||||
|
|
||||||
|
return groupMangaIds.indexOf(a.mangaId) - groupMangaIds.indexOf(b.mangaId);
|
||||||
|
}),
|
||||||
|
firstUnreadEntryByMangaByGroup,
|
||||||
|
);
|
||||||
|
const remainingEntriesByMangaByGroup = mapValues(
|
||||||
|
(entriesByManga) =>
|
||||||
|
mapValues(
|
||||||
|
(mangaEntries) =>
|
||||||
|
difference(
|
||||||
|
mangaEntries!,
|
||||||
|
firstUnreadEntryByMangaByGroup[
|
||||||
|
getDateString(epochToDate(Number(mangaEntries![0].fetchedAt)))
|
||||||
|
]![mangaEntries![0].mangaId],
|
||||||
|
),
|
||||||
|
entriesByManga,
|
||||||
|
),
|
||||||
|
entriesByMangaByGroup,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [Object.entries(firstUnreadEntryByGroup), remainingEntriesByMangaByGroup];
|
||||||
|
}, [allUpdateEntries]);
|
||||||
|
|
||||||
|
const firstUnreadUpdatesGroupCounts = useMemo(
|
||||||
|
() => firstUnreadUpdatesByGroup.map((updatesByGroup) => updatesByGroup[VirtuosoUtil.ITEMS].length),
|
||||||
|
[firstUnreadUpdatesByGroup],
|
||||||
|
);
|
||||||
|
const firstUnreadUpdatesEntries = useMemo(
|
||||||
|
() => firstUnreadUpdatesByGroup.flatMap((updatesByGroup) => updatesByGroup[VirtuosoUtil.ITEMS]),
|
||||||
|
[firstUnreadUpdatesByGroup],
|
||||||
|
);
|
||||||
|
|
||||||
|
const computeFirstUnreadUpdateItemKey = VirtuosoUtil.useCreateGroupedComputeItemKey(
|
||||||
|
firstUnreadUpdatesGroupCounts,
|
||||||
|
useCallback((index) => firstUnreadUpdatesByGroup[index][VirtuosoUtil.GROUP], [firstUnreadUpdatesByGroup]),
|
||||||
|
useCallback((index) => firstUnreadUpdatesEntries[index].id, [firstUnreadUpdatesEntries]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const lastUpdateTimestampCompRef = useRef<HTMLElement>(null);
|
const lastUpdateTimestampCompRef = useRef<HTMLElement>(null);
|
||||||
@@ -73,15 +130,23 @@ export const Updates: React.FC = () => {
|
|||||||
const lastUpdateTimestamp = lastUpdateTimestampData?.lastUpdateTimestamp.timestamp;
|
const lastUpdateTimestamp = lastUpdateTimestampData?.lastUpdateTimestamp.timestamp;
|
||||||
const date = lastUpdateTimestamp ? dateTimeFormatter.format(+lastUpdateTimestamp) : '-';
|
const date = lastUpdateTimestamp ? dateTimeFormatter.format(+lastUpdateTimestamp) : '-';
|
||||||
|
|
||||||
useAppTitleAndAction(t`Updates`, <UpdateChecker />);
|
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (!hasNextPage) {
|
if (!hasNextPage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchMore({ variables: { offset: updateEntries.length } });
|
fetchMore({ variables: { offset: allUpdateEntries.length } }).then(() =>
|
||||||
}, [hasNextPage, endCursor]);
|
setPrevUpdateEntriesCount(firstUnreadUpdatesEntries.length),
|
||||||
|
);
|
||||||
|
}, [hasNextPage, allUpdateEntries.length, firstUnreadUpdatesEntries.length]);
|
||||||
|
|
||||||
|
const filteredOutAllItemsOfFetchedPage =
|
||||||
|
allUpdateEntries.length > 0 && prevUpdateEntriesCount === firstUnreadUpdatesEntries.length;
|
||||||
|
useEffect(() => {
|
||||||
|
if (filteredOutAllItemsOfFetchedPage && hasNextPage && !isLoading) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
}, [isLoading, hasNextPage, filteredOutAllItemsOfFetchedPage, loadMore]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
@@ -93,7 +158,7 @@ export const Updates: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLoading && updateEntries.length === 0) {
|
if (!isLoading && firstUnreadUpdatesEntries.length === 0) {
|
||||||
return <EmptyViewAbsoluteCentered message={t`You don't have any updates yet.`} />;
|
return <EmptyViewAbsoluteCentered message={t`You don't have any updates yet.`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,18 +185,25 @@ export const Updates: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
overscan={window.innerHeight * 0.5}
|
overscan={window.innerHeight * 0.5}
|
||||||
endReached={loadMore}
|
endReached={loadMore}
|
||||||
groupCounts={groupCounts}
|
groupCounts={firstUnreadUpdatesGroupCounts}
|
||||||
groupContent={(index) => (
|
groupContent={(index) => (
|
||||||
<StyledGroupHeader isFirstItem={index === 0}>
|
<StyledGroupHeader isFirstItem={index === 0}>
|
||||||
<Typography variant="h5" component="h2">
|
<Typography variant="h5" component="h2">
|
||||||
{groupedUpdates[index][VirtuosoUtil.GROUP]}
|
{firstUnreadUpdatesByGroup[index][VirtuosoUtil.GROUP]}
|
||||||
</Typography>
|
</Typography>
|
||||||
</StyledGroupHeader>
|
</StyledGroupHeader>
|
||||||
)}
|
)}
|
||||||
computeItemKey={computeItemKey}
|
computeItemKey={computeFirstUnreadUpdateItemKey}
|
||||||
itemContent={(index) => (
|
itemContent={(index) => (
|
||||||
<StyledGroupItemWrapper>
|
<StyledGroupItemWrapper>
|
||||||
<ChapterUpdateCard chapter={updateEntries[index]} />
|
<ChapterUpdateCard
|
||||||
|
chapter={firstUnreadUpdatesEntries[index]}
|
||||||
|
otherChapters={
|
||||||
|
otherUpdatesByMangaByGroup[
|
||||||
|
getDateString(epochToDate(Number(firstUnreadUpdatesEntries[index].fetchedAt)))
|
||||||
|
][firstUnreadUpdatesEntries[index].mangaId]
|
||||||
|
}
|
||||||
|
/>
|
||||||
</StyledGroupItemWrapper>
|
</StyledGroupItemWrapper>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ msgstr "{0, plural, one {1 migrating entry} other {# migrating entries}}"
|
|||||||
msgid "{0, plural, one {1 searching} other {# searching}}"
|
msgid "{0, plural, one {1 searching} other {# searching}}"
|
||||||
msgstr "{0, plural, one {1 searching} other {# searching}}"
|
msgstr "{0, plural, one {1 searching} other {# searching}}"
|
||||||
|
|
||||||
|
#. placeholder {0}: firstFewOtherChapters.length
|
||||||
|
#: src/features/updates/components/ChapterUpdateCard.tsx
|
||||||
|
msgid "{0, plural, one {Plus chapter {firstFewUpdatesString}} other {Plus chapters {firstFewUpdatesString}}}"
|
||||||
|
msgstr "{0, plural, one {Plus chapter {firstFewUpdatesString}} other {Plus chapters {firstFewUpdatesString}}}"
|
||||||
|
|
||||||
#. placeholder {0}: autoScroll.value
|
#. placeholder {0}: autoScroll.value
|
||||||
#: src/features/reader/auto-scroll/settings/quick-setting/ReaderNavBarDesktopAutoScroll.tsx
|
#: src/features/reader/auto-scroll/settings/quick-setting/ReaderNavBarDesktopAutoScroll.tsx
|
||||||
msgid "{0, plural, one {Second} other {Seconds}}"
|
msgid "{0, plural, one {Second} other {Seconds}}"
|
||||||
@@ -2821,6 +2826,10 @@ msgstr "Pin source"
|
|||||||
msgid "Pinned"
|
msgid "Pinned"
|
||||||
msgstr "Pinned"
|
msgstr "Pinned"
|
||||||
|
|
||||||
|
#: src/features/updates/components/ChapterUpdateCard.tsx
|
||||||
|
msgid "Plus chapters {firstFewUpdatesString} and {remainingUpdatesCount} more"
|
||||||
|
msgstr "Plus chapters {firstFewUpdatesString} and {remainingUpdatesCount} more"
|
||||||
|
|
||||||
#: src/features/source/browse/screens/SourceMangas.tsx
|
#: src/features/source/browse/screens/SourceMangas.tsx
|
||||||
msgid "Popular"
|
msgid "Popular"
|
||||||
msgstr "Popular"
|
msgstr "Popular"
|
||||||
|
|||||||
@@ -3430,7 +3430,7 @@ export class RequestManager {
|
|||||||
initialPages: number = 1,
|
initialPages: number = 1,
|
||||||
options?: QueryHookOptions<GetChaptersUpdatesQuery, GetChaptersUpdatesQueryVariables>,
|
options?: QueryHookOptions<GetChaptersUpdatesQuery, GetChaptersUpdatesQueryVariables>,
|
||||||
): AbortableApolloUseQueryResponse<GetChaptersUpdatesQuery, GetChaptersUpdatesQueryVariables> {
|
): AbortableApolloUseQueryResponse<GetChaptersUpdatesQuery, GetChaptersUpdatesQueryVariables> {
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 150;
|
||||||
const CACHE_KEY = 'useGetRecentlyUpdatedChapters';
|
const CACHE_KEY = 'useGetRecentlyUpdatedChapters';
|
||||||
|
|
||||||
const offset = this.cache.getResponseFor<number>(CACHE_KEY, undefined) ?? 0;
|
const offset = this.cache.getResponseFor<number>(CACHE_KEY, undefined) ?? 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user