feat: macos docker badge and unread count setting (#87)

* fix: keep entry param route when reload page

Signed-off-by: Innei <i@innei.in>

* fix: setting modal hmr lost state

Signed-off-by: Innei <i@innei.in>

* fix: radix warning

Signed-off-by: Innei <i@innei.in>

* feat: show unread count setting

Signed-off-by: Innei <i@innei.in>

* fix: setting modal only can present once

Signed-off-by: Innei <i@innei.in>

---------

Signed-off-by: Innei <i@innei.in>
This commit is contained in:
Innei 2024-06-24 19:21:29 +08:00 committed by GitHub
parent 61677cf20a
commit 54d28fdf29
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 377 additions and 189 deletions

View File

@ -156,13 +156,21 @@ export const router = {
openSettingWindow: t.procedure.action(async () => createSettingWindow()),
getSystemFonts: t.procedure.action(async (): Promise<string[]> => 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<string[]> =>
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<number>().action(async ({ input }) => {
app.setBadgeCount(input)
}),
}
export type Router = typeof router

View File

@ -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<Params<string>>
searchParams: URLSearchParams
location: Location<any>
}
export const [routeAtom, , , , getReadonlyRoute, setRoute] = createAtomHooks(
atom<RouteAtom>({
params: {},
searchParams: new URLSearchParams(),
location: {
pathname: "",
search: "",
hash: "",
state: null,
key: "",
},
}),
)
@ -25,7 +33,7 @@ export const useReadonlyRouteSelector = <T>(
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() {} }),

View File

@ -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<any>
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: () => (
<>
<Header loader={loader} />
<module.Component />
</>
),
})
})
.finally(() => {
setLoading(false)
})
return () => {
isUnmounted = true
}
}, [Header, loader])
if (loading) {
return (
<div className="center h-full">
<LoadingCircle size="large" />
</div>
)
}
return createElement(Component.c)
}

View File

@ -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,

View File

