feat: translate on server, global action setting (#3294)

* refactor: translate on server

* refactor: remove summary and translation action

* enable summary and translation from settings

* remove translate and summary from action

* follow setting

* ignore whitespace change

* correct content translation for social item

* update

* update

* fix content

* global setting for summary on mobile

* translation store

* show and cache translation

* update migrate sql

* token header for social sign in

* remove readabilityContent in web view

* ci debug

* check repository

* ci check condition

* fix readability

* show entry content translation

* update

* update

* translation for social item

* Revert "remove translate and summary from action"

This reverts commit c8a195a9b224b308cbcd86a6293ffae03e946a72.

* update text

* support global,once,action summary and translation

* fix condition

* refactor

* refactor: extract use entry translation hook

* update

* rename

* Revert "rename"

This reverts commit 672092f88c04670c7e1017aaf8b9caa6499f1877.

* update

* update

* fix

* translation header action

* unsave prompt

* chore: auto-fix linting and formatting issues

* connect and row

* prompt on mobile before leave

* update

* follow action setting

* hide button when follow action

* update changelog
This commit is contained in:
Stephen Zhou 2025-03-28 15:14:03 +08:00 committed by GitHub
parent 9a2c32a79e
commit abc35be47c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 1387 additions and 478 deletions

View File

@ -2,11 +2,13 @@
## New Features
- New global settings for AI summary and translation (#3294)
- Display estimated audio duration for entry titles (#3292)
- Simplify settings via an enhanced settings toggle (217e1a8f2acb9826bd53f520e5dd9e7a686bf5b9)
## Improvements
- Improved translation for entry content
- Refine toolbar customization (#3284)
- Sign Windows executable files using SignPath (#3286)
- Remove email verification toast notifications (9bb723a33b78e481fdeee8d525f41c7406abb643)

View File

@ -43,10 +43,11 @@
"actions.action_card.source_content": "View source content",
"actions.action_card.then_do": "Then do…",
"actions.action_card.to": "To",
"actions.action_card.translate_into": "Translate into",
"actions.action_card.translate_into": "Translate",
"actions.action_card.value": "Value",
"actions.action_card.webhooks": "Webhooks",
"actions.action_card.when_feeds_match": "When feeds match…",
"actions.navigate.prompt": "You have unsaved Action changes. Are you sure you want to leave?",
"actions.newRule": "New Rule",
"actions.save": "Save",
"actions.saveSuccess": "🎉 Actions saved.",
@ -128,9 +129,11 @@
"feeds.tableHeaders.name": "Name",
"feeds.tableHeaders.subscriptionCount": "Subs",
"feeds.tableHeaders.tipAmount": "Tips",
"general.action.summary": "AI Summary",
"general.action.title": "Action",
"general.action.translation": "AI Translation",
"general.action_language.default": "Default (UI Language)",
"general.action_language.description": "The language used for translation and summary",
"general.action_language.label": "Action Language",
"general.action_language.label": "Language",
"general.advanced": "Advanced",
"general.app": "App",
"general.auto_expand_long_social_media.description": "Automatically expand social media entries containing long text.",

View File

@ -129,7 +129,6 @@
"feeds.tableHeaders.subscriptionCount": "订阅数",
"feeds.tableHeaders.tipAmount": "收到的打赏",
"general.action_language.default": "默认(界面语言)",
"general.action_language.description": "用于翻译和总结的语言。",
"general.action_language.label": "自动化语言",
"general.app": "应用程序",
"general.auto_expand_long_social_media.description": "自动展开包含长文本的社交媒体条目。",

View File

@ -1,11 +1,23 @@
import { atom } from "jotai"
import { createAtomHooks } from "~/lib/jotai"
import type { FlatEntryModel } from "~/store/entry/types"
export const [, , useShowAISummary, , getShowAISummary, setShowAISummary] = createAtomHooks(
atom<boolean>(false),
)
import { useGeneralSettingKey } from "./settings/general"
export const toggleShowAISummary = () => setShowAISummary(!getShowAISummary())
export const enableShowAISummary = () => setShowAISummary(true)
export const disableShowAISummary = () => setShowAISummary(false)
export const [, , useShowAISummaryOnce, , getShowAISummaryOnce, setShowAISummaryOnce] =
createAtomHooks(atom<boolean>(false))
export const toggleShowAISummaryOnce = () => setShowAISummaryOnce((prev) => !prev)
export const enableShowAISummaryOnce = () => setShowAISummaryOnce(true)
export const disableShowAISummaryOnce = () => setShowAISummaryOnce(false)
export const useShowAISummaryAuto = (entry: FlatEntryModel | null) => {
return useGeneralSettingKey("summary") || !!entry?.settings?.summary
}
export const useShowAISummary = (entry: FlatEntryModel | null) => {
const showAISummaryAuto = useShowAISummaryAuto(entry)
const showAISummaryOnce = useShowAISummaryOnce()
return showAISummaryAuto || showAISummaryOnce || !!entry?.settings?.summary
}

View File

@ -1,10 +1,32 @@
import { atom } from "jotai"
import { createAtomHooks } from "~/lib/jotai"
import type { FlatEntryModel } from "~/store/entry/types"
export const [, , useShowAITranslation, , getShowAITranslation, setShowAITranslation] =
import { useGeneralSettingKey } from "./settings/general"
// NOTE: We have three levels of settings can enable AI translation or Summary:
// 1. General setting, which is the global settings for all entries.
// 2. Action setting, which is defined in an action and applied to specific entries.
// 3. Toolbar control, which is a temporary setting for the current entry.
//
// When general setting or action setting is enabled, we should hide the toolbar control, which can save some space.
//
// Different from AI summary, AI translation also can show up in the entry list, which should only be controlled by the General setting or Action setting.
export const [, , useShowAITranslationOnce, , getShowAITranslationOnce, setShowAITranslationOnce] =
createAtomHooks(atom<boolean>(false))
export const toggleShowAITranslation = () => setShowAITranslation(!getShowAITranslation())
export const enableShowAITranslation = () => setShowAITranslation(true)
export const disableShowAITranslation = () => setShowAITranslation(false)
export const toggleShowAITranslationOnce = () => setShowAITranslationOnce((prev) => !prev)
export const enableShowAITranslationOnce = () => setShowAITranslationOnce(true)
export const disableShowAITranslationOnce = () => setShowAITranslationOnce(false)
export const useShowAITranslationAuto = (entry: FlatEntryModel | null) => {
return useGeneralSettingKey("translation") || !!entry?.settings?.translation
}
export const useShowAITranslation = (entry: FlatEntryModel | null) => {
const showAITranslationAuto = useShowAITranslationAuto(entry)
const showAITranslationOnce = useShowAITranslationOnce()
return showAITranslationAuto || showAITranslationOnce
}

View File

@ -2,7 +2,6 @@ import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDanger
import katexStyle from "katex/dist/katex.min.css?raw"
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"
@ -18,23 +17,12 @@ export type HTMLProps<A extends keyof JSX.IntrinsicElements = "div"> = {
accessory?: React.ReactNode
noMedia?: boolean
mediaInfo?: MediaInfoRecord
handleTranslate?: (html: HTMLElement | null) => void
} & JSX.IntrinsicElements[A] &
Partial<{
renderInlineStyle: boolean
}>
const HTMLImpl = <A extends keyof JSX.IntrinsicElements = "div">(props: HTMLProps<A>) => {
const {
children,
renderInlineStyle,
as = "div",
accessory,
noMedia,
mediaInfo,
handleTranslate: translate,
...rest
} = props
const { children, renderInlineStyle, as = "div", accessory, noMedia, mediaInfo, ...rest } = props
const [remarkOptions, setRemarkOptions] = useState({
renderInlineStyle,
noMedia,
@ -54,12 +42,6 @@ const HTMLImpl = <A extends keyof JSX.IntrinsicElements = "div">(props: HTMLProp
const [refElement, setRefElement] = useState<HTMLElement | null>(null)
const showAITranslation = useShowAITranslation()
useEffect(() => {
translate?.(refElement)
}, [refElement, showAITranslation, translate])
const markdownElement = useMemo(
() =>
children &&

View File

@ -3,8 +3,8 @@ import { FeedViewType, UserRole } from "@follow/constants"
import { IN_ELECTRON } from "@follow/shared/constants"
import { useCallback, useMemo } from "react"
import { useShowAISummary } from "~/atoms/ai-summary"
import { useShowAITranslation } from "~/atoms/ai-translation"
import { useShowAISummaryAuto, useShowAISummaryOnce } from "~/atoms/ai-summary"
import { useShowAITranslationAuto, useShowAITranslationOnce } from "~/atoms/ai-translation"
import {
getReadabilityStatus,
ReadabilityStatus,
@ -85,8 +85,10 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view?: Fee
const isInbox = !!inbox
const isShowSourceContent = useShowSourceContent()
const isShowAISummary = useShowAISummary()
const isShowAITranslation = useShowAITranslation()
const isShowAISummaryAuto = useShowAISummaryAuto(entry)
const isShowAISummaryOnce = useShowAISummaryOnce()
const isShowAITranslationAuto = useShowAITranslationAuto(entry)
const isShowAITranslationOnce = useShowAITranslationOnce()
const runCmdFn = useRunCommandFn()
const hasEntry = !!entry
@ -164,22 +166,22 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view?: Fee
id: COMMAND_ID.entry.toggleAISummary,
onClick: runCmdFn(COMMAND_ID.entry.toggleAISummary, []),
hide:
!!entry?.settings?.summary ||
isShowAISummaryAuto ||
([FeedViewType.SocialMedia, FeedViewType.Videos] as (number | undefined)[]).includes(
entry?.view,
),
active: isShowAISummary,
active: isShowAISummaryOnce,
disabled: userRole === UserRole.Trial,
},
{
id: COMMAND_ID.entry.toggleAITranslation,
onClick: runCmdFn(COMMAND_ID.entry.toggleAITranslation, []),
hide:
!!entry?.settings?.translation ||
isShowAITranslationAuto ||
([FeedViewType.SocialMedia, FeedViewType.Videos] as (number | undefined)[]).includes(
entry?.view,
),
active: isShowAITranslation,
active: isShowAITranslationOnce,
disabled: userRole === UserRole.Trial,
},
{
@ -204,8 +206,6 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view?: Fee
entry?.collections,
entry?.entries.url,
entry?.read,
entry?.settings?.summary,
entry?.settings?.translation,
entry?.view,
entryId,
feed?.id,
@ -213,8 +213,10 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view?: Fee
hasEntry,
inList,
isInbox,
isShowAISummary,
isShowAITranslation,
isShowAISummaryAuto,
isShowAISummaryOnce,
isShowAITranslationAuto,
isShowAITranslationOnce,
isShowSourceContent,
runCmdFn,
userRole,

View File

@ -5,8 +5,8 @@ import type { FeedViewType } from "@follow/constants"
import { tracker } from "@follow/tracker"
import { useCallback } from "react"
import { disableShowAISummary } from "~/atoms/ai-summary"
import { disableShowAITranslation } from "~/atoms/ai-translation"
import { disableShowAISummaryOnce } from "~/atoms/ai-summary"
import { disableShowAITranslationOnce } from "~/atoms/ai-translation"
import { resetShowSourceContent } from "~/atoms/source-content"
import {
ROUTE_ENTRY_PENDING,
@ -79,8 +79,8 @@ export const navigateEntry = (options: NavigateEntryOptions) => {
}
resetShowSourceContent()
disableShowAISummary()
disableShowAITranslation()
disableShowAISummaryOnce()
disableShowAITranslationOnce()
tracker.navigateEntry({
feedId: finalFeedId,

View File

@ -1,177 +0,0 @@
import type { SupportedLanguages } from "@follow/models/types"
import type { FlatEntryModel } from "~/store/entry"
import { checkLanguage, translate } from "./translate"
function textNodesUnder(el: Node) {
const children: Node[] = el.nodeType === Node.TEXT_NODE ? [el] : []
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
while (walker.nextNode()) {
const { currentNode } = walker
if (currentNode.textContent) {
children.push(currentNode)
}
}
return children
}
const tagsToDuplicate = ["h1", "h2", "h3", "h4", "h5", "h6", "p", "li", "blockquote", "article"]
export function immersiveTranslate({
html,
entry,
cache,
targetLanguage,
}: {
html?: HTMLElement
entry: FlatEntryModel
cache?: {
get: (key: string) => string | undefined
set: (key: string, value: string) => void
}
targetLanguage?: SupportedLanguages
}) {
if (!html) {
return
}
const immersiveTranslateMark = html.querySelectorAll("[data-immersive-translate-mark=true]")
if (immersiveTranslateMark.length > 0) {
if (targetLanguage) {
return
}
for (const mark of immersiveTranslateMark) {
mark.remove()
}
}
if (!targetLanguage) {
return
}
if (html.childNodes.length === 1 && html.childNodes[0]?.nodeType === Node.TEXT_NODE) {
const textNode = html.childNodes[0] as Text
if (!textNode.textContent) {
return
}
translate({
entry,
language: targetLanguage,
part: textNode.textContent,
extraFields: ["content"],
}).then((transformed) => {
if (!transformed?.content) {
return
}
const p = document.createElement("p")
p.append(document.createTextNode(textNode.textContent!))
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)
})
return
}
const tags = Array.from(html.querySelectorAll(tagsToDuplicate.join(","))).filter((tag) => {
const children = tag.querySelectorAll(tagsToDuplicate.join(","))
if (children.length > 0) {
return false
}
return true
}) as HTMLElement[]
for (const tag of tags) {
if (tag.textContent) {
const isLanguageMatch = checkLanguage({
content: tag.textContent,
language: targetLanguage,
})
if (isLanguageMatch) {
continue
}
}
const children = Array.from(tag.childNodes)
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"
}
for (const child of children) {
const clone = child.cloneNode(true)
const textNodes = textNodesUnder(clone)
if (textNodes.length === 0) {
continue
}
for (const textNode of textNodes) {
if (textNode.textContent === null) {
continue
}
const { textContent } = textNode
const afterTranslate = (translated: string) => {
textNode.textContent = translated
if (tag.dataset.childCount === undefined) {
throw new Error("childCount is undefined")
}
let childCount = Number.parseInt(tag.dataset.childCount)
childCount -= 1
tag.dataset.childCount = childCount.toString()
if (childCount === 0) {
fontTag.style.display = "initial"
}
}
if (cache) {
const cached = cache.get(textContent)
if (cached) {
afterTranslate(cached)
continue
}
}
translate({
entry,
language: targetLanguage,
part: textContent,
extraFields: ["content"],
}).then((transformed) => {
if (!transformed?.content) {
return
}
afterTranslate(transformed.content)
if (cache) {
cache.set(textContent, transformed.content)
}
})
}
fontTag.append(clone)
}
const parentFontTag = document.createElement("font")
parentFontTag.dataset["immersiveTranslateMark"] = "true"
parentFontTag.append(document.createElement("br"))
parentFontTag.append(fontTag)
tag.append(parentFontTag)
}
}

View File

@ -38,13 +38,13 @@ export async function translate({
extraFields,
part,
}: {
entry: FlatEntryModel
entry?: FlatEntryModel | null
view?: number
language?: SupportedLanguages
extraFields?: string[]
part?: string
}) {
if (!language) {
if (!language || !entry) {
return null
}
let fields = language && view !== undefined ? views[view!]!.translation.split(",") : []
@ -64,17 +64,17 @@ export async function translate({
}
})
if (fields.length > 0) {
const res = await apiClient.ai.translation.$get({
query: {
id: entry.entries.id,
language,
fields: fields?.join(",") || "title",
part,
},
})
return res.data
} else {
if (fields.length === 0) {
return null
}
const res = await apiClient.ai.translation.$get({
query: {
id: entry.entries.id,
language,
fields: fields?.join(",") || "title",
part,
},
})
return res.data
}

View File

@ -2,6 +2,7 @@ import { Button } from "@follow/components/ui/button/index.js"
import { LoadingWithIcon } from "@follow/components/ui/loading/index.jsx"
import { useMutation } from "@tanstack/react-query"
import { useTranslation } from "react-i18next"
import { unstable_usePrompt } from "react-router"
import { toast } from "sonner"
import { toastFetchError } from "~/lib/error-parser"
@ -33,6 +34,11 @@ function ActionSettingOperations() {
const actionLength = useActions((actions) => actions.length)
const isDirty = useIsActionDataDirty()
unstable_usePrompt({
message: t("actions.navigate.prompt"),
when: ({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname,
})
const mutation = useMutation({
mutationFn: () => actionActions.updateRemoteActions(),

View File

@ -190,9 +190,13 @@ export const FeedFilter = ({ index }: { index: number }) => {
</TableRow>
{conditionIdx !== orConditions.length - 1 && (
<TableRow className="relative flex items-center">
<Button disabled variant="outline">
{t("actions.action_card.and")}
</Button>
<div className="relative">
<Button disabled variant="outline">
{t("actions.action_card.and")}
</Button>
<div className="bg-theme-disabled absolute left-1/2 h-[5px] w-px" />
<div className="bg-theme-disabled absolute left-1/2 top-0 h-[5px] w-px -translate-y-full" />
</div>
</TableRow>
)}
</Fragment>

View File

@ -5,8 +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 { toggleShowAISummaryOnce } from "~/atoms/ai-summary"
import { toggleShowAITranslationOnce } from "~/atoms/ai-translation"
import {
getShowSourceContent,
toggleShowSourceContent,
@ -317,7 +317,7 @@ export const useRegisterEntryCommands = () => {
presentActivationModal()
return
}
toggleShowAISummary()
toggleShowAISummaryOnce()
},
},
{
@ -329,7 +329,7 @@ export const useRegisterEntryCommands = () => {
presentActivationModal()
return
}
toggleShowAITranslation()
toggleShowAITranslationOnce()
},
},
],

View File

@ -27,10 +27,9 @@ import {
} from "react"
import { useEventCallback } from "usehooks-ts"
import { useActionLanguage, useGeneralSettingKey } from "~/atoms/settings/general"
import { useGeneralSettingKey } from "~/atoms/settings/general"
import { MediaContainerWidthProvider } from "~/components/ui/media"
import { useAuthQuery } from "~/hooks/common/useBizQuery"
import { Queries } from "~/queries"
import { useEntryTranslation } from "~/store/ai/hook"
import { getEntry, useEntry } from "~/store/entry"
import { imageActions } from "~/store/image"
@ -248,22 +247,7 @@ const MasonryRender: React.ComponentType<
> = ({ data, index }) => {
const firstScreenReady = useContext(FirstScreenReadyContext)
const entry = useEntry(data.entryId)
const actionLanguage = useActionLanguage()
const translation = useAuthQuery(
Queries.ai.translation({
entry: entry!,
view: entry?.view,
language: actionLanguage,
}),
{
enabled: !!entry?.settings?.translation,
refetchOnMount: false,
refetchOnWindowFocus: false,
meta: {
persist: true,
},
},
)
const translation = useEntryTranslation({ entry })
if (data.entryId.startsWith("placeholder")) {
return <LoadingSkeletonItem />

View File

@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next"
import { useGeneralSettingKey } from "~/atoms/settings/general"
import { CommandActionButton } from "~/components/ui/button/CommandActionButton"
import { RelativeTime } from "~/components/ui/datetime"
import { HTML } from "~/components/ui/markdown/HTML"
import { Media } from "~/components/ui/media"
import { usePreviewMedia } from "~/components/ui/media/hooks"
import { useAsRead } from "~/hooks/biz/useAsRead"
@ -26,7 +27,6 @@ import { useEntry } from "~/store/entry/hooks"
import { useFeedById } from "~/store/feed"
import { StarIcon } from "../star-icon"
import { EntryTranslation } from "../translation"
import type { EntryItemStatelessProps, EntryListItemFC } from "../types"
const socialMediaContentWidthAtom = atom(0)
@ -103,12 +103,16 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, entryPreview, transl
</div>
<div className={cn("relative mt-1 text-base", !!entry.collections && "pr-5")}>
<EntryContentWrapper entryId={entryId}>
<EntryTranslation
className="prose-blockquote:mt-0 cursor-auto select-text text-sm leading-relaxed [&_br:last-child]:hidden"
source={content}
target={translation?.content}
isHTML
/>
<HTML
as="div"
className={cn(
"prose dark:prose-invert align-middle",
"prose-blockquote:mt-0 cursor-auto select-text text-sm leading-relaxed [&_br:last-child]:hidden",
)}
noMedia
>
{translation?.content || content}
</HTML>
</EntryContentWrapper>
{!!entry.collections && <StarIcon className="absolute right-0 top-0" />}
</div>

View File

@ -5,9 +5,7 @@ import { cn } from "@follow/utils/utils"
import type { FC } from "react"
import { memo } from "react"
import { useActionLanguage } from "~/atoms/settings/general"
import { useAuthQuery } from "~/hooks/common"
import { Queries } from "~/queries"
import { useEntryTranslation } from "~/store/ai/hook"
import type { FlatEntryModel } from "~/store/entry"
import { useEntry } from "~/store/entry/hooks"
@ -21,22 +19,7 @@ interface EntryItemProps {
view?: number
}
function EntryItemImpl({ entry, view }: { entry: FlatEntryModel; view?: number }) {
const actionLanguage = useActionLanguage()
const translation = useAuthQuery(
Queries.ai.translation({
entry,
view,
language: actionLanguage,
}),
{
enabled: !!entry.settings?.translation,
refetchOnMount: false,
refetchOnWindowFocus: false,
meta: {
persist: true,
},
},
)
const translation = useEntryTranslation({ entry })
const Item: EntryListItemFC = getItemComponentByView(view as FeedViewType)

View File

@ -6,15 +6,14 @@ import { HTML } from "~/components/ui/markdown/HTML"
export const EntryTranslation: Component<{
source?: string | null
target?: string
showTranslation?: boolean
isHTML?: boolean
}> = ({ source, target, showTranslation = true, className, isHTML }) => {
}> = ({ source, target, className, isHTML }) => {
const nextTarget = useMemo(() => {
if (!target || !showTranslation || source === target) {
if (!target || source === target) {
return ""
}
return target
}, [source, target, showTranslation])
}, [source, target])
if (!source) {
return null

View File

@ -2,14 +2,11 @@ import { cn, formatEstimatedMins, formatTimeToSeconds } from "@follow/utils/util
import dayjs from "dayjs"
import { useMemo } from "react"
import { useShowAITranslation } from "~/atoms/ai-translation"
import { useActionLanguage } from "~/atoms/settings/general"
import { useUISettingKey } from "~/atoms/settings/ui"
import { useWhoami } from "~/atoms/user"
import { RelativeTime } from "~/components/ui/datetime"
import { useAuthQuery } from "~/hooks/common"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { Queries } from "~/queries"
import { useEntryTranslation } from "~/store/ai/hook"
import { useEntry, useEntryReadHistory } from "~/store/entry"
import { getPreferredTitle, useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
@ -47,24 +44,7 @@ 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 actionLanguage = useActionLanguage()
const translation = useAuthQuery(
Queries.ai.translation({
entry: entry!,
language: actionLanguage,
extraFields: ["title"],
}),
{
enabled: showAITranslation,
refetchOnMount: false,
refetchOnWindowFocus: false,
meta: {
persist: true,
},
},
)
const translation = useEntryTranslation({ entry, extraFields: ["title"] })
const dateFormat = useUISettingKey("dateFormat")
@ -122,7 +102,6 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
>
<div className={cn("select-text break-words font-bold", compact ? "text-2xl" : "text-3xl")}>
<EntryTranslation
showTranslation={showAITranslation}
source={entry.entries.title}
target={translation.data?.title}
className="select-text hyphens-auto"

View File

@ -9,23 +9,20 @@ import { ErrorBoundary } from "@sentry/react"
import * as React from "react"
import { useEffect, useMemo, useRef } from "react"
import { useShowAITranslation } from "~/atoms/ai-translation"
import { useEntryIsInReadability } from "~/atoms/readability"
import { useActionLanguage } from "~/atoms/settings/general"
import { useUISettingKey } from "~/atoms/settings/ui"
import { ShadowDOM } from "~/components/common/ShadowDOM"
import { useInPeekModal } from "~/components/ui/modal/inspire/PeekModal"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useAuthQuery } from "~/hooks/common"
import { checkLanguage } from "~/lib/translate"
import { WrappedElementProvider } from "~/providers/wrapped-element-provider"
import { Queries } from "~/queries"
import { useEntryTranslation } from "~/store/ai/hook"
import { useEntry } from "~/store/entry"
import { useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
import { EntryContentHTMLRenderer } from "../renderer/html"
import { getTranslationCache, setTranslationCache } from "./atoms"
import { EntryTimelineSidebar } from "./components/EntryTimelineSidebar"
import { EntryTitle } from "./components/EntryTitle"
import { SourceContentPanel } from "./components/SourceContentView"
@ -116,43 +113,14 @@ export const EntryContent: Component<EntryContentProps> = ({
[entry?.entries.media, data?.entries.media],
)
const customCSS = useUISettingKey("customCSS")
const showAITranslation = useShowAITranslation()
const actionLanguage = useActionLanguage()
const contentTranslated = useEntryTranslation({ entry, extraFields: ["content"] })
if (!entry) return null
const content = entry?.entries.content ?? data?.entries.content
const translate = async (html: HTMLElement | null) => {
if (!html || !entry) return
const fullText = html.textContent ?? ""
if (!fullText) return
const translation = showAITranslation ? actionLanguage : undefined
if (translation) {
const isLanguageMatch = checkLanguage({
content: fullText,
language: translation,
})
if (isLanguageMatch) {
return
}
}
const { immersiveTranslate } = await import("~/lib/immersive-translate")
immersiveTranslate({
html,
entry,
targetLanguage: translation,
cache: {
get: (key: string) => getTranslationCache()[key],
set: (key: string, value: string) =>
setTranslationCache({ ...getTranslationCache(), [key]: value }),
},
})
}
const entryContent = entry?.entries.content ?? data?.entries.content
const translatedContent = contentTranslated.data?.content
const content = translatedContent || entryContent
const isInbox = !!inbox
@ -203,7 +171,6 @@ export const EntryContent: Component<EntryContentProps> = ({
view={view}
feedId={feed?.id}
entryId={entryId}
handleTranslate={translate}
mediaInfo={mediaInfo}
noMedia={noMedia}
accessory={contentAccessories}
@ -231,7 +198,7 @@ export const EntryContent: Component<EntryContentProps> = ({
<div className="center mt-16 min-w-0">
{isPending ? (
<EntryContentLoading
icon={!isInbox ? (feed as FeedModel)?.siteUrl! : undefined}
icon={!isInbox ? (feed as FeedModel)?.siteUrl : undefined}
/>
) : error ? (
<div className="center flex min-w-0 flex-col gap-2">

View File

@ -7,24 +7,21 @@ import { cn } from "@follow/utils/utils"
import { ErrorBoundary } from "@sentry/react"
import { useEffect, useMemo, useState } from "react"
import { useShowAITranslation } from "~/atoms/ai-translation"
import { useAudioPlayerAtomSelector } from "~/atoms/player"
import { useActionLanguage } from "~/atoms/settings/general"
import { useUISettingKey } from "~/atoms/settings/ui"
import { ShadowDOM } from "~/components/common/ShadowDOM"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useAuthQuery, usePreventOverscrollBounce } from "~/hooks/common"
import { checkLanguage } from "~/lib/translate"
import { WrappedElementProvider } from "~/providers/wrapped-element-provider"
import { Queries } from "~/queries"
import { useEntryTranslation } from "~/store/ai/hook"
import { useEntry } from "~/store/entry"
import { useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
import { CornerPlayer } from "../player/corner-player"
import { EntryContentHTMLRenderer } from "../renderer/html"
import { getTranslationCache, setTranslationCache } from "./atoms"
import { EntryReadHistory } from "./components/EntryReadHistory"
import { EntryTitle } from "./components/EntryTitle"
import { SupportCreator } from "./components/SupportCreator"
@ -103,8 +100,8 @@ export const EntryContent: Component<{
const [scrollElement, setScrollElement] = useState<HTMLElement | null>(null)
const customCSS = useUISettingKey("customCSS")
const showAITranslation = useShowAITranslation()
const actionLanguage = useActionLanguage()
const contentTranslated = useEntryTranslation({ entry, extraFields: ["content"] })
const contentLineHeight = useUISettingKey("contentLineHeight")
const contentFontSize = useUISettingKey("contentFontSize")
@ -124,38 +121,9 @@ export const EntryContent: Component<{
if (!entry) return null
const content = entry?.entries.content ?? data?.entries.content
const translate = async (html: HTMLElement | null) => {
if (!html || !entry) return
const fullText = html.textContent ?? ""
if (!fullText) return
const translation = showAITranslation ? actionLanguage : undefined
if (translation) {
const isLanguageMatch = checkLanguage({
content: fullText,
language: translation,
})
if (isLanguageMatch) {
return
}
}
const { immersiveTranslate } = await import("~/lib/immersive-translate")
immersiveTranslate({
html,
entry,
targetLanguage: translation,
cache: {
get: (key: string) => getTranslationCache()[key],
set: (key: string, value: string) =>
setTranslationCache({ ...getTranslationCache(), [key]: value }),
},
})
}
const entryContent = entry?.entries.content ?? data?.entries.content
const translatedContent = contentTranslated.data?.content
const content = translatedContent || entryContent
const isInbox = !!inbox
@ -214,7 +182,6 @@ export const EntryContent: Component<{
view={view}
feedId={feed?.id}
entryId={entryId}
handleTranslate={translate}
mediaInfo={mediaInfo}
noMedia={noMedia}
as="article"
@ -233,7 +200,7 @@ export const EntryContent: Component<{
<div className="center mt-16 min-w-0">
{isPending ? (
<EntryContentLoading
icon={!isInbox ? (feed as FeedModel)?.siteUrl! : undefined}
icon={!isInbox ? (feed as FeedModel)?.siteUrl : undefined}
/>
) : error ? (
<div className="center flex min-w-0 flex-col gap-2">

View File

@ -319,7 +319,7 @@ const BackTopIndicator: Component = memo(({ className }) => {
export function AISummary({ entryId }: { entryId: string }) {
const { t } = useTranslation()
const entry = useEntry(entryId)
const showAISummary = useShowAISummary() || !!entry?.settings?.summary
const showAISummary = useShowAISummary(entry)
const actionLanguage = useActionLanguage()
const summary = useAuthQuery(
Queries.ai.summary({

View File

@ -2,7 +2,7 @@ import { Avatar, AvatarImage } from "@follow/components/ui/avatar/index.jsx"
import { LoadingCircle, LoadingWithIcon } from "@follow/components/ui/loading/index.jsx"
import { getUrlIcon } from "@follow/utils/utils"
export const EntryContentLoading = (props: { icon?: string }) => {
export const EntryContentLoading = (props: { icon?: string | null }) => {
if (!props.icon) {
return <LoadingWithIcon size="large" icon={<i className="i-mgc-docment-cute-re" />} />
}

View File

@ -1,5 +1,6 @@
import { useMobile } from "@follow/components/hooks/useMobile.js"
import { ResponsiveSelect } from "@follow/components/ui/select/responsive.js"
import { UserRole } from "@follow/constants"
import { useTypeScriptHappyCallback } from "@follow/hooks"
import { LANGUAGE_MAP } from "@follow/shared"
import { IN_ELECTRON } from "@follow/shared/constants"
@ -20,6 +21,7 @@ import {
useGeneralSettingSelector,
useGeneralSettingValue,
} from "~/atoms/settings/general"
import { useUserRole } from "~/atoms/user"
import { useProxyValue, useSetProxy } from "~/hooks/biz/useProxySetting"
import { useMinimizeToTrayValue, useSetMinimizeToTray } from "~/hooks/biz/useTraySetting"
import { fallbackLanguage } from "~/i18n"
@ -58,6 +60,8 @@ export const SettingGeneral = () => {
)
const isMobile = useMobile()
const role = useUserRole()
const isTrialUser = role === UserRole.Trial
const reRenderKey = useGeneralSettingKey("enhancedSettings")
@ -81,6 +85,20 @@ export const SettingGeneral = () => {
IN_ELECTRON && MinimizeToTraySetting,
isMobile && StartupScreenSelector,
LanguageSelector,
{
type: "title",
value: t("general.action.title"),
disabled: isTrialUser,
},
defineSettingItem("summary", {
label: t("general.action.summary"),
disabled: isTrialUser,
}),
defineSettingItem("translation", {
label: t("general.action.translation"),
disabled: isTrialUser,
}),
ActionLanguageSelector,
{
@ -265,15 +283,14 @@ export const LanguageSelector = ({
const ActionLanguageSelector = () => {
const { t } = useTranslation("settings")
const actionLanguage = useGeneralSettingKey("actionLanguage")
const role = useUserRole()
if (role === UserRole.Trial) {
return null
}
return (
<div className="mb-3 mt-4 flex items-center justify-between">
<div>
<span className="shrink-0 text-sm font-medium">{t("general.action_language.label")}</span>
<SettingDescription className="w-auto">
{t("general.action_language.description")}
</SettingDescription>
</div>
<span className="shrink-0 text-sm font-medium">{t("general.action_language.label")}</span>
<ResponsiveSelect
size="sm"
triggerClassName="w-48"

View File

@ -13,13 +13,13 @@ export const ai = {
extraFields,
part,
}: {
entry: FlatEntryModel
entry?: FlatEntryModel | null
view?: number
language?: SupportedLanguages
extraFields?: string[]
part?: string
}) =>
defineQuery(["translation", entry?.entries?.id, language, part], () =>
defineQuery(["translation", entry, view, language, extraFields, part], () =>
translate({ entry, view, language, extraFields, part }),
),
summary: ({ entryId, language }: { entryId: string; language?: SupportedLanguages }) =>

View File

@ -0,0 +1,48 @@
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"
import type { FlatEntryModel } from "../entry/types"
export function useEntryTranslation({
entry,
extraFields,
}: {
entry: FlatEntryModel | null
extraFields?: string[]
}) {
const actionLanguage = useActionLanguage()
const showAITranslationFinal = useShowAITranslation(entry)
const showAITranslationAuto = useShowAITranslationAuto(entry)
const showAITranslation =
!extraFields || extraFields.length === 0 ? showAITranslationAuto : showAITranslationFinal
const res = useAuthQuery(
Queries.ai.translation({
entry,
view: entry?.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

@ -2,6 +2,8 @@
## New Features
- New global settings for AI summary and translation (#3294)
- AI translation support
- Introduce an invite code input field along with a helpful prompt (359093dcf140edcb612c5920183c62184b05b395)
## Improvements

View File

@ -0,0 +1,10 @@
CREATE TABLE `translations` (
`entry_id` text PRIMARY KEY NOT NULL,
`language` text NOT NULL,
`title` text NOT NULL,
`description` text NOT NULL,
`content` text NOT NULL,
`created_at` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `translation-unique-index` ON `translations` (`entry_id`,`language`);

View File

@ -0,0 +1,687 @@
{
"version": "6",
"dialect": "sqlite",
"id": "e32f739c-b3ca-43e0-afb2-767cd6daf30c",
"prevId": "bfd1be54-8339-4937-8c75-52fe36a25d84",
"tables": {
"collections": {
"name": "collections",
"columns": {
"feed_id": {
"name": "feed_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"entry_id": {
"name": "entry_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"view": {
"name": "view",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"entries": {
"name": "entries",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_content": {
"name": "source_content",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"guid": {
"name": "guid",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author_url": {
"name": "author_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author_avatar": {
"name": "author_avatar",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"inserted_at": {
"name": "inserted_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"media": {
"name": "media",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"categories": {
"name": "categories",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"attachments": {
"name": "attachments",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"extra": {
"name": "extra",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"feed_id": {
"name": "feed_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"inbox_handle": {
"name": "inbox_handle",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"read": {
"name": "read",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sources": {
"name": "sources",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"settings": {
"name": "settings",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"feeds": {
"name": "feeds",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error_at": {
"name": "error_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"site_url": {
"name": "site_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"owner_user_id": {
"name": "owner_user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"images": {
"name": "images",
"columns": {
"url": {
"name": "url",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"colors": {
"name": "colors",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(CURRENT_TIMESTAMP)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"inboxes": {
"name": "inboxes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"lists": {
"name": "lists",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"feed_ids": {
"name": "feed_ids",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"view": {
"name": "view",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"fee": {
"name": "fee",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"owner_user_id": {
"name": "owner_user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"subscriptions": {
"name": "subscriptions",
"columns": {
"feed_id": {
"name": "feed_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"list_id": {
"name": "list_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"inbox_id": {
"name": "inbox_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"view": {
"name": "view",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_private": {
"name": "is_private",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"summaries": {
"name": "summaries",
"columns": {
"entry_id": {
"name": "entry_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"unq": {
"name": "unq",
"columns": ["entry_id", "language"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"translations": {
"name": "translations",
"columns": {
"entry_id": {
"name": "entry_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"translation-unique-index": {
"name": "translation-unique-index",
"columns": ["entry_id", "language"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"unread": {
"name": "unread",
"columns": {
"subscription_id": {
"name": "subscription_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"handle": {
"name": "handle",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_me": {
"name": "is_me",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -127,6 +127,13 @@
"when": 1741700557982,
"tag": "0017_talented_captain_cross",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1743043720748,
"tag": "0018_dashing_the_fury",
"breakpoints": true
}
]
}

View File

@ -18,6 +18,7 @@ import m0014 from "./0014_chemical_shocker.sql"
import m0015 from "./0015_colorful_warbird.sql"
import m0016 from "./0016_curious_carnage.sql"
import m0017 from "./0017_talented_captain_cross.sql"
import m0018 from "./0018_dashing_the_fury.sql"
import journal from "./meta/_journal.json"
export default {
@ -41,5 +42,6 @@ export default {
m0015,
m0016,
m0017,
m0018,
},
}

View File

@ -6,6 +6,10 @@ const createDefaultSettings = (): GeneralSettings => ({
// App
language: "en",
// Action
summary: false,
translation: false,
actionLanguage: "zh-CN",
// Data control

View File

@ -11,7 +11,7 @@ import {
useState,
} from "react"
import type { LayoutChangeEvent } from "react-native"
import { StyleSheet, TouchableOpacity, View } from "react-native"
import { Alert, StyleSheet, TouchableOpacity, View } from "react-native"
import type { AnimatedProps } from "react-native-reanimated"
import Animated, {
useAnimatedReaction,
@ -44,6 +44,7 @@ interface NavigationHeaderButtonProps {
canGoBack: boolean
canDismiss: boolean
modal?: boolean
promptBeforeLeave?: boolean
}
export interface NavigationHeaderRawProps {
headerLeft?: FC<NavigationHeaderButtonProps>
@ -136,6 +137,7 @@ export interface InternalNavigationHeaderProps
canGoBack: boolean
}>
| ReactNode
promptBeforeLeave?: boolean
headerRight?:
| FC<{
canGoBack: boolean
@ -163,6 +165,8 @@ export const InternalNavigationHeader = ({
hideableBottom,
hideableBottomHeight,
headerTitleAbsolute,
promptBeforeLeave,
...rest
}: InternalNavigationHeaderProps) => {
const insets = useSafeAreaInsets()
@ -289,7 +293,12 @@ export const InternalNavigationHeader = ({
pointerEvents={"box-none"}
>
{typeof HeaderLeft === "function" ? (
<HeaderLeft canGoBack={canBack} canDismiss={canDismiss} modal={sheetModal} />
<HeaderLeft
canGoBack={canBack}
canDismiss={canDismiss}
modal={sheetModal}
promptBeforeLeave={promptBeforeLeave}
/>
) : (
HeaderLeft
)}
@ -337,17 +346,40 @@ export const InternalNavigationHeader = ({
)
}
export const DefaultHeaderBackButton = ({ canGoBack, canDismiss }: NavigationHeaderButtonProps) => {
export const DefaultHeaderBackButton = ({
canGoBack,
canDismiss,
promptBeforeLeave,
}: NavigationHeaderButtonProps) => {
const label = useColor("label")
const navigation = useNavigation()
if (!canGoBack && !canDismiss) return null
return (
<UINavigationHeaderActionButton
onPress={() => {
if (canGoBack) {
navigation.back()
} else if (canDismiss) {
navigation.dismiss()
const leave = () => {
if (canGoBack) {
navigation.back()
} else if (canDismiss) {
navigation.dismiss()
}
}
if (promptBeforeLeave) {
Alert.alert("Are you sure you want to exit?", "You have unsaved changes.", [
{
text: "Cancel",
style: "cancel",
},
{
text: "Exit",
onPress: () => {
leave()
},
},
])
} else {
leave()
}
}}
>

View File

@ -139,6 +139,7 @@ export const NavigationBlurEffectHeader = ({
hideableBottomHeight={headerHideableBottomHeight}
headerTitleAbsolute={headerTitleAbsolute}
headerTitle={props.headerTitle}
promptBeforeLeave={props.promptBeforeLeave}
/>
</SetNavigationHeaderHeightContext.Provider>
),
@ -155,6 +156,7 @@ export const NavigationBlurEffectHeader = ({
setSlot,
store,
props.headerTitle,
props.promptBeforeLeave,
])
return null

View File

@ -9,7 +9,7 @@ import { ActivityIndicator, TouchableOpacity, View } from "react-native"
import { useUISettingKey } from "@/src/atoms/settings/ui"
import { BugCuteReIcon } from "@/src/icons/bug_cute_re"
import type { EntryModel } from "@/src/store/entry/types"
import type { EntryModel, EntryWithTranslation } from "@/src/store/entry/types"
import { sharedWebViewHeightAtom } from "./atom"
import { htmlUrl } from "./constants"
@ -23,9 +23,10 @@ const NativeView: React.ComponentType<
> = requireNativeView("FOSharedWebView")
type EntryContentWebViewProps = {
entry: EntryModel
entry: EntryWithTranslation
noMedia?: boolean
showReadability?: boolean
showTranslation?: boolean
}
const setCodeTheme = (light: string, dark: string) => {
@ -49,24 +50,16 @@ const setReaderRenderInlineStyle = (value: boolean) => {
SharedWebViewModule.evaluateJavaScript(`setReaderRenderInlineStyle(${value})`)
}
const setShowReadability = (value: boolean) => {
SharedWebViewModule.evaluateJavaScript(`setShowReadability(${value})`)
}
export function EntryContentWebView(props: EntryContentWebViewProps) {
const [contentHeight, setContentHeight] = useAtom(sharedWebViewHeightAtom)
const codeThemeLight = useUISettingKey("codeHighlightThemeLight")
const codeThemeDark = useUISettingKey("codeHighlightThemeDark")
const readerRenderInlineStyle = useUISettingKey("readerRenderInlineStyle")
const { entry, noMedia, showReadability } = props
const { entry, noMedia, showReadability, showTranslation } = props
const [mode, setMode] = React.useState<"normal" | "debug">("normal")
useEffect(() => {
setShowReadability(!!showReadability)
}, [showReadability])
useEffect(() => {
setNoMedia(!!noMedia)
}, [noMedia, mode])
@ -79,9 +72,27 @@ export function EntryContentWebView(props: EntryContentWebViewProps) {
setCodeTheme(codeThemeLight, codeThemeDark)
}, [codeThemeLight, codeThemeDark, mode])
const entryInWebview = React.useMemo(() => {
if (showReadability) {
return {
...entry,
content: entry.readabilityContent,
}
}
if (showTranslation) {
return {
...entry,
content: entry.translation?.content || entry.content,
}
}
return entry
}, [entry, showReadability, showTranslation])
useEffect(() => {
setWebViewEntry(entry)
}, [entry])
setWebViewEntry(entryInWebview)
}, [entryInWebview])
const onceRef = React.useRef(false)
if (!onceRef.current) {
@ -95,7 +106,7 @@ export function EntryContentWebView(props: EntryContentWebViewProps) {
key={mode}
style={{ height: contentHeight, transform: [{ translateY: 0 }] }}
onLayout={() => {
setWebViewEntry(entry)
setWebViewEntry(entryInWebview)
}}
>
<NativeView

View File

@ -2,6 +2,8 @@ import type { FeedViewType } from "@follow/constants"
import { sql } from "drizzle-orm"
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"
import type { SupportedLanguages } from "@/src/lib/language"
import type {
ActionSettings,
AttachmentsModel,
@ -114,6 +116,22 @@ export const summariesTable = sqliteTable(
}),
)
export const translationsTable = sqliteTable(
"translations",
(t) => ({
entryId: t.text("entry_id").notNull().primaryKey(),
language: t.text("language").$type<SupportedLanguages>().notNull(),
title: t.text("title").notNull(),
description: t.text("description").notNull(),
content: t.text("content").notNull(),
createdAt: t
.text("created_at")
.notNull()
.$defaultFn(() => new Date().toISOString()),
}),
(t) => [uniqueIndex("translation-unique-index").on(t.entryId, t.language)],
)
export const imagesTable = sqliteTable("images", (t) => ({
url: t.text("url").notNull().primaryKey(),
colors: t.text("colors", { mode: "json" }).$type<ImageColorsResult>().notNull(),

View File

@ -9,6 +9,7 @@ import type {
listsTable,
subscriptionsTable,
summariesTable,
translationsTable,
unreadTable,
usersTable,
} from "."
@ -31,6 +32,8 @@ export type CollectionSchema = typeof collectionsTable.$inferSelect
export type SummarySchema = typeof summariesTable.$inferSelect
export type TranslationSchema = typeof translationsTable.$inferSelect
export type ImageSchema = typeof imagesTable.$inferInsert
export type ActionSettings = HonoApiClient.ActionSettings

View File

@ -1,5 +1,8 @@
export interface GeneralSettings {
language: string
summary: boolean
translation: boolean
actionLanguage: string
sendAnonymousData: boolean

View File

@ -1,8 +1,9 @@
import { useAtomValue } from "jotai"
import type { FC } from "react"
import { useGeneralSettingKey } from "@/src/atoms/settings/general"
import { SummaryGeneratingStatus } from "@/src/store/summary/enum"
import { useSummary } from "@/src/store/summary/hooks"
import { usePrefetchSummary, useSummary } from "@/src/store/summary/hooks"
import { useSummaryStore } from "@/src/store/summary/store"
import { AISummary } from "../ai/summary"
@ -12,8 +13,10 @@ export const EntryAISummary: FC<{
entryId: string
}> = ({ entryId }) => {
const ctx = useEntryContentContext()
const showAISummary = useAtomValue(ctx.showAISummaryAtom)
const showAISummaryOnce = useAtomValue(ctx.showAISummaryAtom)
const showAISummary = useGeneralSettingKey("summary") || showAISummaryOnce
const summary = useSummary(entryId)
usePrefetchSummary(entryId, { enabled: showAISummary })
const status = useSummaryStore((state) => state.generatingStatus[entryId])
if (!showAISummary) return null

View File

@ -6,6 +6,7 @@ import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated
import { useColor } from "react-native-uikit-colors"
import type { MenuItemIconProps } from "zeego/lib/typescript/menu"
import { getGeneralSettings, useGeneralSettingKey } from "@/src/atoms/settings/general"
import { ActionBarItem } from "@/src/components/ui/action-bar/ActionBarItem"
import { DropdownMenu } from "@/src/components/ui/context-menu"
import { DocmentCuteReIcon } from "@/src/icons/docment_cute_re"
@ -14,6 +15,8 @@ import { More1CuteReIcon } from "@/src/icons/more_1_cute_re"
import { Share3CuteReIcon } from "@/src/icons/share_3_cute_re"
import { StarCuteFiIcon } from "@/src/icons/star_cute_fi"
import { StarCuteReIcon } from "@/src/icons/star_cute_re"
import { Translate2CuteReIcon } from "@/src/icons/translate_2_cute_re"
import type { SupportedLanguages } from "@/src/lib/language"
import { hideIntelligenceGlowEffect, openLink, showIntelligenceGlowEffect } from "@/src/lib/native"
import { toast } from "@/src/lib/toast"
import { useIsEntryStarred } from "@/src/store/collection/hooks"
@ -23,6 +26,7 @@ import { entrySyncServices } from "@/src/store/entry/store"
import { useFeed } from "@/src/store/feed/hooks"
import { useSubscription } from "@/src/store/subscription/hooks"
import { summaryActions, summarySyncService } from "@/src/store/summary/store"
import { translationSyncService } from "@/src/store/translation/store"
import { useEntryContentContext } from "./ctx"
@ -54,9 +58,6 @@ const HeaderRightActionsImpl = ({
}: HeaderRightActionsProps) => {
const labelColor = useColor("label")
const isStarred = useIsEntryStarred(entryId)
const { showAISummaryAtom, showReadabilityAtom } = useEntryContentContext()
const [showAISummary, setShowAISummary] = useAtom(showAISummaryAtom)
const [showReadability, setShowReadability] = useAtom(showReadabilityAtom)
const [extraActionContainerWidth, setExtraActionContainerWidth] = useState(0)
const entry = useEntry(
@ -66,9 +67,18 @@ const HeaderRightActionsImpl = ({
url: entry.url,
feedId: entry.feedId,
title: entry.title,
settings: entry.settings,
},
)
const { showAISummaryAtom, showReadabilityAtom, showAITranslationAtom } = useEntryContentContext()
const [showAISummary, setShowAISummary] = useAtom(showAISummaryAtom)
const [showTranslation, setShowTranslation] = useAtom(showAITranslationAtom)
const [showReadability, setShowReadability] = useAtom(showReadabilityAtom)
const showAISummarySetting = useGeneralSettingKey("summary") || !!entry?.settings?.summary
const showAITranslationSetting =
useGeneralSettingKey("translation") || !!entry?.settings?.translation
const feed = useFeed(entry?.feedId as string, (feed) => feed && { feedId: feed.id })
const subscription = useSubscription(feed?.feedId as string)
@ -89,7 +99,7 @@ const HeaderRightActionsImpl = ({
Share.share({ title: entry.title, url: entry.url })
}
const handleAISummary = () => {
const toggleAISummary = () => {
if (!entry) return
const getCachedOrGenerateSummary = async () => {
@ -108,7 +118,15 @@ const HeaderRightActionsImpl = ({
})
}
const handleShowReadability = useCallback(() => {
const toggleAITranslation = () => {
translationSyncService.generateTranslation(
entryId,
getGeneralSettings().actionLanguage as SupportedLanguages,
)
setShowTranslation((prev) => !prev)
}
const toggleReadability = useCallback(() => {
entrySyncServices.fetchEntryReadabilityContent(entryId)
setShowReadability((prev) => !prev)
}, [entryId, setShowReadability])
@ -147,19 +165,28 @@ const HeaderRightActionsImpl = ({
title: "Show Readability",
icon: <DocmentCuteReIcon />,
iconIOS: { name: "doc.text" },
onPress: handleShowReadability,
onPress: toggleReadability,
active: showReadability,
isCheckbox: true,
},
{
!showAISummarySetting && {
key: "GenerateSummary",
title: "Generate Summary",
icon: <Magic2CuteReIcon />,
iconIOS: { name: "sparkles" },
onPress: handleAISummary,
onPress: toggleAISummary,
active: showAISummary,
isCheckbox: true,
},
!showAITranslationSetting && {
key: "ShowTranslation",
title: "Show Translation",
icon: <Translate2CuteReIcon />,
iconIOS: { name: "globe" },
onPress: toggleAITranslation,
active: showTranslation,
isCheckbox: true,
},
{
key: "Share",
title: "Share",

View File

@ -3,6 +3,7 @@ import { createContext, useContext } from "react"
interface EntryContentContextType {
showAISummaryAtom: PrimitiveAtom<boolean>
showAITranslationAtom: PrimitiveAtom<boolean>
showReadabilityAtom: PrimitiveAtom<boolean>
}
export const EntryContentContext = createContext<EntryContentContextType>(null!)

View File

@ -23,13 +23,17 @@ import { EntryDetailScreen } from "@/src/screens/(stack)/entries/[entryId]"
import { useEntry } from "@/src/store/entry/hooks"
import { getInboxFrom } from "@/src/store/entry/utils"
import { useFeed } from "@/src/store/feed/hooks"
import { useEntryTranslation, usePrefetchEntryTranslation } from "@/src/store/translation/hooks"
import { EntryItemContextMenu } from "../../context-menu/entry"
import { EntryItemSkeleton } from "../EntryListContentArticle"
import { useEntryListContextView } from "../EntryListContext"
import { EntryTranslation } from "./EntryTranslation"
export function EntryNormalItem({ entryId, extraData }: { entryId: string; extraData: string }) {
const entry = useEntry(entryId)
usePrefetchEntryTranslation(entryId)
const translation = useEntryTranslation(entryId)
const from = getInboxFrom(entry)
const feed = useFeed(entry?.feedId as string)
const view = useEntryListContextView()
@ -131,14 +135,23 @@ export function EntryNormalItem({ entryId, extraData }: { entryId: string; extra
/>
</View>
{!!entry.title && (
<Text numberOfLines={2} className="text-label text-lg font-semibold">
{entry.title.trim()}
</Text>
<EntryTranslation
numberOfLines={2}
className="text-label text-lg font-semibold"
source={entry.title}
target={translation?.title}
showTranslation={!!entry.settings?.translation}
inline
/>
)}
{view !== FeedViewType.Notifications && !!entry.description && (
<Text numberOfLines={2} className="text-secondary-label text-sm">
{entry.description}
</Text>
<EntryTranslation
numberOfLines={2}
className="text-secondary-label text-sm"
source={entry.description}
target={translation?.description}
showTranslation={!!entry.settings?.translation}
/>
)}
</View>
{view !== FeedViewType.Notifications && (

View File

@ -19,13 +19,17 @@ import { EntryDetailScreen } from "@/src/screens/(stack)/entries/[entryId]"
import { FeedScreen } from "@/src/screens/(stack)/feeds/[feedId]"
import { useEntry } from "@/src/store/entry/hooks"
import { useFeed } from "@/src/store/feed/hooks"
import { useEntryTranslation, usePrefetchEntryTranslation } from "@/src/store/translation/hooks"
import { unreadSyncService } from "@/src/store/unread/store"
import { EntryItemContextMenu } from "../../context-menu/entry"
import { EntryItemSkeleton } from "../EntryListContentSocial"
import { EntryTranslation } from "./EntryTranslation"
export function EntrySocialItem({ entryId }: { entryId: string }) {
const entry = useEntry(entryId)
usePrefetchEntryTranslation(entryId)
const translation = useEntryTranslation(entryId)
const feed = useFeed(entry?.feedId || "")
@ -117,12 +121,13 @@ export function EntrySocialItem({ entryId }: { entryId: string }) {
</View>
<View className="relative -mt-4">
<Text
<EntryTranslation
numberOfLines={autoExpandLongSocialMedia ? undefined : 7}
className="text-label ml-12 text-base"
>
{description}
</Text>
source={description}
target={translation?.description}
showTranslation={!!entry.settings?.translation}
/>
</View>
{media && media.length > 0 && (

View File

@ -0,0 +1,59 @@
import { useMemo } from "react"
import type { TextProps } from "react-native"
import { Text, View } from "react-native"
import { useGeneralSettingKey } from "@/src/atoms/settings/general"
export const EntryTranslation = ({
source,
target,
className,
inline,
showTranslation,
...props
}: {
source?: string | null
target?: string
className?: string
inline?: boolean
showTranslation?: boolean
} & TextProps) => {
const nextSource = useMemo(() => {
if (!source) {
return ""
}
return source.trim()
}, [source])
const showTranslationFinal = useGeneralSettingKey("translation") || showTranslation
const nextTarget = useMemo(() => {
if (
!target ||
!showTranslationFinal ||
nextSource.replaceAll(/\s/g, "") === target.replaceAll(/\s/g, "")
) {
return ""
}
return target.trim()
}, [nextSource, target, showTranslationFinal])
if (inline) {
return (
<Text {...props} className={className}>
{`${nextTarget ? `${nextTarget} ` : ""}${nextSource}`}
</Text>
)
}
return (
<View>
{nextTarget && (
<Text {...props} className={className}>
{nextTarget}
</Text>
)}
<Text {...props} className={className}>
{nextSource}
</Text>
</View>
)
}

View File

@ -102,7 +102,7 @@ export const availableActionList: Array<{
},
{
value: "translation",
label: "Translate into",
label: "Translate",
},
{
value: "readability",

View File

@ -48,6 +48,7 @@ export const ActionsScreen = () => {
),
[isDirty],
)}
promptBeforeLeave={isDirty}
/>
<View className="mt-6">

View File

@ -19,6 +19,8 @@ import type { NavigationControllerView } from "@/src/lib/navigation/types"
export const GeneralScreen: NavigationControllerView = () => {
const locales = useLocales()
const translation = useGeneralSettingKey("translation")
const summary = useGeneralSettingKey("summary")
const actionLanguage = useGeneralSettingKey("actionLanguage")
const autoGroup = useGeneralSettingKey("autoGroup")
const showUnreadOnLaunch = useGeneralSettingKey("unreadOnly")
@ -40,9 +42,31 @@ export const GeneralScreen: NavigationControllerView = () => {
<Text className="text-label">{(locales[0]?.languageTag, "English")}</Text>
</GroupedInsetListBaseCell>
</GroupedInsetListCard>
{/* Content Behavior */}
<GroupedInsetListSectionHeader label="Action" />
<GroupedInsetListCard>
<GroupedInsetListCell label="AI Summary">
<Switch
size="sm"
value={summary}
onValueChange={(value) => {
setGeneralSetting("summary", value)
}}
/>
</GroupedInsetListCell>
<GroupedInsetListCell label="AI Translation">
<Switch
size="sm"
value={translation}
onValueChange={(value) => {
setGeneralSetting("translation", value)
}}
/>
</GroupedInsetListCell>
<GroupedInsetListBaseCell>
<Text className="text-label">Action Language</Text>
<Text className="text-label">Language</Text>
<View className="w-[150px]">
<Select

View File

@ -6,6 +6,7 @@ import { Pressable, Text, View } from "react-native"
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useGeneralSettingKey } from "@/src/atoms/settings/general"
import { BottomTabBarHeightContext } from "@/src/components/layouts/tabbar/contexts/BottomTabBarHeightContext"
import { SafeNavigationScrollView } from "@/src/components/layouts/views/SafeNavigationScrollView"
import { EntryContentWebView } from "@/src/components/native/webview/EntryContentWebView"
@ -15,9 +16,9 @@ import { EntryContentContext, useEntryContentContext } from "@/src/modules/entry
import { EntryAISummary } from "@/src/modules/entry-content/EntryAISummary"
import { useEntry, usePrefetchEntryContent } from "@/src/store/entry/hooks"
import { entrySyncServices } from "@/src/store/entry/store"
import type { EntryModel } from "@/src/store/entry/types"
import type { EntryWithTranslation } from "@/src/store/entry/types"
import { useFeed } from "@/src/store/feed/hooks"
import { summarySyncService } from "@/src/store/summary/store"
import { useEntryTranslation } from "@/src/store/translation/hooks"
import { useAutoMarkAsRead } from "@/src/store/unread/hooks"
import { EntrySocialTitle, EntryTitle } from "../../../../modules/entry-content/EntryTitle"
@ -26,17 +27,26 @@ export const EntryDetailScreen: NavigationControllerView<{
entryId: string
view: FeedViewType
}> = ({ entryId, view: viewType }) => {
usePrefetchEntryContent(entryId as string)
useAutoMarkAsRead(entryId as string)
const entry = useEntry(entryId as string)
usePrefetchEntryContent(entryId)
useAutoMarkAsRead(entryId)
const entry = useEntry(entryId)
const translation = useEntryTranslation(entryId)
const entryWithTranslation = useMemo(() => {
if (!entry) return entry
return {
...entry,
translation,
} as EntryWithTranslation
}, [entry, translation])
const insets = useSafeAreaInsets()
const ctxValue = useMemo(
() => ({
showAISummaryAtom: atom(entry?.settings?.summary || false),
showAITranslationAtom: atom(!!entry?.settings?.translation || false),
showReadabilityAtom: atom(entry?.settings?.readability || false),
}),
[entry?.settings?.readability, entry?.settings?.summary],
[entry?.settings?.readability, entry?.settings?.summary, entry?.settings?.translation],
)
useEffect(() => {
@ -45,12 +55,6 @@ export const EntryDetailScreen: NavigationControllerView<{
}
}, [entry?.settings?.readability, entryId])
useEffect(() => {
if (entry?.settings?.summary) {
summarySyncService.generateSummary(entryId)
}
}, [entry?.settings?.summary, entryId])
return (
<EntryContentContext.Provider value={ctxValue}>
<PortalProvider>
@ -81,9 +85,9 @@ export const EntryDetailScreen: NavigationControllerView<{
)}
</Pressable>
<EntryAISummary entryId={entryId as string} />
{entry && (
{entryWithTranslation && (
<View className="mt-3">
<EntryContentWebViewWithContext entry={entry} />
<EntryContentWebViewWithContext entry={entryWithTranslation} />
</View>
)}
{viewType === FeedViewType.SocialMedia && (
@ -98,10 +102,18 @@ export const EntryDetailScreen: NavigationControllerView<{
)
}
const EntryContentWebViewWithContext = ({ entry }: { entry: EntryModel }) => {
const { showReadabilityAtom } = useEntryContentContext()
const EntryContentWebViewWithContext = ({ entry }: { entry: EntryWithTranslation }) => {
const { showReadabilityAtom, showAITranslationAtom } = useEntryContentContext()
const showReadability = useAtomValue(showReadabilityAtom)
return <EntryContentWebView entry={entry} showReadability={showReadability} />
const translationSetting = useGeneralSettingKey("translation")
const showTranslation = useAtomValue(showAITranslationAtom)
return (
<EntryContentWebView
entry={entry}
showReadability={showReadability}
showTranslation={translationSetting || showTranslation}
/>
)
}
const EntryInfo = ({ entryId }: { entryId: string }) => {

View File

@ -6,6 +6,7 @@ import { InboxService } from "./inbox"
import type { Hydratable } from "./internal/base"
import { ListService } from "./list"
import { SubscriptionService } from "./subscription"
import { TranslationService } from "./translation"
import { UnreadService } from "./unread"
import { UserService } from "./user"
@ -19,6 +20,7 @@ const hydrates: Hydratable[] = [
EntryService,
CollectionService,
ImagesService,
TranslationService,
]
export const hydrateDatabaseToStore = async () => {

View File

@ -0,0 +1,44 @@
import { eq } from "drizzle-orm"
import { db } from "../database"
import { translationsTable } from "../database/schemas"
import type { TranslationSchema } from "../database/schemas/types"
import { translationActions } from "../store/translation/store"
import type { Hydratable, Resetable } from "./internal/base"
class TranslationServiceStatic implements Hydratable, Resetable {
async hydrate() {
const translations = await db.query.translationsTable.findMany()
translationActions.upsertManyInSession(translations)
}
async reset() {
await db.delete(translationsTable).execute()
}
async insertTranslation(data: Omit<TranslationSchema, "createdAt">) {
const updateExceptEmpty = Object.fromEntries(
Object.entries({
title: data.title,
description: data.description,
content: data.content,
}).filter(([_, value]) => !!value),
)
await db
.insert(translationsTable)
.values({
...data,
createdAt: new Date().toISOString(),
})
.onConflictDoUpdate({
target: [translationsTable.entryId, translationsTable.language],
set: updateExceptEmpty,
})
}
async deleteTranslation(entryId: string) {
await db.delete(translationsTable).where(eq(translationsTable.entryId, entryId))
}
}
export const TranslationService = new TranslationServiceStatic()

View File

@ -1,6 +1,9 @@
import type { EntrySchema } from "@/src/database/schemas/types"
import type { EntryTranslation } from "../translation/types"
export type EntryModel = EntrySchema
export type EntryWithTranslation = EntryModel & { translation?: EntryTranslation }
export type FetchEntriesProps = {
feedId?: number | string
inboxId?: number | string

View File

@ -1,4 +1,6 @@
import { useSummaryStore } from "./store"
import { useQuery } from "@tanstack/react-query"
import { summarySyncService, useSummaryStore } from "./store"
export const useSummary = (entryId: string) => {
const summary = useSummaryStore((state) => state.data[entryId])
@ -9,3 +11,13 @@ export const useSummaryStatus = (entryId: string) => {
const status = useSummaryStore((state) => state.generatingStatus[entryId])
return status
}
export const usePrefetchSummary = (entryId: string, options?: { enabled?: boolean }) => {
return useQuery({
queryKey: ["summary", entryId],
queryFn: () => {
return summarySyncService.generateSummary(entryId)
},
enabled: options?.enabled,
})
}

View File

@ -1,6 +1,7 @@
import { getGeneralSettings } from "@/src/atoms/settings/general"
import type { SummarySchema } from "@/src/database/schemas/types"
import { apiClient } from "@/src/lib/api-fetch"
import type { SupportedLanguages } from "@/src/lib/language"
import { summaryService } from "@/src/services/summary"
import { getEntry } from "../entry/getter"
@ -111,7 +112,7 @@ class SummarySyncService {
.$get({
query: {
id: entryId,
language: actionLanguage as any,
language: actionLanguage as SupportedLanguages,
},
})
.then((summary) => {

View File

@ -0,0 +1,31 @@
import { useQuery } from "@tanstack/react-query"
import { useCallback } from "react"
import { useGeneralSettingKey } from "@/src/atoms/settings/general"
import type { SupportedLanguages } from "@/src/lib/language"
import { useEntry } from "../entry/hooks"
import { translationSyncService, useTranslationStore } from "./store"
export const usePrefetchEntryTranslation = (entryId: string) => {
const entry = useEntry(entryId)
const translation = useGeneralSettingKey("translation") || !!entry?.settings?.translation
const actionLanguage = useGeneralSettingKey("actionLanguage") as SupportedLanguages
return useQuery({
queryKey: ["entry-translation", entryId, actionLanguage],
queryFn: () => translationSyncService.generateTranslation(entryId, actionLanguage),
enabled: translation,
})
}
export const useEntryTranslation = (entryId: string) => {
const language = useGeneralSettingKey("actionLanguage") as SupportedLanguages
return useTranslationStore(
useCallback(
(state) => {
return state.data[entryId]?.[language]
},
[entryId, language],
),
)
}

View File

@ -0,0 +1,85 @@
import type { TranslationSchema } from "@/src/database/schemas/types"
import { apiClient } from "@/src/lib/api-fetch"
import type { SupportedLanguages } from "@/src/lib/language"
import { TranslationService } from "@/src/services/translation"
import { getEntry } from "../entry/getter"
import { createImmerSetter, createZustandStore } from "../internal/helper"
import type { EntryTranslation } from "./types"
type TranslationModel = Omit<TranslationSchema, "createdAt">
interface TranslationState {
data: Record<string, Partial<Record<SupportedLanguages, EntryTranslation>>>
}
const emptyDataSet: Record<string, EntryTranslation> = {}
export const useTranslationStore = createZustandStore<TranslationState>("translation")(() => ({
data: emptyDataSet,
}))
const get = useTranslationStore.getState
const immerSet = createImmerSetter(useTranslationStore)
class TranslationActions {
upsertManyInSession(translations: TranslationModel[]) {
translations.forEach((translation) => {
immerSet((state) => {
const translationData = {
title: translation.title,
description: translation.description,
content: translation.content,
}
if (!state.data[translation.entryId]) {
state.data[translation.entryId] = {}
}
state.data[translation.entryId]![translation.language] = translationData
})
})
}
async upsertMany(translations: TranslationModel[]) {
this.upsertManyInSession(translations)
for (const translation of translations) {
TranslationService.insertTranslation(translation)
}
}
getTranslation(entryId: string, language: SupportedLanguages) {
return get().data[entryId]?.[language]
}
}
export const translationActions = new TranslationActions()
class TranslationSyncService {
async generateTranslation(entryId: string, language: SupportedLanguages) {
const entry = getEntry(entryId)
if (!entry) return
const translationSession = translationActions.getTranslation(entryId, language)
if (translationSession) return translationSession
const res = await apiClient.ai.translation.$get({
query: { id: entryId, language, fields: ["title", "description", "content"].join(",") },
})
if (!res.data) return null
const translation: TranslationModel = {
entryId,
language,
title: res.data.title || "",
description: res.data.description || "",
content: res.data.content || "",
}
await translationActions.upsertMany([translation])
return translation
}
}
export const translationSyncService = new TranslationSyncService()

View File

@ -0,0 +1,5 @@
export interface EntryTranslation {
title: string
description: string
content: string
}

View File

@ -7,7 +7,6 @@ import {
entryAtom,
noMediaAtom,
readerRenderInlineStyleAtom,
showReadabilityAtom,
} from "./atoms"
import { HTML } from "./HTML"
@ -28,9 +27,6 @@ Object.assign(window, {
setNoMedia(value: boolean) {
store.set(noMediaAtom, value)
},
setShowReadability(value: boolean) {
store.set(showReadabilityAtom, value)
},
reset() {
store.set(entryAtom, null)
bridge.measure()
@ -41,12 +37,11 @@ export const App = () => {
const entry = useAtomValue(entryAtom, { store })
const readerRenderInlineStyle = useAtomValue(readerRenderInlineStyleAtom, { store })
const noMedia = useAtomValue(noMediaAtom, { store })
const showReadability = useAtomValue(showReadabilityAtom, { store })
return (
<Provider store={store}>
<HTML
children={showReadability ? entry?.readabilityContent : entry?.content}
children={entry?.content}
renderInlineStyle={readerRenderInlineStyle}
noMedia={noMedia}
/>

View File

@ -8,4 +8,3 @@ export const codeThemeLightAtom = atom<string | null>(null)
export const codeThemeDarkAtom = atom<string | null>(null)
export const readerRenderInlineStyleAtom = atom<boolean>(false)
export const noMediaAtom = atom<boolean>(false)
export const showReadabilityAtom = atom<boolean>(false)

View File

@ -9,7 +9,6 @@ export interface MediaModel {
export interface EntryModel {
content?: string
readabilityContent?: string
title?: string
media?: MediaModel[]
}

View File

@ -4,6 +4,8 @@ export const defaultGeneralSettings: GeneralSettings = {
// App
appLaunchOnStartup: false,
language: "en",
translation: false,
summary: false,
actionLanguage: "default",
// mobile app

View File

@ -1,6 +1,8 @@
export interface GeneralSettings {
appLaunchOnStartup: boolean
language: string
translation: boolean
summary: boolean
actionLanguage: string
startupScreen: "subscription" | "timeline"
dataPersist: boolean