diff --git a/apps/renderer/src/atoms/ai-summary.ts b/apps/renderer/src/atoms/ai-summary.ts new file mode 100644 index 0000000000..a1a6f485ae --- /dev/null +++ b/apps/renderer/src/atoms/ai-summary.ts @@ -0,0 +1,11 @@ +import { atom } from "jotai" + +import { createAtomHooks } from "~/lib/jotai" + +export const [, , useShowAISummary, , getShowAISummary, setShowAISummary] = createAtomHooks( + atom(false), +) + +export const toggleShowAISummary = () => setShowAISummary(!getShowAISummary()) +export const enableShowAISummary = () => setShowAISummary(true) +export const disableShowAISummary = () => setShowAISummary(false) diff --git a/apps/renderer/src/atoms/ai-translation.ts b/apps/renderer/src/atoms/ai-translation.ts new file mode 100644 index 0000000000..c08e5a8502 --- /dev/null +++ b/apps/renderer/src/atoms/ai-translation.ts @@ -0,0 +1,10 @@ +import { atom } from "jotai" + +import { createAtomHooks } from "~/lib/jotai" + +export const [, , useShowAITranslation, , getShowAITranslation, setShowAITranslation] = + createAtomHooks(atom(false)) + +export const toggleShowAITranslation = () => setShowAITranslation(!getShowAITranslation()) +export const enableShowAITranslation = () => setShowAITranslation(true) +export const disableShowAITranslation = () => setShowAITranslation(false) diff --git a/apps/renderer/src/atoms/settings/general.ts b/apps/renderer/src/atoms/settings/general.ts index c6021f948e..66323ee14f 100644 --- a/apps/renderer/src/atoms/settings/general.ts +++ b/apps/renderer/src/atoms/settings/general.ts @@ -7,6 +7,8 @@ const createDefaultSettings = (): GeneralSettings => ({ // App appLaunchOnStartup: false, language: "en", + translationLanguage: "zh-CN", + // mobile app startupScreen: "timeline", // Data control diff --git a/apps/renderer/src/components/ui/markdown/HTML.tsx b/apps/renderer/src/components/ui/markdown/HTML.tsx index c6f45f6da9..e4f601ee1d 100644 --- a/apps/renderer/src/components/ui/markdown/HTML.tsx +++ b/apps/renderer/src/components/ui/markdown/HTML.tsx @@ -1,7 +1,8 @@ import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js" import katexStyle from "katex/dist/katex.min.css?raw" -import { createElement, Fragment, memo, useEffect, useMemo, useRef, useState } from "react" +import { createElement, Fragment, memo, useEffect, useMemo, useState } from "react" +import { useShowAITranslation } from "~/atoms/ai-translation" import { ENTRY_CONTENT_RENDER_CONTAINER_ID } from "~/constants/dom" import { parseHtml } from "~/lib/parse-html" import { useWrappedElementSize } from "~/providers/wrapped-element-provider" @@ -53,15 +54,11 @@ const HTMLImpl = (props: HTMLProp const [refElement, setRefElement] = useState(null) - const onceRef = useRef(false) - useEffect(() => { - if (onceRef.current || !refElement) { - return - } + const showAITranslation = useShowAITranslation() + useEffect(() => { translate?.(refElement) - onceRef.current = true - }, [translate, refElement]) + }, [refElement, showAITranslation, translate]) const markdownElement = useMemo( () => diff --git a/apps/renderer/src/hooks/biz/useEntryActions.tsx b/apps/renderer/src/hooks/biz/useEntryActions.tsx index af2f6ff8b8..b6435b86c1 100644 --- a/apps/renderer/src/hooks/biz/useEntryActions.tsx +++ b/apps/renderer/src/hooks/biz/useEntryActions.tsx @@ -1,8 +1,10 @@ import { isMobile } from "@follow/components/hooks/useMobile.js" -import type { FeedViewType } from "@follow/constants" +import { FeedViewType } from "@follow/constants" import type { ReactNode } from "react" import { useCallback, useMemo } from "react" +import { useShowAISummary } from "~/atoms/ai-summary" +import { useShowAITranslation } from "~/atoms/ai-translation" import { getReadabilityStatus, ReadabilityStatus, @@ -82,6 +84,9 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view?: Fee const isInbox = !!inbox const isShowSourceContent = useShowSourceContent() + const isShowAISummary = useShowAISummary() + const isShowAITranslation = useShowAITranslation() + const getCmd = useGetCommand() const runCmdFn = useRunCommandFn() const actionConfigs = useMemo(() => { @@ -156,6 +161,26 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view?: Fee hide: !isShowSourceContent, active: true, }, + { + id: COMMAND_ID.entry.toggleAISummary, + onClick: runCmdFn(COMMAND_ID.entry.toggleAISummary, []), + hide: + !!entry?.settings?.summary || + ([FeedViewType.SocialMedia, FeedViewType.Videos] as (number | undefined)[]).includes( + entry?.view, + ), + active: isShowAISummary, + }, + { + id: COMMAND_ID.entry.toggleAITranslation, + onClick: runCmdFn(COMMAND_ID.entry.toggleAITranslation, []), + hide: + !!entry?.settings?.translation || + ([FeedViewType.SocialMedia, FeedViewType.Videos] as (number | undefined)[]).includes( + entry?.view, + ), + active: isShowAITranslation, + }, { id: COMMAND_ID.entry.share, onClick: runCmdFn(COMMAND_ID.entry.share, [{ entryId }]), @@ -194,6 +219,8 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view?: Fee getCmd, inList, isInbox, + isShowAISummary, + isShowAITranslation, isShowSourceContent, runCmdFn, view, diff --git a/apps/renderer/src/hooks/biz/useNavigateEntry.ts b/apps/renderer/src/hooks/biz/useNavigateEntry.ts index c8f5092211..584aada0a0 100644 --- a/apps/renderer/src/hooks/biz/useNavigateEntry.ts +++ b/apps/renderer/src/hooks/biz/useNavigateEntry.ts @@ -5,6 +5,8 @@ import { FeedViewType } from "@follow/constants" import { isUndefined } from "es-toolkit/compat" import { useCallback } from "react" +import { disableShowAISummary } from "~/atoms/ai-summary" +import { disableShowAITranslation } from "~/atoms/ai-translation" import { setSidebarActiveView } from "~/atoms/sidebar" import { resetShowSourceContent } from "~/atoms/source-content" import { @@ -69,6 +71,8 @@ export const navigateEntry = (options: NavigateEntryOptions) => { setSidebarActiveView(view) } resetShowSourceContent() + disableShowAISummary() + disableShowAITranslation() const finalView = nextSearchParams.get("view") diff --git a/apps/renderer/src/lib/immersive-translate.ts b/apps/renderer/src/lib/immersive-translate.ts index 29c53da8d6..f1be28d339 100644 --- a/apps/renderer/src/lib/immersive-translate.ts +++ b/apps/renderer/src/lib/immersive-translate.ts @@ -1,3 +1,4 @@ +import type { SupportedLanguages } from "@follow/models/types" import { franc } from "franc-min" import type { FlatEntryModel } from "~/store/entry" @@ -22,6 +23,7 @@ export function immersiveTranslate({ html, entry, cache, + targetLanguage, }: { html?: HTMLElement entry: FlatEntryModel @@ -29,8 +31,26 @@ export function immersiveTranslate({ get: (key: string) => string | undefined set: (key: string, value: string) => void } + targetLanguage?: SupportedLanguages }) { - if (!html || !entry.settings?.translation) { + if (!html) { + return + } + + const translation = entry.settings?.translation ?? targetLanguage + + const immersiveTranslateMark = html.querySelectorAll("[data-immersive-translate-mark=true]") + if (immersiveTranslateMark.length > 0) { + if (translation) { + return + } + + for (const mark of immersiveTranslateMark) { + mark.remove() + } + } + + if (!translation) { return } @@ -42,7 +62,7 @@ export function immersiveTranslate({ translate({ entry, - language: entry.settings?.translation, + language: translation, part: textNode.textContent, extraFields: ["content"], }).then((transformed) => { @@ -52,8 +72,13 @@ export function immersiveTranslate({ const p = document.createElement("p") p.append(document.createTextNode(textNode.textContent!)) - p.append(document.createElement("br")) - p.append(document.createTextNode(transformed.content)) + + const fontTag = document.createElement("font") + fontTag.dataset["immersiveTranslateMark"] = "true" + fontTag.append(document.createElement("br")) + fontTag.append(document.createTextNode(transformed.content)) + + p.append(fontTag) textNode.replaceWith(p) }) @@ -70,7 +95,7 @@ export function immersiveTranslate({ for (const tag of tags) { const sourceLanguage = franc(tag.textContent ?? "") - if (sourceLanguage === LanguageMap[entry.settings?.translation].code) { + if (sourceLanguage === LanguageMap[translation].code) { return } @@ -78,6 +103,7 @@ export function immersiveTranslate({ tag.dataset.childCount = children.filter((child) => child.textContent).length.toString() const fontTag = document.createElement("font") + fontTag.dataset["immersiveTranslateMark"] = "true" if (children.length > 0) { fontTag.style.display = "none" @@ -121,7 +147,7 @@ export function immersiveTranslate({ translate({ entry, - language: entry.settings?.translation, + language: translation, part: textContent, extraFields: ["content"], }).then((transformed) => { @@ -140,6 +166,7 @@ export function immersiveTranslate({ } const parentFontTag = document.createElement("font") + parentFontTag.dataset["immersiveTranslateMark"] = "true" parentFontTag.append(document.createElement("br")) parentFontTag.append(fontTag) diff --git a/apps/renderer/src/lib/translate.ts b/apps/renderer/src/lib/translate.ts index 23306dd776..2736c925ac 100644 --- a/apps/renderer/src/lib/translate.ts +++ b/apps/renderer/src/lib/translate.ts @@ -8,19 +8,29 @@ import { apiClient } from "./api-fetch" export const LanguageMap: Record< SupportedLanguages, { + label: string + value: string code: string } > = { en: { + value: "en", + label: "English", code: "eng", }, ja: { + value: "ja", + label: "Japanese", code: "jpn", }, "zh-CN": { + value: "zh-CN", + label: "Simplified Chinese", code: "cmn", }, "zh-TW": { + value: "zh-TW", + label: "Traditional Chinese(Taiwan)", code: "cmn", }, } @@ -40,18 +50,17 @@ export async function translate({ if (!language) { return null } - let fields = - entry.settings?.translation && view !== undefined ? views[view!].translation.split(",") : [] + let fields = language && view !== undefined ? views[view!].translation.split(",") : [] if (extraFields) { fields = [...fields, ...extraFields] } const { franc } = await import("franc-min") fields = fields.filter((field) => { - if (entry.settings?.translation && entry.entries[field]) { + if (language && entry.entries[field]) { const sourceLanguage = franc(entry.entries[field]) - if (sourceLanguage === LanguageMap[entry.settings?.translation].code) { + if (sourceLanguage === LanguageMap[language].code) { return false } else { return true diff --git a/apps/renderer/src/modules/command/commands/entry.tsx b/apps/renderer/src/modules/command/commands/entry.tsx index a7d96ef624..3f74f8819c 100644 --- a/apps/renderer/src/modules/command/commands/entry.tsx +++ b/apps/renderer/src/modules/command/commands/entry.tsx @@ -5,6 +5,8 @@ import { useMutation } from "@tanstack/react-query" import { useTranslation } from "react-i18next" import { toast } from "sonner" +import { toggleShowAISummary } from "~/atoms/ai-summary" +import { toggleShowAITranslation } from "~/atoms/ai-translation" import { setShowSourceContent, useSourceContentModal } from "~/atoms/source-content" import { navigateEntry } from "~/hooks/biz/useNavigateEntry" import { getRouteParams } from "~/hooks/biz/useRouteParams" @@ -290,5 +292,21 @@ export const useRegisterEntryCommands = () => { unread.mutate({ entryId, feedId: entry.feedId }) }, }, + { + id: COMMAND_ID.entry.toggleAISummary, + label: t("entry_actions.toggle_ai_summary"), + icon: , + run: () => { + toggleShowAISummary() + }, + }, + { + id: COMMAND_ID.entry.toggleAITranslation, + label: t("entry_actions.toggle_ai_translation"), + icon: , + run: () => { + toggleShowAITranslation() + }, + }, ]) } diff --git a/apps/renderer/src/modules/command/commands/id.ts b/apps/renderer/src/modules/command/commands/id.ts index 2af9f98e2e..92304bd913 100644 --- a/apps/renderer/src/modules/command/commands/id.ts +++ b/apps/renderer/src/modules/command/commands/id.ts @@ -12,6 +12,8 @@ export const COMMAND_ID = { share: "entry:share", read: "entry:read", unread: "entry:unread", + toggleAISummary: "entry:toggle-ai-summary", + toggleAITranslation: "entry:toggle-ai-translation", }, integration: { saveToEagle: "integration:save-to-eagle", diff --git a/apps/renderer/src/modules/command/commands/types.ts b/apps/renderer/src/modules/command/commands/types.ts index 244be39bcf..fcb69338c0 100644 --- a/apps/renderer/src/modules/command/commands/types.ts +++ b/apps/renderer/src/modules/command/commands/types.ts @@ -63,6 +63,16 @@ export type UnReadCommand = Command<{ fn: ({ entryId }) => void }> +export type ToggleAISummaryCommand = Command<{ + id: typeof COMMAND_ID.entry.toggleAISummary + fn: () => void +}> + +export type ToggleAITranslationCommand = Command<{ + id: typeof COMMAND_ID.entry.toggleAITranslation + fn: () => void +}> + export type EntryCommand = | TipCommand | StarCommand @@ -76,6 +86,8 @@ export type EntryCommand = | ShareCommand | ReadCommand | UnReadCommand + | ToggleAISummaryCommand + | ToggleAITranslationCommand // Integration commands diff --git a/apps/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx b/apps/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx index 466838d025..687efe9a17 100644 --- a/apps/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx +++ b/apps/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx @@ -14,6 +14,7 @@ import { useFeedActions } from "~/hooks/biz/useFeedActions" import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry" import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams" import { useContextMenu } from "~/hooks/common/useContextMenu" +import { COMMAND_ID } from "~/modules/command/commands/id" import type { FlatEntryModel } from "~/store/entry" import { entryActions } from "~/store/entry" @@ -77,12 +78,23 @@ export const EntryItemWrapper: FC< setIsContextMenuOpen(true) await showContextMenu( [ - ...actionConfigs.map((item) => ({ - type: "text" as const, - label: item.name, - click: () => item.onClick(), - shortcut: item.shortcut, - })), + ...actionConfigs + .filter( + (item) => + !( + [ + COMMAND_ID.entry.viewSourceContent, + COMMAND_ID.entry.toggleAISummary, + COMMAND_ID.entry.toggleAITranslation, + ] as string[] + ).includes(item.id), + ) + .map((item) => ({ + type: "text" as const, + label: item.name, + click: () => item.onClick(), + shortcut: item.shortcut, + })), { type: "separator" as const, }, diff --git a/apps/renderer/src/modules/entry-column/translation.tsx b/apps/renderer/src/modules/entry-column/translation.tsx index fd0aa42534..a6ea34ca1e 100644 --- a/apps/renderer/src/modules/entry-column/translation.tsx +++ b/apps/renderer/src/modules/entry-column/translation.tsx @@ -9,12 +9,13 @@ import { HTML } from "~/components/ui/markdown/HTML" export const EntryTranslation: Component<{ source?: string | null target?: string + showTranslation?: boolean side?: "top" | "bottom" useOverlay?: boolean isHTML?: boolean -}> = ({ source, target, className, side, useOverlay, isHTML }) => { +}> = ({ source, target, showTranslation, className, side, useOverlay, isHTML }) => { let nextTarget = target if (source === target) { nextTarget = undefined @@ -26,66 +27,66 @@ export const EntryTranslation: Component<{ return null } - if (!nextTarget && source) { - return isHTML ? ( - - {source} - - ) : ( -
{source}
- ) - } - return ( - - - - {isHTML ? ( - - {nextTarget} - - ) : ( - {nextTarget} - )} - - - - {useOverlay ? ( -
- - {isHTML ? ( - - {source} - - ) : ( - {source} - )} - -
- ) : isHTML ? ( - - {source} + if (nextTarget && showTranslation) { + return ( + + + + {isHTML ? ( + + {nextTarget} ) : ( - {source} + {nextTarget} )} -
-
-
+ + + + {useOverlay ? ( +
+ + {isHTML ? ( + + {source} + + ) : ( + {source} + )} + +
+ ) : isHTML ? ( + + {source} + + ) : ( + {source} + )} +
+
+ + ) + } + return isHTML ? ( + + {source} + + ) : ( +
{source}
) } diff --git a/apps/renderer/src/modules/entry-content/components/EntryTitle.tsx b/apps/renderer/src/modules/entry-content/components/EntryTitle.tsx index 30a311e1b8..2e790614cd 100644 --- a/apps/renderer/src/modules/entry-content/components/EntryTitle.tsx +++ b/apps/renderer/src/modules/entry-content/components/EntryTitle.tsx @@ -1,6 +1,9 @@ +import type { SupportedLanguages } from "@follow/models/types" import { cn } from "@follow/utils/utils" import { useMemo } from "react" +import { useShowAITranslation } from "~/atoms/ai-translation" +import { useGeneralSettingSelector } from "~/atoms/settings/general" import { useWhoami } from "~/atoms/user" import { RelativeTime } from "~/components/ui/datetime" import { useAuthQuery } from "~/hooks/common" @@ -43,14 +46,17 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => { return href }, [entry?.entries.authorUrl, entry?.entries.url, feed?.siteUrl, feed?.type, inbox]) + const showAITranslation = useShowAITranslation() || !!entry?.settings?.translation + const translationLanguage = useGeneralSettingSelector((s) => s.translationLanguage) + const translation = useAuthQuery( Queries.ai.translation({ entry: entry!, - language: entry?.settings?.translation, + language: entry?.settings?.translation || (translationLanguage as SupportedLanguages), extraFields: ["title"], }), { - enabled: !!entry?.settings?.translation, + enabled: showAITranslation, refetchOnMount: false, refetchOnWindowFocus: false, meta: { @@ -88,6 +94,7 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => { >
config.id !== COMMAND_ID.entry.openInBrowser) + .filter( + (item) => + !( + [ + COMMAND_ID.entry.read, + COMMAND_ID.entry.unread, + COMMAND_ID.entry.copyLink, + COMMAND_ID.entry.openInBrowser, + ] as string[] + ).includes(item.id), + ) .map((config) => { return (
- {actions.map((item) => ( - { - setCtxOpen(false) - item.onClick?.() - }} - key={item.name} - layout={false} - className="flex w-full items-center gap-2 px-4 py-2" - > - {item.icon} - {item.name} - - ))} + {actions + .filter( + (item) => + !( + [ + COMMAND_ID.entry.read, + COMMAND_ID.entry.unread, + COMMAND_ID.entry.copyLink, + ] as string[] + ).includes(item.id), + ) + .map((item) => ( + { + setCtxOpen(false) + item.onClick?.() + }} + key={item.name} + layout={false} + className="flex w-full items-center gap-2 px-4 py-2" + > + {item.icon} + {item.name} + + ))}
diff --git a/apps/renderer/src/modules/entry-content/index.desktop.tsx b/apps/renderer/src/modules/entry-content/index.desktop.tsx index 549202d945..1afd91a63c 100644 --- a/apps/renderer/src/modules/entry-content/index.desktop.tsx +++ b/apps/renderer/src/modules/entry-content/index.desktop.tsx @@ -1,16 +1,16 @@ import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js" -import { AutoResizeHeight } from "@follow/components/ui/auto-resize-height/index.jsx" import { ScrollArea } from "@follow/components/ui/scroll-area/index.js" import { useTitle } from "@follow/hooks" -import type { FeedModel, InboxModel } from "@follow/models/types" +import type { FeedModel, InboxModel, SupportedLanguages } from "@follow/models/types" import { IN_ELECTRON } from "@follow/shared/constants" import { clearSelection, stopPropagation } from "@follow/utils/dom" import { cn } from "@follow/utils/utils" import { ErrorBoundary } from "@sentry/react" import { useEffect, useMemo, useRef } from "react" -import { useTranslation } from "react-i18next" +import { useShowAITranslation } from "~/atoms/ai-translation" import { useEntryIsInReadability } from "~/atoms/readability" +import { useGeneralSettingSelector } from "~/atoms/settings/general" import { useUISettingKey } from "~/atoms/settings/ui" import { ShadowDOM } from "~/components/common/ShadowDOM" import { useInPeekModal } from "~/components/ui/modal/inspire/PeekModal" @@ -33,12 +33,12 @@ import { EntryHeader } from "./header" import { useFocusEntryContainerSubscriptions } from "./hooks" import type { EntryContentProps } from "./index.shared" import { + AISummary, ContainerToc, NoContent, ReadabilityAutoToggleEffect, ReadabilityContent, RenderError, - SummaryLoadingSkeleton, TitleMetaHandler, ViewSourceContentAutoToggleEffect, } from "./index.shared" @@ -51,8 +51,6 @@ export const EntryContent: Component = ({ compact, classNames, }) => { - const { t } = useTranslation() - const entry = useEntry(entryId) useTitle(entry?.entries.title) @@ -67,21 +65,6 @@ export const EntryContent: Component = ({ }, ) - const summary = useAuthQuery( - Queries.ai.summary({ - entryId, - language: entry?.settings?.translation, - }), - { - enabled: !!entry?.settings?.summary, - refetchOnMount: false, - refetchOnWindowFocus: false, - meta: { - persist: true, - }, - }, - ) - const readerFontFamily = useUISettingKey("readerFontFamily") const view = useRouteParamsSelector((route) => route.view) @@ -124,20 +107,25 @@ export const EntryContent: Component = ({ [entry?.entries.media, data?.entries.media], ) const customCSS = useUISettingKey("customCSS") + const showAITranslation = useShowAITranslation() + const translationLanguage = useGeneralSettingSelector((s) => s.translationLanguage) if (!entry) return null const content = entry?.entries.content ?? data?.entries.content const translate = async (html: HTMLElement | null) => { - if (!html || !entry || !entry.settings?.translation) return + if (!html || !entry) return const fullText = html.textContent ?? "" if (!fullText) return const { franc } = await import("franc-min") + const translation = + entry.settings?.translation ?? (showAITranslation ? translationLanguage : undefined) + const sourceLanguage = franc(fullText) - if (sourceLanguage === LanguageMap[entry.settings?.translation].code) { + if (translation && sourceLanguage === LanguageMap[translation].code) { return } @@ -145,6 +133,7 @@ export const EntryContent: Component = ({ immersiveTranslate({ html, entry, + targetLanguage: translation as SupportedLanguages, cache: { get: (key: string) => getTranslationCache()[key], set: (key: string, value: string) => @@ -192,17 +181,7 @@ export const EntryContent: Component = ({
- {(summary.isLoading || summary.data) && ( -
-
- - {t("entry_content.ai_summary")} -
- - {summary.isLoading ? SummaryLoadingSkeleton : summary.data} - -
- )} + {!isInReadabilityMode ? ( diff --git a/apps/renderer/src/modules/entry-content/index.mobile.tsx b/apps/renderer/src/modules/entry-content/index.mobile.tsx index 9a61542f71..247d222db6 100644 --- a/apps/renderer/src/modules/entry-content/index.mobile.tsx +++ b/apps/renderer/src/modules/entry-content/index.mobile.tsx @@ -1,14 +1,14 @@ -import { AutoResizeHeight } from "@follow/components/ui/auto-resize-height/index.jsx" import { ScrollElementContext } from "@follow/components/ui/scroll-area/ctx.js" import { useTitle } from "@follow/hooks" -import type { FeedModel, InboxModel } from "@follow/models/types" +import type { FeedModel, InboxModel, SupportedLanguages } from "@follow/models/types" import { stopPropagation } from "@follow/utils/dom" import { cn } from "@follow/utils/utils" import { ErrorBoundary } from "@sentry/react" import { useMemo, useState } from "react" -import { useTranslation } from "react-i18next" +import { useShowAITranslation } from "~/atoms/ai-translation" import { useAudioPlayerAtomSelector } from "~/atoms/player" +import { useGeneralSettingSelector } from "~/atoms/settings/general" import { useUISettingKey } from "~/atoms/settings/ui" import { ShadowDOM } from "~/components/common/ShadowDOM" import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams" @@ -27,7 +27,7 @@ import { EntryReadHistory } from "./components/EntryReadHistory" import { EntryTitle } from "./components/EntryTitle" import { SupportCreator } from "./components/SupportCreator" import { EntryHeader } from "./header" -import { NoContent, RenderError, SummaryLoadingSkeleton, TitleMetaHandler } from "./index.shared" +import { AISummary, NoContent, RenderError, TitleMetaHandler } from "./index.shared" import { EntryContentLoading } from "./loading" export interface EntryContentClassNames { @@ -40,8 +40,6 @@ export const EntryContent: Component<{ compact?: boolean classNames?: EntryContentClassNames }> = ({ entryId, noMedia, compact, classNames }) => { - const { t } = useTranslation() - const entry = useEntry(entryId) useTitle(entry?.entries.title) @@ -57,21 +55,6 @@ export const EntryContent: Component<{ }, ) - const summary = useAuthQuery( - Queries.ai.summary({ - entryId, - language: entry?.settings?.translation, - }), - { - enabled: !!entry?.settings?.summary, - refetchOnMount: false, - refetchOnWindowFocus: false, - meta: { - persist: true, - }, - }, - ) - const view = useRouteParamsSelector((route) => route.view) const mediaInfo = useMemo( @@ -95,19 +78,26 @@ export const EntryContent: Component<{ usePreventOverscrollBounce() const [scrollElement, setScrollElement] = useState(null) + + const showAITranslation = useShowAITranslation() + const translationLanguage = useGeneralSettingSelector((s) => s.translationLanguage) + if (!entry) return null const content = entry?.entries.content ?? data?.entries.content const translate = async (html: HTMLElement | null) => { - if (!html || !entry || !entry.settings?.translation) return + if (!html || !entry) return const fullText = html.textContent ?? "" if (!fullText) return const { franc } = await import("franc-min") + const translation = + entry.settings?.translation ?? (showAITranslation ? translationLanguage : undefined) + const sourceLanguage = franc(fullText) - if (sourceLanguage === LanguageMap[entry.settings?.translation].code) { + if (translation && sourceLanguage === LanguageMap[translation].code) { return } @@ -115,6 +105,7 @@ export const EntryContent: Component<{ immersiveTranslate({ html, entry, + targetLanguage: translation as SupportedLanguages, cache: { get: (key: string) => getTranslationCache()[key], set: (key: string, value: string) => @@ -170,17 +161,7 @@ export const EntryContent: Component<{
- {(summary.isLoading || summary.data) && ( -
-
- - {t("entry_content.ai_summary")} -
- - {summary.isLoading ? SummaryLoadingSkeleton : summary.data} - -
- )} + { ) }) + +export function AISummary({ entryId }: { entryId: string }) { + const { t } = useTranslation() + const entry = useEntry(entryId) + const showAISummary = useShowAISummary() || !!entry?.settings?.summary + const summary = useAuthQuery( + Queries.ai.summary({ + entryId, + language: entry?.settings?.translation, + }), + { + enabled: showAISummary, + refetchOnMount: false, + refetchOnWindowFocus: false, + meta: { + persist: true, + }, + }, + ) + + if (!showAISummary || (!summary.isLoading && !summary.data)) { + return null + } + + return ( +
+
+ + {t("entry_content.ai_summary")} +
+ + {summary.isLoading ? SummaryLoadingSkeleton : summary.data} + +
+ ) +} diff --git a/apps/renderer/src/modules/settings/tabs/general.tsx b/apps/renderer/src/modules/settings/tabs/general.tsx index 2853f5d77b..2d83fbd77b 100644 --- a/apps/renderer/src/modules/settings/tabs/general.tsx +++ b/apps/renderer/src/modules/settings/tabs/general.tsx @@ -29,6 +29,8 @@ import { useProxyValue, useSetProxy } from "~/hooks/biz/useProxySetting" import { useMinimizeToTrayValue, useSetMinimizeToTray } from "~/hooks/biz/useTraySetting" import { fallbackLanguage } from "~/i18n" import { tipcClient } from "~/lib/client" +import { LanguageMap } from "~/lib/translate" +import { setTranslationCache } from "~/modules/entry-content/atoms" import { SettingDescription, SettingInput, SettingSwitch } from "../control" import { createSetting } from "../helper/builder" @@ -73,6 +75,7 @@ export const SettingGeneral = () => { IN_ELECTRON && MinimizeToTraySetting, isMobile && StartupScreenSelector, LanguageSelector, + TranslateLanguageSelector, { type: "title", @@ -244,6 +247,36 @@ export const LanguageSelector = ({ ) } +const TranslateLanguageSelector = () => { + const { t } = useTranslation("settings") + const translationLanguage = useGeneralSettingKey("translationLanguage") + + return ( +
+ {t("general.translation_language")} + +
+ ) +} + const NettingSetting = () => { const { t } = useTranslation("settings") const proxyConfig = useProxyValue() diff --git a/changelog/next.md b/changelog/next.md index f62ec5968d..bc6438699d 100644 --- a/changelog/next.md +++ b/changelog/next.md @@ -3,6 +3,7 @@ ## New Features - Customizable columns for masonry view +- Manually trigger AI summary or translation ![](https://fastly.jsdelivr.net/gh/RSSNext/assets@main/masonry.mp4) diff --git a/locales/app/en.json b/locales/app/en.json index a7ee68486f..9c2634efaa 100644 --- a/locales/app/en.json +++ b/locales/app/en.json @@ -134,6 +134,8 @@ "entry_actions.star": "Star", "entry_actions.starred": "Starred.", "entry_actions.tip": "Tip", + "entry_actions.toggle_ai_summary": "Toggle AI Summary", + "entry_actions.toggle_ai_translation": "Toggle AI Translation", "entry_actions.unstar": "Unstar", "entry_actions.unstarred": "Unstarred.", "entry_actions.view_source_content": "View Source Content", diff --git a/locales/settings/en.json b/locales/settings/en.json index d49abc8411..2397ca3dea 100644 --- a/locales/settings/en.json +++ b/locales/settings/en.json @@ -157,6 +157,7 @@ "general.startup_screen.timeline": "Timeline", "general.startup_screen.title": "Startup Screen", "general.timeline": "Timeline", + "general.translation_language": "Translation Language", "general.unread": "Unread", "general.voices": "Voices", "integration.eagle.enable.description": "Display 'Save media to Eagle' button when available.", diff --git a/packages/shared/src/interface/settings.ts b/packages/shared/src/interface/settings.ts index 20d84d841a..c3e77422be 100644 --- a/packages/shared/src/interface/settings.ts +++ b/packages/shared/src/interface/settings.ts @@ -1,6 +1,7 @@ export interface GeneralSettings { appLaunchOnStartup: boolean language: string + translationLanguage: string startupScreen: "subscription" | "timeline" dataPersist: boolean sendAnonymousData: boolean