refactor: new command abstractions in follow (#1566)
* feat: implement command abstractions and hooks * chore: register follow commands in search command context * refactor: replace entry actions with command buttons in entry header * chore: add usePopulatedEntry hook * feat: add user focus context with atom hooks * fix: types * refactor: redesign command system * refactor: update command button and hotkey hooks for improved type safety * refactor: update command types for consistency and add new command types * refactor: enhance entry actions component with dynamic command buttons * chore: clean code * refactor: streamline entry content handling and enhance command button configuration * refactor: enhance command handling and streamline entry actions integration * refactor: deprecate command button components and simplify command registration * fix: disable shortcut in modal present Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com> Co-authored-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
4cbf8a54fc
commit
ea8c8d0926
|
|
@ -51,7 +51,7 @@ const useShowWebContextMenu = () => {
|
|||
|
||||
// Menu
|
||||
|
||||
type BaseMenuItemText = {
|
||||
export type BaseMenuItemText = {
|
||||
type: "text"
|
||||
label: string
|
||||
click?: () => void
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import type { DragControls } from "framer-motion"
|
||||
import { atom, useAtomValue } from "jotai"
|
||||
import type { ResizeCallback, ResizeStartCallback } from "re-resizable"
|
||||
import { useCallback, useContext, useId, useRef, useState } from "react"
|
||||
import { flushSync } from "react-dom"
|
||||
|
|
@ -203,3 +204,6 @@ export const useDialog = (): DialogInstance => {
|
|||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const modalStackLengthAtom = atom((get) => get(modalStackAtom).length)
|
||||
export const useHasModal = () => useAtomValue(modalStackLengthAtom) > 0
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ type Shortcuts = Record<
|
|||
string,
|
||||
Record<string, { name: I18nKeysForShortcuts; key: string; extra?: string }>
|
||||
>
|
||||
export const shortcuts: Shortcuts = {
|
||||
export const shortcuts = {
|
||||
feeds: {
|
||||
add: {
|
||||
name: "keys.feeds.add",
|
||||
|
|
@ -114,7 +114,7 @@ export const shortcuts: Shortcuts = {
|
|||
key: "Meta+K",
|
||||
},
|
||||
},
|
||||
}
|
||||
} as const satisfies Shortcuts
|
||||
|
||||
export const shortcutsType: { [key in keyof typeof shortcuts]: I18nKeysForShortcuts } = {
|
||||
feeds: "keys.type.feeds",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,4 @@
|
|||
import { FeedViewType } from "@follow/constants"
|
||||
import type { CombinedEntryModel } from "@follow/models/types"
|
||||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import { nextFrame } from "@follow/utils/dom"
|
||||
import { getOS } from "@follow/utils/utils"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import type { ReactNode } from "react"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
import { useCallback } from "react"
|
||||
|
||||
import {
|
||||
getReadabilityStatus,
|
||||
|
|
@ -15,27 +6,17 @@ import {
|
|||
setReadabilityContent,
|
||||
setReadabilityStatus,
|
||||
} from "~/atoms/readability"
|
||||
import {
|
||||
setShowSourceContent,
|
||||
toggleShowSourceContent,
|
||||
useShowSourceContent,
|
||||
useSourceContentModal,
|
||||
} from "~/atoms/source-content"
|
||||
import { useShowSourceContent } from "~/atoms/source-content"
|
||||
import { whoami } from "~/atoms/user"
|
||||
import { mountLottie } from "~/components/ui/lottie-container"
|
||||
import { shortcuts } from "~/constants/shortcuts"
|
||||
import { tipcClient } from "~/lib/client"
|
||||
import StarAnimationUri from "~/lottie/star.lottie?url"
|
||||
import { useTipModal } from "~/modules/wallet/hooks"
|
||||
import type { FlatEntryModel } from "~/store/entry"
|
||||
import { entryActions } from "~/store/entry"
|
||||
import { getFeedById, useFeedById } from "~/store/feed"
|
||||
import { COMMAND_ID } from "~/modules/command/commands/id"
|
||||
import { useGetCommand, useRunCommandFn } from "~/modules/command/hooks/use-command"
|
||||
import { useEntry } from "~/store/entry"
|
||||
import { useFeedById } from "~/store/feed"
|
||||
import { useInboxById } from "~/store/inbox"
|
||||
|
||||
import { useIntegrationActions } from "./useIntegrationActions"
|
||||
import { navigateEntry } from "./useNavigateEntry"
|
||||
|
||||
const absoluteStarAnimationUri = new URL(StarAnimationUri, import.meta.url).href
|
||||
import { useRouteParamsSelector } from "./useRouteParams"
|
||||
|
||||
export const useEntryReadabilityToggle = ({ id, url }: { id: string; url: string }) =>
|
||||
useCallback(async () => {
|
||||
|
|
@ -72,85 +53,9 @@ export const useEntryReadabilityToggle = ({ id, url }: { id: string; url: string
|
|||
})
|
||||
}
|
||||
}, [id, url])
|
||||
export const useCollect = (entry: Nullable<CombinedEntryModel>) => {
|
||||
const { t } = useTranslation()
|
||||
return useMutation({
|
||||
mutationFn: async () => entry && entryActions.markStar(entry.entries.id, true),
|
||||
|
||||
onSuccess: () => {
|
||||
toast.success(t("entry_actions.starred"), {
|
||||
duration: 1000,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useUnCollect = (entry: Nullable<CombinedEntryModel>) => {
|
||||
const { t } = useTranslation()
|
||||
return useMutation({
|
||||
mutationFn: async () => entry && entryActions.markStar(entry.entries.id, false),
|
||||
|
||||
onSuccess: () => {
|
||||
toast.success(t("entry_actions.unstarred"), {
|
||||
duration: 1000,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useRead = () =>
|
||||
useMutation({
|
||||
mutationFn: async (entry: Nullable<CombinedEntryModel>) => {
|
||||
const relatedId = entry?.feeds?.id || entry?.inboxes?.id
|
||||
if (!relatedId) return
|
||||
return entryActions.markRead({
|
||||
feedId: relatedId,
|
||||
entryId: entry.entries.id,
|
||||
read: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
export const useUnread = () =>
|
||||
useMutation({
|
||||
mutationFn: async (entry: Nullable<CombinedEntryModel>) => {
|
||||
const relatedId = entry?.feeds?.id || entry?.inboxes?.id
|
||||
if (!relatedId) return
|
||||
return entryActions.markRead({
|
||||
feedId: relatedId,
|
||||
entryId: entry.entries.id,
|
||||
read: false,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const useDeleteInboxEntry = () => {
|
||||
const { t } = useTranslation()
|
||||
return useMutation({
|
||||
mutationFn: async (entryId: string) => {
|
||||
await entryActions.deleteInboxEntry(entryId)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("entry_actions.deleted"))
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("entry_actions.failed_to_delete"))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useEntryActions = ({
|
||||
view,
|
||||
entry,
|
||||
type,
|
||||
inList,
|
||||
}: {
|
||||
view?: number
|
||||
entry?: FlatEntryModel | null
|
||||
type?: "toolbar" | "entryList"
|
||||
inList?: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
export const useEntryActions = ({ entryId }: { entryId: string }) => {
|
||||
const entry = useEntry(entryId)
|
||||
const feed = useFeedById(entry?.feedId, (feed) => {
|
||||
return {
|
||||
type: feed.type,
|
||||
|
|
@ -158,234 +63,115 @@ export const useEntryActions = ({
|
|||
id: feed.id,
|
||||
}
|
||||
})
|
||||
const integrationActions = useIntegrationActions({ entry, view })
|
||||
const listId = useRouteParamsSelector((s) => s.listId)
|
||||
const inList = !!listId
|
||||
const inbox = useInboxById(entry?.inboxId)
|
||||
const isInbox = !!inbox
|
||||
|
||||
const populatedEntry = useMemo(() => {
|
||||
if (!entry) return null
|
||||
if (!feed?.id && !inbox?.id) return null
|
||||
const isShowSourceContent = useShowSourceContent()
|
||||
const getCmd = useGetCommand()
|
||||
const runCmdFn = useRunCommandFn()
|
||||
if (!entryId) return []
|
||||
const actionConfigs = [
|
||||
{
|
||||
id: COMMAND_ID.integration.saveToEagle,
|
||||
onClick: runCmdFn(COMMAND_ID.integration.saveToEagle, [{ entryId }]),
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.integration.saveToReadwise,
|
||||
onClick: runCmdFn(COMMAND_ID.integration.saveToReadwise, [{ entryId }]),
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.integration.saveToInstapaper,
|
||||
onClick: runCmdFn(COMMAND_ID.integration.saveToInstapaper, [{ entryId }]),
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.integration.saveToOmnivore,
|
||||
onClick: runCmdFn(COMMAND_ID.integration.saveToOmnivore, [{ entryId }]),
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.integration.saveToObsidian,
|
||||
onClick: runCmdFn(COMMAND_ID.integration.saveToObsidian, [{ entryId }]),
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.integration.saveToOutline,
|
||||
onClick: runCmdFn(COMMAND_ID.integration.saveToOutline, [{ entryId }]),
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.entry.tip,
|
||||
onClick: runCmdFn(COMMAND_ID.entry.tip, [{ entryId, feedId: feed?.id }]),
|
||||
hide: isInbox || feed?.ownerUserId === whoami()?.id,
|
||||
shortcut: shortcuts.entry.tip.key,
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.entry.unstar,
|
||||
onClick: runCmdFn(COMMAND_ID.entry.unstar, [{ entryId }]),
|
||||
hide: !entry?.collections,
|
||||
shortcut: shortcuts.entry.toggleStarred.key,
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.entry.star,
|
||||
onClick: runCmdFn(COMMAND_ID.entry.star, [{ entryId }]),
|
||||
hide: !!entry?.collections,
|
||||
shortcut: shortcuts.entry.toggleStarred.key,
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.entry.delete,
|
||||
onClick: runCmdFn(COMMAND_ID.entry.delete, [{ entryId }]),
|
||||
hide: !isInbox,
|
||||
shortcut: shortcuts.entry.copyLink.key,
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.entry.copyLink,
|
||||
onClick: runCmdFn(COMMAND_ID.entry.copyLink, [{ entryId }]),
|
||||
hide: !entry?.entries.url,
|
||||
shortcut: shortcuts.entry.copyTitle.key,
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.entry.openInBrowser,
|
||||
onClick: runCmdFn(COMMAND_ID.entry.openInBrowser, [{ entryId }]),
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.entry.viewSourceContent,
|
||||
onClick: runCmdFn(COMMAND_ID.entry.viewSourceContent, [{ entryId }]),
|
||||
hide: isShowSourceContent || !entry?.entries.url,
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.entry.viewEntryContent,
|
||||
onClick: runCmdFn(COMMAND_ID.entry.viewEntryContent, []),
|
||||
hide: !isShowSourceContent,
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.entry.share,
|
||||
onClick: runCmdFn(COMMAND_ID.entry.share, [{ entryId }]),
|
||||
hide: !entry?.entries.url || !("share" in navigator),
|
||||
shortcut: shortcuts.entry.share.key,
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.entry.read,
|
||||
onClick: runCmdFn(COMMAND_ID.entry.read, [{ entryId }]),
|
||||
hide: !entry || !!entry.read || !!entry.collections || !!inList,
|
||||
shortcut: shortcuts.entry.toggleRead.key,
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.entry.unread,
|
||||
onClick: runCmdFn(COMMAND_ID.entry.unread, [{ entryId }]),
|
||||
hide: !entry || !entry.read || !!entry.collections || !!inList,
|
||||
shortcut: shortcuts.entry.toggleRead.key,
|
||||
},
|
||||
]
|
||||
.filter((config) => !config.hide)
|
||||
.map((config) => {
|
||||
const cmd = getCmd(config.id)
|
||||
if (!cmd) return null
|
||||
return {
|
||||
...config,
|
||||
name: cmd.label.title,
|
||||
icon: cmd.icon,
|
||||
}
|
||||
})
|
||||
.filter((i) => i !== null)
|
||||
|
||||
return {
|
||||
...entry,
|
||||
feeds: feed ? getFeedById(feed.id) : undefined,
|
||||
inboxes: inbox,
|
||||
} as CombinedEntryModel
|
||||
}, [entry, feed, inbox])
|
||||
|
||||
const openTipModal = useTipModal()
|
||||
|
||||
const showSourceContent = useShowSourceContent()
|
||||
const showSourceContentModal = useSourceContentModal()
|
||||
|
||||
const collect = useCollect(populatedEntry)
|
||||
const uncollect = useUnCollect(populatedEntry)
|
||||
const read = useRead()
|
||||
const unread = useUnread()
|
||||
const deleteInboxEntry = useDeleteInboxEntry()
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!populatedEntry || view === undefined) return []
|
||||
|
||||
const items: {
|
||||
key: string
|
||||
className?: string
|
||||
shortcut?: string
|
||||
name: string
|
||||
icon?: ReactNode
|
||||
hide?: boolean
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
onClick: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void
|
||||
}[] = [
|
||||
...integrationActions.items,
|
||||
{
|
||||
key: "tip",
|
||||
shortcut: shortcuts.entry.tip.key,
|
||||
name: t("entry_actions.tip"),
|
||||
className: "i-mgc-power-outline",
|
||||
hide: isInbox || feed?.ownerUserId === whoami()?.id,
|
||||
onClick: () => {
|
||||
nextFrame(() =>
|
||||
openTipModal({
|
||||
userId: populatedEntry?.feeds?.ownerUserId ?? undefined,
|
||||
feedId: populatedEntry?.feeds?.id ?? undefined,
|
||||
entryId: populatedEntry?.entries.id ?? undefined,
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "star",
|
||||
shortcut: shortcuts.entry.toggleStarred.key,
|
||||
name: t("entry_actions.star"),
|
||||
className: "i-mgc-star-cute-re",
|
||||
hide: !!populatedEntry.collections,
|
||||
onClick: (e) => {
|
||||
if (type === "toolbar") {
|
||||
mountLottie(absoluteStarAnimationUri, {
|
||||
x: e.clientX - 90,
|
||||
y: e.clientY - 70,
|
||||
height: 126,
|
||||
width: 252,
|
||||
})
|
||||
}
|
||||
|
||||
collect.mutate()
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "unstar",
|
||||
name: t("entry_actions.unstar"),
|
||||
shortcut: shortcuts.entry.toggleStarred.key,
|
||||
className: "i-mgc-star-cute-fi text-orange-500",
|
||||
hide: !populatedEntry.collections,
|
||||
onClick: () => {
|
||||
uncollect.mutate()
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
name: t("entry_actions.delete"),
|
||||
hide: !isInbox,
|
||||
className: "i-mgc-delete-2-cute-re",
|
||||
onClick: () => {
|
||||
deleteInboxEntry.mutate(populatedEntry.entries.id)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "copyLink",
|
||||
name: t("entry_actions.copy_link"),
|
||||
className: "i-mgc-link-cute-re",
|
||||
hide: !populatedEntry.entries.url,
|
||||
shortcut: shortcuts.entry.copyLink.key,
|
||||
onClick: () => {
|
||||
if (!populatedEntry.entries.url) return
|
||||
navigator.clipboard.writeText(populatedEntry.entries.url)
|
||||
toast(t("entry_actions.copied_notify", { which: t("words.link") }), {
|
||||
duration: 1000,
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "copyTitle",
|
||||
name: t("entry_actions.copy_title"),
|
||||
className: tw`i-mgc-copy-cute-re`,
|
||||
hide: !populatedEntry.entries.title || type === "toolbar",
|
||||
shortcut: shortcuts.entry.copyTitle.key,
|
||||
onClick: () => {
|
||||
if (!populatedEntry.entries.title) return
|
||||
navigator.clipboard.writeText(populatedEntry.entries.title)
|
||||
toast(t("entry_actions.copied_notify", { which: t("words.title") }), {
|
||||
duration: 1000,
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "openInBrowser",
|
||||
name: t("entry_actions.open_in_browser", {
|
||||
which: t(IN_ELECTRON ? "words.browser" : "words.newTab"),
|
||||
}),
|
||||
shortcut: shortcuts.entry.openInBrowser.key,
|
||||
className: "i-mgc-world-2-cute-re",
|
||||
hide: type === "toolbar" || !populatedEntry.entries.url,
|
||||
onClick: () => {
|
||||
if (!populatedEntry.entries.url) return
|
||||
window.open(populatedEntry.entries.url, "_blank")
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "viewSourceContent",
|
||||
name: t("entry_actions.view_source_content"),
|
||||
// shortcut: shortcuts.entry.openInBrowser.key,
|
||||
className: !showSourceContent ? "i-mgc-world-2-cute-re" : tw`i-mgc-world-2-cute-fi`,
|
||||
hide: !populatedEntry.entries.url,
|
||||
active: showSourceContent,
|
||||
onClick: () => {
|
||||
if (!populatedEntry.entries.url) return
|
||||
const viewPreviewInModal = [
|
||||
FeedViewType.SocialMedia,
|
||||
FeedViewType.Videos,
|
||||
FeedViewType.Pictures,
|
||||
].includes(view!)
|
||||
if (viewPreviewInModal) {
|
||||
showSourceContentModal({
|
||||
title: populatedEntry.entries.title ?? undefined,
|
||||
src: populatedEntry.entries.url,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (type === "toolbar") {
|
||||
toggleShowSourceContent()
|
||||
return
|
||||
}
|
||||
navigateEntry({ entryId: populatedEntry.entries.id })
|
||||
setShowSourceContent(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t("entry_actions.share"),
|
||||
key: "share",
|
||||
className: getOS() === "macOS" ? `i-mgc-share-3-cute-re` : "i-mgc-share-forward-cute-re",
|
||||
shortcut: shortcuts.entry.share.key,
|
||||
hide: !(populatedEntry.entries.url && navigator.share),
|
||||
|
||||
onClick: () => {
|
||||
if (!populatedEntry.entries.url) return
|
||||
|
||||
if (IN_ELECTRON) {
|
||||
return tipcClient?.showShareMenu(populatedEntry.entries.url)
|
||||
} else {
|
||||
navigator.share({
|
||||
url: populatedEntry.entries.url,
|
||||
})
|
||||
}
|
||||
return
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "read",
|
||||
name: t("entry_actions.mark_as_read"),
|
||||
shortcut: shortcuts.entry.toggleRead.key,
|
||||
className: "i-mgc-round-cute-fi",
|
||||
hide: !!(!!populatedEntry.read || populatedEntry.collections) || inList,
|
||||
onClick: () => {
|
||||
read.mutate(populatedEntry)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "unread",
|
||||
name: t("entry_actions.mark_as_unread"),
|
||||
shortcut: shortcuts.entry.toggleRead.key,
|
||||
className: "i-mgc-round-cute-re",
|
||||
hide: !!(!populatedEntry.read || populatedEntry.collections) || inList,
|
||||
onClick: () => {
|
||||
unread.mutate(populatedEntry)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return items
|
||||
}, [
|
||||
populatedEntry,
|
||||
view,
|
||||
integrationActions.items,
|
||||
t,
|
||||
isInbox,
|
||||
feed?.ownerUserId,
|
||||
type,
|
||||
showSourceContent,
|
||||
inList,
|
||||
openTipModal,
|
||||
collect,
|
||||
uncollect,
|
||||
deleteInboxEntry,
|
||||
showSourceContentModal,
|
||||
read,
|
||||
unread,
|
||||
])
|
||||
|
||||
return {
|
||||
items,
|
||||
}
|
||||
return actionConfigs
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { feedUnreadActions } from "~/store/unread"
|
|||
import { navigateEntry } from "./useNavigateEntry"
|
||||
import { getRouteParams } from "./useRouteParams"
|
||||
|
||||
export const useDeleteSubscription = ({ onSuccess }: { onSuccess?: () => void }) => {
|
||||
export const useDeleteSubscription = ({ onSuccess }: { onSuccess?: () => void } = {}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMutation({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
# Follow Command Abstractions
|
||||
|
||||
this module encapsulates the command abstractions specifically designed for the Follow feature, which is leveraged through CMD-K.
|
||||
|
||||
The architectural design of the API takes inspiration from the [VSCode Command Abstractions](https://code.visualstudio.com/api/references/contribution-points#contributes.commands).
|
||||
|
||||
The registry component has been adapted from the innovative work found in [AFFiNE](https://github.com/toeverything/AFFiNE/blob/de81527e294ee99865ae7218fa4d22ad0660bf34/packages/frontend/core/src/commands/registry/README.md).
|
||||
|
||||
Furthermore, the hook design principles draw inspiration from the ideas proposed by [Supabase](https://github.com/supabase/supabase/pull/27044).
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import { assertType, test } from "vitest"
|
||||
|
||||
import { CommandActionButton, CommandIdButton } from "./command-button"
|
||||
import { COMMAND_ID } from "./commands/id"
|
||||
import type { OpenInBrowserCommand, TipCommand } from "./commands/types"
|
||||
|
||||
test("CommandActionButton types", () => {
|
||||
const mockCommand = {} as OpenInBrowserCommand
|
||||
assertType(
|
||||
CommandActionButton({
|
||||
command: mockCommand,
|
||||
args: [{ entryId: "" }],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
CommandActionButton({
|
||||
command: {} as TipCommand,
|
||||
args: [
|
||||
{
|
||||
feedId: "",
|
||||
// @ts-expect-error - invalid entryId type
|
||||
entryId: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
CommandActionButton({
|
||||
command: mockCommand,
|
||||
// @ts-expect-error - missing required options
|
||||
args: [],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
CommandActionButton({
|
||||
command: mockCommand,
|
||||
// @ts-expect-error - invalid args type
|
||||
args: [1],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
CommandActionButton({
|
||||
command: mockCommand,
|
||||
// @ts-expect-error - redundant args
|
||||
args: ["", ""],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
CommandActionButton({
|
||||
command: mockCommand,
|
||||
// @ts-expect-error - invalid args type
|
||||
args: [{}],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
CommandActionButton({
|
||||
command: mockCommand,
|
||||
// @ts-expect-error - invalid args type
|
||||
args: [""],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("CommandIdButton types", () => {
|
||||
const commandId = COMMAND_ID.entry.openInBrowser
|
||||
assertType(
|
||||
CommandIdButton({
|
||||
commandId,
|
||||
args: [{ entryId: "" }],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
CommandIdButton({
|
||||
commandId,
|
||||
// @ts-expect-error - missing required options
|
||||
args: [],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
CommandIdButton({
|
||||
commandId,
|
||||
// @ts-expect-error - invalid args type
|
||||
args: [1],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
CommandIdButton({
|
||||
commandId,
|
||||
// @ts-expect-error - invalid args type
|
||||
args: [{}],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
CommandIdButton({
|
||||
commandId,
|
||||
// @ts-expect-error - invalid args type
|
||||
args: [""],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
CommandIdButton({
|
||||
commandId,
|
||||
// @ts-expect-error - redundant args
|
||||
args: ["", ""],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import type { ActionButtonProps } from "@follow/components/ui/button/index.js"
|
||||
import { ActionButton } from "@follow/components/ui/button/index.js"
|
||||
|
||||
import { useCommand } from "./hooks/use-command"
|
||||
import type { FollowCommand, FollowCommandId, FollowCommandMap } from "./types"
|
||||
|
||||
interface CommandButtonProps<T extends FollowCommand> extends ActionButtonProps {
|
||||
command: T
|
||||
args: Parameters<T["run"]>
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
export interface CommandIdButtonProps<T extends FollowCommandId = FollowCommandId>
|
||||
extends ActionButtonProps {
|
||||
commandId: T
|
||||
args: Parameters<FollowCommandMap[T]["run"]>
|
||||
shortcut?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const CommandActionButton = <T extends FollowCommand>({
|
||||
command,
|
||||
args,
|
||||
shortcut,
|
||||
...props
|
||||
}: CommandButtonProps<T>) => {
|
||||
return (
|
||||
<ActionButton
|
||||
icon={command.icon}
|
||||
shortcut={shortcut}
|
||||
tooltip={command.label.title}
|
||||
// @ts-expect-error - The type should be discriminated
|
||||
onClick={() => command.run(...args)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const CommandIdButton = <T extends FollowCommandId>({
|
||||
commandId,
|
||||
...props
|
||||
}: CommandIdButtonProps<T>) => {
|
||||
const cmd = useCommand(commandId)
|
||||
if (!cmd) return
|
||||
return <CommandActionButton command={cmd} {...props} />
|
||||
}
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
import { FeedViewType } from "@follow/constants"
|
||||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import { nextFrame } from "@follow/utils/dom"
|
||||
import { getOS } from "@follow/utils/utils"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { setShowSourceContent, useSourceContentModal } from "~/atoms/source-content"
|
||||
import { navigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { getRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { tipcClient } from "~/lib/client"
|
||||
import { useTipModal } from "~/modules/wallet/hooks"
|
||||
import { entryActions, useEntryStore } from "~/store/entry"
|
||||
|
||||
import { useRegisterCommandEffect } from "../hooks/use-register-command-effect"
|
||||
import { defineFollowCommand } from "../registry/command"
|
||||
import { COMMAND_ID } from "./id"
|
||||
|
||||
const useCollect = () => {
|
||||
const { t } = useTranslation()
|
||||
return useMutation({
|
||||
mutationFn: async (entryId: string) => entryActions.markStar(entryId, true),
|
||||
|
||||
onSuccess: () => {
|
||||
toast.success(t("entry_actions.starred"), {
|
||||
duration: 1000,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const useUnCollect = () => {
|
||||
const { t } = useTranslation()
|
||||
return useMutation({
|
||||
mutationFn: async (entryId: string) => entryActions.markStar(entryId, false),
|
||||
|
||||
onSuccess: () => {
|
||||
toast.success(t("entry_actions.unstarred"), {
|
||||
duration: 1000,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const useDeleteInboxEntry = () => {
|
||||
const { t } = useTranslation()
|
||||
return useMutation({
|
||||
mutationFn: async (entryId: string) => {
|
||||
await entryActions.deleteInboxEntry(entryId)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("entry_actions.deleted"))
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("entry_actions.failed_to_delete"))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useRead = () =>
|
||||
useMutation({
|
||||
mutationFn: async ({ feedId, entryId }: { feedId: string; entryId: string }) =>
|
||||
entryActions.markRead({
|
||||
feedId,
|
||||
entryId,
|
||||
read: true,
|
||||
}),
|
||||
})
|
||||
|
||||
export const useUnread = () =>
|
||||
useMutation({
|
||||
mutationFn: async ({ feedId, entryId }: { feedId: string; entryId: string }) =>
|
||||
entryActions.markRead({
|
||||
feedId,
|
||||
entryId,
|
||||
read: false,
|
||||
}),
|
||||
})
|
||||
|
||||
export const useRegisterEntryCommands = () => {
|
||||
const { t } = useTranslation()
|
||||
const collect = useCollect()
|
||||
const uncollect = useUnCollect()
|
||||
const deleteInboxEntry = useDeleteInboxEntry()
|
||||
const showSourceContentModal = useSourceContentModal()
|
||||
const openTipModal = useTipModal()
|
||||
const read = useRead()
|
||||
const unread = useUnread()
|
||||
|
||||
useRegisterCommandEffect([
|
||||
defineFollowCommand({
|
||||
id: COMMAND_ID.entry.tip,
|
||||
label: t("entry_actions.tip"),
|
||||
icon: <i className="i-mgc-power-outline" />,
|
||||
// keyBinding: shortcuts.entry.tip.key,
|
||||
// when: !isInbox && feed?.ownerUserId !== whoami()?.id && !!populatedEntry,
|
||||
run: ({ userId, feedId, entryId }) => {
|
||||
nextFrame(() =>
|
||||
openTipModal({
|
||||
userId,
|
||||
feedId,
|
||||
entryId,
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
defineFollowCommand({
|
||||
id: COMMAND_ID.entry.star,
|
||||
label: t("entry_actions.star"),
|
||||
icon: <i className="i-mgc-star-cute-re" />,
|
||||
run: ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
toast.error("Failed to star: entry is not available", { duration: 3000 })
|
||||
return
|
||||
}
|
||||
// if (type === "toolbar") {
|
||||
// const absoluteStarAnimationUri = new URL(StarAnimationUri, import.meta.url).href
|
||||
// mountLottie(absoluteStarAnimationUri, {
|
||||
// x: e.clientX - 90,
|
||||
// y: e.clientY - 70,
|
||||
// height: 126,
|
||||
// width: 252,
|
||||
// })
|
||||
// }
|
||||
collect.mutate(entry.entries.id)
|
||||
},
|
||||
}),
|
||||
defineFollowCommand({
|
||||
id: COMMAND_ID.entry.unstar,
|
||||
label: t("entry_actions.unstar"),
|
||||
icon: <i className="i-mgc-star-cute-fi text-orange-500" />,
|
||||
run: ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
toast.error("Failed to unstar: entry is not available", { duration: 3000 })
|
||||
return
|
||||
}
|
||||
uncollect.mutate(entry.entries.id)
|
||||
},
|
||||
}),
|
||||
defineFollowCommand({
|
||||
id: COMMAND_ID.entry.delete,
|
||||
label: t("entry_actions.delete"),
|
||||
icon: <i className="i-mgc-delete-2-cute-re" />,
|
||||
run: ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
toast.error("Failed to delete: entry is not available", { duration: 3000 })
|
||||
return
|
||||
}
|
||||
deleteInboxEntry.mutate(entry.entries.id)
|
||||
},
|
||||
}),
|
||||
defineFollowCommand({
|
||||
id: COMMAND_ID.entry.copyLink,
|
||||
label: t("entry_actions.copy_link"),
|
||||
icon: <i className="i-mgc-link-cute-re" />,
|
||||
run: ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
toast.error("Failed to copy link: entry is not available", { duration: 3000 })
|
||||
return
|
||||
}
|
||||
if (!entry.entries.url) return
|
||||
navigator.clipboard.writeText(entry.entries.url)
|
||||
toast(t("entry_actions.copied_notify", { which: t("words.link") }), {
|
||||
duration: 1000,
|
||||
})
|
||||
},
|
||||
}),
|
||||
defineFollowCommand({
|
||||
id: COMMAND_ID.entry.copyTitle,
|
||||
label: t("entry_actions.copy_title"),
|
||||
icon: <i className="i-mgc-copy-cute-re" />,
|
||||
run: ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
toast.error("Failed to copy link: entry is not available", { duration: 3000 })
|
||||
return
|
||||
}
|
||||
if (!entry.entries.title) return
|
||||
navigator.clipboard.writeText(entry.entries.title)
|
||||
toast(t("entry_actions.copied_notify", { which: t("words.title") }), {
|
||||
duration: 1000,
|
||||
})
|
||||
},
|
||||
}),
|
||||
defineFollowCommand({
|
||||
id: COMMAND_ID.entry.openInBrowser,
|
||||
label: t("entry_actions.open_in_browser", {
|
||||
which: t(IN_ELECTRON ? "words.browser" : "words.newTab"),
|
||||
}),
|
||||
icon: <i className="i-mgc-world-2-cute-re" />,
|
||||
run: ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry || !entry.entries.url) {
|
||||
toast.error("Failed to open in browser: url is not available", { duration: 3000 })
|
||||
return
|
||||
}
|
||||
window.open(entry.entries.url, "_blank")
|
||||
},
|
||||
}),
|
||||
defineFollowCommand({
|
||||
id: COMMAND_ID.entry.viewSourceContent,
|
||||
label: t("entry_actions.view_source_content"),
|
||||
icon: <i className="i-mgc-world-2-cute-re" />,
|
||||
run: ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry || !entry.entries.url) {
|
||||
toast.error("Failed to view source content: url is not available", { duration: 3000 })
|
||||
return
|
||||
}
|
||||
const routeParams = getRouteParams()
|
||||
const viewPreviewInModal = [
|
||||
FeedViewType.SocialMedia,
|
||||
FeedViewType.Videos,
|
||||
FeedViewType.Pictures,
|
||||
].includes(routeParams.view)
|
||||
if (viewPreviewInModal) {
|
||||
showSourceContentModal({
|
||||
title: entry.entries.title ?? undefined,
|
||||
src: entry.entries.url,
|
||||
})
|
||||
return
|
||||
}
|
||||
const layoutEntryId = routeParams.entryId
|
||||
if (layoutEntryId !== entry.entries.id) {
|
||||
navigateEntry({ entryId: entry.entries.id })
|
||||
}
|
||||
setShowSourceContent(true)
|
||||
},
|
||||
}),
|
||||
defineFollowCommand({
|
||||
id: COMMAND_ID.entry.viewEntryContent,
|
||||
label: t("entry_actions.view_source_content"),
|
||||
icon: <i className="i-mgc-world-2-cute-fi" />,
|
||||
run: () => {
|
||||
setShowSourceContent(false)
|
||||
},
|
||||
}),
|
||||
defineFollowCommand({
|
||||
id: COMMAND_ID.entry.share,
|
||||
label: t("entry_actions.share"),
|
||||
icon:
|
||||
getOS() === "macOS" ? (
|
||||
<i className="i-mgc-share-3-cute-re" />
|
||||
) : (
|
||||
<i className="i-mgc-share-forward-cute-re" />
|
||||
),
|
||||
run: ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry || !entry.entries.url) {
|
||||
toast.error("Failed to share: url is not available", { duration: 3000 })
|
||||
return
|
||||
}
|
||||
if (!entry.entries.url) return
|
||||
|
||||
if (IN_ELECTRON) {
|
||||
return tipcClient?.showShareMenu(entry.entries.url)
|
||||
} else {
|
||||
navigator.share({
|
||||
url: entry.entries.url,
|
||||
})
|
||||
}
|
||||
return
|
||||
},
|
||||
}),
|
||||
defineFollowCommand({
|
||||
id: COMMAND_ID.entry.read,
|
||||
label: t("entry_actions.mark_as_read"),
|
||||
icon: <i className="i-mgc-round-cute-fi" />,
|
||||
run: ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
toast.error("Failed to mark as unread: feed is not available", { duration: 3000 })
|
||||
return
|
||||
}
|
||||
read.mutate({ entryId, feedId: entry.feedId })
|
||||
},
|
||||
}),
|
||||
defineFollowCommand({
|
||||
id: COMMAND_ID.entry.unread,
|
||||
label: t("entry_actions.mark_as_unread"),
|
||||
icon: <i className="i-mgc-round-cute-re" />,
|
||||
run: ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
toast.error("Failed to mark as unread: feed is not available", { duration: 3000 })
|
||||
return
|
||||
}
|
||||
unread.mutate({ entryId, feedId: entry.feedId })
|
||||
},
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
export const COMMAND_ID = {
|
||||
entry: {
|
||||
tip: "entry:tip",
|
||||
star: "entry:star",
|
||||
unstar: "entry:unstar",
|
||||
delete: "entry:delete",
|
||||
copyLink: "entry:copy-link",
|
||||
copyTitle: "entry:copy-title",
|
||||
openInBrowser: "entry:open-in-browser",
|
||||
viewSourceContent: "entry:view-source-content",
|
||||
viewEntryContent: "entry:view-entry-content",
|
||||
share: "entry:share",
|
||||
read: "entry:read",
|
||||
unread: "entry:unread",
|
||||
},
|
||||
integration: {
|
||||
saveToEagle: "integration:save-to-eagle",
|
||||
saveToReadwise: "integration:save-to-readwise",
|
||||
saveToInstapaper: "integration:save-to-instapaper",
|
||||
saveToOmnivore: "integration:save-to-omnivore",
|
||||
saveToObsidian: "integration:save-to-obsidian",
|
||||
saveToOutline: "integration:save-to-outline",
|
||||
},
|
||||
list: {
|
||||
edit: "list:edit",
|
||||
unfollow: "list:unfollow",
|
||||
navigateTo: "list:navigate-to",
|
||||
openInBrowser: "list:open-in-browser",
|
||||
copyUrl: "list:copy-url",
|
||||
copyId: "list:copy-id",
|
||||
},
|
||||
theme: {
|
||||
toAuto: "follow:change-color-mode-to-auto",
|
||||
toDark: "follow:change-color-mode-to-dark",
|
||||
toLight: "follow:change-color-mode-to-light",
|
||||
},
|
||||
} as const
|
||||
|
|
@ -0,0 +1,450 @@
|
|||
import {
|
||||
SimpleIconsEagle,
|
||||
SimpleIconsInstapaper,
|
||||
SimpleIconsObsidian,
|
||||
SimpleIconsOmnivore,
|
||||
SimpleIconsOutline,
|
||||
SimpleIconsReadwise,
|
||||
} from "@follow/components/ui/platform-icon/icons.js"
|
||||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import type { FetchError } from "ofetch"
|
||||
import { ofetch } from "ofetch"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { getReadabilityContent, getReadabilityStatus, ReadabilityStatus } from "~/atoms/readability"
|
||||
import { useIntegrationSettingKey } from "~/atoms/settings/integration"
|
||||
import { useRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { tipcClient } from "~/lib/client"
|
||||
import { parseHtml } from "~/lib/parse-html"
|
||||
import type { FlatEntryModel } from "~/store/entry"
|
||||
import { useEntryStore } from "~/store/entry"
|
||||
|
||||
import { useRegisterCommandEffect } from "../hooks/use-register-command-effect"
|
||||
import { defineFollowCommand } from "../registry/command"
|
||||
import { COMMAND_ID } from "./id"
|
||||
|
||||
export const useRegisterIntegrationCommands = () => {
|
||||
useRegisterEagleCommands()
|
||||
useRegisterReadwiseCommands()
|
||||
useRegisterInstapaperCommands()
|
||||
useRegisterOmnivoreCommands()
|
||||
useRegisterObsidianCommands()
|
||||
useRegisterOutlineCommands()
|
||||
}
|
||||
|
||||
const useRegisterEagleCommands = () => {
|
||||
const { t } = useTranslation()
|
||||
const { view } = useRouteParams()
|
||||
|
||||
const enableEagle = useIntegrationSettingKey("enableEagle")
|
||||
|
||||
const checkEagle = useQuery({
|
||||
queryKey: ["check-eagle"],
|
||||
enabled: ELECTRON && enableEagle && view !== undefined,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
await ofetch("http://localhost:41595")
|
||||
return true
|
||||
} catch (error: unknown) {
|
||||
return (error as FetchError).data?.code === 401
|
||||
}
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const isEagleAvailable = enableEagle && (checkEagle.isLoading ? false : !!checkEagle.data)
|
||||
|
||||
useRegisterCommandEffect(
|
||||
!isEagleAvailable
|
||||
? []
|
||||
: defineFollowCommand({
|
||||
id: COMMAND_ID.integration.saveToEagle,
|
||||
label: t("entry_actions.save_media_to_eagle"),
|
||||
icon: <SimpleIconsEagle />,
|
||||
run: async ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
toast.error("Failed to save to Eagle: entry is not available", {
|
||||
duration: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!entry.entries.url || !entry.entries.media?.length) {
|
||||
toast.error('Failed to save to Eagle: "url" or "media" is not available', {
|
||||
duration: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
const response = await tipcClient?.saveToEagle({
|
||||
url: entry.entries.url,
|
||||
mediaUrls: entry.entries.media.map((m) => m.url),
|
||||
})
|
||||
if (response?.status === "success") {
|
||||
toast.success(t("entry_actions.saved_to_eagle"), {
|
||||
duration: 3000,
|
||||
})
|
||||
} else {
|
||||
toast.error(t("entry_actions.failed_to_save_to_eagle"), {
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const useRegisterReadwiseCommands = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const enableReadwise = useIntegrationSettingKey("enableReadwise")
|
||||
const readwiseToken = useIntegrationSettingKey("readwiseToken")
|
||||
|
||||
const isReadwiseAvailable = enableReadwise && !!readwiseToken
|
||||
|
||||
useRegisterCommandEffect(
|
||||
!isReadwiseAvailable
|
||||
? []
|
||||
: defineFollowCommand({
|
||||
id: COMMAND_ID.integration.saveToReadwise,
|
||||
label: t("entry_actions.save_to_readwise"),
|
||||
icon: <SimpleIconsReadwise />,
|
||||
run: async ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
toast.error("Failed to save to Readwise: entry is not available", { duration: 3000 })
|
||||
return
|
||||
}
|
||||
try {
|
||||
window.analytics?.capture("integration", {
|
||||
type: "readwise",
|
||||
event: "save",
|
||||
})
|
||||
const data = await ofetch("https://readwise.io/api/v3/save/", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Token ${readwiseToken}`,
|
||||
},
|
||||
body: {
|
||||
url: entry.entries.url,
|
||||
html: entry.entries.content || undefined,
|
||||
title: entry.entries.title || undefined,
|
||||
author: entry.entries.author || undefined,
|
||||
summary: entry.entries.description || undefined,
|
||||
published_date: entry.entries.publishedAt || undefined,
|
||||
image_url: entry.entries.media?.[0]?.url || undefined,
|
||||
saved_using: "Follow",
|
||||
},
|
||||
})
|
||||
|
||||
toast.success(
|
||||
<>
|
||||
{t("entry_actions.saved_to_readwise")},{" "}
|
||||
<a target="_blank" className="underline" href={data.url}>
|
||||
view
|
||||
</a>
|
||||
</>,
|
||||
{
|
||||
duration: 3000,
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
toast.error(t("entry_actions.failed_to_save_to_readwise"), {
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const useRegisterInstapaperCommands = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const enableInstapaper = useIntegrationSettingKey("enableInstapaper")
|
||||
const instapaperUsername = useIntegrationSettingKey("instapaperUsername")
|
||||
const instapaperPassword = useIntegrationSettingKey("instapaperPassword")
|
||||
|
||||
const isInstapaperAvailable = enableInstapaper && !!instapaperPassword && !!instapaperUsername
|
||||
|
||||
useRegisterCommandEffect(
|
||||
!isInstapaperAvailable
|
||||
? []
|
||||
: defineFollowCommand({
|
||||
id: COMMAND_ID.integration.saveToInstapaper,
|
||||
label: t("entry_actions.save_to_instapaper"),
|
||||
icon: <SimpleIconsInstapaper />,
|
||||
run: async ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
toast.error("Failed to save to Instapaper: entry is not available", {
|
||||
duration: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
window.analytics?.capture("integration", {
|
||||
type: "instapaper",
|
||||
event: "save",
|
||||
})
|
||||
const data = await ofetch("https://www.instapaper.com/api/add", {
|
||||
query: {
|
||||
url: entry.entries.url,
|
||||
title: entry.entries.title,
|
||||
},
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${btoa(`${instapaperUsername}:${instapaperPassword}`)}`,
|
||||
},
|
||||
parseResponse: JSON.parse,
|
||||
})
|
||||
|
||||
toast.success(
|
||||
<>
|
||||
{t("entry_actions.saved_to_instapaper")},{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
className="underline"
|
||||
href={`https://www.instapaper.com/read/${data.bookmark_id}`}
|
||||
>
|
||||
view
|
||||
</a>
|
||||
</>,
|
||||
{
|
||||
duration: 3000,
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
toast.error(t("entry_actions.failed_to_save_to_instapaper"), {
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const useRegisterOmnivoreCommands = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const enableOmnivore = useIntegrationSettingKey("enableOmnivore")
|
||||
const omnivoreToken = useIntegrationSettingKey("omnivoreToken")
|
||||
const omnivoreEndpoint = useIntegrationSettingKey("omnivoreEndpoint")
|
||||
|
||||
const isOmnivoreAvailable = enableOmnivore && !!omnivoreToken && !!omnivoreEndpoint
|
||||
|
||||
useRegisterCommandEffect(
|
||||
!isOmnivoreAvailable
|
||||
? []
|
||||
: defineFollowCommand({
|
||||
id: COMMAND_ID.integration.saveToOmnivore,
|
||||
label: t("entry_actions.save_to_omnivore"),
|
||||
icon: <SimpleIconsOmnivore />,
|
||||
run: async ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
toast.error("Failed to save to Omnivore: entry is not available", { duration: 3000 })
|
||||
return
|
||||
}
|
||||
const saveUrlQuery = `
|
||||
mutation SaveUrl($input: SaveUrlInput!) {
|
||||
saveUrl(input: $input) {
|
||||
... on SaveSuccess {
|
||||
url
|
||||
clientRequestId
|
||||
}
|
||||
... on SaveError {
|
||||
errorCodes
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
window.analytics?.capture("integration", {
|
||||
type: "omnivore",
|
||||
event: "save",
|
||||
})
|
||||
try {
|
||||
const data = await ofetch(omnivoreEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: omnivoreToken,
|
||||
},
|
||||
body: {
|
||||
query: saveUrlQuery,
|
||||
variables: {
|
||||
input: {
|
||||
url: entry.entries.url,
|
||||
source: "Follow",
|
||||
clientRequestId: globalThis.crypto.randomUUID(),
|
||||
publishedAt: new Date(entry.entries.publishedAt),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
toast.success(
|
||||
<>
|
||||
{t("entry_actions.saved_to_omnivore")},{" "}
|
||||
<a target="_blank" className="underline" href={data.data.saveUrl.url}>
|
||||
view
|
||||
</a>
|
||||
</>,
|
||||
{
|
||||
duration: 3000,
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
toast.error(t("entry_actions.failed_to_save_to_omnivore"), {
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const getEntryContentAsMarkdown = async (entry: FlatEntryModel) => {
|
||||
const isReadabilityReady = getReadabilityStatus()[entry.entries.id] === ReadabilityStatus.SUCCESS
|
||||
const content =
|
||||
(isReadabilityReady
|
||||
? getReadabilityContent()[entry.entries.id].content
|
||||
: entry.entries.content) || ""
|
||||
const [toMarkdown, toMdast, gfmTableToMarkdown] = await Promise.all([
|
||||
import("mdast-util-to-markdown").then((m) => m.toMarkdown),
|
||||
import("hast-util-to-mdast").then((m) => m.toMdast),
|
||||
import("mdast-util-gfm-table").then((m) => m.gfmTableToMarkdown),
|
||||
])
|
||||
return toMarkdown(toMdast(parseHtml(content).hastTree), {
|
||||
extensions: [gfmTableToMarkdown()],
|
||||
})
|
||||
}
|
||||
|
||||
const useRegisterObsidianCommands = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const enableObsidian = useIntegrationSettingKey("enableObsidian")
|
||||
const obsidianVaultPath = useIntegrationSettingKey("obsidianVaultPath")
|
||||
const isObsidianAvailable = enableObsidian && !!obsidianVaultPath
|
||||
|
||||
const saveToObsidian = useMutation({
|
||||
mutationKey: ["save-to-obsidian"],
|
||||
mutationFn: async (data: {
|
||||
url: string
|
||||
title: string
|
||||
content: string
|
||||
author: string
|
||||
publishedAt: string
|
||||
vaultPath: string
|
||||
}) => {
|
||||
return await tipcClient?.saveToObsidian(data)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data?.success) {
|
||||
toast.success(t("entry_actions.saved_to_obsidian"), {
|
||||
duration: 3000,
|
||||
})
|
||||
} else {
|
||||
toast.error(`${t("entry_actions.failed_to_save_to_obsidian")}: ${data?.error}`, {
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
useRegisterCommandEffect(
|
||||
!IN_ELECTRON || !isObsidianAvailable
|
||||
? []
|
||||
: defineFollowCommand({
|
||||
id: COMMAND_ID.integration.saveToObsidian,
|
||||
label: t("entry_actions.save_to_obsidian"),
|
||||
icon: <SimpleIconsObsidian />,
|
||||
run: async ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
toast.error("Failed to save to Obsidian: entry is not available", { duration: 3000 })
|
||||
return
|
||||
}
|
||||
const markdownContent = await getEntryContentAsMarkdown(entry)
|
||||
window.analytics?.capture("integration", {
|
||||
type: "obsidian",
|
||||
event: "save",
|
||||
})
|
||||
saveToObsidian.mutate({
|
||||
url: entry.entries.url || "",
|
||||
title: entry.entries.title || "",
|
||||
content: markdownContent,
|
||||
author: entry.entries.author || "",
|
||||
publishedAt: entry.entries.publishedAt || "",
|
||||
vaultPath: obsidianVaultPath,
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const useRegisterOutlineCommands = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const enableOutline = useIntegrationSettingKey("enableOutline")
|
||||
const outlineEndpoint = useIntegrationSettingKey("outlineEndpoint")
|
||||
const outlineToken = useIntegrationSettingKey("outlineToken")
|
||||
const outlineCollection = useIntegrationSettingKey("outlineCollection")
|
||||
const outlineAvailable =
|
||||
enableOutline && !!outlineToken && !!outlineEndpoint && !!outlineCollection
|
||||
|
||||
useRegisterCommandEffect(
|
||||
!IN_ELECTRON || !outlineAvailable
|
||||
? []
|
||||
: defineFollowCommand({
|
||||
id: COMMAND_ID.integration.saveToOutline,
|
||||
label: t("entry_actions.save_to_outline"),
|
||||
icon: <SimpleIconsOutline />,
|
||||
run: async ({ entryId }) => {
|
||||
const entry = useEntryStore.getState().flatMapEntries[entryId]
|
||||
if (!entry) {
|
||||
toast.error("Failed to save to Outline: entry is not available", { duration: 3000 })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const request = async (method: string, params: Record<string, unknown>) => {
|
||||
return await ofetch(`${outlineEndpoint.replace(/\/$/, "")}/${method}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${outlineToken}`,
|
||||
},
|
||||
body: params,
|
||||
})
|
||||
}
|
||||
let collectionId = outlineCollection
|
||||
if (!/^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(collectionId)) {
|
||||
const collection = await request("collections.info", {
|
||||
id: collectionId,
|
||||
})
|
||||
collectionId = collection.data.id
|
||||
}
|
||||
const markdownContent = await getEntryContentAsMarkdown(entry)
|
||||
await request("documents.create", {
|
||||
title: entry.entries.title,
|
||||
text: markdownContent,
|
||||
collectionId,
|
||||
publish: true,
|
||||
})
|
||||
toast.success(t("entry_actions.saved_to_outline"), {
|
||||
duration: 3000,
|
||||
})
|
||||
} catch {
|
||||
toast.error(t("entry_actions.failed_to_save_to_outline"), {
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import { UrlBuilder } from "@follow/utils/url-builder"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { getRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { useDeleteSubscription } from "~/hooks/biz/useSubscriptionActions"
|
||||
import { ListForm } from "~/modules/discover/list-form"
|
||||
|
||||
import { useRegisterCommandEffect } from "../hooks/use-register-command-effect"
|
||||
import { COMMAND_ID } from "./id"
|
||||
|
||||
export const useRegisterListCommands = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { mutateAsync: deleteSubscription } = useDeleteSubscription()
|
||||
const navigateEntry = useNavigateEntry()
|
||||
const { present } = useModalStack()
|
||||
|
||||
useRegisterCommandEffect([
|
||||
{
|
||||
id: COMMAND_ID.list.edit,
|
||||
label: t("sidebar.feed_actions.edit"),
|
||||
// keyBinding: "E",
|
||||
run: ({ listId }) => {
|
||||
if (!listId) return
|
||||
present({
|
||||
title: t("sidebar.feed_actions.edit_list"),
|
||||
content: ({ dismiss }) => <ListForm asWidget id={listId} onSuccess={dismiss} />,
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.list.unfollow,
|
||||
label: t("sidebar.feed_actions.unfollow"),
|
||||
// keyBinding: "Meta+Backspace",
|
||||
run: ({ subscription }) => deleteSubscription({ subscription }),
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.list.navigateTo,
|
||||
label: t("sidebar.feed_actions.navigate_to_list"),
|
||||
// keyBinding: "Meta+G",
|
||||
// when: routeListId !== listId,
|
||||
run: ({ listId }) => {
|
||||
if (!listId) return
|
||||
navigateEntry({ listId })
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.list.openInBrowser,
|
||||
label: t("sidebar.feed_actions.open_list_in_browser", {
|
||||
which: IN_ELECTRON ? t("words.browser") : t("words.newTab"),
|
||||
}),
|
||||
// keyBinding: "O",
|
||||
run: ({ listId }) => {
|
||||
if (!listId) return
|
||||
const { view } = getRouteParams()
|
||||
window.open(UrlBuilder.shareList(listId, view), "_blank")
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.list.copyUrl,
|
||||
label: t("sidebar.feed_actions.copy_list_url"),
|
||||
// keyBinding: "Meta+C",
|
||||
run: async ({ listId }) => {
|
||||
if (!listId) return
|
||||
const { view } = getRouteParams()
|
||||
await navigator.clipboard.writeText(UrlBuilder.shareList(listId, view))
|
||||
toast.success("copy success!", {
|
||||
duration: 1000,
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: COMMAND_ID.list.copyId,
|
||||
label: t("sidebar.feed_actions.copy_list_id"),
|
||||
// keyBinding: "Meta+Shift+C",
|
||||
run: async ({ listId }) => {
|
||||
if (!listId) return
|
||||
await navigator.clipboard.writeText(listId)
|
||||
toast.success("copy success!", {
|
||||
duration: 1000,
|
||||
})
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { useThemeAtomValue } from "@follow/hooks"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { useSetTheme } from "~/hooks/common"
|
||||
|
||||
import { useRegisterCommandEffect } from "../hooks/use-register-command-effect"
|
||||
|
||||
export const useRegisterThemeCommands = () => {
|
||||
const [t] = useTranslation("settings")
|
||||
const theme = useThemeAtomValue()
|
||||
const setTheme = useSetTheme()
|
||||
|
||||
useRegisterCommandEffect([
|
||||
{
|
||||
id: "follow:change-color-mode-to-auto",
|
||||
label: `To ${t("appearance.theme.system")}`,
|
||||
category: "follow:settings",
|
||||
icon: <i className="i-mgc-settings-7-cute-re" />,
|
||||
when: theme !== "system",
|
||||
run() {
|
||||
setTheme("system")
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "follow:change-color-mode-to-dark",
|
||||
label: `To ${t("appearance.theme.dark")}`,
|
||||
category: "follow:settings",
|
||||
icon: <i className="i-mingcute-moon-line" />,
|
||||
when: theme !== "dark",
|
||||
run() {
|
||||
setTheme("dark")
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "follow:change-color-mode-to-light",
|
||||
label: `To ${t("appearance.theme.light")}`,
|
||||
category: "follow:settings",
|
||||
icon: <i className="i-mingcute-sun-line" />,
|
||||
when: theme !== "light",
|
||||
run() {
|
||||
setTheme("light")
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
// Entry commands
|
||||
|
||||
import type { Command } from "../types"
|
||||
import type { COMMAND_ID } from "./id"
|
||||
|
||||
export type TipCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.tip
|
||||
fn: (data: { userId?: string; feedId?: string; entryId?: string }) => void
|
||||
}>
|
||||
|
||||
export type StarCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.star
|
||||
fn: (data: { entryId: string }) => void
|
||||
}>
|
||||
export type UnStarCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.unstar
|
||||
fn: (data: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type DeleteCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.delete
|
||||
fn: (data: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type CopyLinkCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.copyLink
|
||||
fn: (data: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type CopyTitleCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.copyTitle
|
||||
fn: (data: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type OpenInBrowserCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.openInBrowser
|
||||
fn: (data: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type ViewSourceContentCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.viewSourceContent
|
||||
fn: (data: { entryId: string }) => void
|
||||
}>
|
||||
export type ViewEntryContentCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.viewEntryContent
|
||||
fn: () => void
|
||||
}>
|
||||
|
||||
export type ShareCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.share
|
||||
fn: ({ entryId }) => void
|
||||
}>
|
||||
|
||||
export type ReadCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.read
|
||||
fn: ({ entryId }) => void
|
||||
}>
|
||||
|
||||
export type UnReadCommand = Command<{
|
||||
id: typeof COMMAND_ID.entry.unread
|
||||
fn: ({ entryId }) => void
|
||||
}>
|
||||
|
||||
export type EntryCommand =
|
||||
| TipCommand
|
||||
| StarCommand
|
||||
| UnStarCommand
|
||||
| DeleteCommand
|
||||
| CopyLinkCommand
|
||||
| CopyTitleCommand
|
||||
| OpenInBrowserCommand
|
||||
| ViewSourceContentCommand
|
||||
| ViewEntryContentCommand
|
||||
| ShareCommand
|
||||
| ReadCommand
|
||||
| UnReadCommand
|
||||
|
||||
// Integration commands
|
||||
|
||||
export type SaveToEagleCommand = Command<{
|
||||
id: typeof COMMAND_ID.integration.saveToEagle
|
||||
fn: (payload: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type SaveToReadwiseCommand = Command<{
|
||||
id: typeof COMMAND_ID.integration.saveToReadwise
|
||||
fn: (payload: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type SaveToInstapaperCommand = Command<{
|
||||
id: typeof COMMAND_ID.integration.saveToInstapaper
|
||||
fn: (payload: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type SaveToOmnivoreCommand = Command<{
|
||||
id: typeof COMMAND_ID.integration.saveToOmnivore
|
||||
fn: (payload: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type SaveToObsidianCommand = Command<{
|
||||
id: typeof COMMAND_ID.integration.saveToObsidian
|
||||
fn: (payload: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type SaveToOutlineCommand = Command<{
|
||||
id: typeof COMMAND_ID.integration.saveToOutline
|
||||
fn: (payload: { entryId: string }) => void
|
||||
}>
|
||||
|
||||
export type IntegrationCommand =
|
||||
| SaveToEagleCommand
|
||||
| SaveToReadwiseCommand
|
||||
| SaveToInstapaperCommand
|
||||
| SaveToOmnivoreCommand
|
||||
| SaveToObsidianCommand
|
||||
| SaveToOutlineCommand
|
||||
|
||||
export type BasicCommand = EntryCommand | IntegrationCommand
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { assertType, expectTypeOf, test } from "vitest"
|
||||
|
||||
import { COMMAND_ID } from "../commands/id"
|
||||
import type { TipCommand } from "../commands/types"
|
||||
import { useCommand, useGetCommand, useRunCommandFn } from "./use-command"
|
||||
|
||||
test("useGetCommand types work properly", () => {
|
||||
const getCmd = useGetCommand()
|
||||
expectTypeOf(getCmd).toBeFunction()
|
||||
expectTypeOf(getCmd(COMMAND_ID.entry.tip)).toMatchTypeOf<TipCommand | null>()
|
||||
|
||||
// @ts-expect-error - get an unknown command should throw an error
|
||||
assertType(getCmd("unknown command"))
|
||||
})
|
||||
|
||||
test("useCommand types work properly", () => {
|
||||
const tipCmd = useCommand(COMMAND_ID.entry.tip)
|
||||
expectTypeOf(tipCmd).toMatchTypeOf<TipCommand | null>()
|
||||
|
||||
// @ts-expect-error - get an unknown command should throw an error
|
||||
assertType(useCommand("unknown command"))
|
||||
})
|
||||
|
||||
test("useRunCommandFn types work properly", () => {
|
||||
const runCmdFn = useRunCommandFn()
|
||||
expectTypeOf(runCmdFn).toBeFunction()
|
||||
|
||||
assertType(runCmdFn(COMMAND_ID.entry.tip, [{ entryId: "1" }]))
|
||||
// @ts-expect-error - invalid argument type
|
||||
assertType(runCmdFn(COMMAND_ID.entry.tip, [{ entryId: 1 }]))
|
||||
// @ts-expect-error - invalid argument type
|
||||
assertType(runCmdFn(COMMAND_ID.entry.tip, []))
|
||||
// @ts-expect-error - invalid argument type
|
||||
assertType(runCmdFn(COMMAND_ID.entry.tip, [1]))
|
||||
})
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { useAtomValue } from "jotai"
|
||||
import { useCallback } from "react"
|
||||
|
||||
import { CommandRegistry } from "../registry/registry"
|
||||
import type { FollowCommandId, FollowCommandMap } from "../types"
|
||||
|
||||
export function useGetCommand() {
|
||||
const commands = useAtomValue(CommandRegistry.atom) as FollowCommandMap
|
||||
return <T extends FollowCommandId>(id: T): FollowCommandMap[T] | null =>
|
||||
id in commands ? commands[id] : null
|
||||
}
|
||||
|
||||
export function useCommand<T extends FollowCommandId>(id: T): FollowCommandMap[T] | null {
|
||||
const getCmd = useGetCommand()
|
||||
return getCmd(id)
|
||||
}
|
||||
|
||||
const noop = () => {}
|
||||
export function useRunCommandFn() {
|
||||
const getCmd = useGetCommand()
|
||||
return useCallback(
|
||||
<T extends FollowCommandId>(id: T, args: Parameters<FollowCommandMap[T]["run"]>) => {
|
||||
const cmd = getCmd(id)
|
||||
if (!cmd) return noop
|
||||
// @ts-expect-error - The type should be discriminated
|
||||
return () => cmd.run(...args)
|
||||
},
|
||||
[getCmd],
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { useEffect } from "react"
|
||||
|
||||
import { registerCommand } from "../registry/registry"
|
||||
import type { CommandOptions } from "../types"
|
||||
|
||||
export type RegisterOptions = {
|
||||
deps?: unknown[]
|
||||
enabled?: boolean
|
||||
// forceMountSection?: boolean
|
||||
// sectionMeta?: Record<string, unknown>
|
||||
// orderSection?: OrderSectionInstruction
|
||||
// orderCommands?: OrderCommandsInstruction
|
||||
}
|
||||
|
||||
export const useRegisterCommandEffect = (
|
||||
options: CommandOptions | CommandOptions[],
|
||||
registerOptions?: RegisterOptions,
|
||||
) => {
|
||||
// TODO memo command via useMemo
|
||||
// See https://github.com/supabase/supabase/blob/master/packages/ui-patterns/CommandMenu/api/hooks/commandsHooks.ts
|
||||
|
||||
useEffect(() => {
|
||||
if (!Array.isArray(options)) {
|
||||
return registerCommand(options)
|
||||
}
|
||||
|
||||
const unsubscribes = options.map((option) => registerCommand(option))
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => unsubscribe())
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we want to run this effect only once
|
||||
}, registerOptions?.deps ?? [])
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { assertType, test } from "vitest"
|
||||
|
||||
import { COMMAND_ID } from "../commands/id"
|
||||
import { useCommandHotkey } from "./use-register-hotkey"
|
||||
|
||||
test("useRegisterHotkey types", () => {
|
||||
assertType(
|
||||
useCommandHotkey({
|
||||
shortcut: "",
|
||||
commandId: COMMAND_ID.entry.openInBrowser,
|
||||
args: [{ entryId: "" }],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
useCommandHotkey({
|
||||
shortcut: "",
|
||||
commandId: COMMAND_ID.entry.openInBrowser,
|
||||
// @ts-expect-error - missing required options
|
||||
args: [],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
useCommandHotkey({
|
||||
shortcut: "",
|
||||
commandId: COMMAND_ID.entry.openInBrowser,
|
||||
// @ts-expect-error - invalid args type
|
||||
args: [1],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
useCommandHotkey({
|
||||
shortcut: "",
|
||||
commandId: COMMAND_ID.entry.openInBrowser,
|
||||
// @ts-expect-error - invalid args number
|
||||
args: ["", ""],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
useCommandHotkey({
|
||||
shortcut: "",
|
||||
// @ts-expect-error - invalid command id
|
||||
commandId: "unknown command",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
|
||||
import type { FollowCommand, FollowCommandId } from "../types"
|
||||
import { useGetCommand } from "./use-command"
|
||||
|
||||
interface RegisterHotkeyOptions<T extends FollowCommandId> {
|
||||
shortcut: string
|
||||
commandId: T
|
||||
args?: Parameters<Extract<FollowCommand, { id: T }>["run"]>
|
||||
when?: boolean
|
||||
// hotkeyOptions?: Options
|
||||
}
|
||||
|
||||
export const useCommandHotkey = <T extends FollowCommandId>({
|
||||
shortcut,
|
||||
commandId,
|
||||
when,
|
||||
args,
|
||||
}: RegisterHotkeyOptions<T>) => {
|
||||
const getCommand = useGetCommand()
|
||||
useHotkeys(
|
||||
shortcut,
|
||||
() => {
|
||||
const command = getCommand(commandId)
|
||||
|
||||
if (!command) return
|
||||
if (Array.isArray(args)) {
|
||||
// It should be safe to spread the args here because we are checking if it is an array
|
||||
// @ts-expect-error - A spread argument must either have a tuple type or be passed to a rest parameter.ts(2556)
|
||||
command.run(...args)
|
||||
return
|
||||
}
|
||||
if (args === undefined) {
|
||||
// @ts-expect-error
|
||||
command.run()
|
||||
return
|
||||
}
|
||||
console.error("Invalid args", typeof args, args)
|
||||
},
|
||||
{
|
||||
enabled: when,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import { assertType, expectTypeOf, test } from "vitest"
|
||||
|
||||
import { COMMAND_ID } from "../commands/id"
|
||||
import { defineCommandArgsArray, defineFollowCommand, defineFollowCommandArgs } from "./command"
|
||||
|
||||
test("defineFollowCommand types", () => {
|
||||
assertType(
|
||||
defineFollowCommand({
|
||||
id: COMMAND_ID.entry.openInBrowser,
|
||||
label: "",
|
||||
run: (data) => {
|
||||
expectTypeOf(data).toEqualTypeOf<{
|
||||
entryId: string
|
||||
}>()
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
defineFollowCommand({
|
||||
id: COMMAND_ID.entry.openInBrowser,
|
||||
label: "",
|
||||
// @ts-expect-error - redundant parameters
|
||||
run: (url, _b: number) => console.info(url),
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
defineFollowCommand({
|
||||
// @ts-expect-error - unknown id
|
||||
id: "unknown id",
|
||||
label: "",
|
||||
run: () => {},
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
defineFollowCommand({
|
||||
id: COMMAND_ID.entry.openInBrowser,
|
||||
label: "",
|
||||
// @ts-expect-error - invalid type
|
||||
run: (_n: number) => {},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("defineFollowCommand with keyBinding types", () => {
|
||||
assertType(
|
||||
defineFollowCommand({
|
||||
id: COMMAND_ID.entry.viewEntryContent,
|
||||
label: "",
|
||||
when: true,
|
||||
keyBinding: "",
|
||||
run: () => {},
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
defineFollowCommand({
|
||||
id: COMMAND_ID.entry.viewSourceContent,
|
||||
label: "",
|
||||
when: true,
|
||||
// @ts-expect-error - only simple command can set keybinding
|
||||
keyBinding: "",
|
||||
run: ({ entryId }) => {
|
||||
expectTypeOf(entryId).toEqualTypeOf<string>()
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("defineCommandArgs with keyBinding types", () => {
|
||||
assertType(
|
||||
defineFollowCommandArgs({
|
||||
commandId: COMMAND_ID.entry.star,
|
||||
args: [{ entryId: "1" }],
|
||||
}),
|
||||
)
|
||||
|
||||
assertType(
|
||||
defineFollowCommandArgs({
|
||||
commandId: COMMAND_ID.entry.star,
|
||||
// @ts-expect-error - invalid args
|
||||
args: [],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("defineCommandArgsArray with keyBinding types", () => {
|
||||
assertType(
|
||||
defineCommandArgsArray([
|
||||
{
|
||||
commandId: COMMAND_ID.entry.star,
|
||||
args: [{ entryId: "1" }],
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
assertType(
|
||||
defineCommandArgsArray([
|
||||
{
|
||||
commandId: COMMAND_ID.entry.star,
|
||||
// @ts-expect-error - invalid args
|
||||
args: [],
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
assertType(
|
||||
defineCommandArgsArray([
|
||||
{
|
||||
commandId: COMMAND_ID.entry.star,
|
||||
// @ts-expect-error - invalid args
|
||||
args: [],
|
||||
},
|
||||
{
|
||||
commandId: COMMAND_ID.entry.viewEntryContent,
|
||||
args: [],
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
assertType(
|
||||
defineCommandArgsArray<{ test: boolean }>([
|
||||
{
|
||||
commandId: COMMAND_ID.entry.star,
|
||||
args: [{ entryId: "1" }],
|
||||
test: true,
|
||||
},
|
||||
{
|
||||
commandId: COMMAND_ID.entry.viewEntryContent,
|
||||
args: [],
|
||||
// @ts-expect-error - invalid extra property
|
||||
test: 1,
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import type { Command, CommandOptions, FollowCommand, FollowCommandId } from "../types"
|
||||
|
||||
export function createCommand<
|
||||
T extends { id: string; fn: (...args: any[]) => unknown } = {
|
||||
id: string
|
||||
fn: (...args: unknown[]) => unknown
|
||||
},
|
||||
>(options: CommandOptions<T>): Command<T> {
|
||||
return {
|
||||
id: options.id,
|
||||
run: options.run,
|
||||
icon: options.icon,
|
||||
category: options.category ?? "follow:general",
|
||||
get label() {
|
||||
let { label } = options
|
||||
label = typeof label === "function" ? label?.() : label
|
||||
label = typeof label === "string" ? { title: label } : label
|
||||
return label
|
||||
},
|
||||
// when: !!(options.when ?? true),
|
||||
// keyBinding:
|
||||
// typeof options.keyBinding === "string" ? { binding: options.keyBinding } : options.keyBinding,
|
||||
}
|
||||
}
|
||||
|
||||
// Follow command
|
||||
|
||||
export function createFollowCommand<T extends FollowCommand>(
|
||||
options: CommandOptions<{ id: T["id"]; fn: T["run"] }>,
|
||||
) {
|
||||
return createCommand(options)
|
||||
}
|
||||
|
||||
export function defineFollowCommand<T extends FollowCommandId>(
|
||||
options: CommandOptions<{ id: T; fn: Extract<FollowCommand, { id: T }>["run"] }>,
|
||||
) {
|
||||
return options as CommandOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const defineFollowCommandArgs = <T extends FollowCommandId>(config: {
|
||||
commandId: T
|
||||
args: Parameters<Extract<FollowCommand, { id: T }>["run"]>
|
||||
}) => config
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const defineCommandArgsArray = <
|
||||
Ext extends Record<string, unknown>,
|
||||
T extends FollowCommandId[] = FollowCommandId[],
|
||||
>(
|
||||
config: [
|
||||
...{
|
||||
[K in keyof T]: {
|
||||
commandId: T[K]
|
||||
args: Parameters<Extract<FollowCommand, { id: T[K] }>["run"]>
|
||||
// [key: string]: unknown
|
||||
} & Ext
|
||||
},
|
||||
],
|
||||
) => config
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { atom } from "jotai"
|
||||
|
||||
// import { createKeybindingsHandler } from "tinykeys"
|
||||
import { jotaiStore } from "~/lib/jotai"
|
||||
|
||||
import type { Command, CommandOptions } from "../types"
|
||||
import { createCommand } from "./command"
|
||||
|
||||
export const CommandRegistry = new (class {
|
||||
readonly atom = atom<Record<string, Command>>({})
|
||||
|
||||
get commands() {
|
||||
return {
|
||||
get: (id: string) => jotaiStore.get(this.atom)[id],
|
||||
set: (id: string, value: Command) =>
|
||||
jotaiStore.set(this.atom, (prev) => ({ ...prev, [id]: value })),
|
||||
has: (id: string) => !!jotaiStore.get(this.atom)[id],
|
||||
delete: (id: string) =>
|
||||
jotaiStore.set(this.atom, (prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[id]
|
||||
return next
|
||||
}),
|
||||
values: () => Object.values(jotaiStore.get(this.atom)) as Command[],
|
||||
}
|
||||
}
|
||||
|
||||
register(options: CommandOptions) {
|
||||
if (this.commands.has(options.id)) {
|
||||
console.warn(`Command ${options.id} already registered.`)
|
||||
return () => {}
|
||||
}
|
||||
const command = createCommand(options)
|
||||
this.commands.set(command.id, command)
|
||||
|
||||
return () => {
|
||||
this.commands.delete(command.id)
|
||||
}
|
||||
}
|
||||
|
||||
has(id: string): boolean {
|
||||
return this.commands.has(id)
|
||||
}
|
||||
|
||||
get(id: string): Command | undefined {
|
||||
if (!this.commands.has(id)) {
|
||||
console.warn(`Command ${id} not registered.`)
|
||||
return undefined
|
||||
}
|
||||
return this.commands.get(id)
|
||||
}
|
||||
|
||||
getAll(): Command[] {
|
||||
return Array.from(this.commands.values())
|
||||
}
|
||||
|
||||
run(id: string, ...args: unknown[]) {
|
||||
const command = this.get(id)
|
||||
if (!command) return
|
||||
|
||||
command.run(args)
|
||||
}
|
||||
})()
|
||||
|
||||
export function registerCommand(options: CommandOptions) {
|
||||
return CommandRegistry.register(options)
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import type { ReactNode } from "react"
|
||||
|
||||
import type { BasicCommand } from "./commands/types"
|
||||
|
||||
export type CommandCategory =
|
||||
| "follow:settings"
|
||||
| "follow:layout"
|
||||
| "follow:updates"
|
||||
| "follow:help"
|
||||
| "follow:general"
|
||||
|
||||
export interface KeybindingOptions {
|
||||
binding: string
|
||||
capture?: boolean
|
||||
// some keybindings are already registered in other places
|
||||
// we can skip the registration of these keybindings __FOR NOW__
|
||||
// skipRegister?: boolean
|
||||
}
|
||||
|
||||
export interface CommandKeybindingOptions<
|
||||
ID extends string,
|
||||
T extends (...args: any[]) => unknown = (...args: unknown[]) => unknown,
|
||||
> {
|
||||
/**
|
||||
* the command id.
|
||||
*/
|
||||
commandId: ID
|
||||
/**
|
||||
* a set of predefined precondition strategies.
|
||||
*
|
||||
* note: this only used for keybinding and command menu.
|
||||
* command will always available when called directly.
|
||||
*/
|
||||
when?: boolean
|
||||
/**
|
||||
* we use https://github.com/jamiebuilds/tinykeys so that we can use the same keybinding definition
|
||||
* for both mac and windows.
|
||||
*
|
||||
* Use `$mod` for `Cmd` on Mac and `Ctrl` on Windows and Linux.
|
||||
*/
|
||||
keyBinding?: KeybindingOptions | string
|
||||
/**
|
||||
* additional arguments for the command.
|
||||
*
|
||||
* Only used when the command is called from a keybinding.
|
||||
*/
|
||||
args?: Parameters<T>
|
||||
}
|
||||
|
||||
export interface Command<
|
||||
T extends { id: string; fn: (...args: any[]) => unknown } = {
|
||||
id: string
|
||||
fn: (...args: unknown[]) => unknown
|
||||
},
|
||||
> {
|
||||
readonly id: T["id"]
|
||||
readonly label: {
|
||||
title: string
|
||||
subTitle?: string
|
||||
}
|
||||
readonly icon?: ReactNode
|
||||
readonly category: CommandCategory
|
||||
readonly run: T["fn"]
|
||||
|
||||
// readonly when: boolean
|
||||
// readonly keyBinding?: KeybindingOptions
|
||||
}
|
||||
|
||||
export type SimpleCommand<T extends string> = Command<{ id: T; fn: () => void }>
|
||||
|
||||
export interface CommandOptions<
|
||||
T extends { id: string; fn: (...args: any[]) => unknown } = {
|
||||
id: string
|
||||
fn: (...args: any[]) => unknown
|
||||
},
|
||||
> {
|
||||
id: T["id"]
|
||||
// main text on the left..
|
||||
// make text a function so that we can do i18n and interpolation when we need to
|
||||
label:
|
||||
| string
|
||||
| (() => string)
|
||||
| { title: string; subTitle?: string }
|
||||
| (() => { title: string; subTitle?: string })
|
||||
icon?: ReactNode
|
||||
category?: CommandCategory
|
||||
run: T["fn"]
|
||||
|
||||
when?: boolean
|
||||
keyBinding?: T["fn"] extends () => void ? KeybindingOptions | string : never
|
||||
}
|
||||
|
||||
export type FollowCommandMap = {
|
||||
[K in FollowCommand["id"]]: Extract<FollowCommand, { id: K }>
|
||||
// [K in FollowCommand["id"]]: K extends UnknownCommand["id"]
|
||||
// ? UnknownCommand
|
||||
// : Extract<FollowCommand, { id: K }>
|
||||
}
|
||||
|
||||
// type AnyCommand = Command<string & {}, (...args: any[]) => void>
|
||||
export type UnknownCommand = Command<{
|
||||
id: string & { __brand: true }
|
||||
fn: (...args: unknown[]) => void
|
||||
}>
|
||||
|
||||
export type FollowCommandId = FollowCommand["id"]
|
||||
export type FollowCommand = BasicCommand // | UnknownCommand
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { useRegisterEntryCommands } from "./commands/entry"
|
||||
import { useRegisterIntegrationCommands } from "./commands/integration"
|
||||
import { useRegisterListCommands } from "./commands/list"
|
||||
import { useRegisterThemeCommands } from "./commands/theme"
|
||||
|
||||
export function useRegisterFollowCommands() {
|
||||
useRegisterThemeCommands()
|
||||
useRegisterListCommands()
|
||||
useRegisterEntryCommands()
|
||||
useRegisterIntegrationCommands()
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { ActionButton } from "@follow/components/ui/button/index.js"
|
||||
import { Skeleton } from "@follow/components/ui/skeleton/index.jsx"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { atom } from "jotai"
|
||||
import { useLayoutEffect, useRef } from "react"
|
||||
|
||||
|
|
@ -10,10 +9,10 @@ import { Media } from "~/components/ui/media"
|
|||
import { usePreviewMedia } from "~/components/ui/media/hooks"
|
||||
import { useAsRead } from "~/hooks/biz/useAsRead"
|
||||
import { useEntryActions } from "~/hooks/biz/useEntryActions"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { getImageProxyUrl } from "~/lib/img-proxy"
|
||||
import { jotaiStore } from "~/lib/jotai"
|
||||
import { parseSocialMedia } from "~/lib/parsers"
|
||||
import { COMMAND_ID } from "~/modules/command/commands/id"
|
||||
import { FeedIcon } from "~/modules/feed/feed-icon"
|
||||
import { FeedTitle } from "~/modules/feed/feed-title"
|
||||
import { useEntry } from "~/store/entry/hooks"
|
||||
|
|
@ -173,26 +172,20 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, entryPreview, transl
|
|||
SocialMediaItem.wrapperClassName = tw`w-[645px] max-w-full m-auto`
|
||||
|
||||
const ActionBar = ({ entryId }: { entryId: string }) => {
|
||||
const entry = useEntry(entryId)
|
||||
const view = useRouteParamsSelector((s) => s.view)
|
||||
const { items } = useEntryActions({
|
||||
entry,
|
||||
view,
|
||||
type: "toolbar",
|
||||
})
|
||||
const entryActions = useEntryActions({ entryId })
|
||||
return (
|
||||
<div className="flex origin-right scale-90 items-center gap-1">
|
||||
{items
|
||||
.filter((item) => !item.hide && item.key !== "read" && item.key !== "unread")
|
||||
{entryActions
|
||||
.filter(
|
||||
(item) =>
|
||||
!item.hide &&
|
||||
item.id !== COMMAND_ID.entry.read &&
|
||||
item.id !== COMMAND_ID.entry.unread &&
|
||||
item.id !== COMMAND_ID.entry.openInBrowser,
|
||||
)
|
||||
.map((item) => (
|
||||
<ActionButton
|
||||
icon={
|
||||
item.icon ? (
|
||||
<Slot className="size-4">{item.icon}</Slot>
|
||||
) : (
|
||||
<i className={item.className} />
|
||||
)
|
||||
}
|
||||
icon={item.icon}
|
||||
onClick={item.onClick}
|
||||
tooltip={item.name}
|
||||
key={item.name}
|
||||
|
|
|
|||
|
|
@ -24,14 +24,7 @@ export const EntryItemWrapper: FC<
|
|||
style?: React.CSSProperties
|
||||
} & PropsWithChildren
|
||||
> = ({ entry, view, children, itemClassName, style }) => {
|
||||
const listId = useRouteParamsSelector((s) => s.listId)
|
||||
const { items } = useEntryActions({
|
||||
view,
|
||||
entry,
|
||||
type: "entryList",
|
||||
inList: !!listId,
|
||||
})
|
||||
|
||||
const actionConfigs = useEntryActions({ entryId: entry.entries.id })
|
||||
const { items: feedItems } = useFeedActions({
|
||||
feedId: entry.feedId || entry.inboxId,
|
||||
view,
|
||||
|
|
@ -82,12 +75,12 @@ export const EntryItemWrapper: FC<
|
|||
setIsContextMenuOpen(true)
|
||||
await showContextMenu(
|
||||
[
|
||||
...items
|
||||
...actionConfigs
|
||||
.filter((item) => !item.hide)
|
||||
.map((item) => ({
|
||||
type: "text" as const,
|
||||
label: item.name,
|
||||
click: () => item.onClick(e),
|
||||
click: () => item.onClick(),
|
||||
shortcut: item.shortcut,
|
||||
})),
|
||||
{
|
||||
|
|
@ -110,7 +103,7 @@ export const EntryItemWrapper: FC<
|
|||
)
|
||||
setIsContextMenuOpen(false)
|
||||
},
|
||||
[showContextMenu, items, feedItems, t, entry.entries.id],
|
||||
[showContextMenu, actionConfigs, feedItems, t, entry.entries.id],
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { FeedViewType, views } from "@follow/constants"
|
|||
import type { CombinedEntryModel, MediaModel } from "@follow/models/types"
|
||||
import { IN_ELECTRON } from "@follow/shared/constants"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { noop } from "foxact/noop"
|
||||
import { AnimatePresence, m } from "framer-motion"
|
||||
import { memo, useMemo, useState } from "react"
|
||||
|
|
@ -17,10 +16,9 @@ import {
|
|||
useEntryInReadabilityStatus,
|
||||
} from "~/atoms/readability"
|
||||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { useHasModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { shortcuts } from "~/constants/shortcuts"
|
||||
import { useEntryActions, useEntryReadabilityToggle } from "~/hooks/biz/useEntryActions"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { tipcClient } from "~/lib/client"
|
||||
import { parseHtml } from "~/lib/parse-html"
|
||||
import { filterSmallMedia } from "~/lib/utils"
|
||||
|
|
@ -28,10 +26,42 @@ import type { FlatEntryModel } from "~/store/entry"
|
|||
import { useEntry } from "~/store/entry/hooks"
|
||||
import { useFeedById } from "~/store/feed"
|
||||
|
||||
import { COMMAND_ID } from "../command/commands/id"
|
||||
import { useCommandHotkey } from "../command/hooks/use-register-hotkey"
|
||||
import { ImageGallery } from "./actions/picture-gallery"
|
||||
import { useEntryContentScrollToTop, useEntryTitleMeta } from "./atoms"
|
||||
import { EntryReadHistory } from "./components/EntryReadHistory"
|
||||
|
||||
const EntryHeaderActions = ({ entryId }: { entryId: string }) => {
|
||||
const actionConfigs = useEntryActions({ entryId })
|
||||
const entry = useEntry(entryId)
|
||||
|
||||
const hasModal = useHasModal()
|
||||
|
||||
useCommandHotkey({
|
||||
when: !!entry?.entries.url && !hasModal,
|
||||
shortcut: shortcuts.entry.openInBrowser.key,
|
||||
commandId: COMMAND_ID.entry.openInBrowser,
|
||||
args: [{ entryId }],
|
||||
})
|
||||
|
||||
return actionConfigs
|
||||
.filter((config) => config.id !== COMMAND_ID.entry.openInBrowser)
|
||||
.map((config) => {
|
||||
return (
|
||||
<ActionButton
|
||||
key={config.id}
|
||||
tooltip={config.name}
|
||||
icon={config.icon}
|
||||
onClick={config.onClick}
|
||||
shortcut={config.shortcut}
|
||||
active={config.active}
|
||||
disableTriggerShortcut={hasModal}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function EntryHeaderImpl({
|
||||
view,
|
||||
entryId,
|
||||
|
|
@ -44,15 +74,6 @@ function EntryHeaderImpl({
|
|||
compact?: boolean
|
||||
}) {
|
||||
const entry = useEntry(entryId)
|
||||
|
||||
const listId = useRouteParamsSelector((s) => s.listId)
|
||||
const { items } = useEntryActions({
|
||||
view,
|
||||
entry,
|
||||
type: "toolbar",
|
||||
inList: !!listId,
|
||||
})
|
||||
|
||||
const entryTitleMeta = useEntryTitleMeta()
|
||||
const isAtTop = useEntryContentScrollToTop()
|
||||
|
||||
|
|
@ -110,25 +131,7 @@ function EntryHeaderImpl({
|
|||
{!compact && <ElectronAdditionActions view={view} entry={entry} key={entry.entries.id} />}
|
||||
|
||||
<SpecialActions id={entry.entries.id} />
|
||||
{items
|
||||
.filter((item) => !item.hide)
|
||||
.map((item) => (
|
||||
<ActionButton
|
||||
disabled={item.disabled}
|
||||
icon={
|
||||
item.icon ? (
|
||||
<Slot className="size-4">{item.icon}</Slot>
|
||||
) : (
|
||||
<i className={item.className} />
|
||||
)
|
||||
}
|
||||
active={item.active}
|
||||
shortcut={item.shortcut}
|
||||
onClick={item.onClick}
|
||||
tooltip={item.name}
|
||||
key={item.name}
|
||||
/>
|
||||
))}
|
||||
<EntryHeaderActions entryId={entry.entries.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import type { SearchInstance } from "~/store/search/types"
|
|||
import { getSubscriptionByFeedId } from "~/store/subscription"
|
||||
import { useFeedUnreadStore } from "~/store/unread"
|
||||
|
||||
import { useRegisterFollowCommands } from "../command/use-register-follow-commands"
|
||||
import styles from "./cmdk.module.css"
|
||||
|
||||
const SearchCmdKContext = React.createContext<Promise<SearchInstance> | null>(null)
|
||||
|
|
@ -68,6 +69,7 @@ export const SearchCmdK: React.FC = () => {
|
|||
$input.focus()
|
||||
}
|
||||
}, [open])
|
||||
useRegisterFollowCommands()
|
||||
|
||||
const { onCompositionEnd, onCompositionStart, isCompositionRef } =
|
||||
useInputComposition<HTMLInputElement>({})
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export interface BaseButtonProps {
|
|||
|
||||
// BIZ buttons
|
||||
|
||||
interface ActionButtonProps {
|
||||
export interface ActionButtonProps {
|
||||
icon?: React.ReactNode | React.FC<ComponentType>
|
||||
tooltip?: React.ReactNode
|
||||
tooltipSide?: "top" | "bottom"
|
||||
|
|
|
|||
Loading…
Reference in New Issue