feat: red dot indicator for new unread articles

This commit is contained in:
Eshwar Tangirala
2025-05-10 14:16:34 -04:00
parent 2c089ddb5e
commit 1bd504cbfb
3 changed files with 366 additions and 282 deletions

View File

@@ -1,194 +1,225 @@
import { Trans } from "@lingui/react/macro"
import { Box, Stack } from "@mantine/core"
import { Constants } from "app/constants"
import { Trans } from "@lingui/react/macro";
import { Box, Stack } from "@mantine/core";
import { Constants } from "app/constants";
import {
redirectToCategory,
redirectToCategoryDetails,
redirectToFeed,
redirectToFeedDetails,
redirectToTag,
redirectToTagDetails,
} from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import { collapseTreeCategory } from "app/tree/thunks"
import type { Category, Subscription } from "app/types"
import { categoryUnreadCount, flattenCategoryTree } from "app/utils"
import { Loader } from "components/Loader"
import { OnDesktop } from "components/responsive/OnDesktop"
import React from "react"
import { TbChevronDown, TbChevronRight, TbInbox, TbStar, TbTag } from "react-icons/tb"
import { TreeNode } from "./TreeNode"
import { TreeSearch } from "./TreeSearch"
redirectToCategory,
redirectToCategoryDetails,
redirectToFeed,
redirectToFeedDetails,
redirectToTag,
redirectToTagDetails,
} from "app/redirect/thunks";
import { useAppDispatch, useAppSelector } from "app/store";
import { collapseTreeCategory } from "app/tree/thunks";
import type { Category, Subscription } from "app/types";
import { categoryUnreadCount, flattenCategoryTree } from "app/utils";
import { Loader } from "components/Loader";
import { OnDesktop } from "components/responsive/OnDesktop";
import React, { useEffect, useState } from "react";
import {
TbChevronDown,
TbChevronRight,
TbInbox,
TbStar,
TbTag,
} from "react-icons/tb";
import { TreeNode } from "./TreeNode";
import { TreeSearch } from "./TreeSearch";
const allIcon = <TbInbox size={16} />
const starredIcon = <TbStar size={16} />
const tagIcon = <TbTag size={16} />
const expandedIcon = <TbChevronDown size={16} />
const collapsedIcon = <TbChevronRight size={16} />
const allIcon = <TbInbox size={16} />;
const starredIcon = <TbStar size={16} />;
const tagIcon = <TbTag size={16} />;
const expandedIcon = <TbChevronDown size={16} />;
const collapsedIcon = <TbChevronRight size={16} />;
const errorThreshold = 9
const errorThreshold = 9;
export function Tree() {
const root = useAppSelector(state => state.tree.rootCategory)
const source = useAppSelector(state => state.entries.source)
const tags = useAppSelector(state => state.user.tags)
const showRead = useAppSelector(state => state.user.settings?.showRead)
const dispatch = useAppDispatch()
const [hasNewMessages, setHasNewMessages] = useState(false);
const root = useAppSelector((state) => state.tree.rootCategory);
const source = useAppSelector((state) => state.entries.source);
const tags = useAppSelector((state) => state.user.tags);
const showRead = useAppSelector((state) => state.user.settings?.showRead);
const dispatch = useAppDispatch();
const isFeedDisplayed = (feed: Subscription) => {
const isCurrentFeed = source.type === "feed" && source.id === String(feed.id)
return isCurrentFeed || feed.unread > 0 || showRead
}
const isFeedDisplayed = (feed: Subscription) => {
const isCurrentFeed =
source.type === "feed" && source.id === String(feed.id);
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) => {
if (e.detail === 2) {
dispatch(redirectToCategoryDetails(id))
} else {
dispatch(redirectToCategory(id))
}
}
const categoryIconClicked = (e: React.MouseEvent, category: Category) => {
e.stopPropagation()
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 = () => (
<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}
/>
)
const categoryNode = (category: Category, level = 0) => {
if (!isCategoryDisplayed(category)) return null
const hasError = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold))
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 feedNode = (feed: Subscription, 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}
/>
)
}
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)
const isCategoryDisplayed = (category: Category): boolean => {
const isCurrentCategory =
source.type === "category" && source.id === category.id;
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>
)
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) => {
if (e.detail === 2) {
dispatch(redirectToCategoryDetails(id));
} else {
dispatch(redirectToCategory(id));
}
};
const categoryIconClicked = (e: React.MouseEvent, category: Category) => {
e.stopPropagation();
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));
}
};
useEffect(() => {
const prevCount = JSON.parse(localStorage.getItem("FeedCount") || "0");
const currentCount = categoryUnreadCount(root);
if (currentCount > prevCount) {
setHasNewMessages(true);
}
localStorage.setItem("FeedCount", JSON.stringify(currentCount));
}, [root?.feeds.length]);
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}
newMessages={hasNewMessages}
/>
);
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) => {
if (!isCategoryDisplayed(category)) return null;
const hasError =
!category.expanded &&
flattenCategoryTree(category).some((c) =>
c.feeds.some((f) => f.errorCount > errorThreshold)
);
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 feedNode = (feed: Subscription, 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}
/>
);
};
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,88 +1,107 @@
import { Box, Center } from "@mantine/core"
import type { EntrySourceType } from "app/entries/slice"
import { FeedFavicon } from "components/content/FeedFavicon"
import type React from "react"
import { tss } from "tss"
import { UnreadCount } from "./UnreadCount"
import { Box, Center, Indicator } from "@mantine/core";
import type { EntrySourceType } from "app/entries/slice";
import { FeedFavicon } from "components/content/FeedFavicon";
import type React from "react";
import { tss } from "tss";
import { UnreadCount } from "./UnreadCount";
interface TreeNodeProps {
id: string
type: EntrySourceType
name: React.ReactNode
icon: React.ReactNode
unread: number
selected: boolean
expanded?: boolean
level: number
hasError: boolean
onClick: (e: React.MouseEvent, id: string) => void
onIconClick?: (e: React.MouseEvent, id: string) => void
id: string;
type: EntrySourceType;
name: React.ReactNode;
icon: React.ReactNode;
unread: number;
selected: boolean;
expanded?: boolean;
level: number;
hasError: boolean;
newMessages?: boolean
onClick: (e: React.MouseEvent, id: string) => void;
onIconClick?: (e: React.MouseEvent, id: string) => void;
}
const useStyles = tss
.withParams<{
selected: boolean
hasError: boolean
hasUnread: boolean
}>()
.create(({ theme, colorScheme, selected, hasError, hasUnread }) => {
let backgroundColor = "inherit"
if (selected) backgroundColor = colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1]
.withParams<{
selected: boolean;
hasError: boolean;
hasUnread: boolean;
}>()
.create(({ theme, colorScheme, selected, hasError, hasUnread }) => {
let backgroundColor = "inherit";
if (selected)
backgroundColor =
colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1];
let color: string
if (hasError) {
color = theme.colors.red[6]
} else if (colorScheme === "dark") {
color = hasUnread ? theme.colors.dark[0] : theme.colors.dark[3]
} else {
color = hasUnread ? theme.black : theme.colors.gray[6]
}
let color: string;
if (hasError) {
color = theme.colors.red[6];
} else if (colorScheme === "dark") {
color = hasUnread ? theme.colors.dark[0] : theme.colors.dark[3];
} else {
color = hasUnread ? theme.black : theme.colors.gray[6];
}
return {
node: {
display: "flex",
alignItems: "center",
cursor: "pointer",
color,
backgroundColor,
"&:hover": {
backgroundColor: colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[0],
},
},
nodeText: {
flexGrow: 1,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
},
}
})
return {
node: {
display: "flex",
alignItems: "center",
cursor: "pointer",
color,
backgroundColor,
"&:hover": {
backgroundColor:
colorScheme === "dark"
? theme.colors.dark[6]
: theme.colors.gray[0],
},
},
nodeText: {
flexGrow: 1,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
},
};
});
export function TreeNode(props: TreeNodeProps) {
const { classes } = useStyles({
selected: props.selected,
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}
>
<Box mr={6} onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)} className="cf-treenode-icon">
<Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center>
</Box>
<Box className={classes.nodeText}>{props.name}</Box>
{!props.expanded && (
<Box className="cf-treenode-unread-count">
<UnreadCount unreadCount={props.unread} />
</Box>
)}
const { classes } = useStyles({
selected: props.selected,
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}
>
<Box
mr={6}
onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)}
className="cf-treenode-icon"
>
<Center>
{typeof props.icon === "string" ? (
<FeedFavicon url={props.icon} />
) : (
props.icon
)}
</Center>
</Box>
<Box className={classes.nodeText}>{props.name}</Box>
{!props.expanded && (
<Box className="cf-treenode-unread-count">
<UnreadCount
unreadCount={props.unread}
newMessages={props.id === "all" ? true: false}
/>
</Box>
)
)}
</Box>
);
}

