From 2142e20e7d722fd66bd289f6f6e0e9d96bf08f43 Mon Sep 17 00:00:00 2001 From: Athou Date: Fri, 23 May 2025 15:57:53 +0200 Subject: [PATCH] cleanup --- commafeed-client/src/app/entries/thunks.ts | 4 -- commafeed-client/src/app/tree/slice.ts | 63 ++++++++++++++----- commafeed-client/src/app/tree/thunks.ts | 4 +- commafeed-client/src/app/tree/tree.test.ts | 59 ++++++++++++++++- commafeed-client/src/app/types.ts | 6 +- commafeed-client/src/app/utils.ts | 17 +++-- .../src/components/sidebar/Tree.tsx | 11 +++- .../src/components/sidebar/TreeNode.tsx | 6 +- .../src/components/sidebar/UnreadCount.tsx | 6 +- 9 files changed, 135 insertions(+), 41 deletions(-) diff --git a/commafeed-client/src/app/entries/thunks.ts b/commafeed-client/src/app/entries/thunks.ts index 3a85f601..88c5abd1 100644 --- a/commafeed-client/src/app/entries/thunks.ts +++ b/commafeed-client/src/app/entries/thunks.ts @@ -3,7 +3,6 @@ import { client } from "app/client" import { Constants } from "app/constants" import { type EntrySource, type EntrySourceType, entriesSlice, setMarkAllAsReadConfirmationDialogOpen, setSearch } from "app/entries/slice" import type { RootState } from "app/store" -import { setHasNewEntries } from "app/tree/slice" import { reloadTree } from "app/tree/thunks" import type { Entry, MarkRequest, TagRequest } from "app/types" import { reloadTags } from "app/user/thunks" @@ -27,9 +26,6 @@ export const loadEntries = createAppAsyncThunk( const state = thunkApi.getState() const endpoint = getEndpoint(arg.source.type) const result = await endpoint(buildGetEntriesPaginatedRequest(state, arg.source, 0)) - if (arg.source.type === "feed") { - thunkApi.dispatch(setHasNewEntries({ feedId: +arg.source.id, value: false })) - } return result.data } ) diff --git a/commafeed-client/src/app/tree/slice.ts b/commafeed-client/src/app/tree/slice.ts index 4cf942ef..14d2a91b 100644 --- a/commafeed-client/src/app/tree/slice.ts +++ b/commafeed-client/src/app/tree/slice.ts @@ -1,12 +1,22 @@ import { type PayloadAction, createSlice } from "@reduxjs/toolkit" -import { markEntry } from "app/entries/thunks" +import { loadEntries, markEntry } from "app/entries/thunks" import { redirectTo } from "app/redirect/slice" import { collapseTreeCategory, reloadTree } from "app/tree/thunks" -import type { Category } from "app/types" -import { visitCategoryTree } from "app/utils" +import type { Category, Subscription } from "app/types" +import { flattenCategoryTree, visitCategoryTree } from "app/utils" + +export interface TreeSubscription extends Subscription { + // client-side only flag + hasNewEntries?: boolean +} + +export interface TreeCategory extends Category { + feeds: TreeSubscription[] + children: TreeCategory[] +} interface TreeState { - rootCategory?: Category + rootCategory?: TreeCategory mobileMenuOpen: boolean sidebarVisible: boolean } @@ -37,21 +47,27 @@ export const treeSlice = createSlice({ visitCategoryTree(state.rootCategory, c => { for (const f of c.feeds.filter(f => f.id === action.payload.feedId)) { f.unread += action.payload.amount + f.hasNewEntries = true } }) }, - setHasNewEntries: (state, action: PayloadAction<{ feedId: number; value: boolean }>) => { - if (!state.rootCategory) return - - visitCategoryTree(state.rootCategory, category => { - category.feeds = category.feeds.map(feed => - feed.id === action.payload.feedId ? { ...feed, hasNewEntries: action.payload.value } : feed - ) - }) - }, }, extraReducers: builder => { builder.addCase(reloadTree.fulfilled, (state, action) => { + // set hasNewEntries to true if new unread > previous unread + if (state.rootCategory) { + const oldFeeds = flattenCategoryTree(state.rootCategory).flatMap(c => c.feeds) + const oldFeedsById = new Map(oldFeeds.map(f => [f.id, f])) + + const newFeeds = flattenCategoryTree(action.payload).flatMap(c => c.feeds) + for (const newFeed of newFeeds) { + const oldFeed = oldFeedsById.get(newFeed.id) + if (oldFeed && newFeed.unread > oldFeed.unread) { + newFeed.hasNewEntries = true + } + } + } + state.rootCategory = action.payload }) builder.addCase(collapseTreeCategory.pending, (state, action) => { @@ -68,10 +84,29 @@ export const treeSlice = createSlice({ } }) }) + builder.addCase(loadEntries.pending, (state, action) => { + if (!state.rootCategory) return + + const { source } = action.meta.arg + if (source.type === "category") { + visitCategoryTree(state.rootCategory, c => { + if (c.id === source.id) { + for (const f of flattenCategoryTree(c).flatMap(c => c.feeds)) { + f.hasNewEntries = false + } + } + }) + } else if (source.type === "feed") { + const feeds = flattenCategoryTree(state.rootCategory).flatMap(c => c.feeds) + for (const f of feeds.filter(f => f.id === +source.id)) { + f.hasNewEntries = false + } + } + }) builder.addCase(redirectTo, state => { state.mobileMenuOpen = false }) }, }) -export const { setMobileMenuOpen, toggleSidebar, incrementUnreadCount, setHasNewEntries } = treeSlice.actions +export const { setMobileMenuOpen, toggleSidebar, incrementUnreadCount } = treeSlice.actions diff --git a/commafeed-client/src/app/tree/thunks.ts b/commafeed-client/src/app/tree/thunks.ts index 22ef4b8d..ac0d7a33 100644 --- a/commafeed-client/src/app/tree/thunks.ts +++ b/commafeed-client/src/app/tree/thunks.ts @@ -1,7 +1,7 @@ import { createAppAsyncThunk } from "app/async-thunk" import { client } from "app/client" import { redirectToCategory, redirectToFeed } from "app/redirect/thunks" -import { incrementUnreadCount, setHasNewEntries } from "app/tree/slice" +import { incrementUnreadCount } from "app/tree/slice" import type { CollapseRequest, Subscription } from "app/types" import { flattenCategoryTree, visitCategoryTree } from "app/utils" @@ -11,6 +11,7 @@ export const collapseTreeCategory = createAppAsyncThunk( "tree/category/collapse", async (req: CollapseRequest) => await client.category.collapse(req).then(r => r.data) ) + export const selectNextUnreadTreeItem = createAppAsyncThunk( "tree/selectNextUnreadItem", ( @@ -74,7 +75,6 @@ export const newFeedEntriesDiscovered = createAppAsyncThunk( amount, }) ) - thunkApi.dispatch(setHasNewEntries({ feedId, value: true })) } } ) diff --git a/commafeed-client/src/app/tree/tree.test.ts b/commafeed-client/src/app/tree/tree.test.ts index 43aa0e15..71d46646 100644 --- a/commafeed-client/src/app/tree/tree.test.ts +++ b/commafeed-client/src/app/tree/tree.test.ts @@ -1,8 +1,13 @@ import { configureStore } from "@reduxjs/toolkit" +import { client } from "app/client" +import { loadEntries } from "app/entries/thunks" import { type RootState, reducers } from "app/store" -import { selectNextUnreadTreeItem } from "app/tree/thunks" -import type { Category, Subscription } from "app/types" -import { describe, expect, it } from "vitest" +import { newFeedEntriesDiscovered, selectNextUnreadTreeItem } from "app/tree/thunks" +import type { Category, Entries, Entry, Subscription } from "app/types" +import type { AxiosResponse } from "axios" +import { beforeEach, describe, expect, it, vi } from "vitest" + +vi.mock(import("app/client")) const createCategory = (id: string): Category => ({ id, @@ -117,3 +122,51 @@ describe("selectNextUnreadTreeItem", () => { expect(store.getState().redirect.to).toBe("/app/feed/3") }) }) + +describe("hasNewEntries", () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it("sets and clear flag for a feed", async () => { + vi.mocked(client.feed.getEntries).mockResolvedValue({ + data: { + entries: [{ id: "3" } as Entry], + hasMore: false, + name: "my-feed", + errorCount: 3, + feedLink: "https://mysite.com/feed", + timestamp: 123, + ignoredReadStatus: false, + }, + } as AxiosResponse) + + const store = configureStore({ + reducer: reducers, + preloadedState: { + tree: { + rootCategory: root, + }, + entries: { + source: { + type: "feed", + id: "1", + }, + }, + } as RootState, + }) + + // initial state + expect(store.getState().tree.rootCategory?.children[0].feeds[0].unread).toBe(0) + expect(store.getState().tree.rootCategory?.children[0].feeds[0].hasNewEntries).toBeFalsy() + + // increments unread count and sets hasNewEntries to true + await store.dispatch(newFeedEntriesDiscovered({ feedId: 1, amount: 3 })) + expect(store.getState().tree.rootCategory?.children[0].feeds[0].unread).toBe(3) + expect(store.getState().tree.rootCategory?.children[0].feeds[0].hasNewEntries).toBe(true) + + // reload entries and sets hasNewEntries to false + await store.dispatch(loadEntries({ source: { type: "feed", id: "1" }, clearSearch: true })) + expect(store.getState().tree.rootCategory?.children[0].feeds[0].hasNewEntries).toBe(false) + }) +}) diff --git a/commafeed-client/src/app/types.ts b/commafeed-client/src/app/types.ts index db57202b..68a2218d 100644 --- a/commafeed-client/src/app/types.ts +++ b/commafeed-client/src/app/types.ts @@ -30,17 +30,13 @@ export interface Subscription { filter?: string } -export interface TreeSubscription extends Subscription { - hasNewEntries?: boolean -} - export interface Category { id: string parentId?: string parentName?: string name: string children: Category[] - feeds: TreeSubscription[] + feeds: Subscription[] expanded: boolean position: number } diff --git a/commafeed-client/src/app/utils.ts b/commafeed-client/src/app/utils.ts index 08f07e7d..2a2d15a9 100644 --- a/commafeed-client/src/app/utils.ts +++ b/commafeed-client/src/app/utils.ts @@ -1,9 +1,10 @@ +import type { TreeCategory } from "app/tree/slice" import { throttle } from "throttle-debounce" import type { Category } from "./types" export function visitCategoryTree( - category: Category, - visitor: (category: Category) => void, + category: TreeCategory, + visitor: (category: TreeCategory) => void, options?: { childrenFirst?: boolean } @@ -19,13 +20,13 @@ export function visitCategoryTree( if (childrenFirst) visitor(category) } -export function flattenCategoryTree(category: Category): Category[] { +export function flattenCategoryTree(category: TreeCategory): TreeCategory[] { const categories: Category[] = [] visitCategoryTree(category, c => categories.push(c)) return categories } -export function categoryUnreadCount(category?: Category): number { +export function categoryUnreadCount(category?: TreeCategory): number { if (!category) return 0 return flattenCategoryTree(category) @@ -34,6 +35,14 @@ export function categoryUnreadCount(category?: Category): number { .reduce((total, current) => total + current, 0) } +export function categoryHasNewEntries(category?: TreeCategory): boolean { + if (!category) return false + + return flattenCategoryTree(category) + .flatMap(c => c.feeds) + .some(f => f.hasNewEntries) +} + export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => { const placeholderWidth = width && Math.min(width, maxWidth) const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height diff --git a/commafeed-client/src/components/sidebar/Tree.tsx b/commafeed-client/src/components/sidebar/Tree.tsx index 0d805737..cd3be9af 100644 --- a/commafeed-client/src/components/sidebar/Tree.tsx +++ b/commafeed-client/src/components/sidebar/Tree.tsx @@ -10,9 +10,10 @@ import { redirectToTagDetails, } from "app/redirect/thunks" import { useAppDispatch, useAppSelector } from "app/store" +import type { TreeSubscription } from "app/tree/slice" import { collapseTreeCategory } from "app/tree/thunks" -import type { Category, Subscription, TreeSubscription } from "app/types" -import { categoryUnreadCount, flattenCategoryTree } from "app/utils" +import type { Category, Subscription } from "app/types" +import { categoryHasNewEntries, categoryUnreadCount, flattenCategoryTree } from "app/utils" import { Loader } from "components/Loader" import { OnDesktop } from "components/responsive/OnDesktop" import React from "react" @@ -89,6 +90,7 @@ export function Tree() { name={All} icon={allIcon} unread={categoryUnreadCount(root)} + hasNewEntries={categoryHasNewEntries(root)} selected={source.type === "category" && source.id === Constants.categories.all.id} expanded={false} level={0} @@ -103,6 +105,7 @@ export function Tree() { name={Starred} icon={starredIcon} unread={0} + hasNewEntries={false} selected={source.type === "category" && source.id === Constants.categories.starred.id} expanded={false} level={0} @@ -122,6 +125,7 @@ export function Tree() { name={category.name} icon={category.expanded ? expandedIcon : collapsedIcon} unread={categoryUnreadCount(category)} + hasNewEntries={categoryHasNewEntries(category)} selected={source.type === "category" && source.id === category.id} expanded={category.expanded} level={level} @@ -143,12 +147,12 @@ export function Tree() { name={feed.name} icon={feed.iconUrl} unread={feed.unread} + hasNewEntries={!!feed.hasNewEntries} selected={source.type === "feed" && source.id === String(feed.id)} level={level} hasError={feed.errorCount > errorThreshold} onClick={feedClicked} key={feed.id} - newMessages={feed.hasNewEntries} /> ) } @@ -160,6 +164,7 @@ export function Tree() { name={tag} icon={tagIcon} unread={0} + hasNewEntries={false} selected={source.type === "tag" && source.id === tag} level={0} hasError={false} diff --git a/commafeed-client/src/components/sidebar/TreeNode.tsx b/commafeed-client/src/components/sidebar/TreeNode.tsx index f61b6eb1..43b32d59 100644 --- a/commafeed-client/src/components/sidebar/TreeNode.tsx +++ b/commafeed-client/src/components/sidebar/TreeNode.tsx @@ -15,7 +15,7 @@ interface TreeNodeProps { expanded?: boolean level: number hasError: boolean - newMessages?: boolean + hasNewEntries: boolean onClick: (e: React.MouseEvent, id: string) => void onIconClick?: (e: React.MouseEvent, id: string) => void } @@ -65,12 +65,12 @@ export function TreeNode(props: TreeNodeProps) { hasError: props.hasError, hasUnread: props.unread > 0, }) - return ( props.onClick(e, props.id)} data-id={props.id} data-type={props.type} data-unread-count={props.unread} @@ -81,7 +81,7 @@ export function TreeNode(props: TreeNodeProps) { {props.name} {!props.expanded && ( - + )} diff --git a/commafeed-client/src/components/sidebar/UnreadCount.tsx b/commafeed-client/src/components/sidebar/UnreadCount.tsx index a1e6e154..c9bd853e 100644 --- a/commafeed-client/src/components/sidebar/UnreadCount.tsx +++ b/commafeed-client/src/components/sidebar/UnreadCount.tsx @@ -5,20 +5,20 @@ import { tss } from "tss" const useStyles = tss.create(() => ({ badge: { width: "3.2rem", + // for some reason, mantine Badge has "cursor: 'default'" cursor: "pointer", }, })) -export function UnreadCount(props: { unreadCount: number; newMessages: boolean | undefined }) { +export function UnreadCount(props: { unreadCount: number; showIndicator: boolean }) { const { classes } = useStyles() if (props.unreadCount <= 0) return null const count = props.unreadCount >= 10000 ? "10k+" : props.unreadCount - return ( - + {count}