diff --git a/src/main/tipc.ts b/src/main/tipc.ts index 94abeea4c..3fa8ab1d6 100644 --- a/src/main/tipc.ts +++ b/src/main/tipc.ts @@ -156,13 +156,21 @@ export const router = { openSettingWindow: t.procedure.action(async () => createSettingWindow()), - getSystemFonts: t.procedure.action(async (): Promise => new Promise((resolve) => { - // NOTE: should external font-list deps - // use `require` to avoid bundling, vite behavior - require("font-list").getFonts().then((fonts) => { - resolve(fonts.map((font) => font.replaceAll("\"", ""))) - }) - })), + getSystemFonts: t.procedure.action( + async (): Promise => + new Promise((resolve) => { + // NOTE: should external font-list deps + // use `require` to avoid bundling, vite behavior + require("font-list") + .getFonts() + .then((fonts) => { + resolve(fonts.map((font) => font.replaceAll("\"", ""))) + }) + }), + ), + setMacOSBadge: t.procedure.input().action(async ({ input }) => { + app.setBadgeCount(input) + }), } export type Router = typeof router diff --git a/src/renderer/src/atoms/route.ts b/src/renderer/src/atoms/route.ts index 8a1de63cc..1d6e1219a 100644 --- a/src/renderer/src/atoms/route.ts +++ b/src/renderer/src/atoms/route.ts @@ -2,17 +2,25 @@ import { createAtomHooks } from "@renderer/lib/jotai" import { atom, useAtomValue } from "jotai" import { selectAtom } from "jotai/utils" import { useMemo } from "react" -import type { NavigateFunction, Params } from "react-router-dom" +import type { Location, NavigateFunction, Params } from "react-router-dom" interface RouteAtom { params: Readonly> searchParams: URLSearchParams + location: Location } export const [routeAtom, , , , getReadonlyRoute, setRoute] = createAtomHooks( atom({ params: {}, searchParams: new URLSearchParams(), + location: { + pathname: "", + search: "", + hash: "", + state: null, + key: "", + }, }), ) @@ -25,7 +33,7 @@ export const useReadonlyRouteSelector = ( useMemo(() => selectAtom(routeAtom, (route) => selector(route)), deps), ) -// VITE HMR will create new router instance, but RouterProvider always stable +// Vite HMR will create new router instance, but RouterProvider always stable const [, , , , navigate, setNavigate] = createAtomHooks( atom<{ fn: NavigateFunction | null }>({ fn() {} }), diff --git a/src/renderer/src/components/common/LoadRemixAsyncComponent.tsx b/src/renderer/src/components/common/LoadRemixAsyncComponent.tsx new file mode 100644 index 000000000..9f5b95287 --- /dev/null +++ b/src/renderer/src/components/common/LoadRemixAsyncComponent.tsx @@ -0,0 +1,57 @@ +import type { FC, ReactNode } from "react" +import { + createElement, + useEffect, + useState, +} from "react" + +import { LoadingCircle } from "../ui/loading" + +export const LoadRemixAsyncComponent: FC<{ + loader: () => Promise + Header: FC<{ loader: () => any, [key: string]: any }> +}> = ({ loader, Header }) => { + const [loading, setLoading] = useState(true) + + const [Component, setComponent] = useState<{ c: () => ReactNode }>({ + c: () => null, + }) + + useEffect(() => { + let isUnmounted = false + setLoading(true) + loader() + .then((module) => { + if (!module.Component) { + return + } + if (isUnmounted) return + + const { loader } = module + setComponent({ + c: () => ( + <> +
+ + + ), + }) + }) + .finally(() => { + setLoading(false) + }) + return () => { + isUnmounted = true + } + }, [Header, loader]) + + if (loading) { + return ( +
+ +
+ ) + } + + return createElement(Component.c) +} diff --git a/src/renderer/src/components/ui/modal/stacked/hooks.tsx b/src/renderer/src/components/ui/modal/stacked/hooks.tsx index 3ad2ba88f..b9c474e39 100644 --- a/src/renderer/src/components/ui/modal/stacked/hooks.tsx +++ b/src/renderer/src/components/ui/modal/stacked/hooks.tsx @@ -12,23 +12,36 @@ export const useModalStack = (options?: ModalStackOptions) => { return { present: useCallback( (props: ModalProps & { id?: string }) => { - const modalId = `${id}-${++currentCount.current}` - jotaiStore.set(modalStackAtom, (p) => { - const modalProps = { - ...props, - id: props.id ?? modalId, - wrapper, - } - modalIdToPropsMap[modalProps.id] = modalProps - return p.concat(modalProps) - }) + const fallbackModelId = `${id}-${++currentCount.current}` + const modalId = props.id ?? fallbackModelId + + const currentStack = jotaiStore.get(modalStackAtom) + + const existingModal = currentStack.find((item) => item.id === modalId) + if (existingModal) { + // Move to top + jotaiStore.set(modalStackAtom, (p) => { + const index = p.indexOf(existingModal) + return [...p.slice(0, index), ...p.slice(index + 1), existingModal] + }) + } else { + jotaiStore.set(modalStackAtom, (p) => { + const modalProps = { + ...props, + id: modalId, + wrapper, + } + modalIdToPropsMap[modalProps.id] = modalProps + return p.concat(modalProps) + }) + } return () => { jotaiStore.set(modalStackAtom, (p) => p.filter((item) => item.id !== modalId)) } }, - [id], + [id, wrapper], ), ...actions, diff --git a/src/renderer/src/components/ui/modal/stacked/modal.tsx b/src/renderer/src/components/ui/modal/stacked/modal.tsx index 112837e45..c4a739b4a 100644 --- a/src/renderer/src/components/ui/modal/stacked/modal.tsx +++ b/src/renderer/src/components/ui/modal/stacked/modal.tsx @@ -157,6 +157,9 @@ export const ModalInternal: Component<{ + + {title} +
feed.feedId) @@ -62,9 +66,13 @@ function FeedCategoryImpl({ ), ) - const isActive = useRouteParamsSelector((routerParams) => routerParams?.level === levels.folder && - routerParams.feedId === data.list.map((feed) => feed.feedId).join(",")) + const isActive = useRouteParamsSelector( + (routerParams) => + routerParams?.level === levels.folder && + routerParams.feedId === data.list.map((feed) => feed.feedId).join(","), + ) const { present } = useModalStack() + return ( - {data.name} + + {data.name} +
- {!!unread && ( + {!!unread && showUnreadCount && (
{unread}
)} @@ -154,6 +164,7 @@ function FeedCategoryImpl({ > {sortByUnreadFeedList.map((feed) => ( { + const navigate = useNavigateEntry() + + return useCallback((overvideActive?: number) => { + navigate({ + feedId: null, + entryId: null, + view: overvideActive ?? active, + level: levels.view, + }) + }, [active, navigate]) +} export function FeedColumn() { const carouselRef = useRef(null) - const [active, setActive] = useState(0) + const [active, setActive_] = useState(0) const spring = useSpring(0, { stiffness: 700, damping: 40, }) + const navigateBackHome = useBackHome(active) + const setActive: typeof setActive_ = useCallback( + (args) => { + const nextActive = typeof args === "function" ? args(active) : args + setActive_(args) - useHotkeys(shortcuts.feeds.switchBetweenViews.key, () => { - if (isHotkeyPressed("Left")) { - setActive((i) => { - if (i === 0) { - return views.length - 1 - } else { - return i - 1 - } - }) - } else { - setActive((i) => (i + 1) % views.length) - } - }, { scopes: ["home"] }) + if (getReadonlyRoute().location.pathname.startsWith(Routes.Feeds)) { + navigateBackHome(nextActive) + } + spring.set(-nextActive * 256) + }, + [active, navigateBackHome, spring], + ) + + useHotkeys( + shortcuts.feeds.switchBetweenViews.key, + () => { + if (isHotkeyPressed("Left")) { + setActive((i) => { + if (i === 0) { + return views.length - 1 + } else { + return i - 1 + } + }) + } else { + setActive((i) => (i + 1) % views.length) + } + }, + { scopes: ["home"] }, + ) useWheel( ({ event, last, memo: wait = false, direction: [dx], delta: [dex] }) => { @@ -67,25 +98,10 @@ export function FeedColumn() { const normalStyle = !window.electron || window.electron.process.platform !== "darwin" - const navigate = useNavigateEntry() - - useEffect(() => { - spring.set(-active * 256) - navigateBackHome() - }, [active]) - - const navigateBackHome = useCallback(() => { - navigate({ - feedId: null, - entryId: null, - view: active, - level: levels.view, - }) - }, [active, navigate]) return ( navigateBackHome(), [navigateBackHome])} >
{ + showUnreadCount = true, +}: FeedItemProps) => { const navigate = useNavigateEntry() const handleNavigate: React.MouseEventHandler = useCallback( (e) => { @@ -213,7 +216,14 @@ const FeedItemImpl = ({ )} > -
{feed.title}
+
+ {feed.title} +
{feed.errorAt && ( @@ -250,7 +260,7 @@ const FeedItemImpl = ({ )}
- {!!feedUnread && ( + {showUnreadCount && !!feedUnread && (
{feedUnread}
)} diff --git a/src/renderer/src/modules/feed-column/list.tsx b/src/renderer/src/modules/feed-column/list.tsx index 14e36d2bc..b837145ad 100644 --- a/src/renderer/src/modules/feed-column/list.tsx +++ b/src/renderer/src/modules/feed-column/list.tsx @@ -5,14 +5,13 @@ import { FEED_COLLECTION_LIST, levels, views } from "@renderer/lib/constants" import { stopPropagation } from "@renderer/lib/dom" import type { FeedViewType } from "@renderer/lib/enum" import { cn } from "@renderer/lib/utils" -import type { - FeedListModel, -} from "@renderer/models" +import type { FeedListModel } from "@renderer/models" import { Queries } from "@renderer/queries" import type { SubscriptionPlainModel } from "@renderer/store" import { getFeedById, useSubscriptionByView, + useUIStore, useUnreadStore, } from "@renderer/store" import { useMemo, useState } from "react" @@ -124,6 +123,7 @@ export function FeedList({ const feedId = useRouteFeedId() const navigate = useNavigateEntry() + const showUnreadCount = useUIStore((state) => state.sidebarShowUnreadCount) return (
@@ -187,6 +187,7 @@ export function FeedList({ {data?.list?.length ? sortedByUnread?.map((category) => ( { const map = import.meta.glob("@renderer/pages/settings/*.tsx") @@ -27,60 +19,14 @@ const pages = (() => { } return pages })() - -function Layout(props: PropsWithChildren) { - const { children } = props - const setTab = useSetSettingTab() - const tab = useSettingTab() - - useEffect(() => { - if (!tab) setTab(settings[0].path) - }, []) - return ( - -
-
- {settings.map((t) => ( - - ))} -
-
- {children} -
-
-
- ) -} - export const SettingModalContent = () => ( - +
-
+
) @@ -100,48 +46,5 @@ const Content = () => { if (!Component) return null - return -} - -const LoadRemixAsyncComponent: FC<{ - loader: () => Promise -}> = ({ loader }) => { - const [loading, setLoading] = useState(true) - - const [Component, setComponent] = useState<{ c: () => ReactNode }>({ - c: () => null, - }) - - useEffect(() => { - setLoading(true) - loader() - .then((module) => { - if (!module.Component) { - return - } - - const { loader } = module - setComponent({ - c: () => ( - <> - - - - ), - }) - }) - .finally(() => { - setLoading(false) - }) - }, [loader]) - - if (loading) { - return ( -
- -
- ) - } - - return createElement(Component.c) + return } diff --git a/src/renderer/src/modules/settings/modal/context.tsx b/src/renderer/src/modules/settings/modal/context.tsx new file mode 100644 index 000000000..cfa6fabbe --- /dev/null +++ b/src/renderer/src/modules/settings/modal/context.tsx @@ -0,0 +1,3 @@ +import { createContextState } from "foxact/context-state" + +export const [SettingTabProvider, useSettingTab, useSetSettingTab] = createContextState("") diff --git a/src/renderer/src/modules/settings/modal/hooks.ts b/src/renderer/src/modules/settings/modal/hooks.ts index 2da3dd520..51c189724 100644 --- a/src/renderer/src/modules/settings/modal/hooks.ts +++ b/src/renderer/src/modules/settings/modal/hooks.ts @@ -8,6 +8,7 @@ export const useSettingModal = () => { return useCallback(() => present({ title: "Setting", + id: "setting", content: SettingModalContent, CustomModalComponent: (props) => createElement("div", { className: "center h-full center", diff --git a/src/renderer/src/modules/settings/modal/layout.tsx b/src/renderer/src/modules/settings/modal/layout.tsx new file mode 100644 index 000000000..a82134c04 --- /dev/null +++ b/src/renderer/src/modules/settings/modal/layout.tsx @@ -0,0 +1,59 @@ +import { Logo } from "@renderer/components/icons/logo" +import { APP_NAME } from "@renderer/lib/constants" +import { preventDefault } from "@renderer/lib/dom" +import { m } from "framer-motion" +import type { PropsWithChildren } from "react" +import { useEffect } from "react" + +import { settings } from "../constants" +import { SettingsSidebarTitle } from "../title" +import { useSetSettingTab, useSettingTab } from "./context" + +export function SettingModalLayout(props: PropsWithChildren) { + const { children } = props + const setTab = useSetSettingTab() + const tab = useSettingTab() + + useEffect(() => { + if (!tab) setTab(settings[0].path) + }, []) + return ( + +
+
+
+ + {APP_NAME} +
+ {settings.map((t) => ( + + ))} +
+
+ {children} +
+
+
+ ) +} diff --git a/src/renderer/src/modules/settings/tabs/apperance.tsx b/src/renderer/src/modules/settings/tabs/apperance.tsx index 61dc899a9..53aa5b67d 100644 --- a/src/renderer/src/modules/settings/tabs/apperance.tsx +++ b/src/renderer/src/modules/settings/tabs/apperance.tsx @@ -23,6 +23,7 @@ export const SettingAppearance = () => { }, []) const state = useUIStore() + const onlyMacos = window.electron && getOS() === "macOS" return (
@@ -33,7 +34,7 @@ export const SettingAppearance = () => { checked={isDark} onCheckedChange={saveDarkSetting} /> - {window.electron && getOS() === "macOS" && ( + {onlyMacos && ( { {window.electron && } + + {onlyMacos && ( + { + uiActions.set("showDockBadge", c) + }} + /> + )} + { + uiActions.set("sidebarShowUnreadCount", c) + }} + />
) } @@ -108,12 +126,16 @@ const TextSize = () => { ) }} > - + {Object.entries(textSizeMap).map(([size, value]) => ( - + {size} ))} diff --git a/src/renderer/src/providers/biz-router-provider.tsx b/src/renderer/src/providers/biz-router-provider.tsx index 68ef8b612..aaefc774f 100644 --- a/src/renderer/src/providers/biz-router-provider.tsx +++ b/src/renderer/src/providers/biz-router-provider.tsx @@ -1,17 +1,25 @@ import { setNavigate, setRoute } from "@renderer/atoms" import { useLayoutEffect } from "react" -import { useNavigate, useParams, useSearchParams } from "react-router-dom" - -export const BizRouterProvider = () => { +import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom" +/** + * Why this. + * Remix router always update immutable object when the router has any changes, lead to the component which uses router hooks re-render. + * This provider is hold a empty component, to store the router hooks value. + * And use our router hooks will not re-render the component when the router has any changes. + * Also it can access values outside of the component and provide a value selector + */ +export const StableRouterProvider = () => { const [searchParams] = useSearchParams() const params = useParams() const nav = useNavigate() + const location = useLocation() useLayoutEffect(() => { setRoute({ params, searchParams, + location, }) setNavigate({ fn: nav }) - }, [searchParams, params, nav]) + }, [searchParams, params, location, nav]) return null } diff --git a/src/renderer/src/providers/root-providers.tsx b/src/renderer/src/providers/root-providers.tsx index 35df1690c..1d8e68454 100644 --- a/src/renderer/src/providers/root-providers.tsx +++ b/src/renderer/src/providers/root-providers.tsx @@ -9,7 +9,7 @@ import { Provider } from "jotai" import type { FC, PropsWithChildren } from "react" import { HelmetProvider } from "react-helmet-async" -import { BizRouterProvider } from "./biz-router-provider" +import { StableRouterProvider } from "./biz-router-provider" import { ContextMenuProvider } from "./context-menu-provider" import { UISettingInitialize } from "./ui-setting-Initialize" import { UserProvider } from "./user-provider" @@ -36,7 +36,7 @@ export const RootProviders: FC = ({ children }) => ( {children} - + diff --git a/src/renderer/src/providers/ui-setting-Initialize.tsx b/src/renderer/src/providers/ui-setting-Initialize.tsx index 711aa0922..7283717fb 100644 --- a/src/renderer/src/providers/ui-setting-Initialize.tsx +++ b/src/renderer/src/providers/ui-setting-Initialize.tsx @@ -1,5 +1,6 @@ -import { useUIStore } from "@renderer/store" -import { useInsertionEffect } from "react" +import { tipcClient } from "@renderer/lib/client" +import { unreadActions, useUIStore } from "@renderer/store" +import { useEffect, useInsertionEffect } from "react" export const UISettingInitialize = () => { const state = useUIStore() @@ -8,5 +9,14 @@ export const UISettingInitialize = () => { const root = document.documentElement root.style.fontSize = `${state.uiTextSize}px` }, [state.uiTextSize]) + + useEffect(() => { + if (state.showDockBadge) { + return unreadActions.subscribeUnreadCount((count) => tipcClient?.setMacOSBadge(count), true) + } else { + tipcClient?.setMacOSBadge(0) + } + return + }, [state.showDockBadge]) return null } diff --git a/src/renderer/src/store/ui.ts b/src/renderer/src/store/ui.ts index dce929ce3..0e641f995 100644 --- a/src/renderer/src/store/ui.ts +++ b/src/renderer/src/store/ui.ts @@ -1,11 +1,21 @@ -import { createZustandStore, getStoreActions } from "./utils/helper" +import { buildStorageNS } from "@renderer/lib/ns" + +import { + createZustandStore, + getStoreActions, + localStorage, +} from "./utils/helper" interface UIState { entryColWidth: number - opaqueSidebar: boolean readerFontFamily: string uiTextSize: number + + // Display counts + /** macOS only */ + showDockBadge: boolean + sidebarShowUnreadCount: boolean } const createDefaultUIState = (): UIState => ({ @@ -13,13 +23,18 @@ const createDefaultUIState = (): UIState => ({ opaqueSidebar: false, readerFontFamily: "SN Pro", uiTextSize: 16, + + showDockBadge: true, + sidebarShowUnreadCount: true, }) interface UIActions { clear: () => void set: (key: T, value: UIState[T]) => void } -export const useUIStore = createZustandStore("ui", { +const storageKey = buildStorageNS("ui") +export const useUIStore = createZustandStore(storageKey, { version: 1, + storage: localStorage, })((set) => ({ ...createDefaultUIState(), diff --git a/src/renderer/src/store/unread.ts b/src/renderer/src/store/unread.ts index 4135323fe..3cf97bfe6 100644 --- a/src/renderer/src/store/unread.ts +++ b/src/renderer/src/store/unread.ts @@ -16,6 +16,8 @@ interface UnreadActions { fetchUnreadAll: () => Promise> incrementByFeedId: (feedId: string, inc: number) => void + subscribeUnreadCount: (fn: (count: number) => void, immediately?: boolean) => () => void + internal_reset: () => void clear: () => void @@ -25,7 +27,7 @@ export const useUnreadStore = createZustandStore( { version: 1, }, -)((set) => ({ +)((set, get) => ({ data: {}, internal_reset() { @@ -89,6 +91,21 @@ export const useUnreadStore = createZustandStore( }), ) }, + + subscribeUnreadCount(fn, immediately) { + const handler = (state: UnreadState & UnreadActions): void => { + let unread = 0 + for (const key in state.data) { + unread += state.data[key] + } + + fn(unread) + } + if (immediately) { + handler(get()) + } + return useUnreadStore.subscribe(handler) + }, })) export const unreadActions = getStoreActions(useUnreadStore) diff --git a/src/renderer/src/store/utils/helper.ts b/src/renderer/src/store/utils/helper.ts index b20391cb6..3be357b16 100644 --- a/src/renderer/src/store/utils/helper.ts +++ b/src/renderer/src/store/utils/helper.ts @@ -28,6 +28,23 @@ export const dbStorage: PersistStorage = { await del(name) }, } +export const localStorage: PersistStorage = { + getItem: (name: string) => { + const data = window.localStorage.getItem(name) + + if (data === null) { + return null + } + + return JSON.parse(data) + }, + setItem: (name, value) => { + window.localStorage.setItem(name, JSON.stringify(value)) + }, + removeItem: (name: string) => { + window.localStorage.removeItem(name) + }, +} enableMapSet() export const zustandStorage = dbStorage