diff --git a/apps/renderer/src/atoms/context-menu.ts b/apps/renderer/src/atoms/context-menu.ts index 29a0fe229a..21287b8f47 100644 --- a/apps/renderer/src/atoms/context-menu.ts +++ b/apps/renderer/src/atoms/context-menu.ts @@ -51,7 +51,7 @@ const useShowWebContextMenu = () => { // Menu -type BaseMenuItemText = { +export type BaseMenuItemText = { type: "text" label: string click?: () => void diff --git a/apps/renderer/src/components/ui/modal/stacked/hooks.tsx b/apps/renderer/src/components/ui/modal/stacked/hooks.tsx index 74ce912a8c..4ce40744e1 100644 --- a/apps/renderer/src/components/ui/modal/stacked/hooks.tsx +++ b/apps/renderer/src/components/ui/modal/stacked/hooks.tsx @@ -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 diff --git a/apps/renderer/src/constants/shortcuts.ts b/apps/renderer/src/constants/shortcuts.ts index a92ba8d00e..9b697bd53b 100644 --- a/apps/renderer/src/constants/shortcuts.ts +++ b/apps/renderer/src/constants/shortcuts.ts @@ -4,7 +4,7 @@ type Shortcuts = Record< string, Record > -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", diff --git a/apps/renderer/src/hooks/biz/useEntryActions.tsx b/apps/renderer/src/hooks/biz/useEntryActions.tsx index 74ac4359a2..b4231de139 100644 --- a/apps/renderer/src/hooks/biz/useEntryActions.tsx +++ b/apps/renderer/src/hooks/biz/useEntryActions.tsx @@ -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) => { - 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) => { - 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) => { - 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) => { - 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 - - 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) => 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, - ]) + 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 { - items, - } + return actionConfigs } diff --git a/apps/renderer/src/hooks/biz/useSubscriptionActions.tsx b/apps/renderer/src/hooks/biz/useSubscriptionActions.tsx index a186a51607..6ee082f5bc 100644 --- a/apps/renderer/src/hooks/biz/useSubscriptionActions.tsx +++ b/apps/renderer/src/hooks/biz/useSubscriptionActions.tsx @@ -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({ diff --git a/apps/renderer/src/modules/command/README.md b/apps/renderer/src/modules/command/README.md new file mode 100644 index 0000000000..a41fa1cb8d --- /dev/null +++ b/apps/renderer/src/modules/command/README.md @@ -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). diff --git a/apps/renderer/src/modules/command/command-button.test-d.ts b/apps/renderer/src/modules/command/command-button.test-d.ts new file mode 100644 index 0000000000..d2d611c93d --- /dev/null +++ b/apps/renderer/src/modules/command/command-button.test-d.ts @@ -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: ["", ""], + }), + ) +}) diff --git a/apps/renderer/src/modules/command/command-button.tsx b/apps/renderer/src/modules/command/command-button.tsx new file mode 100644 index 0000000000..d4b89cdb70 --- /dev/null +++ b/apps/renderer/src/modules/command/command-button.tsx @@ -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 extends ActionButtonProps { + command: T + args: Parameters + shortcut?: string +} + +export interface CommandIdButtonProps + extends ActionButtonProps { + commandId: T + args: Parameters + shortcut?: string +} + +/** + * @deprecated + */ +export const CommandActionButton = ({ + command, + args, + shortcut, + ...props +}: CommandButtonProps) => { + return ( + command.run(...args)} + {...props} + /> + ) +} + +/** + * @deprecated + */ +export const CommandIdButton = ({ + commandId, + ...props +}: CommandIdButtonProps) => { + const cmd = useCommand(commandId) + if (!cmd) return + return +} diff --git a/apps/renderer/src/modules/command/commands/entry.tsx b/apps/renderer/src/modules/command/commands/entry.tsx new file mode 100644 index 0000000000..04230cd723 --- /dev/null +++ b/apps/renderer/src/modules/command/commands/entry.tsx @@ -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: , + // 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: , + 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: , + 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: , + 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: , + 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: , + 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: , + 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: , + 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: , + run: () => { + setShowSourceContent(false) + }, + }), + defineFollowCommand({ + id: COMMAND_ID.entry.share, + label: t("entry_actions.share"), + icon: + getOS() === "macOS" ? ( + + ) : ( + + ), + 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: , + 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: , + 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 }) + }, + }), + ]) +} diff --git a/apps/renderer/src/modules/command/commands/id.ts b/apps/renderer/src/modules/command/commands/id.ts new file mode 100644 index 0000000000..2af9f98e2e --- /dev/null +++ b/apps/renderer/src/modules/command/commands/id.ts @@ -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 diff --git a/apps/renderer/src/modules/command/commands/integration.tsx b/apps/renderer/src/modules/command/commands/integration.tsx new file mode 100644 index 0000000000..87595f26c2 --- /dev/null +++ b/apps/renderer/src/modules/command/commands/integration.tsx @@ -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: , + 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: , + 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")},{" "} + + view + + , + { + 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: , + 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")},{" "} + + view + + , + { + 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: , + 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")},{" "} + + view + + , + { + 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: , + 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: , + 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) => { + 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, + }) + } + }, + }), + ) +} diff --git a/apps/renderer/src/modules/command/commands/list.tsx b/apps/renderer/src/modules/command/commands/list.tsx new file mode 100644 index 0000000000..77e69e99a5 --- /dev/null +++ b/apps/renderer/src/modules/command/commands/list.tsx @@ -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 }) => , + }) + }, + }, + { + 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, + }) + }, + }, + ]) +} diff --git a/apps/renderer/src/modules/command/commands/theme.tsx b/apps/renderer/src/modules/command/commands/theme.tsx new file mode 100644 index 0000000000..4fa2c8aa2b --- /dev/null +++ b/apps/renderer/src/modules/command/commands/theme.tsx @@ -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: , + when: theme !== "system", + run() { + setTheme("system") + }, + }, + { + id: "follow:change-color-mode-to-dark", + label: `To ${t("appearance.theme.dark")}`, + category: "follow:settings", + icon: , + when: theme !== "dark", + run() { + setTheme("dark") + }, + }, + { + id: "follow:change-color-mode-to-light", + label: `To ${t("appearance.theme.light")}`, + category: "follow:settings", + icon: , + when: theme !== "light", + run() { + setTheme("light") + }, + }, + ]) +} diff --git a/apps/renderer/src/modules/command/commands/types.ts b/apps/renderer/src/modules/command/commands/types.ts new file mode 100644 index 0000000000..2ae84c7ab4 --- /dev/null +++ b/apps/renderer/src/modules/command/commands/types.ts @@ -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 diff --git a/apps/renderer/src/modules/command/hooks/use-command.test-d.ts b/apps/renderer/src/modules/command/hooks/use-command.test-d.ts new file mode 100644 index 0000000000..316f3d3f6c --- /dev/null +++ b/apps/renderer/src/modules/command/hooks/use-command.test-d.ts @@ -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() + + // @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() + + // @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])) +}) diff --git a/apps/renderer/src/modules/command/hooks/use-command.ts b/apps/renderer/src/modules/command/hooks/use-command.ts new file mode 100644 index 0000000000..0300e13198 --- /dev/null +++ b/apps/renderer/src/modules/command/hooks/use-command.ts @@ -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 (id: T): FollowCommandMap[T] | null => + id in commands ? commands[id] : null +} + +export function useCommand(id: T): FollowCommandMap[T] | null { + const getCmd = useGetCommand() + return getCmd(id) +} + +const noop = () => {} +export function useRunCommandFn() { + const getCmd = useGetCommand() + return useCallback( + (id: T, args: Parameters) => { + const cmd = getCmd(id) + if (!cmd) return noop + // @ts-expect-error - The type should be discriminated + return () => cmd.run(...args) + }, + [getCmd], + ) +} diff --git a/apps/renderer/src/modules/command/hooks/use-register-command-effect.ts b/apps/renderer/src/modules/command/hooks/use-register-command-effect.ts new file mode 100644 index 0000000000..f7c5ff114b --- /dev/null +++ b/apps/renderer/src/modules/command/hooks/use-register-command-effect.ts @@ -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 + // 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 ?? []) +} diff --git a/apps/renderer/src/modules/command/hooks/use-register-hotkey.test-d.ts b/apps/renderer/src/modules/command/hooks/use-register-hotkey.test-d.ts new file mode 100644 index 0000000000..ec3d913d3d --- /dev/null +++ b/apps/renderer/src/modules/command/hooks/use-register-hotkey.test-d.ts @@ -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", + }), + ) +}) diff --git a/apps/renderer/src/modules/command/hooks/use-register-hotkey.ts b/apps/renderer/src/modules/command/hooks/use-register-hotkey.ts new file mode 100644 index 0000000000..0e1fa17507 --- /dev/null +++ b/apps/renderer/src/modules/command/hooks/use-register-hotkey.ts @@ -0,0 +1,44 @@ +import { useHotkeys } from "react-hotkeys-hook" + +import type { FollowCommand, FollowCommandId } from "../types" +import { useGetCommand } from "./use-command" + +interface RegisterHotkeyOptions { + shortcut: string + commandId: T + args?: Parameters["run"]> + when?: boolean + // hotkeyOptions?: Options +} + +export const useCommandHotkey = ({ + shortcut, + commandId, + when, + args, +}: RegisterHotkeyOptions) => { + 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, + }, + ) +} diff --git a/apps/renderer/src/modules/command/registry/command.test-d.ts b/apps/renderer/src/modules/command/registry/command.test-d.ts new file mode 100644 index 0000000000..4627ba6385 --- /dev/null +++ b/apps/renderer/src/modules/command/registry/command.test-d.ts @@ -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() + }, + }), + ) +}) + +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, + }, + ]), + ) +}) diff --git a/apps/renderer/src/modules/command/registry/command.ts b/apps/renderer/src/modules/command/registry/command.ts new file mode 100644 index 0000000000..ebea2b9868 --- /dev/null +++ b/apps/renderer/src/modules/command/registry/command.ts @@ -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): Command { + 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( + options: CommandOptions<{ id: T["id"]; fn: T["run"] }>, +) { + return createCommand(options) +} + +export function defineFollowCommand( + options: CommandOptions<{ id: T; fn: Extract["run"] }>, +) { + return options as CommandOptions +} + +/** + * @deprecated + */ +export const defineFollowCommandArgs = (config: { + commandId: T + args: Parameters["run"]> +}) => config + +/** + * @deprecated + */ +export const defineCommandArgsArray = < + Ext extends Record, + T extends FollowCommandId[] = FollowCommandId[], +>( + config: [ + ...{ + [K in keyof T]: { + commandId: T[K] + args: Parameters["run"]> + // [key: string]: unknown + } & Ext + }, + ], +) => config diff --git a/apps/renderer/src/modules/command/registry/registry.ts b/apps/renderer/src/modules/command/registry/registry.ts new file mode 100644 index 0000000000..fb4ce7485b --- /dev/null +++ b/apps/renderer/src/modules/command/registry/registry.ts @@ -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>({}) + + 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) +} diff --git a/apps/renderer/src/modules/command/types.ts b/apps/renderer/src/modules/command/types.ts new file mode 100644 index 0000000000..31199f7e28 --- /dev/null +++ b/apps/renderer/src/modules/command/types.ts @@ -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 +} + +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 = 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 + // [K in FollowCommand["id"]]: K extends UnknownCommand["id"] + // ? UnknownCommand + // : Extract +} + +// type AnyCommand = Command void> +export type UnknownCommand = Command<{ + id: string & { __brand: true } + fn: (...args: unknown[]) => void +}> + +export type FollowCommandId = FollowCommand["id"] +export type FollowCommand = BasicCommand // | UnknownCommand diff --git a/apps/renderer/src/modules/command/use-register-follow-commands.ts b/apps/renderer/src/modules/command/use-register-follow-commands.ts new file mode 100644 index 0000000000..359d0e534f --- /dev/null +++ b/apps/renderer/src/modules/command/use-register-follow-commands.ts @@ -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() +} diff --git a/apps/renderer/src/modules/entry-column/Items/social-media-item.tsx b/apps/renderer/src/modules/entry-column/Items/social-media-item.tsx index e900fa2043..175a5ac602 100644 --- a/apps/renderer/src/modules/entry-column/Items/social-media-item.tsx +++ b/apps/renderer/src/modules/entry-column/Items/social-media-item.tsx @@ -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 (
- {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) => ( {item.icon} - ) : ( - - ) - } + icon={item.icon} onClick={item.onClick} tooltip={item.name} key={item.name} diff --git a/apps/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx b/apps/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx index f02d2ffc20..778515d834 100644 --- a/apps/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx +++ b/apps/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx @@ -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 ( diff --git a/apps/renderer/src/modules/entry-content/header.tsx b/apps/renderer/src/modules/entry-content/header.tsx index ca81f124b6..cc802d0342 100644 --- a/apps/renderer/src/modules/entry-content/header.tsx +++ b/apps/renderer/src/modules/entry-content/header.tsx @@ -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 ( + + ) + }) +} + 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 && } - {items - .filter((item) => !item.hide) - .map((item) => ( - {item.icon} - ) : ( - - ) - } - active={item.active} - shortcut={item.shortcut} - onClick={item.onClick} - tooltip={item.name} - key={item.name} - /> - ))} +
diff --git a/apps/renderer/src/modules/panel/cmdk.tsx b/apps/renderer/src/modules/panel/cmdk.tsx index b72c67bf27..5ba69099f9 100644 --- a/apps/renderer/src/modules/panel/cmdk.tsx +++ b/apps/renderer/src/modules/panel/cmdk.tsx @@ -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 | null>(null) @@ -68,6 +69,7 @@ export const SearchCmdK: React.FC = () => { $input.focus() } }, [open]) + useRegisterFollowCommands() const { onCompositionEnd, onCompositionStart, isCompositionRef } = useInputComposition({}) diff --git a/packages/components/src/ui/button/index.tsx b/packages/components/src/ui/button/index.tsx index 34c406e981..84704aee0b 100644 --- a/packages/components/src/ui/button/index.tsx +++ b/packages/components/src/ui/button/index.tsx @@ -20,7 +20,7 @@ export interface BaseButtonProps { // BIZ buttons -interface ActionButtonProps { +export interface ActionButtonProps { icon?: React.ReactNode | React.FC tooltip?: React.ReactNode tooltipSide?: "top" | "bottom"