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 { 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 } from "app/types" import type { Category, Subscription } 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 from "react" import React, { useEffect, useState } from "react";
import { TbChevronDown, TbChevronRight, TbInbox, TbStar, TbTag } from "react-icons/tb" import {
import { TreeNode } from "./TreeNode" TbChevronDown,
import { TreeSearch } from "./TreeSearch" TbChevronRight,
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 [hasNewMessages, setHasNewMessages] = useState(false);
const source = useAppSelector(state => state.entries.source) const root = useAppSelector((state) => state.tree.rootCategory);
const tags = useAppSelector(state => state.user.tags) const source = useAppSelector((state) => state.entries.source);
const showRead = useAppSelector(state => state.user.settings?.showRead) const tags = useAppSelector((state) => state.user.tags);
const dispatch = useAppDispatch() const showRead = useAppSelector((state) => state.user.settings?.showRead);
const dispatch = useAppDispatch();
const isFeedDisplayed = (feed: Subscription) => { const isFeedDisplayed = (feed: Subscription) => {
const isCurrentFeed = source.type === "feed" && source.id === String(feed.id) const isCurrentFeed =
return isCurrentFeed || feed.unread > 0 || showRead source.type === "feed" && source.id === String(feed.id);
} return isCurrentFeed || feed.unread > 0 || showRead;
};
const isCategoryDisplayed = (category: Category): boolean => { const isCategoryDisplayed = (category: Category): boolean => {
const isCurrentCategory = source.type === "category" && source.id === category.id const isCurrentCategory =
return ( source.type === "category" && source.id === category.id;
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)
return ( return (
<Stack> isCurrentCategory ||
<OnDesktop> showRead ||
<TreeSearch feeds={feeds} /> category.children.some((c) => isCategoryDisplayed(c)) ||
</OnDesktop> category.feeds.some((f) => isFeedDisplayed(f))
<Box className="cf-tree"> );
{allCategoryNode()} };
{starredCategoryNode()}
{root.children.map(c => recursiveCategoryNode(c))} const feedClicked = (e: React.MouseEvent, id: string) => {
{root.feeds.map(f => feedNode(f))} if (e.detail === 2) {
{tags?.map(tag => tagNode(tag))} dispatch(redirectToFeedDetails(id));
</Box> } else {
</Stack> 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 { Box, Center, Indicator } 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;
onClick: (e: React.MouseEvent, id: string) => void newMessages?: boolean
onIconClick?: (e: React.MouseEvent, id: string) => void onClick: (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) backgroundColor = colorScheme === "dark" ? theme.colors.dark[4] : theme.colors.gray[1] if (selected)
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: colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[0], backgroundColor:
}, colorScheme === "dark"
}, ? theme.colors.dark[6]
nodeText: { : theme.colors.gray[0],
flexGrow: 1, },
whiteSpace: "nowrap", },
overflow: "hidden", nodeText: {
textOverflow: "ellipsis", flexGrow: 1,
}, 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 ( return (
<Box <Box
py={1} py={1}
pl={props.level * 20} pl={props.level * 20}
className={`${classes.node} cf-treenode cf-treenode-${props.type}`} className={`${classes.node} cf-treenode cf-treenode-${props.type}`}
onClick={(e: React.MouseEvent) => props.onClick(e, props.id)} onClick={(e: React.MouseEvent) => props.onClick(e, props.id)}
data-id={props.id} data-id={props.id}
data-type={props.type} data-type={props.type}
data-unread-count={props.unread} data-unread-count={props.unread}
> >
<Box mr={6} onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)} className="cf-treenode-icon"> <Box
<Center>{typeof props.icon === "string" ? <FeedFavicon url={props.icon} /> : props.icon}</Center> mr={6}
</Box> onClick={(e: React.MouseEvent) => props.onIconClick?.(e, props.id)}
<Box className={classes.nodeText}>{props.name}</Box> className="cf-treenode-icon"
{!props.expanded && ( >
<Box className="cf-treenode-unread-count"> <Center>
<UnreadCount unreadCount={props.unread} /> {typeof props.icon === "string" ? (
</Box> <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>
) )}
</Box>
);
} }

View File

@@ -1,26 +1,60 @@
import { Badge, Tooltip } from "@mantine/core" import { Badge, Box, Flex, Tooltip } from "@mantine/core";
import { Constants } from "app/constants" import { Constants } from "app/constants";
import { tss } from "tss" import { tss } from "tss";
const useStyles = tss.create(() => ({ const useStyles = tss.create(() => ({
badge: { badge: {
width: "3.2rem", width: "3.2rem",
// for some reason, mantine Badge has "cursor: 'default'" cursor: "pointer",
cursor: "pointer", display: "flex",
}, justifyContent: "flex-start",
})) alignItems: "center",
},
}));
export function UnreadCount(props: { unreadCount: number }) { export function UnreadCount(props: {
const { classes } = useStyles() 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 const count = props.unreadCount >= 10000 ? "10k+" : props.unreadCount;
return (
<Tooltip label={props.unreadCount} disabled={props.unreadCount === count} openDelay={Constants.tooltip.delay}> return (
<Badge className={`${classes.badge} cf-badge`} variant="light" fullWidth> <Tooltip
{count} label={props.unreadCount}
</Badge> disabled={props.unreadCount === count}
</Tooltip> 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>
);
} }