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:
Whitewater 2024-11-19 13:34:55 +08:00 committed by GitHub
parent 4cbf8a54fc
commit ea8c8d0926
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1968 additions and 395 deletions

View File

@ -51,7 +51,7 @@ const useShowWebContextMenu = () => {
// Menu
type BaseMenuItemText = {
export type BaseMenuItemText = {
type: "text"
label: string
click?: () => void

View File

@ -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

View File

@ -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",

View File

@ -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
}

View File

@ -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({

View File

@ -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).

View File

@ -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: ["", ""],
}),
)
})

View File

@ -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} />
}

View File

@ -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 })
},
}),
])
}

View File

@ -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

View File

@ -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,
})
}
},
}),
)
}

View File

@ -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,
})
},
},
])
}

View File

@ -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")
},
},
])
}

View File

@ -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

View File

@ -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]))
})

View File

@ -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],
)
}

View File

@ -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 ?? [])
}

View File

@ -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",
}),
)
})

View File

@ -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,
},
)
}

View File

@ -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,
},
]),
)
})

View File

@ -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

View File

@ -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)
}

View File

@ -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

View File

@ -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()
}

View File

@ -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}

View File

@ -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 (

View File

@ -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>

View File

@ -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>({})

View File

@ -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"