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} + + + + ); }