Skip to content

Commit

Permalink
feat: add more actions to entry header (#1993)
Browse files Browse the repository at this point in the history
* feat: add more actions to entry header

* feat: optimize available actions filtering in MoreActions component
  • Loading branch information
lawvs authored Dec 3, 2024
1 parent 36d235e commit 5c4b5f0
Show file tree
Hide file tree
Showing 7 changed files with 264 additions and 197 deletions.
122 changes: 122 additions & 0 deletions apps/renderer/src/modules/entry-content/actions/electron-actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { ActionButton } from "@follow/components/ui/button/index.js"
import { DividerVertical } from "@follow/components/ui/divider/index.js"
import { FeedViewType, views } from "@follow/constants"
import type { CombinedEntryModel } from "@follow/models/types"
import { IN_ELECTRON } from "@follow/shared/constants"
import { cn } from "@follow/utils/utils"
import { noop } from "foxact/noop"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"

import { AudioPlayer, getAudioPlayerAtomValue } from "~/atoms/player"
import {
isInReadability,
ReadabilityStatus,
useEntryInReadabilityStatus,
} from "~/atoms/readability"
import { shortcuts } from "~/constants/shortcuts"
import { useEntryReadabilityToggle } from "~/hooks/biz/useEntryActions"
import { tipcClient } from "~/lib/client"
import { parseHtml } from "~/lib/parse-html"
import type { FlatEntryModel } from "~/store/entry"
import { useFeedById } from "~/store/feed"

export const ElectronAdditionActions = IN_ELECTRON
? ({
view = FeedViewType.Articles,
entry,
}: {
view: FeedViewType
entry?: FlatEntryModel | null
}) => {
const entryReadabilityStatus = useEntryInReadabilityStatus(entry?.entries.id)

const { t } = useTranslation()

const feed = useFeedById(entry?.feedId)

const populatedEntry = useMemo(() => {
if (!entry) return null
if (!feed) return null
return {
...entry,
feeds: feed!,
} as CombinedEntryModel
}, [entry, feed])

const readabilityToggle = useEntryReadabilityToggle({
id: populatedEntry?.entries.id ?? "",
url: populatedEntry?.entries.url ?? "",
})

const [ttsLoading, setTtsLoading] = useState(false)

if (!populatedEntry) return null

const items = [
{
key: "tts",
name: t("entry_content.header.play_tts"),
shortcut: shortcuts.entry.tts.key,
className: ttsLoading ? "i-mgc-loading-3-cute-re animate-spin" : "i-mgc-voice-cute-re",

disabled: !populatedEntry.entries.content,
onClick: async () => {
if (ttsLoading) return
if (!populatedEntry.entries.content) return
setTtsLoading(true)
if (getAudioPlayerAtomValue().entryId === populatedEntry.entries.id) {
AudioPlayer.togglePlayAndPause()
} else {
const filePath = await tipcClient?.tts({
id: populatedEntry.entries.id,
text: (await parseHtml(populatedEntry.entries.content)).toText(),
})
if (filePath) {
AudioPlayer.mount({
type: "audio",
entryId: populatedEntry.entries.id,
src: `file://${filePath}`,
currentTime: 0,
})
}
}
setTtsLoading(false)
},
},
{
name: t("entry_content.header.readability"),
className: cn(
isInReadability(entryReadabilityStatus)
? `i-mgc-docment-cute-fi`
: `i-mgc-docment-cute-re`,
entryReadabilityStatus === ReadabilityStatus.WAITING ? `animate-pulse` : "",
),
key: "readability",
hide: views[view].wideMode || !populatedEntry.entries.url,
active: isInReadability(entryReadabilityStatus),
onClick: readabilityToggle,
},
]

if (items.length === 0) return null
return (
<>
{items
.filter((item) => !item.hide)
.map((item) => (
<ActionButton
disabled={item.disabled}
icon={<i className={item.className} />}
active={item.active}
shortcut={item.shortcut}
onClick={item.onClick}
tooltip={item.name}
key={item.name}
/>
))}
<DividerVertical className="mx-2 w-px" />
</>
)
}
: noop
50 changes: 50 additions & 0 deletions apps/renderer/src/modules/entry-content/actions/header-actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { ActionButton } from "@follow/components/ui/button/index.js"
import type { FeedViewType } from "@follow/constants"

import { useHasModal } from "~/components/ui/modal/stacked/hooks"
import { shortcuts } from "~/constants/shortcuts"
import { useEntryActions } from "~/hooks/biz/useEntryActions"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { useCommandHotkey } from "~/modules/command/hooks/use-register-hotkey"
import { useEntry } from "~/store/entry/hooks"

export const EntryHeaderActions = ({ entryId, view }: { entryId: string; view?: FeedViewType }) => {
const actionConfigs = useEntryActions({ entryId, view })
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(
(item) =>
!item.id.startsWith("integration") &&
!(
[
COMMAND_ID.entry.read,
COMMAND_ID.entry.unread,
COMMAND_ID.entry.copyLink,
COMMAND_ID.entry.openInBrowser,
] as string[]
).includes(item.id),
)
.map((config) => {
return (
<ActionButton
key={config.id}
tooltip={config.name}
icon={config.icon}
onClick={config.onClick}
shortcut={config.shortcut}
active={config.active}
disableTriggerShortcut={hasModal}
/>
)
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ActionButton } from "@follow/components/ui/button/index.js"
import type { MediaModel } from "@follow/models"

import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { filterSmallMedia } from "~/lib/utils"
import { useEntry } from "~/store/entry"

import { ImageGallery } from "./picture-gallery"

export const ImageGalleryAction = ({ id }: { id: string }) => {
const images = useEntry(id, (entry) => entry.entries.media)
const { present } = useModalStack()
const filteredImages = filterSmallMedia(images)
if (filteredImages?.length && filteredImages.length > 5) {
return (
<ActionButton
onClick={() => {
window.analytics?.capture("entry_content_header_image_gallery_click")
present({
title: "Image Gallery",
content: () => <ImageGallery images={filteredImages as any as MediaModel[]} />,
max: true,
clickOutsideToDismiss: true,
})
}}
icon={<i className="i-mgc-pic-cute-fi" />}
tooltip={`Image Gallery`}
/>
)
}
return null
}
51 changes: 51 additions & 0 deletions apps/renderer/src/modules/entry-content/actions/more-actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { ActionButton } from "@follow/components/ui/button/index.js"
import type { FeedViewType } from "@follow/constants"
import { useMemo } from "react"

import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu/dropdown-menu"
import { useEntryActions } from "~/hooks/biz/useEntryActions"
import { COMMAND_ID } from "~/modules/command/commands/id"

export const MoreActions = ({ entryId, view }: { entryId: string; view?: FeedViewType }) => {
const actionConfigs = useEntryActions({ entryId, view })
const availableActions = useMemo(
() =>
actionConfigs.filter(
(item) =>
item.id.startsWith("integration") ||
([COMMAND_ID.entry.copyLink, COMMAND_ID.entry.openInBrowser] as string[]).includes(
item.id,
),
),
[actionConfigs],
)

if (availableActions.length === 0) {
return null
}

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ActionButton icon={<i className="i-mgc-more-1-cute-re" />} />
</DropdownMenuTrigger>
<DropdownMenuContent>
{availableActions.map((config) => (
<DropdownMenuItem
key={config.id}
className="pl-3"
icon={config.icon}
onSelect={config.onClick}
>
{config.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
Loading

0 comments on commit 5c4b5f0

Please sign in to comment.