Working on indicator feature for new unread feeds

This commit is contained in:
Eshwar Tangirala
2025-05-18 00:03:28 -04:00
parent 3f2f6e83fa
commit 0199a36238
4 changed files with 277 additions and 309 deletions

View File

@@ -44,19 +44,25 @@ export const treeSlice = createSlice({
extraReducers: builder => { extraReducers: builder => {
builder.addCase(reloadTree.fulfilled, (state, action) => { builder.addCase(reloadTree.fulfilled, (state, action) => {
visitCategoryTree(action.payload, category => { visitCategoryTree(action.payload, category => {
category.feeds = category.feeds.map(feed => { category.feeds = category.feeds.map(feed => {
const storageKey = `feed-${feed.id}-unread` const storageKey = `feed-${feed.id}-unread`
const prevUnread = parseInt(localStorage.getItem(storageKey) || "0", 10) const existing = localStorage.getItem(storageKey)
const hasNewEntries = feed.unread > prevUnread const prevUnread = Number.parseInt(existing || "0", 10)
const isNewFeed = existing === null
localStorage.setItem(storageKey, feed.unread.toString()) const hasNewEntries = isNewFeed ? true : feed.unread > prevUnread
return { if (!isNewFeed) {
...feed, localStorage.setItem(storageKey, feed.unread.toString())
hasNewEntries }
}
return {
...feed,
hasNewEntries,
}
})
}) })
})
state.rootCategory = action.payload state.rootCategory = action.payload
}) })
builder.addCase(collapseTreeCategory.pending, (state, action) => { builder.addCase(collapseTreeCategory.pending, (state, action) => {

View File

@@ -1,216 +1,195 @@
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro"
import { Box, Stack } from "@mantine/core"; import { Box, Stack } from "@mantine/core"
import { Constants } from "app/constants"; import { Constants } from "app/constants"
import { import {
redirectToCategory, redirectToCategory,
redirectToCategoryDetails, redirectToCategoryDetails,
redirectToFeed, redirectToFeed,
redirectToFeedDetails, redirectToFeedDetails,
redirectToTag, redirectToTag,
redirectToTagDetails, redirectToTagDetails,
} from "app/redirect/thunks"; } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"; import { useAppDispatch, useAppSelector } from "app/store"
import { collapseTreeCategory } from "app/tree/thunks"; import { collapseTreeCategory } from "app/tree/thunks"
import type { Category, Subscription, TreeSubscription } from "app/types"; import type { Category, Subscription, TreeSubscription } from "app/types"
import { categoryUnreadCount, flattenCategoryTree } from "app/utils"; import { categoryUnreadCount, flattenCategoryTree } from "app/utils"
import { Loader } from "components/Loader"; import { Loader } from "components/Loader"
import { OnDesktop } from "components/responsive/OnDesktop"; import { OnDesktop } from "components/responsive/OnDesktop"
import React, { useEffect, useState } from "react"; import React from "react"
import { import { TbChevronDown, TbChevronRight, TbInbox, TbStar, TbTag } from "react-icons/tb"
TbChevronDown, import { TreeNode } from "./TreeNode"
TbChevronRight, import { TreeSearch } from "./TreeSearch"
TbInbox,
TbStar,
TbTag,
} from "react-icons/tb";
import { TreeNode } from "./TreeNode";
import { TreeSearch } from "./TreeSearch";
const allIcon = <TbInbox size={16} />; const allIcon = <TbInbox size={16} />
const starredIcon = <TbStar size={16} />; const starredIcon = <TbStar size={16} />
const tagIcon = <TbTag size={16} />; const tagIcon = <TbTag size={16} />
const expandedIcon = <TbChevronDown size={16} />; const expandedIcon = <TbChevronDown size={16} />
const collapsedIcon = <TbChevronRight size={16} />; const collapsedIcon = <TbChevronRight size={16} />
const errorThreshold = 9; const errorThreshold = 9
export function Tree() { export function Tree() {
const root = useAppSelector((state) => state.tree.rootCategory); const root = useAppSelector(state => state.tree.rootCategory)
const source = useAppSelector((state) => state.entries.source); const source = useAppSelector(state => state.entries.source)
const tags = useAppSelector((state) => state.user.tags); const tags = useAppSelector(state => state.user.tags)
const showRead = useAppSelector((state) => state.user.settings?.showRead); const showRead = useAppSelector(state => state.user.settings?.showRead)
const dispatch = useAppDispatch(); const dispatch = useAppDispatch()
const isFeedDisplayed = (feed: Subscription) => { const isFeedDisplayed = (feed: Subscription) => {
const isCurrentFeed = const isCurrentFeed = source.type === "feed" && source.id === String(feed.id)
source.type === "feed" && source.id === String(feed.id); return isCurrentFeed || feed.unread > 0 || showRead
return isCurrentFeed || feed.unread > 0 || showRead;
};
const isCategoryDisplayed = (category: Category): boolean => {
const isCurrentCategory =
source.type === "category" && source.id === category.id;
return (
isCurrentCategory ||
showRead ||
category.children.some((c) => isCategoryDisplayed(c)) ||
category.feeds.some((f) => isFeedDisplayed(f))
);
};
const feedClicked = (e: React.MouseEvent, id: string) => {
if (e.detail === 2) {
dispatch(redirectToFeedDetails(id));
} else {
dispatch(redirectToFeed(id));
} }
};
const categoryClicked = (e: React.MouseEvent, id: string) => { const isCategoryDisplayed = (category: Category): boolean => {
if (e.detail === 2) { const isCurrentCategory = source.type === "category" && source.id === category.id
dispatch(redirectToCategoryDetails(id)); return (
} else { isCurrentCategory ||
dispatch(redirectToCategory(id)); showRead ||
category.children.some(c => isCategoryDisplayed(c)) ||
category.feeds.some(f => isFeedDisplayed(f))
)
} }
};
const categoryIconClicked = (e: React.MouseEvent, category: Category) => {
e.stopPropagation();
dispatch( const feedClicked = (e: React.MouseEvent, id: string) => {
collapseTreeCategory({ if (e.detail === 2) {
id: +category.id, dispatch(redirectToFeedDetails(id))
collapse: category.expanded, } else {
}) dispatch(redirectToFeed(id))
); }
};
const tagClicked = (e: React.MouseEvent, id: string) => {
if (e.detail === 2) {
dispatch(redirectToTagDetails(id));
} else {
dispatch(redirectToTag(id));
} }
}; const categoryClicked = (e: React.MouseEvent, id: string) => {
if (e.detail === 2) {
dispatch(redirectToCategoryDetails(id))
} else {
dispatch(redirectToCategory(id))
}
}
const categoryIconClicked = (e: React.MouseEvent, category: Category) => {
e.stopPropagation()
console.log(root?.feeds.map(f => f.hasNewEntries)); dispatch(
collapseTreeCategory({
id: +category.id,
collapse: category.expanded,
})
)
}
const tagClicked = (e: React.MouseEvent, id: string) => {
if (e.detail === 2) {
dispatch(redirectToTagDetails(id))
} else {
dispatch(redirectToTag(id))
}
}
const allCategoryNode = () => ( const allCategoryNode = () => (
<TreeNode
id={Constants.categories.all.id}
type="category"
name={<Trans>All</Trans>}
icon={allIcon}
unread={categoryUnreadCount(root)}
selected={source.type === "category" && source.id === Constants.categories.all.id}
expanded={false}
level={0}
hasError={false}
onClick={categoryClicked}
/>
)
const starredCategoryNode = () => (
<TreeNode
id={Constants.categories.starred.id}
type="category"
name={<Trans>Starred</Trans>}
icon={starredIcon}
unread={0}
selected={source.type === "category" && source.id === Constants.categories.starred.id}
expanded={false}
level={0}
hasError={false}
onClick={categoryClicked}
/>
)
<TreeNode const categoryNode = (category: Category, level = 0) => {
id={Constants.categories.all.id} if (!isCategoryDisplayed(category)) return null
type="category"
name={<Trans>All</Trans>}
icon={allIcon}
unread={categoryUnreadCount(root)}
selected={
source.type === "category" && source.id === Constants.categories.all.id
}
expanded={false}
level={0}
hasError={false}
onClick={categoryClicked}
/>
);
const starredCategoryNode = () => (
<TreeNode
id={Constants.categories.starred.id}
type="category"
name={<Trans>Starred</Trans>}
icon={starredIcon}
unread={0}
selected={
source.type === "category" &&
source.id === Constants.categories.starred.id
}
expanded={false}
level={0}
hasError={false}
onClick={categoryClicked}
/>
);
const categoryNode = (category: Category, level = 0) => { const hasError = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
if (!isCategoryDisplayed(category)) return null; return (
<TreeNode
id={category.id}
type="category"
name={category.name}
icon={category.expanded ? expandedIcon : collapsedIcon}
unread={categoryUnreadCount(category)}
selected={source.type === "category" && source.id === category.id}
expanded={category.expanded}
level={level}
hasError={hasError}
onClick={categoryClicked}
onIconClick={e => categoryIconClicked(e, category)}
key={category.id}
/>
)
}
const hasError = const feedNode = (feed: TreeSubscription, level = 0) => {
!category.expanded && if (!isFeedDisplayed(feed)) return null
flattenCategoryTree(category).some((c) =>
c.feeds.some((f) => f.errorCount > errorThreshold) return (
); <TreeNode
id={String(feed.id)}
type="feed"
name={feed.name}
icon={feed.iconUrl}
unread={feed.unread}
selected={source.type === "feed" && source.id === String(feed.id)}
level={level}
hasError={feed.errorCount > errorThreshold}
onClick={feedClicked}
key={feed.id}
newMessages={feed.hasNewEntries}
/>
)
}
const tagNode = (tag: string) => (
<TreeNode
id={tag}
type="tag"
name={tag}
icon={tagIcon}
unread={0}
selected={source.type === "tag" && source.id === tag}
level={0}
hasError={false}
onClick={tagClicked}
key={tag}
/>
)
const recursiveCategoryNode = (category: Category, level = 0) => (
<React.Fragment key={`recursiveCategoryNode-${category.id}`}>
{categoryNode(category, level)}
{category.expanded && category.children.map(c => recursiveCategoryNode(c, level + 1))}
{category.expanded && category.feeds.map(f => feedNode(f, level + 1))}
</React.Fragment>
)
if (!root) return <Loader />
const feeds = flattenCategoryTree(root).flatMap(c => c.feeds)
return ( return (
<TreeNode <Stack>
id={category.id} <OnDesktop>
type="category" <TreeSearch feeds={feeds} />
name={category.name} </OnDesktop>
icon={category.expanded ? expandedIcon : collapsedIcon} <Box className="cf-tree">
unread={categoryUnreadCount(category)} {allCategoryNode()}
selected={source.type === "category" && source.id === category.id} {starredCategoryNode()}
expanded={category.expanded} {root.children.map(c => recursiveCategoryNode(c))}
level={level} {root.feeds.map(f => feedNode(f))}
hasError={hasError} {tags?.map(tag => tagNode(tag))}
onClick={categoryClicked} </Box>
onIconClick={(e) => categoryIconClicked(e, category)} </Stack>
key={category.id} )
/>
);
};
const feedNode = (feed: TreeSubscription, level = 0) => {
if (!isFeedDisplayed(feed)) return null;
return (
<TreeNode
id={String(feed.id)}
type="feed"
name={feed.name}
icon={feed.iconUrl}
unread={feed.unread}
selected={source.type === "feed" && source.id === String(feed.id)}
level={level}
hasError={feed.errorCount > errorThreshold}
onClick={feedClicked}
key={feed.id}
newMessages={feed.hasNewEntries}
/>
);
};
const tagNode = (tag: string) => (
<TreeNode
id={tag}
type="tag"
name={tag}
icon={tagIcon}
unread={0}
selected={source.type === "tag" && source.id === tag}
level={0}
hasError={false}
onClick={tagClicked}
key={tag}
/>
);
const recursiveCategoryNode = (category: Category, level = 0) => (
<React.Fragment key={`recursiveCategoryNode-${category.id}`}>
{categoryNode(category, level)}
{category.expanded &&
category.children.map((c) => recursiveCategoryNode(c, level + 1))}
{category.expanded && category.feeds.map((f) => feedNode(f, level + 1))}
</React.Fragment>
);
if (!root) return <Loader />;
const feeds = flattenCategoryTree(root).flatMap((c) => c.feeds);
return (
<Stack>
<OnDesktop>
<TreeSearch feeds={feeds} />
</OnDesktop>
<Box className="cf-tree">
{allCategoryNode()}
{starredCategoryNode()}
{root.children.map((c) => recursiveCategoryNode(c))}
{root.feeds.map((f) => feedNode(f))}
{tags?.map((tag) => tagNode(tag))}
</Box>
</Stack>
);
} }

View File

@@ -1,107 +1,93 @@
import { Box, Center, Indicator } from "@mantine/core"; import { Box, Center } from "@mantine/core"
import type { EntrySourceType } from "app/entries/slice"; import type { EntrySourceType } from "app/entries/slice"
import { FeedFavicon } from "components/content/FeedFavicon"; import { FeedFavicon } from "components/content/FeedFavicon"
import type React from "react"; import type React from "react"
import { tss } from "tss"; import { tss } from "tss"
import { UnreadCount } from "./UnreadCount"; import { UnreadCount } from "./UnreadCount"
interface TreeNodeProps { interface TreeNodeProps {
id: string; id: string
type: EntrySourceType; type: EntrySourceType
name: React.ReactNode; name: React.ReactNode
icon: React.ReactNode; icon: React.ReactNode
unread: number; unread: number
selected: boolean; selected: boolean
expanded?: boolean; expanded?: boolean
level: number; level: number
hasError: boolean; hasError: boolean
newMessages?: boolean newMessages?: boolean
onClick: (e: React.MouseEvent, id: string) => void; onClick: (e: React.MouseEvent, id: string) => void
onIconClick?: (e: React.MouseEvent, id: string) => void; onIconClick?: (e: React.MouseEvent, id: string) => void
} }
const useStyles = tss const useStyles = tss
.withParams<{ .withParams<{
selected: boolean; selected: boolean
hasError: boolean; hasError: boolean
hasUnread: boolean; hasUnread: boolean
}>() }>()
.create(({ theme, colorScheme, selected, hasError, hasUnread }) => { .create(({ theme, colorScheme, selected, hasError, hasUnread }) => {
let backgroundColor = "inherit"; let backgroundColor = "inherit"
if (selected) if (selected) backgroundColor = colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]
backgroundColor =
colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1];
let color: string; let color: string
if (hasError) { if (hasError) {
color = theme.colors.red[6]; color = theme.colors.red[6]
} else if (colorScheme === "dark") { } else if (colorScheme === "dark") {
color = hasUnread ? theme.colors.dark[0] : theme.colors.dark[3]; color = hasUnread ? theme.colors.dark[0] : theme.colors.dark[3]
} else { } else {
color = hasUnread ? theme.black : theme.colors.gray[6]; color = hasUnread ? theme.black : theme.colors.gray[6]
} }
return { return {
node: { node: {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
cursor: "pointer", cursor: "pointer",
color, color,
backgroundColor, backgroundColor,
"&:hover": { "&:hover": {
backgroundColor: backgroundColor: colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[0],
colorScheme === "dark" },
? theme.colors.dark[6] },
: theme.colors.gray[0], nodeText: {
}, flexGrow: 1,
}, whiteSpace: "nowrap",
nodeText: { overflow: "hidden",
flexGrow: 1, textOverflow: "ellipsis",
whiteSpace: "nowrap", },
overflow: "hidden", }
textOverflow: "ellipsis", })
},
};
});
export function TreeNode(props: TreeNodeProps) { export function TreeNode(props: TreeNodeProps) {
const { classes } = useStyles({ const { classes } = useStyles({
selected: props.selected, selected: props.selected,
hasError: props.hasError, hasError: props.hasError,
hasUnread: props.unread > 0, hasUnread: props.unread > 0,
}); })
return (
<Box return (
py={1} <Box
pl={props.level * 20} py={1}
className={`${classes.node} cf-treenode cf-treenode-${props.type}`} pl={props.level * 20}
onClick={(e: React.MouseEvent) => props.onClick(e, props.id)} className={`${classes.node} cf-treenode cf-treenode-${props.type}`}
data-id={props.id} onClick={e => {
data-type={props.type} props.onClick(e, props.id)
data-unread-count={props.unread} props.type === "feed" && localStorage.setItem(`feed-${props.id}-unread`, props.unread.toString())
> }}
<Box data-id={props.id}
mr={6} data-type={props.type}
onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)} data-unread-count={props.unread}
className="cf-treenode-icon" >
> <Box mr={6} onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)} className="cf-treenode-icon">
<Center> <Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center>
{typeof props.icon === "string" ? ( </Box>
<FeedFavicon url={props.icon} /> <Box className={classes.nodeText}>{props.name}</Box>
) : ( {!props.expanded && (
props.icon <Box className="cf-treenode-unread-count">
)} <UnreadCount unreadCount={props.unread} newMessages={props.id !== "all" ? props.newMessages : false} />
</Center> </Box>
</Box> )}
<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}
/>
</Box> </Box>
)} )
</Box>
);
} }

View File

@@ -9,16 +9,13 @@ const useStyles = tss.create(() => ({
}, },
})) }))
export function UnreadCount(props: { unreadCount: number, newMessages: boolean | undefined }) { export function UnreadCount(props: { unreadCount: number; newMessages: boolean | undefined }) {
const { classes } = useStyles() const { classes } = useStyles()
if (props.unreadCount <= 0) return null if (props.unreadCount <= 0) return null
const count = props.unreadCount >= 10000 ? "10k+" : props.unreadCount const count = props.unreadCount >= 10000 ? "10k+" : props.unreadCount
console.log(props.newMessages);
return ( return (
<Tooltip label={props.unreadCount} disabled={props.unreadCount === count} openDelay={Constants.tooltip.delay}> <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.newMessages} size={4} offset={10} position="top-start" color="orange" withBorder={false} zIndex={5}>