From 38de1e9e08d3895aa9b6d31327247a5d7067255e Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:55:09 +0200 Subject: [PATCH] Show only first unread chapter per manga per day in updates page --- CHANGELOG.md | 1 + .../updates/components/ChapterUpdateCard.tsx | 109 +++++++++++----- src/features/updates/screens/Updates.tsx | 118 ++++++++++++++---- src/i18n/locales/en.po | 9 ++ src/lib/requests/RequestManager.ts | 2 +- 5 files changed, 183 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d5a856d..f0712607 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - (**Download**) Respect manga chapter filters on bulk manga download in the library - (**History**) Show only the last read chapter per manga +- (**Updates**) Show only the first unread chapter per manga per day ### Fixed diff --git a/src/features/updates/components/ChapterUpdateCard.tsx b/src/features/updates/components/ChapterUpdateCard.tsx index ec0bd0f9..61b8fd78 100644 --- a/src/features/updates/components/ChapterUpdateCard.tsx +++ b/src/features/updates/components/ChapterUpdateCard.tsx @@ -10,7 +10,7 @@ import Box from '@mui/material/Box'; import CardActionArea from '@mui/material/CardActionArea'; import Card from '@mui/material/Card'; import { Link } from 'react-router-dom'; -import { memo } from 'react'; +import { memo, useMemo } from 'react'; import { DownloadStateIndicator } from '@/base/components/downloads/DownloadStateIndicator.tsx'; import type { ChapterUpdateListFieldsFragment } from '@/lib/graphql/generated/graphql.ts'; import { AppRoutes } from '@/base/AppRoute.constants.ts'; @@ -20,36 +20,81 @@ import { ChapterDownloadButton } from '@/features/chapter/components/buttons/Cha import { ChapterDownloadRetryButton } from '@/features/chapter/components/buttons/ChapterDownloadRetryButton.tsx'; import { Chapters } from '@/features/chapter/services/Chapters.ts'; 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 }) => { - const { manga } = chapter; +export const ChapterUpdateCard = memo( + ({ + chapter, + otherChapters, + }: { + chapter: ChapterUpdateListFieldsFragment; + otherChapters: ChapterUpdateListFieldsFragment[]; + }) => { + const { manga } = chapter; - return ( - - theme.palette.text[chapter.isRead ? 'disabled' : 'primary'], - }} - > - - - - - - - - - - - - ); -}); + 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 ( + + theme.palette.text[chapter.isRead ? 'disabled' : 'primary'], + }} + > + + + + + + + + + + + + ); + }, +); diff --git a/src/features/updates/screens/Updates.tsx b/src/features/updates/screens/Updates.tsx index 6936c81a..7c4d9c08 100644 --- a/src/features/updates/screens/Updates.tsx +++ b/src/features/updates/screens/Updates.tsx @@ -7,7 +7,7 @@ */ 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 { requestManager } from '@/lib/requests/RequestManager.ts'; 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 { StyledGroupHeader } from '@/base/components/virtuoso/StyledGroupHeader.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 { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx'; 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 { GROUPED_VIRTUOSO_Z_INDEX } from '@/lib/virtuoso/Virtuoso.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 = () => { const { t } = useLingui(); const { appBarHeight } = useNavBarContext(); + useAppTitleAndAction(t`Updates`, ); + const { data: chapterUpdateData, loading: isLoading, @@ -41,21 +46,73 @@ export const Updates: React.FC = () => { fetchPolicy: 'cache-and-network', }); const hasNextPage = !!chapterUpdateData?.chapters.pageInfo.hasNextPage; - const endCursor = chapterUpdateData?.chapters.pageInfo.endCursor; - const updateEntries = chapterUpdateData?.chapters.nodes ?? STABLE_EMPTY_ARRAY; - const groupedUpdates = useMemo( - () => Object.entries(Chapters.groupByDate(updateEntries, 'fetchedAt')), - [updateEntries], + const allUpdateEntries = chapterUpdateData?.chapters.nodes ?? STABLE_EMPTY_ARRAY; + + const [prevUpdateEntriesCount, setPrevUpdateEntriesCount] = useState(0); + + const [firstUnreadUpdatesByGroup, otherUpdatesByMangaByGroup] = useMemo(() => { + const groupedEntries = Chapters.groupByDate(allUpdateEntries, 'fetchedAt'); + + const mangaIdByGroup = mapValues( + (groupEntries) => uniqBy('mangaId', groupEntries).map((entry) => entry.mangaId), + groupedEntries, + ); + + const entriesByMangaByGroup = mapValues( + (entries) => Object.groupBy(entries!, (entry) => entry.mangaId), + groupedEntries, + ); + + 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 groupCounts: number[] = useMemo( - () => groupedUpdates.map((group) => group[VirtuosoUtil.ITEMS].length), - [groupedUpdates], + const firstUnreadUpdatesEntries = useMemo( + () => firstUnreadUpdatesByGroup.flatMap((updatesByGroup) => updatesByGroup[VirtuosoUtil.ITEMS]), + [firstUnreadUpdatesByGroup], ); - const computeItemKey = VirtuosoUtil.useCreateGroupedComputeItemKey( - groupCounts, - useCallback((index) => groupedUpdates[index][VirtuosoUtil.GROUP], [groupedUpdates]), - useCallback((index) => updateEntries[index].id, [updateEntries]), + const computeFirstUnreadUpdateItemKey = VirtuosoUtil.useCreateGroupedComputeItemKey( + firstUnreadUpdatesGroupCounts, + useCallback((index) => firstUnreadUpdatesByGroup[index][VirtuosoUtil.GROUP], [firstUnreadUpdatesByGroup]), + useCallback((index) => firstUnreadUpdatesEntries[index].id, [firstUnreadUpdatesEntries]), ); const lastUpdateTimestampCompRef = useRef(null); @@ -73,15 +130,23 @@ export const Updates: React.FC = () => { const lastUpdateTimestamp = lastUpdateTimestampData?.lastUpdateTimestamp.timestamp; const date = lastUpdateTimestamp ? dateTimeFormatter.format(+lastUpdateTimestamp) : '-'; - useAppTitleAndAction(t`Updates`, ); - const loadMore = useCallback(() => { if (!hasNextPage) { return; } - fetchMore({ variables: { offset: updateEntries.length } }); - }, [hasNextPage, endCursor]); + fetchMore({ variables: { offset: allUpdateEntries.length } }).then(() => + 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) { return ( @@ -93,7 +158,7 @@ export const Updates: React.FC = () => { ); } - if (!isLoading && updateEntries.length === 0) { + if (!isLoading && firstUnreadUpdatesEntries.length === 0) { return ; } @@ -120,18 +185,25 @@ export const Updates: React.FC = () => { }} overscan={window.innerHeight * 0.5} endReached={loadMore} - groupCounts={groupCounts} + groupCounts={firstUnreadUpdatesGroupCounts} groupContent={(index) => ( - {groupedUpdates[index][VirtuosoUtil.GROUP]} + {firstUnreadUpdatesByGroup[index][VirtuosoUtil.GROUP]} )} - computeItemKey={computeItemKey} + computeItemKey={computeFirstUnreadUpdateItemKey} itemContent={(index) => ( - + )} /> diff --git a/src/i18n/locales/en.po b/src/i18n/locales/en.po index 2a7ae641..b314e07a 100644 --- a/src/i18n/locales/en.po +++ b/src/i18n/locales/en.po @@ -111,6 +111,11 @@ msgstr "{0, plural, one {1 migrating entry} other {# migrating entries}}" msgid "{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 #: src/features/reader/auto-scroll/settings/quick-setting/ReaderNavBarDesktopAutoScroll.tsx msgid "{0, plural, one {Second} other {Seconds}}" @@ -2821,6 +2826,10 @@ msgstr "Pin source" msgid "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 msgid "Popular" msgstr "Popular" diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts index a94ad54d..cb7834a3 100644 --- a/src/lib/requests/RequestManager.ts +++ b/src/lib/requests/RequestManager.ts @@ -3430,7 +3430,7 @@ export class RequestManager { initialPages: number = 1, options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { - const PAGE_SIZE = 50; + const PAGE_SIZE = 150; const CACHE_KEY = 'useGetRecentlyUpdatedChapters'; const offset = this.cache.getResponseFor(CACHE_KEY, undefined) ?? 0;