refactor(desktop): use translation and readability from shared store (#3902)

This commit is contained in:
Stephen Zhou 2025-06-11 12:36:26 +08:00 committed by GitHub
parent c2e0c3d20d
commit d3f889a42d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 104 additions and 277 deletions

View File

@ -1,45 +1,12 @@
import { getStorageNS } from "@follow/utils/ns"
import { atom } from "jotai"
import { atomWithStorage } from "jotai/utils"
import { createAtomHooks } from "~/lib/jotai"
type Readability = {
title?: string | null
content?: string | null
textContent?: string | null
length?: number | null
excerpt?: string | null
byline?: string | null
dir?: string | null
siteName?: string | null
lang?: string | null
publishedTime?: string | null
}
const mergeObjectSetter =
<T>(setter: (prev: T) => void, getter: () => T) =>
(value: Partial<T>) =>
setter({ ...getter(), ...value })
export const [
,
,
useReadabilityContent,
,
getReadabilityContent,
__setReadabilityContent,
useReadabilityContentSelector,
] = createAtomHooks(
atomWithStorage<Record<string, Readability>>(getStorageNS("readability-content"), {}, undefined, {
getOnInit: true,
}),
)
export const setReadabilityContent = mergeObjectSetter(
__setReadabilityContent,
getReadabilityContent,
)
export enum ReadabilityStatus {
INITIAL = 1,
WAITING = 2,
@ -77,5 +44,3 @@ export const useEntryInReadabilityStatus = (entryId?: string) =>
export const isInReadability = (status: ReadabilityStatus) =>
status !== ReadabilityStatus.INITIAL && !!status
export const useEntryReadabilityContent = (entryId: string) =>
useReadabilityContentSelector((map) => map[entryId], [entryId])

View File

@ -2,7 +2,9 @@ import { isMobile } from "@follow/components/hooks/useMobile.js"
import { FeedViewType, UserRole, views } from "@follow/constants"
import { IN_ELECTRON } from "@follow/shared/constants"
import { useIsEntryStarred } from "@follow/store/collection/hooks"
import { getEntry } from "@follow/store/entry/getter"
import { useEntry } from "@follow/store/entry/hooks"
import { entrySyncServices } from "@follow/store/entry/store"
import type { EntryModel } from "@follow/store/entry/types"
import { useFeedById } from "@follow/store/feed/hooks"
import { useIsInbox } from "@follow/store/inbox/hooks"
@ -13,16 +15,13 @@ import { useShowAISummaryAuto, useShowAISummaryOnce } from "~/atoms/ai-summary"
import { useShowAITranslationAuto, useShowAITranslationOnce } from "~/atoms/ai-translation"
import { MENU_ITEM_SEPARATOR, MenuItemSeparator, MenuItemText } from "~/atoms/context-menu"
import {
getReadabilityContent,
getReadabilityStatus,
ReadabilityStatus,
setReadabilityContent,
setReadabilityStatus,
useEntryIsInReadability,
} from "~/atoms/readability"
import { useShowSourceContent } from "~/atoms/source-content"
import { useUserRole, whoami } from "~/atoms/user"
import { apiClient } from "~/lib/api-fetch"
import { ipcServices } from "~/lib/client"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { getCommand, useRunCommandFn } from "~/modules/command/hooks/use-command"
@ -46,39 +45,22 @@ export const toggleEntryReadability = async ({ id, url }: { id: string; url: str
[id]: ReadabilityStatus.WAITING,
})
try {
let data = getReadabilityContent()[id]
const data = getEntry(id)?.readabilityContent
if (!data) {
const result = await apiClient.entries.readability.$get({ query: { id } })
if (result.data) {
data = result.data
}
await entrySyncServices.fetchEntryReadabilityContent(id, async () => {
const res = await ipcServices?.reader.readability({ url })
return res?.content
})
}
if (data) {
const status = getReadabilityStatus()[id]
if (status !== ReadabilityStatus.WAITING) return
setReadabilityStatus({
[id]: ReadabilityStatus.SUCCESS,
})
setReadabilityContent({
[id]: data,
})
}
setReadabilityStatus({
[id]: ReadabilityStatus.SUCCESS,
})
} catch {
const result = await ipcServices?.reader.readability({ url })
if (result) {
setReadabilityContent({
[id]: result,
})
setReadabilityStatus({
[id]: ReadabilityStatus.SUCCESS,
})
} else {
setReadabilityStatus({
[id]: ReadabilityStatus.FAILURE,
})
}
setReadabilityStatus({
[id]: ReadabilityStatus.FAILURE,
})
}
} else {
setReadabilityStatus({

View File

@ -1,15 +1,9 @@
import { parseHtml } from "@follow/components/ui/markdown/parse-html.js"
import { views } from "@follow/constants"
import type { SupportedActionLanguage } from "@follow/shared"
import { ACTION_LANGUAGE_MAP } from "@follow/shared"
import { getEntry } from "@follow/store/entry/getter"
import { duplicateIfLengthLessThan } from "@follow/utils/utils"
import { franc } from "franc-min"
import { getReadabilityContent } from "~/atoms/readability"
import { apiClient } from "./api-fetch"
export const checkLanguage = ({
content,
language,
@ -32,77 +26,3 @@ export const checkLanguage = ({
return sourceLanguage === code
}
export async function translate({
entryId,
view,
language,
extraFields,
part,
}: {
entryId?: string | null
view?: number | null
language?: SupportedActionLanguage
extraFields?: string[]
part?: string
}) {
if (!language || !entryId) {
return null
}
let fields = language && typeof view === "number" ? views[view!]!.translation.split(",") : []
if (extraFields) {
fields = [...fields, ...extraFields]
}
const readabilityContent = getReadabilityContent()[entryId]?.content
const entries = getEntry(entryId)
fields = fields.filter((field) => {
if (language && field === "readabilityContent") {
if (!readabilityContent) return false
const isLanguageMatch = checkLanguage({
content: readabilityContent,
language,
})
return !isLanguageMatch
}
if (language && entries?.[field]) {
const isLanguageMatch = checkLanguage({
content: entries[field],
language,
})
return !isLanguageMatch
} else {
return false
}
})
if (fields.length === 0) {
return null
}
const res = await apiClient.ai.translation.$get({
query: {
id: entryId,
language,
fields: fields?.join(",") || "title",
part,
},
})
const data: {
description?: string
title?: string
content?: string
readabilityContent?: string
} = {}
fields.forEach((field) => {
const content = field === "readabilityContent" ? readabilityContent : entries?.[field]
if (content !== res.data?.[field]) {
data[field] = res.data?.[field]
}
})
return data
}

View File

@ -18,7 +18,7 @@ import { ofetch } from "ofetch"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { getReadabilityContent, getReadabilityStatus, ReadabilityStatus } from "~/atoms/readability"
import { getReadabilityStatus, ReadabilityStatus } from "~/atoms/readability"
import { getActionLanguage } from "~/atoms/settings/general"
import { getIntegrationSettings, useIntegrationSettingKey } from "~/atoms/settings/integration"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
@ -252,8 +252,7 @@ const useRegisterInstapaperCommands = () => {
const getEntryContentAsMarkdown = async (entry: EntryModel) => {
const isReadabilityReady = getReadabilityStatus()[entry.id] === ReadabilityStatus.SUCCESS
const content =
(isReadabilityReady ? getReadabilityContent()[entry.id]!.content : entry.content) || ""
const content = (isReadabilityReady ? entry.readabilityContent || "" : entry.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),

View File

@ -9,9 +9,9 @@ import { useMasonryColumn } from "@follow/components/ui/masonry/hooks.js"
import { Masonry } from "@follow/components/ui/masonry/index.js"
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import { Skeleton } from "@follow/components/ui/skeleton/index.jsx"
import { FeedViewType } from "@follow/constants"
import { useRefValue } from "@follow/hooks"
import { getEntry } from "@follow/store/entry/getter"
import { useEntryTranslation } from "@follow/store/translation/hooks"
import { clsx } from "@follow/utils/utils"
import type { RenderComponentProps } from "masonic"
import { useInfiniteLoader } from "masonic"
@ -30,9 +30,8 @@ import {
} from "react"
import { useEventCallback } from "usehooks-ts"
import { useGeneralSettingKey } from "~/atoms/settings/general"
import { useActionLanguage, useGeneralSettingKey } from "~/atoms/settings/general"
import { MediaContainerWidthProvider } from "~/components/ui/media"
import { useEntryTranslation } from "~/store/ai/hook"
import { imageActions } from "~/store/image"
import { getMasonryColumnValue, setMasonryColumnValue, useMasonryColumnValue } from "../atoms"
@ -259,7 +258,8 @@ const MasonryRender: React.ComponentType<
}>
> = ({ data, index }) => {
const firstScreenReady = use(FirstScreenReadyContext)
const translation = useEntryTranslation({ entryId: data.entryId, view: FeedViewType.Pictures })
const actionLanguage = useActionLanguage()
const translation = useEntryTranslation(data.entryId, actionLanguage)
if (data.entryId.startsWith("placeholder")) {
return <LoadingSkeletonItem />
@ -273,7 +273,7 @@ const MasonryRender: React.ComponentType<
)}
entryId={data.entryId}
index={index}
translation={translation.data}
translation={translation}
/>
)
}

View File

@ -1,8 +1,8 @@
import { isMobile } from "@follow/components/hooks/useMobile.js"
import { Skeleton } from "@follow/components/ui/skeleton/index.jsx"
import { FeedViewType } from "@follow/constants"
import { IN_ELECTRON } from "@follow/shared/constants"
import { useEntry } from "@follow/store/entry/hooks"
import { useEntryTranslation, usePrefetchEntryTranslation } from "@follow/store/translation/hooks"
import { stopPropagation } from "@follow/utils/dom"
import { formatDuration } from "@follow/utils/duration"
import { transformVideoUrl } from "@follow/utils/url-for-video"
@ -11,6 +11,7 @@ import { useHover } from "@use-gesture/react"
import { useEffect, useMemo, useRef, useState } from "react"
import { AudioPlayer } from "~/atoms/player"
import { useActionLanguage, useGeneralSettingKey } from "~/atoms/settings/general"
import { m } from "~/components/common/Motion"
import { RelativeTime } from "~/components/ui/datetime"
import { HTML } from "~/components/ui/markdown/HTML"
@ -22,9 +23,9 @@ import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { checkLanguage } from "~/lib/translate"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { FeedTitle } from "~/modules/feed/feed-title"
import { useEntryTranslation } from "~/store/ai/hook"
import { GridItem } from "../templates/grid-item-template"
import type { EntryItemStatelessProps, UniversalItemProps } from "../types"
@ -169,12 +170,18 @@ const PreviewVideoModalContent: ModalContentComponent<{
entryId: string
}> = ({ dismiss, src, entryId }) => {
const entry = useEntry(entryId, (state) => ({ content: state.content }))
const translation = useEntryTranslation({
entryId,
extraFields: ["content"],
view: FeedViewType.Videos,
const actionLanguage = useActionLanguage()
const enableTranslation = useGeneralSettingKey("translation")
const translation = useEntryTranslation(entryId, actionLanguage)
usePrefetchEntryTranslation({
entryIds: [entryId],
checkLanguage,
translation: enableTranslation,
language: actionLanguage,
withContent: true,
})
const content = translation.data?.content || entry?.content
const content = translation?.content || entry?.content
const currentAudioPlayerIsPlay = useRef(AudioPlayer.get().status === "playing")
const renderStyle = useRenderStyle()

View File

@ -1,9 +1,11 @@
import type { FeedViewType } from "@follow/constants"
import { useEntry } from "@follow/store/entry/hooks"
import { useEntryTranslation, usePrefetchEntryTranslation } from "@follow/store/translation/hooks"
import type { FC } from "react"
import { memo } from "react"
import { useEntryTranslation } from "~/store/ai/hook"
import { useActionLanguage, useGeneralSettingKey } from "~/atoms/settings/general"
import { checkLanguage } from "~/lib/translate"
import { getItemComponentByView } from "./Items/getItemComponentByView"
import { EntryItemWrapper } from "./layouts/EntryItemWrapper"
@ -20,13 +22,21 @@ const EntryItemImpl = memo(function EntryItemImpl({
entryId: string
view: FeedViewType
}) {
const translation = useEntryTranslation({ entryId, view })
const actionLanguage = useActionLanguage()
const enableTranslation = useGeneralSettingKey("translation")
const translation = useEntryTranslation(entryId, actionLanguage)
usePrefetchEntryTranslation({
entryIds: [entryId],
checkLanguage,
translation: enableTranslation,
language: actionLanguage,
})
const Item: EntryListItemFC = getItemComponentByView(view)
return (
<EntryItemWrapper itemClassName={Item.wrapperClassName} entryId={entryId} view={view}>
<Item entryId={entryId} translation={translation.data} />
<Item entryId={entryId} translation={translation} />
</EntryItemWrapper>
)
})

View File

@ -6,7 +6,7 @@ import { HTML } from "~/components/ui/markdown/HTML"
export const EntryTranslation: Component<{
source?: string | null
target?: string
target?: string | null
isHTML?: boolean
inline?: boolean
bilingual?: boolean

View File

@ -4,6 +4,7 @@ import type {
FeedModel,
FeedOrListRespModel,
} from "@follow/models/types"
import type { EntryTranslation } from "@follow/store/translation/types"
import type { FC } from "react"
export type UniversalItemProps = {
@ -13,11 +14,7 @@ export type UniversalItemProps = {
feedId: string
inboxId: string
}
translation?: {
title?: string
description?: string
content?: string
} | null
translation?: EntryTranslation
}
export type EntryListItemFC<P extends object = object> = FC<P & UniversalItemProps> & {

View File

@ -1,9 +1,11 @@
import { useEntry, useEntryReadHistory } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { useInboxById } from "@follow/store/inbox/hooks"
import { useEntryTranslation } from "@follow/store/translation/hooks"
import { formatEstimatedMins, formatTimeToSeconds } from "@follow/utils"
import { titleCase } from "title-case"
import { useActionLanguage } from "~/atoms/settings/general"
import { useUISettingKey } from "~/atoms/settings/ui"
import { useWhoami } from "~/atoms/user"
import { RelativeTime } from "~/components/ui/datetime"
@ -11,7 +13,6 @@ import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
import type { FeedIconEntry } from "~/modules/feed/feed-icon"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { useEntryTranslation } from "~/store/ai/hook"
import { getPreferredTitle } from "~/store/feed/hooks"
import { EntryTranslation } from "../../entry-column/translation"
@ -56,7 +57,9 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
const data = useEntryReadHistory(entryId)
const entryHistory = data?.entryReadHistories
const populatedFullHref = useFeedSafeUrl(entryId)
const translation = useEntryTranslation({ entryId, extraFields: ["title"], view: undefined })
const actionLanguage = useActionLanguage()
const translation = useEntryTranslation(entryId, actionLanguage)
const dateFormat = useUISettingKey("dateFormat")
@ -88,7 +91,7 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
>
<EntryTranslation
source={titleCase(entry.title ?? "")}
target={titleCase(translation.data?.title ?? "")}
target={titleCase(translation?.title ?? "")}
className="text-text inline-block select-text hyphens-auto duration-200"
inline={false}
bilingual

View File

@ -1,16 +1,14 @@
import { useEntry, usePrefetchEntryDetail } from "@follow/store/entry/hooks"
import { useEntryTranslation, usePrefetchEntryTranslation } from "@follow/store/translation/hooks"
import { tracker } from "@follow/tracker"
import { createElement, useCallback, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import {
useEntryIsInReadability,
useEntryIsInReadabilitySuccess,
useEntryReadabilityContent,
} from "~/atoms/readability"
import { useEntryIsInReadability, useEntryIsInReadabilitySuccess } from "~/atoms/readability"
import { useActionLanguage, useGeneralSettingKey } from "~/atoms/settings/general"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useEntryTranslation } from "~/store/ai/hook"
import { checkLanguage } from "~/lib/translate"
import { ImageGalleryContent } from "./components/ImageGalleryContent"
@ -40,27 +38,33 @@ export const useGalleryModal = () => {
export const useEntryContent = (entryId: string) => {
const entry = useEntry(entryId, (state) => {
const { inboxHandle, content } = state
return { inboxId: inboxHandle, content }
const { inboxHandle, content, readabilityContent } = state
return { inboxId: inboxHandle, content, readabilityContent }
})
const { error, data, isPending } = usePrefetchEntryDetail(entryId)
const isInReadabilityMode = useEntryIsInReadability(entryId)
const isReadabilitySuccess = useEntryIsInReadabilitySuccess(entryId)
const readabilityContent = useEntryReadabilityContent(entryId)
const contentTranslated = useEntryTranslation({
entryId,
extraFields: isReadabilitySuccess ? ["readabilityContent"] : ["content"],
view: undefined,
const actionLanguage = useActionLanguage()
const enableTranslation = useGeneralSettingKey("translation")
const contentTranslated = useEntryTranslation(entryId, actionLanguage)
usePrefetchEntryTranslation({
entryIds: [entryId],
checkLanguage,
translation: enableTranslation,
language: actionLanguage,
withContent: true,
target: isReadabilitySuccess ? "readabilityContent" : "content",
})
return useMemo(() => {
const entryContent = isInReadabilityMode
? readabilityContent?.content
? entry?.readabilityContent
: (entry?.content ?? data?.content)
const translatedContent = isInReadabilityMode
? contentTranslated.data?.readabilityContent
: contentTranslated.data?.content
? contentTranslated?.readabilityContent
: contentTranslated?.content
const content = translatedContent || entryContent
return {
content,
@ -68,14 +72,14 @@ export const useEntryContent = (entryId: string) => {
isPending,
}
}, [
contentTranslated.data?.content,
contentTranslated.data?.readabilityContent,
contentTranslated?.content,
contentTranslated?.readabilityContent,
data?.content,
entry?.content,
error,
isInReadabilityMode,
isPending,
readabilityContent?.content,
entry?.readabilityContent,
])
}

View File

@ -21,7 +21,6 @@ import {
setReadabilityStatus,
useEntryInReadabilityStatus,
useEntryIsInReadability,
useEntryReadabilityContent,
} from "~/atoms/readability"
import { enableShowSourceContent } from "~/atoms/source-content"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
@ -81,7 +80,7 @@ export const TitleMetaHandler: Component<{
export const ReadabilityNotice = ({ entryId }: { entryId: string }) => {
const { t } = useTranslation()
const { t: T } = useTranslation("common")
const result = useEntryReadabilityContent(entryId)
const result = useEntry(entryId, (state) => state.readabilityContent)
const isInReadability = useEntryIsInReadability(entryId)
const status = useEntryInReadabilityStatus(entryId)

View File

@ -1,29 +1,11 @@
import type { SupportedLanguages } from "@follow/models/types"
import { getEntry } from "@follow/store/entry/getter"
import { getReadabilityContent } from "~/atoms/readability"
import { apiClient } from "~/lib/api-fetch"
import { defineQuery } from "~/lib/defineQuery"
import { parseHtml } from "~/lib/parse-html"
import { translate } from "~/lib/translate"
export const ai = {
translation: ({
entryId,
view,
language,
extraFields,
part,
}: {
entryId?: string | null
view?: number | null
language?: SupportedLanguages
extraFields?: string[]
part?: string
}) =>
defineQuery(["translation", entryId, view, language, extraFields, part], () =>
translate({ entryId, view, language, extraFields, part }),
),
summary: ({
entryId,
language,
@ -34,10 +16,8 @@ export const ai = {
target?: "content" | "readabilityContent"
}) =>
defineQuery(["summary", entryId, language, target], async () => {
const content =
target === "readabilityContent"
? getReadabilityContent()[entryId]?.content
: getEntry(entryId)?.content
const entry = getEntry(entryId)
const content = target === "readabilityContent" ? entry?.readabilityContent : entry?.content
if (!content) {
return null
}

View File

@ -1,53 +0,0 @@
import type { FeedViewType } from "@follow/constants"
import { useEntry } from "@follow/store/entry/hooks"
import { useMemo } from "react"
import { useShowAITranslation, useShowAITranslationAuto } from "~/atoms/ai-translation"
import { useActionLanguage } from "~/atoms/settings/general"
import { useAuthQuery } from "~/hooks/common/useBizQuery"
import { Queries } from "~/queries"
export function useEntryTranslation({
entryId,
extraFields,
view,
}: {
entryId?: string
extraFields?: string[]
view: FeedViewType | undefined
}) {
const entry = useEntry(entryId, (state) => ({
translation: state.settings?.translation,
}))
const actionLanguage = useActionLanguage()
const showAITranslationFinal = useShowAITranslation(!!entry?.translation)
const showAITranslationAuto = useShowAITranslationAuto(!!entry?.translation)
const showAITranslation =
!extraFields || extraFields.length === 0 ? showAITranslationAuto : showAITranslationFinal
const res = useAuthQuery(
Queries.ai.translation({
entryId,
view,
language: actionLanguage,
extraFields,
}),
{
enabled: showAITranslation,
refetchOnMount: false,
refetchOnWindowFocus: false,
meta: {
persist: true,
},
},
)
return useMemo(
() => ({
...res,
// with persist option enabled, we need to explicitly set data to null when showAITranslation is false
data: showAITranslation ? res.data : null,
}),
[res, showAITranslation],
)
}

View File

@ -536,19 +536,33 @@ class EntrySyncServices {
return entry
}
async fetchEntryReadabilityContent(entryId: EntryId) {
async fetchEntryReadabilityContent(
entryId: EntryId,
fallBack?: () => Promise<string | null | undefined>,
) {
const entry = getEntry(entryId)
if (entry?.url && entry?.readabilityContent === null) {
const { data: contentByFetch } = await apiClient().entries.readability.$get({
query: {
id: entryId,
},
})
if (contentByFetch?.content && entry?.readabilityContent !== contentByFetch.content) {
let readabilityContent: string | null | undefined
try {
const { data: contentByFetch } = await apiClient().entries.readability.$get({
query: {
id: entryId,
},
})
readabilityContent = contentByFetch?.content || null
} catch (error) {
if (fallBack) {
readabilityContent = await fallBack()
} else {
throw error
}
}
if (readabilityContent && entry?.readabilityContent !== readabilityContent) {
await entryActions.updateEntryContent({
entryId,
readabilityContent: contentByFetch.content,
readabilityContent,
})
}
}