This commit is contained in:
Athou
2025-05-23 15:57:53 +02:00
parent dc23126570
commit 2142e20e7d
9 changed files with 135 additions and 41 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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={<Trans>All</Trans>}
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={<Trans>Starred</Trans>}
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}

View File

@@ -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 (
<Box
py={1}
pl={props.level * 20}
className={`${classes.node} cf-treenode cf-treenode-${props.type}`}
onClick={(e: React.MouseEvent) => 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) {
<Box className={classes.nodeText}>{props.name}</Box>
{!props.expanded && (
<Box className="cf-treenode-unread-count">
<UnreadCount unreadCount={props.unread} newMessages={props.id !== "all" ? props.newMessages : false} />
<UnreadCount unreadCount={props.unread} showIndicator={props.hasNewEntries} />
</Box>
)}
</Box>

View File

@@ -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 (
<Tooltip label={props.unreadCount} disabled={props.unreadCount === count} openDelay={Constants.tooltip.delay}>
<Indicator disabled={!props.newMessages} size={4} offset={10} position="top-start" color="orange" withBorder={false} zIndex={5}>
<Indicator disabled={!props.showIndicator} size={4} offset={10} position="middle-start">
<Badge className={`${classes.badge} cf-badge`} variant="light" fullWidth>
{count}
</Badge>