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:
parent
9a2c32a79e
commit
abc35be47c
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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": "自动展开包含长文本的社交媒体条目。",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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" />} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ const createDefaultSettings = (): GeneralSettings => ({
|
|||
// App
|
||||
|
||||
language: "en",
|
||||
|
||||
// Action
|
||||
summary: false,
|
||||
translation: false,
|
||||
actionLanguage: "zh-CN",
|
||||
|
||||
// Data control
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
export interface GeneralSettings {
|
||||
language: string
|
||||
|
||||
summary: boolean
|
||||
translation: boolean
|
||||
actionLanguage: string
|
||||
|
||||
sendAnonymousData: boolean
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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!)
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -102,7 +102,7 @@ export const availableActionList: Array<{
|
|||
},
|
||||
{
|
||||
value: "translation",
|
||||
label: "Translate into",
|
||||
label: "Translate",
|
||||
},
|
||||
{
|
||||
value: "readability",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export const ActionsScreen = () => {
|
|||
),
|
||||
[isDirty],
|
||||
)}
|
||||
promptBeforeLeave={isDirty}
|
||||
/>
|
||||
|
||||
<View className="mt-6">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export interface EntryTranslation {
|
||||
title: string
|
||||
description: string
|
||||
content: string
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ export interface MediaModel {
|
|||
|
||||
export interface EntryModel {
|
||||
content?: string
|
||||
readabilityContent?: string
|
||||
title?: string
|
||||
media?: MediaModel[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ export const defaultGeneralSettings: GeneralSettings = {
|
|||
// App
|
||||
appLaunchOnStartup: false,
|
||||
language: "en",
|
||||
translation: false,
|
||||
summary: false,
|
||||
actionLanguage: "default",
|
||||
|
||||
// mobile app
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
export interface GeneralSettings {
|
||||
appLaunchOnStartup: boolean
|
||||
language: string
|
||||
translation: boolean
|
||||
summary: boolean
|
||||
actionLanguage: string
|
||||
startupScreen: "subscription" | "timeline"
|
||||
dataPersist: boolean
|
||||
|
|
|
|||
Loading…
Reference in New Issue