diff --git a/apps/main/package.json b/apps/main/package.json index bc5600d57..090a056dc 100644 --- a/apps/main/package.json +++ b/apps/main/package.json @@ -52,7 +52,6 @@ "@types/node": "^22.7.4", "electron": "32.1.2", "electron-devtools-installer": "3.2.0", - "hono": "4.6.3", - "typescript": "^5.6.2" + "hono": "4.6.3" } } diff --git a/apps/renderer/package.json b/apps/renderer/package.json index c23bf9964..0854d9b8f 100644 --- a/apps/renderer/package.json +++ b/apps/renderer/package.json @@ -119,7 +119,6 @@ "fake-indexeddb": "6.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "typescript": "^5.6.2", "vitest": "2.0" } } diff --git a/apps/renderer/src/components/ui/context-menu/context-menu.tsx b/apps/renderer/src/components/ui/context-menu/context-menu.tsx index 8ce004024..f1ca2dab7 100644 --- a/apps/renderer/src/components/ui/context-menu/context-menu.tsx +++ b/apps/renderer/src/components/ui/context-menu/context-menu.tsx @@ -106,8 +106,8 @@ const ContextMenuCheckboxItem = React.forwardRef< {...props} > - - + + {children} diff --git a/apps/renderer/src/database/db.ts b/apps/renderer/src/database/db.ts index ecdd6e463..8a8ec56a5 100644 --- a/apps/renderer/src/database/db.ts +++ b/apps/renderer/src/database/db.ts @@ -2,10 +2,11 @@ import type { Transaction } from "dexie" import Dexie from "dexie" import { LOCAL_DB_NAME } from "./constants" -import { dbSchemaV1, dbSchemaV2, dbSchemaV3, dbSchemaV4, dbSchemaV5 } from "./db_schema" +import { dbSchemaV1, dbSchemaV2, dbSchemaV3, dbSchemaV4, dbSchemaV5, dbSchemaV6 } from "./db_schema" import type { DB_Cleaner } from "./schemas/cleaner" import type { DB_Entry, DB_EntryRelated } from "./schemas/entry" import type { DB_Feed, DB_FeedUnread } from "./schemas/feed" +import type { DB_List } from "./schemas/list" import type { DB_Subscription } from "./schemas/subscription" export interface LocalDBSchemaMap { @@ -15,6 +16,7 @@ export interface LocalDBSchemaMap { entryRelated: DB_EntryRelated feedUnreads: DB_FeedUnread cleaner: DB_Cleaner + lists: DB_List } // Define a local DB @@ -24,6 +26,7 @@ export class BrowserDB extends Dexie { public subscriptions: BrowserDBTable<"subscriptions"> public entryRelated: BrowserDBTable<"entryRelated"> public feedUnreads: BrowserDBTable<"feedUnreads"> + public lists: BrowserDBTable<"lists"> public cleaner: BrowserDBTable<"cleaner"> constructor() { @@ -33,6 +36,7 @@ export class BrowserDB extends Dexie { this.version(3).stores(dbSchemaV3) this.version(4).stores(dbSchemaV4) this.version(5).stores(dbSchemaV5) + this.version(6).stores(dbSchemaV6) this.entries = this.table("entries") this.feeds = this.table("feeds") @@ -40,6 +44,7 @@ export class BrowserDB extends Dexie { this.entryRelated = this.table("entryRelated") this.feedUnreads = this.table("feedUnreads") this.cleaner = this.table("cleaner") + this.lists = this.table("lists") } async upgradeToV2(trans: Transaction) { diff --git a/apps/renderer/src/database/db_schema.ts b/apps/renderer/src/database/db_schema.ts index c86fb3d8d..d49860381 100644 --- a/apps/renderer/src/database/db_schema.ts +++ b/apps/renderer/src/database/db_schema.ts @@ -28,3 +28,8 @@ export const dbSchemaV5 = { ...dbSchemaV4, cleaner: "&refId, visitedAt", } + +export const dbSchemaV6 = { + ...dbSchemaV5, + lists: "&id, title", +} diff --git a/apps/renderer/src/database/schemas/feed.ts b/apps/renderer/src/database/schemas/feed.ts index fc039099e..563be27fc 100644 --- a/apps/renderer/src/database/schemas/feed.ts +++ b/apps/renderer/src/database/schemas/feed.ts @@ -1,8 +1,8 @@ -import type { FeedOrListRespModel } from "~/models" +import type { FeedModel } from "~/models" export type DB_FeedUnread = { id: string count: number } -export type DB_Feed = FeedOrListRespModel & { id: string } +export type DB_Feed = FeedModel & { id: string } diff --git a/apps/renderer/src/database/schemas/list.ts b/apps/renderer/src/database/schemas/list.ts new file mode 100644 index 000000000..d489fcadb --- /dev/null +++ b/apps/renderer/src/database/schemas/list.ts @@ -0,0 +1,12 @@ +export type DB_List = { + id: string + title: string + createdAt: number + updatedAt: number + description: string + fee: number + image: string + ownerUserId: string + timelineUpdatedAt: string + feedIds: string[] +} diff --git a/apps/renderer/src/hooks/biz/useFeedActions.tsx b/apps/renderer/src/hooks/biz/useFeedActions.tsx index b66ab8e9d..448b41474 100644 --- a/apps/renderer/src/hooks/biz/useFeedActions.tsx +++ b/apps/renderer/src/hooks/biz/useFeedActions.tsx @@ -4,14 +4,19 @@ import { useTranslation } from "react-i18next" import { whoami } from "~/atoms/user" import { useModalStack } from "~/components/ui/modal" +import type { FeedViewType } from "~/lib/enum" import type { NativeMenuItem } from "~/lib/native-menu" import { useFeedClaimModal } from "~/modules/claim" import { FeedForm } from "~/modules/discover/feed-form" -import { Queries } from "~/queries" -import { getFeedById, useAddFeedToFeedList, useFeedById } from "~/store/feed" +import { + getFeedById, + useAddFeedToFeedList, + useFeedById, + useRemoveFeedFromFeedList, +} from "~/store/feed" +import { useListById, useListByView } from "~/store/list" import { subscriptionActions, useSubscriptionByFeedId } from "~/store/subscription" -import { useAuthQuery } from "../common" import { useNavigateEntry } from "./useNavigateEntry" import { getRouteParams } from "./useRouteParams" import { useDeleteSubscription } from "./useSubscriptionActions" @@ -38,25 +43,23 @@ export const useFeedActions = ({ const navigateEntry = useNavigateEntry() const isEntryList = type === "entryList" - const listList = useAuthQuery(Queries.lists.list()) const addMutation = useAddFeedToFeedList() + const removeMutation = useRemoveFeedFromFeedList() + + const listByView = useListByView(view!) const items = useMemo(() => { if (!feed) return [] - const isList = feed?.type === "list" + const items: NativeMenuItem[] = [ - ...(!isList - ? [ - { - type: "text" as const, - label: t("sidebar.feed_actions.mark_all_as_read"), - shortcut: "Meta+Shift+A", - disabled: isEntryList, - click: () => subscriptionActions.markReadByFeedIds({ feedIds: [feedId] }), - }, - ] - : []), - ...(!feed.ownerUserId && !!feed.id && !isList + { + type: "text" as const, + label: t("sidebar.feed_actions.mark_all_as_read"), + shortcut: "Meta+Shift+A", + disabled: isEntryList, + click: () => subscriptionActions.markReadByFeedIds({ feedIds: [feedId] }), + }, + ...(!feed.ownerUserId && !!feed.id && !false ? [ { type: "text" as const, @@ -74,11 +77,7 @@ export const useFeedActions = ({ ? [ { type: "text" as const, - label: t( - isList - ? "sidebar.feed_actions.list_owned_by_you" - : "sidebar.feed_actions.feed_owned_by_you", - ), + label: t("sidebar.feed_actions.feed_owned_by_you"), }, ] : []), @@ -86,41 +85,44 @@ export const useFeedActions = ({ type: "separator" as const, disabled: isEntryList, }, - ...(!isList - ? [ - { - type: "text" as const, - label: t("sidebar.feed_column.context_menu.add_feeds_to_list"), - enabled: !!listList.data?.length, - submenu: listList.data?.map((list) => ({ - label: list.title || "", - type: "text" as const, - click() { - return addMutation.mutate({ - feedId, - listId: list.id, - }) - }, - })), + { + type: "text" as const, + label: t("sidebar.feed_column.context_menu.add_feeds_to_list"), + enabled: !!listByView?.length, + submenu: listByView?.map((list) => { + const isIncluded = list.feedIds.includes(feedId) + return { + label: list.title || "", + type: "text" as const, + checked: isIncluded, + click() { + if (!isIncluded) { + addMutation.mutate({ + feedId, + listId: list.id, + }) + } else { + removeMutation.mutate({ + feedId, + listId: list.id, + }) + } }, - { - type: "separator" as const, - disabled: isEntryList, - }, - ] - : []), + } + }), + }, + { + type: "separator" as const, + disabled: isEntryList, + }, { type: "text" as const, label: isEntryList ? t("sidebar.feed_actions.edit_feed") : t("sidebar.feed_actions.edit"), shortcut: "E", click: () => { present({ - title: isList - ? t("sidebar.feed_actions.edit_list") - : t("sidebar.feed_actions.edit_feed"), - content: ({ dismiss }) => ( - - ), + title: t("sidebar.feed_actions.edit_feed"), + content: ({ dismiss }) => , }) }, }, @@ -134,11 +136,7 @@ export const useFeedActions = ({ }, { type: "text" as const, - label: t( - isList - ? "sidebar.feed_actions.navigate_to_list" - : "sidebar.feed_actions.navigate_to_feed", - ), + label: t("sidebar.feed_actions.navigate_to_feed"), shortcut: "Meta+G", disabled: !isEntryList || getRouteParams().feedId === feedId, click: () => { @@ -151,61 +149,46 @@ export const useFeedActions = ({ }, { type: "text" as const, - label: t( - isList - ? "sidebar.feed_actions.open_list_in_browser" - : "sidebar.feed_actions.open_feed_in_browser", - { which: t(window.electron ? "words.browser" : "words.newTab") }, - ), + label: t("sidebar.feed_actions.open_feed_in_browser", { + which: t(window.electron ? "words.browser" : "words.newTab"), + }), disabled: isEntryList, shortcut: "O", - click: () => - window.open( - isList - ? `${WEB_URL}/list/${feedId}?view=${view}` - : `${WEB_URL}/feed/${feedId}?view=${view}`, - "_blank", - ), + click: () => window.open(`${WEB_URL}/feed/${feedId}?view=${view}`, "_blank"), + }, + { + type: "text" as const, + label: t("sidebar.feed_actions.open_site_in_browser", { + which: t(window.electron ? "words.browser" : "words.newTab"), + }), + shortcut: "Meta+O", + disabled: isEntryList, + click: () => { + const feed = getFeedById(feedId) + if (feed) { + "siteUrl" in feed && feed.siteUrl && window.open(feed.siteUrl, "_blank") + } + }, }, - ...(!isList - ? [ - { - type: "text" as const, - label: t("sidebar.feed_actions.open_site_in_browser", { - which: t(window.electron ? "words.browser" : "words.newTab"), - }), - shortcut: "Meta+O", - disabled: isEntryList, - click: () => { - const feed = getFeedById(feedId) - if (feed) { - "siteUrl" in feed && feed.siteUrl && window.open(feed.siteUrl, "_blank") - } - }, - }, - ] - : []), { type: "separator", disabled: isEntryList, }, { type: "text" as const, - label: t( - isList ? "sidebar.feed_actions.copy_list_url" : "sidebar.feed_actions.copy_feed_url", - ), + label: t("sidebar.feed_actions.copy_feed_url"), disabled: isEntryList, shortcut: "Meta+C", click: () => { - const url = isList ? `${WEB_URL}/list/${feedId}?view=${view}` : feed.url + // @ts-expect-error + const { url } = feed || {} + if (!url) return navigator.clipboard.writeText(url) }, }, { type: "text" as const, - label: t( - isList ? "sidebar.feed_actions.copy_list_id" : "sidebar.feed_actions.copy_feed_id", - ), + label: t("sidebar.feed_actions.copy_feed_id"), shortcut: "Meta+Shift+C", disabled: isEntryList, click: () => { @@ -216,17 +199,117 @@ export const useFeedActions = ({ return items }, [ - t, - claimFeed, - deleteSubscription, feed, - feedId, + t, isEntryList, - navigateEntry, + listByView, + feedId, + claimFeed, + addMutation, present, + deleteSubscription, subscription, + navigateEntry, view, ]) return { items } } + +export const useListActions = ({ listId, view }: { listId: string; view: FeedViewType }) => { + const { t } = useTranslation() + const list = useListById(listId) + const subscription = useSubscriptionByFeedId(listId) + + const { present } = useModalStack() + const deleteSubscription = useDeleteSubscription({}) + + const navigateEntry = useNavigateEntry() + + const items = useMemo(() => { + if (!list) return [] + + const items: NativeMenuItem[] = [ + ...(list.ownerUserId === whoami()?.id + ? [ + { + type: "text" as const, + label: t("sidebar.feed_actions.list_owned_by_you"), + }, + ] + : []), + { + type: "separator" as const, + disabled: false, + }, + + { + type: "text" as const, + label: t("sidebar.feed_actions.edit"), + shortcut: "E", + click: () => { + present({ + title: t("sidebar.feed_actions.edit_list"), + content: ({ dismiss }) => , + }) + }, + }, + { + type: "text" as const, + label: t("sidebar.feed_actions.unfollow"), + shortcut: "Meta+Backspace", + click: () => deleteSubscription.mutate(subscription), + }, + { + type: "text" as const, + label: t("sidebar.feed_actions.navigate_to_list"), + shortcut: "Meta+G", + disabled: getRouteParams().feedId === listId, + click: () => { + navigateEntry({ feedId: listId }) + }, + }, + { + type: "separator" as const, + disabled: false, + }, + { + type: "text" as const, + label: t("sidebar.feed_actions.open_list_in_browser", { + which: t(window.electron ? "words.browser" : "words.newTab"), + }), + disabled: false, + shortcut: "O", + click: () => window.open(`${WEB_URL}/list/${listId}?view=${view}`, "_blank"), + }, + + { + type: "separator", + disabled: false, + }, + { + type: "text" as const, + label: t("sidebar.feed_actions.copy_list_url"), + disabled: false, + shortcut: "Meta+C", + click: () => { + const url = `${WEB_URL}/list/${listId}?view=${view}` + navigator.clipboard.writeText(url) + }, + }, + { + type: "text" as const, + label: t("sidebar.feed_actions.copy_list_id"), + shortcut: "Meta+Shift+C", + disabled: false, + click: () => { + navigator.clipboard.writeText(listId) + }, + }, + ] + + return items + }, [list, t, present, deleteSubscription, subscription, navigateEntry, listId, view]) + + return { items } +} diff --git a/apps/renderer/src/initialize/hydrate.ts b/apps/renderer/src/initialize/hydrate.ts index 17f38bdab..374c8f7ef 100644 --- a/apps/renderer/src/initialize/hydrate.ts +++ b/apps/renderer/src/initialize/hydrate.ts @@ -11,7 +11,9 @@ import { FeedUnreadService, SubscriptionService, } from "~/services" +import { ListService } from "~/services/list" import type { FlatEntryModel } from "~/store/entry" +import { listActions } from "~/store/list" import { entryActions, useEntryStore } from "../store/entry/store" import { feedActions, useFeedStore } from "../store/feed" @@ -25,7 +27,13 @@ export const setHydrated = (v: boolean) => { export const hydrateDatabaseToStore = async () => { async function hydrate() { const now = Date.now() - await Promise.all([hydrateFeed(), hydrateSubscription(), hydrateFeedUnread(), hydrateEntry()]) + await Promise.all([ + hydrateFeed(), + hydrateSubscription(), + hydrateFeedUnread(), + hydrateEntry(), + hydrateList(), + ]) window.__dbIsReady = true const costTime = Date.now() - now @@ -89,6 +97,11 @@ async function hydrateSubscription() { subscriptionActions.upsertMany(subscriptions) } +async function hydrateList() { + const lists = await ListService.findAll() + listActions.upsertMany(lists) +} + const logHydrateError = (message: string) => { // eslint-disable-next-line no-console console.debug(`Hydrate error: ${message}, maybe local database data is dirty.`) diff --git a/apps/renderer/src/lib/native-menu.ts b/apps/renderer/src/lib/native-menu.ts index 1de25549e..bc1d26c18 100644 --- a/apps/renderer/src/lib/native-menu.ts +++ b/apps/renderer/src/lib/native-menu.ts @@ -14,6 +14,7 @@ export type NativeMenuItem = shortcut?: string disabled?: boolean submenu?: NativeMenuItem[] + checked?: boolean } | { type: "separator"; disabled?: boolean } diff --git a/apps/renderer/src/models/types.ts b/apps/renderer/src/models/types.ts index 91fac5826..8e22ddc5b 100644 --- a/apps/renderer/src/models/types.ts +++ b/apps/renderer/src/models/types.ts @@ -25,7 +25,7 @@ export type TransactionModel = ExtractBizResponse< export type FeedModel = ExtractBizResponse["data"]["feed"] -type ListModelPoplutedFeeds = ExtractBizResponse["data"]["list"] +export type ListModelPoplutedFeeds = ExtractBizResponse["data"]["list"] export type ListModel = Omit export type FeedOrListRespModel = FeedModel | ListModelPoplutedFeeds export type FeedOrListModel = FeedModel | ListModel diff --git a/apps/renderer/src/modules/feed-column/category.tsx b/apps/renderer/src/modules/feed-column/category.tsx index 10816a3e0..5d08e6782 100644 --- a/apps/renderer/src/modules/feed-column/category.tsx +++ b/apps/renderer/src/modules/feed-column/category.tsx @@ -10,13 +10,13 @@ import { LoadingCircle } from "~/components/ui/loading" import { ROUTE_FEED_IN_FOLDER, views } from "~/constants" import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry" import { getRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams" -import { useAnyPointDown, useAuthQuery, useInputComposition } from "~/hooks/common" +import { useAnyPointDown, useInputComposition } from "~/hooks/common" import { stopPropagation } from "~/lib/dom" import type { FeedViewType } from "~/lib/enum" import { showNativeMenu } from "~/lib/native-menu" import { cn, sortByAlphabet } from "~/lib/utils" -import { Queries } from "~/queries" import { getPreferredTitle, useAddFeedToFeedList, useFeedStore } from "~/store/feed" +import { useListByView } from "~/store/list" import { subscriptionActions, useSubscriptionByFeedId } from "~/store/subscription" import { useFeedUnreadStore } from "~/store/unread" @@ -46,29 +46,21 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego const folderName = subscription?.category || subscription.defaultCategory const showCollapse = sortByUnreadFeedList.length > 1 || subscription?.category - const open = folderName ? categoryOpenStateData[folderName] : true - const shouldOpen = - useRouteParamsSelector((s) => typeof s.feedId === "string" && ids.includes(s.feedId)) || - ids.length === 1 - - const itemsRef = useRef(null) - - const toggleCategoryOpenState = (e) => { - e.stopPropagation() - if (!isCategoryEditing) { - setCategoryActive() + const [open, setOpen] = useState(() => { + if (!showCollapse) return true + if (folderName && typeof categoryOpenStateData[folderName] === "boolean") { + return categoryOpenStateData[folderName] } - if (view !== undefined && folderName) { - subscriptionActions.toggleCategoryOpenState(view, folderName) - } - } + return false + }) + const shouldOpen = useRouteParamsSelector( + (s) => typeof s.feedId === "string" && ids.includes(s.feedId), + ) useEffect(() => { if (shouldOpen) { - if (!open && view !== undefined && folderName) { - subscriptionActions.changeCategoryOpenState(view, folderName, true) - } + setOpen(true) const $items = itemsRef.current @@ -78,13 +70,31 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego behavior: "smooth", }) } - }, [shouldOpen, open, view, folderName]) + }, [shouldOpen]) + const expansion = folderName ? categoryOpenStateData[folderName] : true + + useEffect(() => { + if (showCollapse) { + setOpen(expansion) + } + }, [expansion]) + + const itemsRef = useRef(null) + + const toggleCategoryOpenState = (e: React.MouseEvent) => { + e.stopPropagation() + if (!isCategoryEditing) { + setCategoryActive() + } + if (view !== undefined && folderName) { + subscriptionActions.toggleCategoryOpenState(view, folderName) + } + } const setCategoryActive = () => { if (view !== undefined) { navigate({ entryId: null, - // TODO joint feedId is too long, need to be optimized folderName, view, }) @@ -117,9 +127,10 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego }) const isCategoryIsWaiting = isChangePending - const listList = useAuthQuery(Queries.lists.list()) const addMutation = useAddFeedToFeedList() + const listList = useListByView(view!) + return (
{!!showCollapse && ( @@ -151,8 +162,8 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego { type: "text", label: t("sidebar.feed_column.context_menu.add_feeds_to_list"), - enabled: !!listList.data?.length, - submenu: listList.data?.map((list) => ({ + enabled: !!listList?.length, + submenu: listList?.map((list) => ({ label: list.title || "", type: "text", click() { @@ -172,7 +183,7 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego .filter((v) => v.view !== view) .map((v) => ({ label: t(v.name), - type: "text", + type: "text" as const, shortcut: (v.view + 1).toString(), icon: v.icon, click() { diff --git a/apps/renderer/src/modules/feed-column/item.tsx b/apps/renderer/src/modules/feed-column/item.tsx index a68874128..e2ca8ab0a 100644 --- a/apps/renderer/src/modules/feed-column/item.tsx +++ b/apps/renderer/src/modules/feed-column/item.tsx @@ -8,15 +8,18 @@ import { FeedCertification } from "~/components/feed-certification" import { FeedIcon } from "~/components/feed-icon" import { OouiUserAnonymous } from "~/components/icons/OouiUserAnonymous" import { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } from "~/components/ui/tooltip" -import { useFeedActions } from "~/hooks/biz/useFeedActions" +import { EllipsisHorizontalTextWithTooltip } from "~/components/ui/typography" +import { useFeedActions, useListActions } from "~/hooks/biz/useFeedActions" import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry" import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams" import { useAnyPointDown } from "~/hooks/common" import { nextFrame } from "~/lib/dom" +import type { FeedViewType } from "~/lib/enum" import { getNewIssueUrl } from "~/lib/issues" import { showNativeMenu } from "~/lib/native-menu" import { cn } from "~/lib/utils" import { getPreferredTitle, useFeedById } from "~/store/feed" +import { useListById } from "~/store/list" import { subscriptionActions, useSubscriptionByFeedId } from "~/store/subscription" import { useFeedUnreadStore } from "~/store/unread" @@ -68,7 +71,7 @@ const FeedItemImpl = ({ view, feedId, className }: FeedItemProps) => { if (!feed) return null const isFeed = feed.type === "feed" || !feed.type - const isList = feed.type === "list" + return ( <>
{ isFeed && feed.errorAt && "text-red-900 dark:text-red-500", )} > - +
{getPreferredTitle(feed)}
{isFeed && } {isFeed && feed.errorAt && ( @@ -156,10 +159,84 @@ const FeedItemImpl = ({ view, feedId, className }: FeedItemProps) => { )}
- +
) } export const FeedItem = memo(FeedItemImpl) + +const ListItemImpl: Component<{ + listId: string + view: FeedViewType +}> = ({ view, listId, className }) => { + const list = useListById(listId) + + const isActive = useRouteParamsSelector((routerParams) => routerParams.feedId === listId) + const { items } = useListActions({ listId, view }) + + const listUnread = useFeedUnreadStore((state) => state.data[listId] || 0) + + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) + useAnyPointDown(() => { + setIsContextMenuOpen(false) + }) + const subscription = useSubscriptionByFeedId(listId) + const navigate = useNavigateEntry() + const handleNavigate = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + + navigate({ + feedId: listId, + entryId: null, + view, + }) + }, + [listId, navigate, view], + ) + const { t } = useTranslation() + if (!list) return null + return ( +
{ + window.open(`${WEB_URL}/list/${listId}?view=${view}`, "_blank") + }} + onContextMenu={(e) => { + setIsContextMenuOpen(true) + + showNativeMenu(items, e) + }} + > +
+ + + {list.title} + + + {subscription.isPrivate && ( + + + + + + {t("feed_item.not_publicly_visible")} + + + )} +
+ +
+ ) +} + +export const ListItem = memo(ListItemImpl) diff --git a/apps/renderer/src/modules/feed-column/list.tsx b/apps/renderer/src/modules/feed-column/list.tsx index 2e58a66cf..dc8ca8786 100644 --- a/apps/renderer/src/modules/feed-column/list.tsx +++ b/apps/renderer/src/modules/feed-column/list.tsx @@ -1,6 +1,6 @@ import * as HoverCard from "@radix-ui/react-hover-card" import { AnimatePresence, m } from "framer-motion" -import { Fragment, memo, useMemo, useState } from "react" +import { memo, useMemo, useState } from "react" import { useTranslation } from "react-i18next" import { Link } from "react-router-dom" @@ -12,25 +12,17 @@ import { useRouteFeedId } from "~/hooks/biz/useRouteParams" import { useAuthQuery } from "~/hooks/common" import { stopPropagation } from "~/lib/dom" import type { FeedViewType } from "~/lib/enum" -import { cn, sortByAlphabet } from "~/lib/utils" +import { cn } from "~/lib/utils" import { Queries } from "~/queries" -import { getPreferredTitle, useFeedStore } from "~/store/feed" import { - getSubscriptionByFeedId, subscriptionActions, useCategoryOpenStateByView, useSubscriptionByView, } from "~/store/subscription" import { useFeedUnreadStore } from "~/store/unread" -import { - getFeedListSort, - setFeedListSortBy, - setFeedListSortOrder, - useFeedListSort, - useFeedListSortSelector, -} from "./atom" -import { FeedCategory } from "./category" +import { getFeedListSort, setFeedListSortBy, setFeedListSortOrder, useFeedListSort } from "./atom" +import { SortableFeedList, SortByAlphabeticalList } from "./sort-by" import { UnreadNumber } from "./unread-number" const useFeedsGroupedData = (view: FeedViewType) => { @@ -108,6 +100,10 @@ function FeedListImpl({ className, view }: { className?: string; view: number }) const { t } = useTranslation() + // Data prefetch + useAuthQuery(Queries.lists.list()) + + const hasListData = Object.keys(listsData).length > 0 return (
@@ -163,29 +159,27 @@ function FeedListImpl({ className, view }: { className?: string; view: number }) {t("words.starred")}
- {Object.keys(listsData).length > 0 && ( + {hasListData && ( <>
{t("words.lists")}
- + + )} -
- {t("words.feeds")} -
+ {hasListData && hasData && ( +
+ {t("words.feeds")} +
+ )} {hasData ? ( - { ) } -type FeedListProps = { - view: number - data: Record - categoryOpenStateData: Record -} -const SortByUnreadList = ({ view, data, categoryOpenStateData }: FeedListProps) => { - const isDesc = useFeedListSortSelector((s) => s.order === "desc") - - const sortedByUnread = useFeedUnreadStore((state) => { - const sortedList = [] as [string, string[]][] - const folderUnread = {} as Record - // Calc total unread count for each folder - for (const category in data) { - folderUnread[category] = data[category].reduce((acc, cur) => (state.data[cur] || 0) + acc, 0) - } - - // Sort by unread count - Object.keys(folderUnread) - .sort((a, b) => folderUnread[b] - folderUnread[a]) - .forEach((key) => { - sortedList.push([key, data[key]]) - }) - - if (!isDesc) { - sortedList.reverse() - } - return sortedList - }) - - return ( - - {sortedByUnread?.map(([category, ids]) => ( - - ))} - - ) -} - -const SortByAlphabeticalList = ({ view, data, categoryOpenStateData }: FeedListProps) => { - const categoryName2RealDisplayNameMap = useFeedStore((state) => { - const map = {} as Record - for (const categoryName in data) { - const feedId = data[categoryName][0] - - if (!feedId) { - continue - } - const feed = state.feeds[feedId] - if (!feed) { - continue - } - const hascategoryNameNotDefault = !!getSubscriptionByFeedId(feedId)?.category - const isSingle = data[categoryName].length === 1 - if (!isSingle || hascategoryNameNotDefault) { - map[categoryName] = categoryName - } else { - map[categoryName] = getPreferredTitle(feed)! - } - } - return map - }) - - const isDesc = useFeedListSortSelector((s) => s.order === "desc") - - let sortedByAlphabetical = Object.keys(data).sort((a, b) => { - const nameA = categoryName2RealDisplayNameMap[a] - const nameB = categoryName2RealDisplayNameMap[b] - return sortByAlphabet(nameA, nameB) - }) - if (!isDesc) { - sortedByAlphabetical = sortedByAlphabetical.reverse() - } - - return ( - - {sortedByAlphabetical.map((category) => ( - - ))} - - ) -} - -const SortableList = (props: FeedListProps & { by?: "count" | "alphabetical" }) => { - const userBy = useFeedListSortSelector((s) => s.by) - - switch (props.by || userBy) { - case "count": { - return - } - case "alphabetical": { - return - } - } -} - export const FeedList = memo(FeedListImpl) diff --git a/apps/renderer/src/modules/feed-column/sort-by/SortByAlphabeticalList.tsx b/apps/renderer/src/modules/feed-column/sort-by/SortByAlphabeticalList.tsx new file mode 100644 index 000000000..01fbae92c --- /dev/null +++ b/apps/renderer/src/modules/feed-column/sort-by/SortByAlphabeticalList.tsx @@ -0,0 +1,86 @@ +import { Fragment, useMemo } from "react" + +import { sortByAlphabet } from "~/lib/utils" +import { getPreferredTitle, useFeedStore } from "~/store/feed" +import { useListByView } from "~/store/list" +import { getSubscriptionByFeedId } from "~/store/subscription" + +import { useFeedListSortSelector } from "../atom" +import { FeedCategory } from "../category" +import { ListItem } from "../item" +import type { FeedListProps, ListListProps } from "./types" + +export const SortByAlphabeticalFeedList = ({ + view, + data, + categoryOpenStateData, +}: FeedListProps) => { + const categoryName2RealDisplayNameMap = useFeedStore((state) => { + const map = {} as Record + for (const categoryName in data) { + const feedId = data[categoryName][0] + + if (!feedId) { + continue + } + const feed = state.feeds[feedId] + if (!feed) { + continue + } + const hascategoryNameNotDefault = !!getSubscriptionByFeedId(feedId)?.category + const isSingle = data[categoryName].length === 1 + if (!isSingle || hascategoryNameNotDefault) { + map[categoryName] = categoryName + } else { + map[categoryName] = getPreferredTitle(feed)! + } + } + return map + }) + + const isDesc = useFeedListSortSelector((s) => s.order === "desc") + + let sortedByAlphabetical = Object.keys(data).sort((a, b) => { + const nameA = categoryName2RealDisplayNameMap[a] + const nameB = categoryName2RealDisplayNameMap[b] + return sortByAlphabet(nameA, nameB) + }) + if (!isDesc) { + sortedByAlphabetical = sortedByAlphabetical.reverse() + } + + return ( + + {sortedByAlphabetical.map((category) => ( + + ))} + + ) +} + +export const SortByAlphabeticalListList = ({ view }: ListListProps) => { + const isDesc = useFeedListSortSelector((s) => s.order === "desc") + + const lists = useListByView(view) + + const sortedLists = useMemo(() => { + const res = lists.sort((a, b) => { + return sortByAlphabet(a.title ?? "", b.title ?? "") + }) + + return isDesc ? res.reverse() : res + }, [isDesc, lists]) + + return ( +
+ {sortedLists.map((list) => ( + + ))} +
+ ) +} diff --git a/apps/renderer/src/modules/feed-column/sort-by/SortByUnreadList.tsx b/apps/renderer/src/modules/feed-column/sort-by/SortByUnreadList.tsx new file mode 100644 index 000000000..06bf5fdd2 --- /dev/null +++ b/apps/renderer/src/modules/feed-column/sort-by/SortByUnreadList.tsx @@ -0,0 +1,45 @@ +import { Fragment } from "react" + +import { useFeedUnreadStore } from "~/store/unread" + +import { useFeedListSortSelector } from "../atom" +import { FeedCategory } from "../category" +import type { FeedListProps } from "./types" + +export const SortByUnreadFeedList = ({ view, data, categoryOpenStateData }: FeedListProps) => { + const isDesc = useFeedListSortSelector((s) => s.order === "desc") + + const sortedByUnread = useFeedUnreadStore((state) => { + const sortedList = [] as [string, string[]][] + const folderUnread = {} as Record + // Calc total unread count for each folder + for (const category in data) { + folderUnread[category] = data[category].reduce((acc, cur) => (state.data[cur] || 0) + acc, 0) + } + + // Sort by unread count + Object.keys(folderUnread) + .sort((a, b) => folderUnread[b] - folderUnread[a]) + .forEach((key) => { + sortedList.push([key, data[key]]) + }) + + if (!isDesc) { + sortedList.reverse() + } + return sortedList + }) + + return ( + + {sortedByUnread?.map(([category, ids]) => ( + + ))} + + ) +} diff --git a/apps/renderer/src/modules/feed-column/sort-by/index.tsx b/apps/renderer/src/modules/feed-column/sort-by/index.tsx new file mode 100644 index 000000000..171070b1c --- /dev/null +++ b/apps/renderer/src/modules/feed-column/sort-by/index.tsx @@ -0,0 +1,27 @@ +import { useFeedListSortSelector } from "../atom" +import { SortByAlphabeticalFeedList, SortByAlphabeticalListList } from "./SortByAlphabeticalList" +import { SortByUnreadFeedList } from "./SortByUnreadList" +import type { FeedListProps, ListListProps } from "./types" + +export const SortableFeedList = (props: FeedListProps) => { + const by = useFeedListSortSelector((s) => s.by) + + switch (by) { + case "count": { + return + } + case "alphabetical": { + return + } + } +} + +export const SortByAlphabeticalList = (props: ListListProps) => { + const by = useFeedListSortSelector((s) => s.by) + + switch (by) { + default: { + return + } + } +} diff --git a/apps/renderer/src/modules/feed-column/sort-by/types.tsx b/apps/renderer/src/modules/feed-column/sort-by/types.tsx new file mode 100644 index 000000000..030e5419e --- /dev/null +++ b/apps/renderer/src/modules/feed-column/sort-by/types.tsx @@ -0,0 +1,12 @@ +import type { FeedViewType } from "~/lib/enum" + +export type FeedListProps = { + view: number + data: Record + categoryOpenStateData: Record +} +export type SortBy = "count" | "alphabetical" + +export type ListListProps = { + view: FeedViewType +} diff --git a/apps/renderer/src/modules/settings/tabs/lists.tsx b/apps/renderer/src/modules/settings/tabs/lists.tsx index 14eb31f6d..691513153 100644 --- a/apps/renderer/src/modules/settings/tabs/lists.tsx +++ b/apps/renderer/src/modules/settings/tabs/lists.tsx @@ -40,7 +40,7 @@ import { views } from "~/constants" import { useAuthQuery, useI18n } from "~/hooks/common" import { apiClient } from "~/lib/api-fetch" import { cn, isBizId } from "~/lib/utils" -import type { FeedModel, ListModel } from "~/models" +import type { FeedModel } from "~/models" import { ViewSelectorRadioGroup } from "~/modules/shared/ViewSelectorRadioGroup" import { Balance } from "~/modules/wallet/balance" import { Queries } from "~/queries" @@ -50,6 +50,7 @@ import { useFeedById, useRemoveFeedFromFeedList, } from "~/store/feed" +import { useListById } from "~/store/list" import { subscriptionActions, useSubscriptionStore } from "~/store/subscription" export const SettingLists = () => { @@ -86,7 +87,9 @@ export const SettingLists = () => { {t.settings("lists.fee.label")} {t.settings("lists.subscriptions")} {t.settings("lists.earnings")} - {t.common("words.actions")} + + {t.common("words.actions")} + @@ -107,9 +110,18 @@ export const SettingLists = () => { - - {views[row.view].icon} - + + + + {views[row.view].icon} + + + + {t(views[row.view].name)} + +
@@ -121,7 +133,7 @@ export const SettingLists = () => { {BigInt(row.purchaseAmount || 0n)} - +