diff --git a/apps/renderer/package.json b/apps/renderer/package.json index 0761a0ed68..189734a5bc 100644 --- a/apps/renderer/package.json +++ b/apps/renderer/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@dnd-kit/core": "^6.1.0", "@egoist/tipc": "0.3.2", "@electron-toolkit/preload": "^3.0.1", "@follow/electron-main": "workspace:*", diff --git a/apps/renderer/src/hooks/biz/useSubscriptionActions.tsx b/apps/renderer/src/hooks/biz/useSubscriptionActions.tsx index fd008ff394..91b0416fb6 100644 --- a/apps/renderer/src/hooks/biz/useSubscriptionActions.tsx +++ b/apps/renderer/src/hooks/biz/useSubscriptionActions.tsx @@ -106,3 +106,23 @@ const UnfollowInfo = ({ title, undo }: { title: string; undo: () => any }) => { ) } + +export const useBatchUpdateSubscription = () => { + return useMutation({ + mutationFn: async ({ + feedIdList, + category, + view, + }: { + feedIdList: string[] + category: string + view: number + }) => { + await subscriptionActions.batchUpdateSubscription({ + category, + feedIdList, + view, + }) + }, + }) +} diff --git a/apps/renderer/src/modules/feed-column/category.tsx b/apps/renderer/src/modules/feed-column/category.tsx index 86d2df2cec..0e7112dd2b 100644 --- a/apps/renderer/src/modules/feed-column/category.tsx +++ b/apps/renderer/src/modules/feed-column/category.tsx @@ -1,3 +1,4 @@ +import { useDroppable } from "@dnd-kit/core" import { MotionButtonBase } from "@follow/components/ui/button/index.js" import { LoadingCircle } from "@follow/components/ui/loading/index.jsx" import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js" @@ -153,13 +154,26 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego const listList = useOwnedListByView(view!) const showContextMenu = useShowContextMenu() + const isAutoGroupedCategory = !!folderName && !subscriptionCategoryExist(folderName) + + const { isOver, setNodeRef } = useDroppable({ + id: `category-${folderName}`, + disabled: isAutoGroupedCategory, + data: { + category: folderName, + view, + }, + }) + return (
{!!showCollapse && (
{ @@ -242,7 +256,7 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego { type: "text", label: t("sidebar.feed_column.context_menu.delete_category"), - hide: !folderName || !subscriptionCategoryExist(folderName), + hide: !folderName || isAutoGroupedCategory, click: () => { present({ title: t("sidebar.feed_column.context_menu.delete_category_confirmation", { @@ -304,7 +318,7 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego {open && ( (null) diff --git a/apps/renderer/src/modules/feed-column/index.tsx b/apps/renderer/src/modules/feed-column/index.tsx index 5db305ce9c..2a15bcfc30 100644 --- a/apps/renderer/src/modules/feed-column/index.tsx +++ b/apps/renderer/src/modules/feed-column/index.tsx @@ -1,4 +1,6 @@ +import { DndContext, pointerWithin, useDroppable } from "@dnd-kit/core" import { ActionButton } from "@follow/components/ui/button/index.js" +import type { FeedViewType } from "@follow/constants" import { Routes, views } from "@follow/constants" import { useTypeScriptHappyCallback } from "@follow/hooks" import { useRegisterGlobalContext } from "@follow/shared/bridge" @@ -19,13 +21,14 @@ import { shortcuts } from "~/constants/shortcuts" import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry" import { useReduceMotion } from "~/hooks/biz/useReduceMotion" import { getRouteParams } from "~/hooks/biz/useRouteParams" +import { useBatchUpdateSubscription } from "~/hooks/biz/useSubscriptionActions" import { useAuthQuery } from "~/hooks/common" import { Queries } from "~/queries" import { useSubscriptionStore } from "~/store/subscription" import { useFeedUnreadStore } from "~/store/unread" import { WindowUnderBlur } from "../../components/ui/background" -import { getSelectedFeedIds, setSelectedFeedIds } from "./atom" +import { getSelectedFeedIds, setSelectedFeedIds, useSelectedFeedIds } from "./atom" import { FeedColumnHeader } from "./header" import { FeedList } from "./list" @@ -87,27 +90,6 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam } }, [setActive_]) - const [useHotkeysSwitch, setUseHotkeysSwitch] = useState(false) - useHotkeys( - shortcuts.feeds.switchBetweenViews.key, - (e) => { - e.preventDefault() - setUseHotkeysSwitch(true) - if (isHotkeyPressed("Left")) { - setActive((i) => { - if (i === 0) { - return views.length - 1 - } else { - return i - 1 - } - }) - } else { - setActive((i) => (i + 1) % views.length) - } - }, - { scopes: HotKeyScopeMap.Home }, - ) - useWheel( ({ event, last, memo: wait = false, direction: [dx], delta: [dex] }) => { if (!last) { @@ -131,88 +113,150 @@ export function FeedColumn({ children, className }: PropsWithChildren<{ classNam }, ) - const unreadByView = useUnreadByView() - const { t } = useTranslation() - - const showSidebarUnreadCount = useUISettingKey("sidebarShowUnreadCount") - useRegisterGlobalContext("goToDiscover", () => { window.router.navigate(Routes.Discover) }) + const [selectedIds, setSelectedIds] = useSelectedFeedIds() + + const { mutate } = useBatchUpdateSubscription() + return ( - navigateBackHome(), [navigateBackHome])} - > - + { + if (!event.over) { + return + } -
- {views.map((item, index) => ( - { - setActive(index) - setUseHotkeysSwitch(false) - e.stopPropagation() - }} - > - {item.icon} - {showSidebarUnreadCount ? ( -
- {unreadByView[index] > 99 ? ( - 99+ - ) : ( - unreadByView[index] - )} -
- ) : ( - - )} -
- ))} -
-
{ - if (!(e.target instanceof HTMLElement) || !e.target.closest("[data-feed-id]")) { - const nextSelectedFeedIds = getSelectedFeedIds() - setSelectedFeedIds(nextSelectedFeedIds.length === 0 ? nextSelectedFeedIds : []) - } - }, [])} + const { category, view } = event.over.data.current as { + category: string + view: FeedViewType + } + + mutate({ category, view, feedIdList: selectedIds }) + + setSelectedIds([]) + }} + > + navigateBackHome(), [navigateBackHome])} > - + + +
{views.map((item, index) => ( -
- -
+ ))} - -
+
+
{ + if (!(e.target instanceof HTMLElement) || !e.target.closest("[data-feed-id]")) { + const nextSelectedFeedIds = getSelectedFeedIds() + setSelectedFeedIds(nextSelectedFeedIds.length === 0 ? nextSelectedFeedIds : []) + } + }, [])} + > + + {views.map((item, index) => ( +
+ +
+ ))} +
+
+ + {children} +
+ + ) +} + +const ViewSwitchButton: FC<{ + item: (typeof views)[number] + index: number + + active: number + setActive: (next: number | ((prev: number) => number)) => void +}> = ({ item, index, active, setActive }) => { + const [useHotkeysSwitch, setUseHotkeysSwitch] = useState(false) + useHotkeys( + shortcuts.feeds.switchBetweenViews.key, + (e) => { + e.preventDefault() + setUseHotkeysSwitch(true) + if (isHotkeyPressed("Left")) { + setActive((i) => { + if (i === 0) { + return views.length - 1 + } else { + return i - 1 + } + }) + } else { + setActive((i) => (i + 1) % views.length) + } + }, + { scopes: HotKeyScopeMap.Home }, + ) - {children} - + const unreadByView = useUnreadByView() + const { t } = useTranslation() + const showSidebarUnreadCount = useUISettingKey("sidebarShowUnreadCount") + + const { isOver, setNodeRef } = useDroppable({ + id: `view-${item.name}`, + data: { + category: "", + view: item.view, + }, + }) + + return ( + { + setActive(index) + setUseHotkeysSwitch(false) + e.stopPropagation() + }} + > + {item.icon} + {showSidebarUnreadCount ? ( +
+ {unreadByView[index] > 99 ? 99+ : unreadByView[index]} +
+ ) : ( + + )} +
) } diff --git a/apps/renderer/src/modules/feed-column/item.tsx b/apps/renderer/src/modules/feed-column/item.tsx index 90ccf5a974..0cdb12fbe2 100644 --- a/apps/renderer/src/modules/feed-column/item.tsx +++ b/apps/renderer/src/modules/feed-column/item.tsx @@ -11,7 +11,7 @@ import { nextFrame } from "@follow/utils/dom" import { UrlBuilder } from "@follow/utils/url-builder" import { cn } from "@follow/utils/utils" import dayjs from "dayjs" -import { memo, useCallback, useState } from "react" +import { memo, useCallback, useContext, useState } from "react" import { useTranslation } from "react-i18next" import { useShowContextMenu } from "~/atoms/context-menu" @@ -29,6 +29,7 @@ import { subscriptionActions, useSubscriptionByFeedId } from "~/store/subscripti import { useFeedUnreadStore } from "~/store/unread" import { useSelectedFeedIds } from "./atom" +import { DraggableContext } from "./context" import { feedColumnStyles } from "./styles" import { UnreadNumber } from "./unread-number" @@ -57,6 +58,8 @@ const FeedItemImpl = ({ view, feedId, className }: FeedItemProps) => { }) const [selectedFeedIds, setSelectedFeedIds] = useSelectedFeedIds() + const draggableContext = useContext(DraggableContext) + const isInMultipleSelection = selectedFeedIds.includes(feedId) const handleClick: React.MouseEventHandler = useCallback( (e) => { @@ -100,8 +103,15 @@ const FeedItemImpl = ({ view, feedId, className }: FeedItemProps) => { return ( <>
(null) const selectoRef = useRef(null) - const setSelectedFeedIds = useSetSelectedFeedIds() + const [selectedFeedIds, setSelectedFeedIds] = useSelectedFeedIds() + + const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ + id: "selected-feed", + disabled: selectedFeedIds.length === 0, + }) + const style = useMemo( + () => + transform + ? { + transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, + } + : undefined, + [transform], + ) + + const draggableContextValue = useMemo( + () => ({ + attributes, + listeners, + style, + }), + [attributes, listeners, style], + ) return (
@@ -139,6 +164,7 @@ function FeedListImpl({ className, view }: { className?: string; view: number }) ref={selectoRef} rootContainer={document.body} dragContainer={"#feeds-area"} + dragCondition={() => !(selectedFeedIds.length > 0 && isDragging)} selectableTargets={["[data-feed-id]"]} continueSelect hitRate={10} @@ -176,8 +202,8 @@ function FeedListImpl({ className, view }: { className?: string; view: number }) }} mask={false} flex - viewportClassName="!px-3" - rootClassName="h-full" + viewportClassName={cn("!px-3", isDragging && "!overflow-visible")} + rootClassName={cn("h-full", isDragging && "overflow-visible")} >
)} -
- {(hasListData || hasInboxData) && ( -
- {t("words.feeds")} -
- )} - {hasData ? ( - - ) : ( -
- +
+ {(hasListData || hasInboxData) && ( +
- - {t("sidebar.add_more_feeds")} - -
- )} -
+ {t("words.feeds")} +
+ )} + {hasData ? ( + + ) : ( +
+ + + {t("sidebar.add_more_feeds")} + +
+ )} +
+
) diff --git a/apps/renderer/src/services/subscription.ts b/apps/renderer/src/services/subscription.ts index 6673a95609..a7e9f3678a 100644 --- a/apps/renderer/src/services/subscription.ts +++ b/apps/renderer/src/services/subscription.ts @@ -50,6 +50,16 @@ class SubscriptionServiceStatic extends BaseService imp async changeView(feedId: string, view: number) { return this.table.where("feedId").equals(feedId).modify({ view }) } + async changeViews(feedIdList: string[], view: number) { + return this.table.where("feedId").anyOf(feedIdList).modify({ view }) + } + + async updateCategory(feedId: string, category: string) { + return this.table.where("feedId").equals(feedId).modify({ category }) + } + async updateCategories(feedIdList: string[], category: string) { + return this.table.where("feedId").anyOf(feedIdList).modify({ category }) + } async removeSubscription(userId: string, feedId: string): Promise // @ts-expect-error diff --git a/apps/renderer/src/store/subscription/store.ts b/apps/renderer/src/store/subscription/store.ts index 02f3cfd9f8..5b8931b439 100644 --- a/apps/renderer/src/store/subscription/store.ts +++ b/apps/renderer/src/store/subscription/store.ts @@ -14,6 +14,7 @@ import { whoami } from "~/atoms/user" import { ROUTE_FEED_IN_LIST } from "~/constants" import { runTransactionInScope } from "~/database" import { apiClient } from "~/lib/api-fetch" +import { queryClient } from "~/lib/query-client" import { updateFeedBoostStatus } from "~/modules/boost/atom" import { SubscriptionService } from "~/services" @@ -508,6 +509,101 @@ class SubscriptionActions { ) } + async batchUpdateSubscription({ + feedIdList, + category, + view, + }: { + feedIdList: string[] + category: string + view: FeedViewType + }) { + const tx = createTransaction< + ReturnType, + { + subscription: Record + feedIdByView: Record + } + >(get(), { + subscription: {}, + feedIdByView: { + [FeedViewType.Articles]: [], + [FeedViewType.Audios]: [], + [FeedViewType.Notifications]: [], + [FeedViewType.Pictures]: [], + [FeedViewType.SocialMedia]: [], + [FeedViewType.Videos]: [], + }, + }) + + tx.execute(async (snapshot) => { + await apiClient.subscriptions.batch.$patch({ + json: { + feedIds: feedIdList, + category, + view, + }, + }) + const oldView = snapshot.data[feedIdList[0]].view + + queryClient.invalidateQueries({ + predicate(query) { + return ( + query.queryKey[0] === "entries" && + [oldView, view].includes(query.queryKey[2] as FeedViewType) + ) + }, + }) + }) + + tx.optimistic(async (_, ctx) => { + set((state) => + produce(state, (draft) => { + for (let i = 0; i < 6; i++) { + ctx.feedIdByView[i] = state.feedIdByView[i] + } + + for (const feedId of feedIdList) { + const subscription = draft.data[feedId] + if (!subscription) return + + subscription.category = category + + if (subscription.view !== view) { + const currentViewFeedIds = draft.feedIdByView[subscription.view] as string[] + currentViewFeedIds.splice(currentViewFeedIds.indexOf(feedId), 1) + subscription.view = view + const changeToViewFeedIds = draft.feedIdByView[view] as string[] + changeToViewFeedIds.push(feedId) + } + + ctx.subscription[feedId] = state.data[feedId] + } + }), + ) + }) + + tx.rollback(async (_, ctx) => { + set((state) => + produce(state, (draft) => { + for (const feedId of feedIdList) { + draft.data[feedId] = ctx.subscription[feedId] + } + for (let i = 0; i < 6; i++) { + draft.feedIdByView[i] = ctx.feedIdByView[i] + } + }), + ) + }) + + tx.persist(async () => { + SubscriptionService.updateCategories(feedIdList, category) + SubscriptionService.changeViews(feedIdList, view) + }) + + await tx.run() + } + async changeListView(listId: string, currentView: FeedViewType, toView: FeedViewType) { const state = get() diff --git a/changelog/next.md b/changelog/next.md index b9494ccadb..1103d6615d 100644 --- a/changelog/next.md +++ b/changelog/next.md @@ -7,6 +7,7 @@ - Now you can export the data from the local database. - App: In consideration of your hard drive, now it supports clearing cache and limiting the size of cache. - Added a new feature to allow minimizing to the system tray by enabling a switch in the settings. +- Quickly update views or categories at once by dragging and dropping. ## Improvements diff --git a/packages/shared/src/hono.ts b/packages/shared/src/hono.ts index 6fc37690cb..3c969a260c 100644 --- a/packages/shared/src/hono.ts +++ b/packages/shared/src/hono.ts @@ -6057,6 +6057,24 @@ declare const _routes: hono_hono_base.HonoBase= 8.9.0'} + '@dnd-kit/accessibility@3.1.0': + resolution: {integrity: sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.1.0': + resolution: {integrity: sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@edge-runtime/format@2.2.1': resolution: {integrity: sha512-JQTRVuiusQLNNLe2W9tnzBlV/GvSVcozLl4XZHk5swnRZ/v6jp8TqR8P7sqmJsQqblDZ3EztcWmLDbhRje/+8g==} engines: {node: '>=16'} @@ -3393,8 +3412,8 @@ packages: '@radix-ui/react-avatar@1.1.1': resolution: {integrity: sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==} peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' + '@types/react': npm:types-react@19.0.0-rc.1 + '@types/react-dom': npm:types-react-dom@19.0.0-rc.1 react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: @@ -10065,7 +10084,7 @@ packages: '@microsoft/api-extractor': ^7.36.0 '@swc/core': ^1 postcss: ^8.4.12 - typescript: 5.6.3 + typescript: '>=4.5.0' peerDependenciesMeta: '@microsoft/api-extractor': optional: true @@ -11649,6 +11668,24 @@ snapshots: ajv: 6.12.6 ajv-keywords: 3.5.2(ajv@6.12.6) + '@dnd-kit/accessibility@3.1.0(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.7.0 + + '@dnd-kit/core@6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/accessibility': 3.1.0(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.7.0 + + '@dnd-kit/utilities@3.2.2(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.7.0 + '@edge-runtime/format@2.2.1': {} '@edge-runtime/node-utils@2.3.0': {}