Show only first unread chapter per manga per day in updates page

This commit is contained in:
schroda
2026-07-05 18:55:09 +02:00
parent 0ffaa0fb3e
commit 38de1e9e08
5 changed files with 183 additions and 56 deletions

View File

@@ -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

View File

@@ -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>
); );
}); },
);

View File

@@ -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>
)} )}
/> />

View File

@@ -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"

View File

@@ -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;