View File

@@ -1,26 +1,60 @@
import { Badge, Tooltip } from "@mantine/core"
import { Constants } from "app/constants"
import { tss } from "tss"
import { Badge, Box, Flex, Tooltip } from "@mantine/core";
import { Constants } from "app/constants";
import { tss } from "tss";
const useStyles = tss.create(() => ({
badge: {
width: "3.2rem",
// for some reason, mantine Badge has "cursor: 'default'"
cursor: "pointer",
},
}))
badge: {
width: "3.2rem",
cursor: "pointer",
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
},
}));
export function UnreadCount(props: { unreadCount: number }) {
const { classes } = useStyles()
export function UnreadCount(props: {
unreadCount: number;
newMessages?: boolean;
}) {
const { classes } = useStyles();
if (props.unreadCount <= 0) return null
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}>
<Badge className={`${classes.badge} cf-badge`} variant="light" fullWidth>
{count}
</Badge>
</Tooltip>
)
const count = props.unreadCount >= 10000 ? "10k+" : props.unreadCount;
return (
<Tooltip
label={props.unreadCount}
disabled={props.unreadCount === count}
openDelay={Constants.tooltip.delay}
>
<Badge className={`${classes.badge} cf-badge`} variant="light">
<Flex align="center" justify="center" style={{ width: "100%" }}>
{/* Dot wrapper: always renders, but conditionally visible */}
<Box
style={{
width: 8,
display: "flex",
justifyContent: "center",
alignItems: "center",
marginRight: 4,
}}
>
{props.newMessages && (
<div
style={{
width: 5,
height: 5,
borderRadius: "50%",
backgroundColor: "orange",
}}
/>
)}
</Box>
<Box style={{ minWidth: "1.3rem", textAlign: "center" }}>{count}</Box>
</Flex>
</Badge>
</Tooltip>
);
}