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,6 +1,6 @@
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,
@@ -8,94 +8,117 @@ import {
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 =
source.type === "category" && source.id === category.id;
return ( return (
isCurrentCategory || isCurrentCategory ||
showRead || showRead ||
category.children.some(c => isCategoryDisplayed(c)) || category.children.some((c) => isCategoryDisplayed(c)) ||
category.feeds.some(f => isFeedDisplayed(f)) category.feeds.some((f) => isFeedDisplayed(f))
) );
} };
const feedClicked = (e: React.MouseEvent, id: string) => { const feedClicked = (e: React.MouseEvent, id: string) => {
if (e.detail === 2) { if (e.detail === 2) {
dispatch(redirectToFeedDetails(id)) dispatch(redirectToFeedDetails(id));
} else { } else {
dispatch(redirectToFeed(id)) dispatch(redirectToFeed(id));
}
} }
};
const categoryClicked = (e: React.MouseEvent, id: string) => { const categoryClicked = (e: React.MouseEvent, id: string) => {
if (e.detail === 2) { if (e.detail === 2) {
dispatch(redirectToCategoryDetails(id)) dispatch(redirectToCategoryDetails(id));
} else { } else {
dispatch(redirectToCategory(id)) dispatch(redirectToCategory(id));
}
} }
};
const categoryIconClicked = (e: React.MouseEvent, category: Category) => { const categoryIconClicked = (e: React.MouseEvent, category: Category) => {
e.stopPropagation() e.stopPropagation();
dispatch( dispatch(
collapseTreeCategory({ collapseTreeCategory({
id: +category.id, id: +category.id,
collapse: category.expanded, collapse: category.expanded,
}) })
) );
} };
const tagClicked = (e: React.MouseEvent, id: string) => { const tagClicked = (e: React.MouseEvent, id: string) => {
if (e.detail === 2) { if (e.detail === 2) {
dispatch(redirectToTagDetails(id)) dispatch(redirectToTagDetails(id));
} else { } else {
dispatch(redirectToTag(id)) 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 = () => ( const allCategoryNode = () => (
<TreeNode <TreeNode
id={Constants.categories.all.id} id={Constants.categories.all.id}
type="category" type="category"
name={<Trans>All</Trans>} name={<Trans>All</Trans>}
icon={allIcon} icon={allIcon}
unread={categoryUnreadCount(root)} unread={categoryUnreadCount(root)}
selected={source.type === "category" && source.id === Constants.categories.all.id} selected={
source.type === "category" && source.id === Constants.categories.all.id
}
expanded={false} expanded={false}
level={0} level={0}
hasError={false} hasError={false}
onClick={categoryClicked} onClick={categoryClicked}
newMessages={hasNewMessages}
/> />
) );
const starredCategoryNode = () => ( const starredCategoryNode = () => (
<TreeNode <TreeNode
id={Constants.categories.starred.id} id={Constants.categories.starred.id}
@@ -103,18 +126,25 @@ export function Tree() {
name={<Trans>Starred</Trans>} name={<Trans>Starred</Trans>}
icon={starredIcon} icon={starredIcon}
unread={0} unread={0}
selected={source.type === "category" && source.id === Constants.categories.starred.id} selected={
source.type === "category" &&
source.id === Constants.categories.starred.id
}
expanded={false} expanded={false}
level={0} level={0}
hasError={false} hasError={false}
onClick={categoryClicked} onClick={categoryClicked}
/> />
) );
const categoryNode = (category: Category, level = 0) => { const categoryNode = (category: Category, level = 0) => {
if (!isCategoryDisplayed(category)) return null if (!isCategoryDisplayed(category)) return null;
const hasError = !category.expanded && flattenCategoryTree(category).some(c => c.feeds.some(f => f.errorCount > errorThreshold)) const hasError =
!category.expanded &&
flattenCategoryTree(category).some((c) =>
c.feeds.some((f) => f.errorCount > errorThreshold)
);
return ( return (
<TreeNode <TreeNode
id={category.id} id={category.id}
@@ -127,14 +157,14 @@ export function Tree() {
level={level} level={level}
hasError={hasError} hasError={hasError}
onClick={categoryClicked} onClick={categoryClicked}
onIconClick={e => categoryIconClicked(e, category)} onIconClick={(e) => categoryIconClicked(e, category)}
key={category.id} key={category.id}
/> />
) );
} };
const feedNode = (feed: Subscription, level = 0) => { const feedNode = (feed: Subscription, level = 0) => {
if (!isFeedDisplayed(feed)) return null if (!isFeedDisplayed(feed)) return null;
return ( return (
<TreeNode <TreeNode
@@ -149,8 +179,8 @@ export function Tree() {
onClick={feedClicked} onClick={feedClicked}
key={feed.id} key={feed.id}
/> />
) );
} };
const tagNode = (tag: string) => ( const tagNode = (tag: string) => (
<TreeNode <TreeNode
@@ -165,18 +195,19 @@ export function Tree() {
onClick={tagClicked} onClick={tagClicked}
key={tag} key={tag}
/> />
) );
const recursiveCategoryNode = (category: Category, level = 0) => ( const recursiveCategoryNode = (category: Category, level = 0) => (
<React.Fragment key={`recursiveCategoryNode-${category.id}`}> <React.Fragment key={`recursiveCategoryNode-${category.id}`}>
{categoryNode(category, level)} {categoryNode(category, level)}
{category.expanded && category.children.map(c => recursiveCategoryNode(c, level + 1))} {category.expanded &&
{category.expanded && category.feeds.map(f => feedNode(f, level + 1))} category.children.map((c) => recursiveCategoryNode(c, level + 1))}
{category.expanded && category.feeds.map((f) => feedNode(f, level + 1))}
</React.Fragment> </React.Fragment>
) );
if (!root) return <Loader /> if (!root) return <Loader />;
const feeds = flattenCategoryTree(root).flatMap(c => c.feeds) const feeds = flattenCategoryTree(root).flatMap((c) => c.feeds);
return ( return (
<Stack> <Stack>
<OnDesktop> <OnDesktop>
@@ -185,10 +216,10 @@ export function Tree() {
<Box className="cf-tree"> <Box className="cf-tree">
{allCategoryNode()} {allCategoryNode()}
{starredCategoryNode()} {starredCategoryNode()}
{root.children.map(c => recursiveCategoryNode(c))} {root.children.map((c) => recursiveCategoryNode(c))}
{root.feeds.map(f => feedNode(f))} {root.feeds.map((f) => feedNode(f))}
{tags?.map(tag => tagNode(tag))} {tags?.map((tag) => tagNode(tag))}
</Box> </Box>
</Stack> </Stack>
) );
} }

View File

@@ -1,41 +1,44 @@
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 {
@@ -46,7 +49,10 @@ const useStyles = tss
color, color,
backgroundColor, backgroundColor,
"&:hover": { "&:hover": {
backgroundColor: colorScheme === "dark" ? theme.colors.dark[6] : theme.colors.gray[0], backgroundColor:
colorScheme === "dark"
? theme.colors.dark[6]
: theme.colors.gray[0],
}, },
}, },
nodeText: { nodeText: {
@@ -55,15 +61,15 @@ const useStyles = tss
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", 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}
@@ -74,15 +80,28 @@ export function TreeNode(props: TreeNodeProps) {
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}
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>
<Box className={classes.nodeText}>{props.name}</Box> <Box className={classes.nodeText}>{props.name}</Box>
{!props.expanded && ( {!props.expanded && (
<Box className="cf-treenode-unread-count"> <Box className="cf-treenode-unread-count">
<UnreadCount unreadCount={props.unread} /> <UnreadCount
unreadCount={props.unread}
newMessages={props.id === "all" ? true: false}
/>
</Box> </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 ( return (
<Tooltip label={props.unreadCount} disabled={props.unreadCount === count} openDelay={Constants.tooltip.delay}> <Tooltip
<Badge className={`${classes.badge} cf-badge`} variant="light" fullWidth> label={props.unreadCount}
{count} 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> </Badge>
</Tooltip> </Tooltip>
) );
} }