diff --git a/commafeed-client/src/components/sidebar/Tree.tsx b/commafeed-client/src/components/sidebar/Tree.tsx
index ccfa8dca..2e775c42 100644
--- a/commafeed-client/src/components/sidebar/Tree.tsx
+++ b/commafeed-client/src/components/sidebar/Tree.tsx
@@ -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 =
-const starredIcon =
-const tagIcon =
-const expandedIcon =
-const collapsedIcon =
+const allIcon = ;
+const starredIcon = ;
+const tagIcon = ;
+const expandedIcon = ;
+const collapsedIcon = ;
-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 = () => (
- All}
- 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 = () => (
- Starred}
- 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 (
- categoryIconClicked(e, category)}
- key={category.id}
- />
- )
- }
-
- const feedNode = (feed: Subscription, level = 0) => {
- if (!isFeedDisplayed(feed)) return null
-
- return (
- errorThreshold}
- onClick={feedClicked}
- key={feed.id}
- />
- )
- }
-
- const tagNode = (tag: string) => (
-
- )
-
- const recursiveCategoryNode = (category: Category, level = 0) => (
-
- {categoryNode(category, level)}
- {category.expanded && category.children.map(c => recursiveCategoryNode(c, level + 1))}
- {category.expanded && category.feeds.map(f => feedNode(f, level + 1))}
-
- )
-
- if (!root) return
- const feeds = flattenCategoryTree(root).flatMap(c => c.feeds)
+ const isCategoryDisplayed = (category: Category): boolean => {
+ const isCurrentCategory =
+ source.type === "category" && source.id === category.id;
return (
-
-
-
-
-
- {allCategoryNode()}
- {starredCategoryNode()}
- {root.children.map(c => recursiveCategoryNode(c))}
- {root.feeds.map(f => feedNode(f))}
- {tags?.map(tag => tagNode(tag))}
-
-
- )
+ 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 = () => (
+
+ All}
+ 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 = () => (
+ Starred}
+ 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 (
+ categoryIconClicked(e, category)}
+ key={category.id}
+ />
+ );
+ };
+
+ const feedNode = (feed: Subscription, level = 0) => {
+ if (!isFeedDisplayed(feed)) return null;
+
+ return (
+ errorThreshold}
+ onClick={feedClicked}
+ key={feed.id}
+ />
+ );
+ };
+
+ const tagNode = (tag: string) => (
+
+ );
+
+ const recursiveCategoryNode = (category: Category, level = 0) => (
+
+ {categoryNode(category, level)}
+ {category.expanded &&
+ category.children.map((c) => recursiveCategoryNode(c, level + 1))}
+ {category.expanded && category.feeds.map((f) => feedNode(f, level + 1))}
+
+ );
+
+ if (!root) return ;
+ const feeds = flattenCategoryTree(root).flatMap((c) => c.feeds);
+ return (
+
+
+
+
+
+ {allCategoryNode()}
+ {starredCategoryNode()}
+ {root.children.map((c) => recursiveCategoryNode(c))}
+ {root.feeds.map((f) => feedNode(f))}
+ {tags?.map((tag) => tagNode(tag))}
+
+
+ );
}
diff --git a/commafeed-client/src/components/sidebar/TreeNode.tsx b/commafeed-client/src/components/sidebar/TreeNode.tsx
index 26d55777..f52895c8 100644
--- a/commafeed-client/src/components/sidebar/TreeNode.tsx
+++ b/commafeed-client/src/components/sidebar/TreeNode.tsx
@@ -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 (
- props.onClick(e, props.id)}
- data-id={props.id}
- data-type={props.type}
- data-unread-count={props.unread}
- >
- props.onIconClick?.(e, props.id)} className="cf-treenode-icon">
- {typeof props.icon === "string" ? : props.icon}
-
- {props.name}
- {!props.expanded && (
-
-
-
- )}
+ const { classes } = useStyles({
+ selected: props.selected,
+ hasError: props.hasError,
+ hasUnread: props.unread > 0,
+ });
+ return (
+ props.onClick(e, props.id)}
+ data-id={props.id}
+ data-type={props.type}
+ data-unread-count={props.unread}
+ >
+ props.onIconClick?.(e, props.id)}
+ className="cf-treenode-icon"
+ >
+
+ {typeof props.icon === "string" ? (
+
+ ) : (
+ props.icon
+ )}
+
+
+ {props.name}
+ {!props.expanded && (
+
+
- )
+ )}
+
+ );
}
diff --git a/commafeed-client/src/components/sidebar/UnreadCount.tsx b/commafeed-client/src/components/sidebar/UnreadCount.tsx
index 63e39235..83e0f047 100644
--- a/commafeed-client/src/components/sidebar/UnreadCount.tsx
+++ b/commafeed-client/src/components/sidebar/UnreadCount.tsx
@@ -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 (
-
-
- {count}
-
-
- )
+ const count = props.unreadCount >= 10000 ? "10k+" : props.unreadCount;
+
+ return (
+
+
+
+ {/* Dot wrapper: always renders, but conditionally visible */}
+
+ {props.newMessages && (
+
+ )}
+
+
+ {count}
+
+
+
+ );
}