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': {}