@ -157,6 +157,9 @@ export const ModalInternal: Component<{
<Dialog.Root open onOpenChange={onClose}>
<Dialog.Portal>
<DialogOverlay zIndex={20} />
<Dialog.DialogTitle className="sr-only">
{title}
</Dialog.DialogTitle>
<Dialog.Content asChild>
<div
className={cn(

View File

@ -73,7 +73,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"shadow-perfect relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,

View File

@ -6,3 +6,9 @@ export enum FeedViewType {
Audios = 4,
Notifications = 5,
}
export enum Routes {
Feeds = "/feeds",
Discover = "/discover",
}

View File

@ -18,15 +18,19 @@ import { CategoryRemoveDialogContent } from "./category-remove-dialog"
import { CategoryRenameContent } from "./category-rename-dialog"
import { FeedItem } from "./item"
interface FeedCategoryProps {
data: FeedListModel["list"][number]
view?: number
expansion: boolean
showUnreadCount?: boolean
}
function FeedCategoryImpl({
data,
view,
expansion,
}: {
data: FeedListModel["list"][number]
view?: number
expansion: boolean
}) {
showUnreadCount = true,
}: FeedCategoryProps) {
const [open, setOpen] = useState(!data.name)
const feedIdList = data.list.map((feed) => 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 (
<Collapsible
open={open}
@ -128,9 +136,11 @@ function FeedCategoryImpl({
>
<i className="i-mgc-right-cute-fi mr-2 transition-transform" />
</CollapsibleTrigger>
<span className="truncate">{data.name}</span>
<span className={cn("truncate", !showUnreadCount && (unread ? "font-bold" : "font-medium opacity-70"))}>
{data.name}
</span>
</div>
{!!unread && (
{!!unread && showUnreadCount && (
<div className="ml-2 text-xs text-zinc-500">{unread}</div>
)}
</div>
@ -154,6 +164,7 @@ function FeedCategoryImpl({
>
{sortByUnreadFeedList.map((feed) => (
<FeedItem
showUnreadCount={showUnreadCount}
key={feed.feedId}
subscription={feed}
view={view}

View File

@ -1,15 +1,17 @@
import { getReadonlyRoute } from "@renderer/atoms"
import { Logo } from "@renderer/components/icons/logo"
import { ActionButton } from "@renderer/components/ui/button"
import { ProfileButton } from "@renderer/components/user-button"
import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry"
import { APP_NAME, levels, views } from "@renderer/lib/constants"
import { stopPropagation } from "@renderer/lib/dom"
import { Routes } from "@renderer/lib/enum"
import { shortcuts } from "@renderer/lib/shortcuts"
import { clamp, cn } from "@renderer/lib/utils"
import { useWheel } from "@use-gesture/react"
import { m, useSpring } from "framer-motion"
import { Lethargy } from "lethargy"
import { useCallback, useEffect, useRef, useState } from "react"
import { useCallback, useRef, useState } from "react"
import { isHotkeyPressed, useHotkeys } from "react-hotkeys-hook"
import { Link } from "react-router-dom"
@ -18,28 +20,57 @@ import { FeedList } from "./list"
const lethargy = new Lethargy()
const useBackHome = (active: number) => {
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<HTMLDivElement>(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 (
<Vibrancy
className="flex h-full flex-col gap-3 pt-2.5"
onClick={navigateBackHome}
onClick={useCallback(() => navigateBackHome(), [navigateBackHome])}
>
<div
className={cn(

View File

@ -28,15 +28,18 @@ import { useFeedClaimModal } from "../claim/hooks"
import { FeedForm } from "../discover/feed-form"
type FeedItemData = SubscriptionPlainModel
interface FeedItemProps {
subscription: FeedItemData
view?: number
className?: string
showUnreadCount?: boolean
}
const FeedItemImpl = ({
subscription,
view,
className,
}: {
subscription: FeedItemData
view?: number
className?: string
}) => {
showUnreadCount = true,
}: FeedItemProps) => {
const navigate = useNavigateEntry()
const handleNavigate: React.MouseEventHandler<HTMLDivElement> = useCallback(
(e) => {
@ -213,7 +216,14 @@ const FeedItemImpl = ({
)}
>
<FeedIcon feed={feed} className="size-4" />
<div className="truncate">{feed.title}</div>
<div
className={cn(
"truncate",
!showUnreadCount && (feedUnread ? "font-bold" : "font-medium opacity-70"),
)}
>
{feed.title}
</div>
{feed.errorAt && (
<TooltipProvider delayDuration={300}>
<Tooltip>
@ -250,7 +260,7 @@ const FeedItemImpl = ({
</TooltipProvider>
)}
</div>
{!!feedUnread && (
{showUnreadCount && !!feedUnread && (
<div className="ml-2 text-xs text-zinc-500">{feedUnread}</div>
)}
</div>

View File

@ -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 (
<div className={cn(className, "font-medium")}>
@ -187,6 +187,7 @@ export function FeedList({
{data?.list?.length ?
sortedByUnread?.map((category) => (
<FeedCategory
showUnreadCount={showUnreadCount}
key={category.name}
data={category}
view={view}

View File

@ -1,20 +1,12 @@
import { LoadRemixAsyncComponent } from "@renderer/components/common/LoadRemixAsyncComponent"
import { MotionButtonBase } from "@renderer/components/ui/button"
import { LoadingCircle } from "@renderer/components/ui/loading"
import { useCurrentModal } from "@renderer/components/ui/modal"
import { preventDefault } from "@renderer/lib/dom"
import {
SettingsSidebarTitle,
SettingsTitle,
} from "@renderer/modules/settings/title"
import { createContextState } from "foxact/context-state"
import { m } from "framer-motion"
import type { FC, PropsWithChildren, ReactNode } from "react"
import { createElement, useEffect, useState } from "react"
import { settings } from "../constants"
const [SettingTabProvider, useSettingTab, useSetSettingTab] =
createContextState("")
import { SettingTabProvider, useSettingTab } from "./context"
import { SettingModalLayout } from "./layout"
const pages = (() => {
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 (
<m.div
exit={{
opacity: 0,
scale: 0.96,
}}
className="flex h-[500px] max-h-[80vh] w-[660px] max-w-full flex-col overflow-hidden rounded-xl border border-border"
onContextMenu={preventDefault}
>
<div className="flex h-0 flex-1 bg-theme-tooltip-background">
<div className="w-44 border-r px-2 py-6">
{settings.map((t) => (
<button
key={t.path}
className={`my-1 flex w-full items-center rounded-lg px-2.5 py-0.5 leading-loose text-theme-foreground/70 transition-colors ${
tab === t.path ?
"bg-theme-item-active text-theme-foreground/90" :
""
}`}
type="button"
onClick={() => setTab(t.path)}
>
<SettingsSidebarTitle
path={t.path}
className="text-[0.94rem] font-medium"
/>
</button>
))}
</div>
<div className="relative h-full flex-1 bg-theme-background p-8 pt-0">
{children}
</div>
</div>
</m.div>
)
}
export const SettingModalContent = () => (
<SettingTabProvider>
<Layout>
<SettingModalLayout>
<div className="h-full flex-1 shrink-0 overflow-auto">
<Content />
<Close />
</div>
</Layout>
</SettingModalLayout>
</SettingTabProvider>
)
@ -100,48 +46,5 @@ const Content = () => {
if (!Component) return null
return <LoadRemixAsyncComponent loader={Component} />
}
const LoadRemixAsyncComponent: FC<{
loader: () => Promise<any>
}> = ({ 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: () => (
<>
<SettingsTitle loader={loader} />
<module.Component />
</>
),
})
})
.finally(() => {
setLoading(false)
})
}, [loader])
if (loading) {
return (
<div className="center h-full">
<LoadingCircle size="large" />
</div>
)
}
return createElement(Component.c)
return <LoadRemixAsyncComponent Header={SettingsTitle} loader={Component} />
}

View File

@ -0,0 +1,3 @@
import { createContextState } from "foxact/context-state"
export const [SettingTabProvider, useSettingTab, useSetSettingTab] = createContextState("")

View File

@ -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",

View File

@ -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 (
<m.div
exit={{
opacity: 0,
scale: 0.96,
}}
className="flex h-[500px] max-h-[80vh] w-[660px] max-w-full flex-col overflow-hidden rounded-xl border border-border"
onContextMenu={preventDefault}
>
<div className="flex h-0 flex-1 bg-theme-tooltip-background">
<div className="w-44 border-r px-2 py-5">
<div className="mb-4 flex h-8 items-center gap-2 px-4 font-bold">
<Logo className="size-6" />
{APP_NAME}
</div>
{settings.map((t) => (
<button
key={t.path}
className={`my-1 flex w-full items-center rounded-lg px-2.5 py-0.5 leading-loose text-theme-foreground/70 transition-colors ${
tab === t.path ?
"bg-theme-item-active text-theme-foreground/90" :
""
}`}
type="button"
onClick={() => setTab(t.path)}
>
<SettingsSidebarTitle
path={t.path}
className="text-[0.94rem] font-medium"
/>
</button>
))}
</div>
<div className="relative h-full flex-1 bg-theme-background p-8 pt-0">
{children}
</div>
</div>
</m.div>
)
}

View File

@ -23,6 +23,7 @@ export const SettingAppearance = () => {
}, [])
const state = useUIStore()
const onlyMacos = window.electron && getOS() === "macOS"
return (
<div>
@ -33,7 +34,7 @@ export const SettingAppearance = () => {
checked={isDark}
onCheckedChange={saveDarkSetting}
/>
{window.electron && getOS() === "macOS" && (
{onlyMacos && (
<SettingSwitch
label="Opaque Sidebars"
checked={state.opaqueSidebar}
@ -46,6 +47,23 @@ export const SettingAppearance = () => {
<SettingSectionTitle title="Text" />
{window.electron && <Fonts />}
<TextSize />
<SettingSectionTitle title="Display counts" />
{onlyMacos && (
<SettingSwitch
label="Dock Badge"
checked={state.showDockBadge}
onCheckedChange={(c) => {
uiActions.set("showDockBadge", c)
}}
/>
)}
<SettingSwitch
label="Show sidebar unread count"
checked={state.sidebarShowUnreadCount}
onCheckedChange={(c) => {
uiActions.set("sidebarShowUnreadCount", c)
}}
/>
</div>
)
}
@ -108,12 +126,16 @@ const TextSize = () => {
)
}}
>
<SelectTrigger className="h-8 w-24">
<SelectTrigger className="h-8 w-24 capitalize">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(textSizeMap).map(([size, value]) => (
<SelectItem key={size} value={value.toString()}>
<SelectItem
className="capitalize"
key={size}
value={value.toString()}
>
{size}
</SelectItem>
))}

View File

@ -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
}

View File

@ -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<PropsWithChildren> = ({ children }) => (
<ModalStackProvider />
<ContextMenuProvider />
<HelmetProvider>{children}</HelmetProvider>
<BizRouterProvider />
<StableRouterProvider />
</Provider>
</TooltipProvider>
</PersistQueryClientProvider>

View File

@ -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
}

View File

@ -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: <T extends keyof UIState>(key: T, value: UIState[T]) => void
}
export const useUIStore = createZustandStore<UIState & UIActions>("ui", {
const storageKey = buildStorageNS("ui")
export const useUIStore = createZustandStore<UIState & UIActions>(storageKey, {
version: 1,
storage: localStorage,
})((set) => ({
...createDefaultUIState(),

View File

@ -16,6 +16,8 @@ interface UnreadActions {
fetchUnreadAll: () => Promise<Record<string, number>>
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<UnreadState & UnreadActions>(
{
version: 1,
},
)((set) => ({
)((set, get) => ({
data: {},
internal_reset() {
@ -89,6 +91,21 @@ export const useUnreadStore = createZustandStore<UnreadState & UnreadActions>(
}),
)
},
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)

View File

@ -28,6 +28,23 @@ export const dbStorage: PersistStorage<any> = {
await del(name)
},
}
export const localStorage: PersistStorage<any> = {
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