From abc35be47c4ab9bf4c854f2d1fbd12cbf0ace66b Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Fri, 28 Mar 2025 15:14:03 +0800 Subject: [PATCH] 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 --- apps/desktop/changelog/next.md | 2 + apps/desktop/locales/settings/en.json | 9 +- apps/desktop/locales/settings/zh-CN.json | 1 - .../src/renderer/src/atoms/ai-summary.ts | 24 +- .../src/renderer/src/atoms/ai-translation.ts | 30 +- .../src/components/ui/markdown/HTML.tsx | 20 +- .../src/hooks/biz/useEntryActions.tsx | 26 +- .../src/hooks/biz/useNavigateEntry.ts | 8 +- .../renderer/src/lib/immersive-translate.ts | 177 ----- .../desktop/src/renderer/src/lib/translate.ts | 26 +- .../src/modules/action/action-setting.tsx | 6 + .../src/modules/action/feed-filter.tsx | 10 +- .../src/modules/command/commands/entry.tsx | 8 +- .../entry-column/Items/picture-masonry.tsx | 22 +- .../entry-column/Items/social-media-item.tsx | 18 +- .../src/modules/entry-column/item.tsx | 21 +- .../src/modules/entry-column/translation.tsx | 7 +- .../entry-content/components/EntryTitle.tsx | 25 +- .../modules/entry-content/index.electron.tsx | 47 +- .../modules/entry-content/index.mobile.tsx | 47 +- .../modules/entry-content/index.shared.tsx | 2 +- .../src/modules/entry-content/loading.tsx | 2 +- .../src/modules/settings/tabs/general.tsx | 29 +- apps/desktop/src/renderer/src/queries/ai.ts | 4 +- .../desktop/src/renderer/src/store/ai/hook.ts | 48 ++ apps/mobile/changelog/next.md | 2 + apps/mobile/drizzle/0018_dashing_the_fury.sql | 10 + apps/mobile/drizzle/meta/0018_snapshot.json | 687 ++++++++++++++++++ apps/mobile/drizzle/meta/_journal.json | 7 + apps/mobile/drizzle/migrations.js | 2 + apps/mobile/src/atoms/settings/general.ts | 4 + .../layouts/header/NavigationHeader.tsx | 46 +- .../views/SafeNavigationScrollView.tsx | 2 + .../native/webview/EntryContentWebView.tsx | 39 +- apps/mobile/src/database/schemas/index.ts | 18 + apps/mobile/src/database/schemas/types.ts | 3 + .../mobile/src/interfaces/settings/general.ts | 3 + .../modules/entry-content/EntryAISummary.tsx | 7 +- .../EntryContentHeaderRightActions.tsx | 43 +- apps/mobile/src/modules/entry-content/ctx.ts | 1 + .../entry-list/templates/EntryNormalItem.tsx | 25 +- .../entry-list/templates/EntrySocialItem.tsx | 13 +- .../entry-list/templates/EntryTranslation.tsx | 59 ++ .../src/modules/settings/actions/constant.ts | 2 +- .../src/modules/settings/routes/Actions.tsx | 1 + .../src/modules/settings/routes/General.tsx | 26 +- .../(stack)/entries/[entryId]/index.tsx | 46 +- apps/mobile/src/services/index.ts | 2 + apps/mobile/src/services/translation.ts | 44 ++ apps/mobile/src/store/entry/types.ts | 3 + apps/mobile/src/store/summary/hooks.ts | 14 +- apps/mobile/src/store/summary/store.ts | 3 +- apps/mobile/src/store/translation/hooks.ts | 31 + apps/mobile/src/store/translation/store.ts | 85 +++ apps/mobile/src/store/translation/types.ts | 5 + apps/mobile/web-app/html-renderer/src/App.tsx | 7 +- .../web-app/html-renderer/src/atoms/index.ts | 1 - .../web-app/html-renderer/types/index.ts | 1 - packages/shared/src/settings/defaults.ts | 2 + packages/shared/src/settings/interface.ts | 2 + 60 files changed, 1387 insertions(+), 478 deletions(-) delete mode 100644 apps/desktop/src/renderer/src/lib/immersive-translate.ts create mode 100644 apps/desktop/src/renderer/src/store/ai/hook.ts create mode 100644 apps/mobile/drizzle/0018_dashing_the_fury.sql create mode 100644 apps/mobile/drizzle/meta/0018_snapshot.json create mode 100644 apps/mobile/src/modules/entry-list/templates/EntryTranslation.tsx create mode 100644 apps/mobile/src/services/translation.ts create mode 100644 apps/mobile/src/store/translation/hooks.ts create mode 100644 apps/mobile/src/store/translation/store.ts create mode 100644 apps/mobile/src/store/translation/types.ts diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 7deb3a7cc..20957341f 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -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) diff --git a/apps/desktop/locales/settings/en.json b/apps/desktop/locales/settings/en.json index 77979f131..e15a57e22 100644 --- a/apps/desktop/locales/settings/en.json +++ b/apps/desktop/locales/settings/en.json @@ -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.", diff --git a/apps/desktop/locales/settings/zh-CN.json b/apps/desktop/locales/settings/zh-CN.json index 81640c3b6..a7058e094 100644 --- a/apps/desktop/locales/settings/zh-CN.json +++ b/apps/desktop/locales/settings/zh-CN.json @@ -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": "自动展开包含长文本的社交媒体条目。", diff --git a/apps/desktop/src/renderer/src/atoms/ai-summary.ts b/apps/desktop/src/renderer/src/atoms/ai-summary.ts index a1a6f485a..277a0cca9 100644 --- a/apps/desktop/src/renderer/src/atoms/ai-summary.ts +++ b/apps/desktop/src/renderer/src/atoms/ai-summary.ts @@ -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(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(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 +} diff --git a/apps/desktop/src/renderer/src/atoms/ai-translation.ts b/apps/desktop/src/renderer/src/atoms/ai-translation.ts index c08e5a850..09a8dd3c7 100644 --- a/apps/desktop/src/renderer/src/atoms/ai-translation.ts +++ b/apps/desktop/src/renderer/src/atoms/ai-translation.ts @@ -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(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 +} diff --git a/apps/desktop/src/renderer/src/components/ui/markdown/HTML.tsx b/apps/desktop/src/renderer/src/components/ui/markdown/HTML.tsx index e4f601ee1..245ed41a4 100644 --- a/apps/desktop/src/renderer/src/components/ui/markdown/HTML.tsx +++ b/apps/desktop/src/renderer/src/components/ui/markdown/HTML.tsx @@ -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 = { accessory?: React.ReactNode noMedia?: boolean mediaInfo?: MediaInfoRecord - - handleTranslate?: (html: HTMLElement | null) => void } & JSX.IntrinsicElements[A] & Partial<{ renderInlineStyle: boolean }> const HTMLImpl = (props: HTMLProps) => { - 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 = (props: HTMLProp const [refElement, setRefElement] = useState(null) - const showAITranslation = useShowAITranslation() - - useEffect(() => { - translate?.(refElement) - }, [refElement, showAITranslation, translate]) - const markdownElement = useMemo( () => children && diff --git a/apps/desktop/src/renderer/src/hooks/biz/useEntryActions.tsx b/apps/desktop/src/renderer/src/hooks/biz/useEntryActions.tsx index 3f2d2b4af..ce805d9e3 100644 --- a/apps/desktop/src/renderer/src/hooks/biz/useEntryActions.tsx +++ b/apps/desktop/src/renderer/src/hooks/biz/useEntryActions.tsx @@ -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, diff --git a/apps/desktop/src/renderer/src/hooks/biz/useNavigateEntry.ts b/apps/desktop/src/renderer/src/hooks/biz/useNavigateEntry.ts index 1aff85bc2..07356e175 100644 --- a/apps/desktop/src/renderer/src/hooks/biz/useNavigateEntry.ts +++ b/apps/desktop/src/renderer/src/hooks/biz/useNavigateEntry.ts @@ -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, diff --git a/apps/desktop/src/renderer/src/lib/immersive-translate.ts b/apps/desktop/src/renderer/src/lib/immersive-translate.ts deleted file mode 100644 index 6665ed9e7..000000000 --- a/apps/desktop/src/renderer/src/lib/immersive-translate.ts +++ /dev/null @@ -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) - } -} diff --git a/apps/desktop/src/renderer/src/lib/translate.ts b/apps/desktop/src/renderer/src/lib/translate.ts index 93aefdaed..8610c858b 100644 --- a/apps/desktop/src/renderer/src/lib/translate.ts +++ b/apps/desktop/src/renderer/src/lib/translate.ts @@ -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 } diff --git a/apps/desktop/src/renderer/src/modules/action/action-setting.tsx b/apps/desktop/src/renderer/src/modules/action/action-setting.tsx index 3f049f2ec..dde2650f2 100644 --- a/apps/desktop/src/renderer/src/modules/action/action-setting.tsx +++ b/apps/desktop/src/renderer/src/modules/action/action-setting.tsx @@ -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(), diff --git a/apps/desktop/src/renderer/src/modules/action/feed-filter.tsx b/apps/desktop/src/renderer/src/modules/action/feed-filter.tsx index 35f458f8c..94be75316 100644 --- a/apps/desktop/src/renderer/src/modules/action/feed-filter.tsx +++ b/apps/desktop/src/renderer/src/modules/action/feed-filter.tsx @@ -190,9 +190,13 @@ export const FeedFilter = ({ index }: { index: number }) => { {conditionIdx !== orConditions.length - 1 && ( - +
+ +
+
+
)} diff --git a/apps/desktop/src/renderer/src/modules/command/commands/entry.tsx b/apps/desktop/src/renderer/src/modules/command/commands/entry.tsx index 8aa3fc1f7..2763c793d 100644 --- a/apps/desktop/src/renderer/src/modules/command/commands/entry.tsx +++ b/apps/desktop/src/renderer/src/modules/command/commands/entry.tsx @@ -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() }, }, ], diff --git a/apps/desktop/src/renderer/src/modules/entry-column/Items/picture-masonry.tsx b/apps/desktop/src/renderer/src/modules/entry-column/Items/picture-masonry.tsx index 2ddaaf46b..4c16012cb 100644 --- a/apps/desktop/src/renderer/src/modules/entry-column/Items/picture-masonry.tsx +++ b/apps/desktop/src/renderer/src/modules/entry-column/Items/picture-masonry.tsx @@ -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 diff --git a/apps/desktop/src/renderer/src/modules/entry-column/Items/social-media-item.tsx b/apps/desktop/src/renderer/src/modules/entry-column/Items/social-media-item.tsx index 236024e74..553ff7545 100644 --- a/apps/desktop/src/renderer/src/modules/entry-column/Items/social-media-item.tsx +++ b/apps/desktop/src/renderer/src/modules/entry-column/Items/social-media-item.tsx @@ -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
- + + {translation?.content || content} + {!!entry.collections && }
diff --git a/apps/desktop/src/renderer/src/modules/entry-column/item.tsx b/apps/desktop/src/renderer/src/modules/entry-column/item.tsx index c030d1535..5a26c70f1 100644 --- a/apps/desktop/src/renderer/src/modules/entry-column/item.tsx +++ b/apps/desktop/src/renderer/src/modules/entry-column/item.tsx @@ -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) diff --git a/apps/desktop/src/renderer/src/modules/entry-column/translation.tsx b/apps/desktop/src/renderer/src/modules/entry-column/translation.tsx index f342ea6da..9a0f4e149 100644 --- a/apps/desktop/src/renderer/src/modules/entry-column/translation.tsx +++ b/apps/desktop/src/renderer/src/modules/entry-column/translation.tsx @@ -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 diff --git a/apps/desktop/src/renderer/src/modules/entry-content/components/EntryTitle.tsx b/apps/desktop/src/renderer/src/modules/entry-content/components/EntryTitle.tsx index cf7877d46..69ef5c355 100644 --- a/apps/desktop/src/renderer/src/modules/entry-content/components/EntryTitle.tsx +++ b/apps/desktop/src/renderer/src/modules/entry-content/components/EntryTitle.tsx @@ -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) => { >
= ({ [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 = ({ view={view} feedId={feed?.id} entryId={entryId} - handleTranslate={translate} mediaInfo={mediaInfo} noMedia={noMedia} accessory={contentAccessories} @@ -231,7 +198,7 @@ export const EntryContent: Component = ({
{isPending ? ( ) : error ? (
diff --git a/apps/desktop/src/renderer/src/modules/entry-content/index.mobile.tsx b/apps/desktop/src/renderer/src/modules/entry-content/index.mobile.tsx index 88be73b4d..44f71442d 100644 --- a/apps/desktop/src/renderer/src/modules/entry-content/index.mobile.tsx +++ b/apps/desktop/src/renderer/src/modules/entry-content/index.mobile.tsx @@ -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(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<{
{isPending ? ( ) : error ? (
diff --git a/apps/desktop/src/renderer/src/modules/entry-content/index.shared.tsx b/apps/desktop/src/renderer/src/modules/entry-content/index.shared.tsx index 13b56ccce..e32601e5e 100644 --- a/apps/desktop/src/renderer/src/modules/entry-content/index.shared.tsx +++ b/apps/desktop/src/renderer/src/modules/entry-content/index.shared.tsx @@ -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({ diff --git a/apps/desktop/src/renderer/src/modules/entry-content/loading.tsx b/apps/desktop/src/renderer/src/modules/entry-content/loading.tsx index f7d3379d3..6d80a333d 100644 --- a/apps/desktop/src/renderer/src/modules/entry-content/loading.tsx +++ b/apps/desktop/src/renderer/src/modules/entry-content/loading.tsx @@ -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 } /> } diff --git a/apps/desktop/src/renderer/src/modules/settings/tabs/general.tsx b/apps/desktop/src/renderer/src/modules/settings/tabs/general.tsx index 1d1148bee..cb96770e8 100644 --- a/apps/desktop/src/renderer/src/modules/settings/tabs/general.tsx +++ b/apps/desktop/src/renderer/src/modules/settings/tabs/general.tsx @@ -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 (
-
- {t("general.action_language.label")} - - {t("general.action_language.description")} - -
+ {t("general.action_language.label")} - 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 }) => diff --git a/apps/desktop/src/renderer/src/store/ai/hook.ts b/apps/desktop/src/renderer/src/store/ai/hook.ts new file mode 100644 index 000000000..fd77e97eb --- /dev/null +++ b/apps/desktop/src/renderer/src/store/ai/hook.ts @@ -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], + ) +} diff --git a/apps/mobile/changelog/next.md b/apps/mobile/changelog/next.md index 64ef01b30..0850cfe6c 100644 --- a/apps/mobile/changelog/next.md +++ b/apps/mobile/changelog/next.md @@ -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 diff --git a/apps/mobile/drizzle/0018_dashing_the_fury.sql b/apps/mobile/drizzle/0018_dashing_the_fury.sql new file mode 100644 index 000000000..ea496d3a2 --- /dev/null +++ b/apps/mobile/drizzle/0018_dashing_the_fury.sql @@ -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`); \ No newline at end of file diff --git a/apps/mobile/drizzle/meta/0018_snapshot.json b/apps/mobile/drizzle/meta/0018_snapshot.json new file mode 100644 index 000000000..b5e13b1b0 --- /dev/null +++ b/apps/mobile/drizzle/meta/0018_snapshot.json @@ -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": {} + } +} diff --git a/apps/mobile/drizzle/meta/_journal.json b/apps/mobile/drizzle/meta/_journal.json index 2f7fd9877..727ec0950 100644 --- a/apps/mobile/drizzle/meta/_journal.json +++ b/apps/mobile/drizzle/meta/_journal.json @@ -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 } ] } diff --git a/apps/mobile/drizzle/migrations.js b/apps/mobile/drizzle/migrations.js index 6c15e40d1..305f58149 100644 --- a/apps/mobile/drizzle/migrations.js +++ b/apps/mobile/drizzle/migrations.js @@ -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, }, } diff --git a/apps/mobile/src/atoms/settings/general.ts b/apps/mobile/src/atoms/settings/general.ts index 4d7bc5681..b4f43587c 100644 --- a/apps/mobile/src/atoms/settings/general.ts +++ b/apps/mobile/src/atoms/settings/general.ts @@ -6,6 +6,10 @@ const createDefaultSettings = (): GeneralSettings => ({ // App language: "en", + + // Action + summary: false, + translation: false, actionLanguage: "zh-CN", // Data control diff --git a/apps/mobile/src/components/layouts/header/NavigationHeader.tsx b/apps/mobile/src/components/layouts/header/NavigationHeader.tsx index 5943625f3..94a0dc79f 100644 --- a/apps/mobile/src/components/layouts/header/NavigationHeader.tsx +++ b/apps/mobile/src/components/layouts/header/NavigationHeader.tsx @@ -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 @@ -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 )} @@ -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 ( { - 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() } }} > diff --git a/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx b/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx index 4e082bdfe..1e201b113 100644 --- a/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx +++ b/apps/mobile/src/components/layouts/views/SafeNavigationScrollView.tsx @@ -139,6 +139,7 @@ export const NavigationBlurEffectHeader = ({ hideableBottomHeight={headerHideableBottomHeight} headerTitleAbsolute={headerTitleAbsolute} headerTitle={props.headerTitle} + promptBeforeLeave={props.promptBeforeLeave} /> ), @@ -155,6 +156,7 @@ export const NavigationBlurEffectHeader = ({ setSlot, store, props.headerTitle, + props.promptBeforeLeave, ]) return null diff --git a/apps/mobile/src/components/native/webview/EntryContentWebView.tsx b/apps/mobile/src/components/native/webview/EntryContentWebView.tsx index 626199613..2688a71f4 100644 --- a/apps/mobile/src/components/native/webview/EntryContentWebView.tsx +++ b/apps/mobile/src/components/native/webview/EntryContentWebView.tsx @@ -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) }} > ({ + entryId: t.text("entry_id").notNull().primaryKey(), + language: t.text("language").$type().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().notNull(), diff --git a/apps/mobile/src/database/schemas/types.ts b/apps/mobile/src/database/schemas/types.ts index 690404746..981ab2499 100644 --- a/apps/mobile/src/database/schemas/types.ts +++ b/apps/mobile/src/database/schemas/types.ts @@ -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 diff --git a/apps/mobile/src/interfaces/settings/general.ts b/apps/mobile/src/interfaces/settings/general.ts index 78c1ac969..00118ff58 100644 --- a/apps/mobile/src/interfaces/settings/general.ts +++ b/apps/mobile/src/interfaces/settings/general.ts @@ -1,5 +1,8 @@ export interface GeneralSettings { language: string + + summary: boolean + translation: boolean actionLanguage: string sendAnonymousData: boolean diff --git a/apps/mobile/src/modules/entry-content/EntryAISummary.tsx b/apps/mobile/src/modules/entry-content/EntryAISummary.tsx index 3b1b504b1..d6cdf6707 100644 --- a/apps/mobile/src/modules/entry-content/EntryAISummary.tsx +++ b/apps/mobile/src/modules/entry-content/EntryAISummary.tsx @@ -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 diff --git a/apps/mobile/src/modules/entry-content/EntryContentHeaderRightActions.tsx b/apps/mobile/src/modules/entry-content/EntryContentHeaderRightActions.tsx index d7ca89e18..d9cdd8469 100644 --- a/apps/mobile/src/modules/entry-content/EntryContentHeaderRightActions.tsx +++ b/apps/mobile/src/modules/entry-content/EntryContentHeaderRightActions.tsx @@ -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: , iconIOS: { name: "doc.text" }, - onPress: handleShowReadability, + onPress: toggleReadability, active: showReadability, isCheckbox: true, }, - { + !showAISummarySetting && { key: "GenerateSummary", title: "Generate Summary", icon: , iconIOS: { name: "sparkles" }, - onPress: handleAISummary, + onPress: toggleAISummary, active: showAISummary, isCheckbox: true, }, + !showAITranslationSetting && { + key: "ShowTranslation", + title: "Show Translation", + icon: , + iconIOS: { name: "globe" }, + onPress: toggleAITranslation, + active: showTranslation, + isCheckbox: true, + }, { key: "Share", title: "Share", diff --git a/apps/mobile/src/modules/entry-content/ctx.ts b/apps/mobile/src/modules/entry-content/ctx.ts index 00bad707c..2f92a5663 100644 --- a/apps/mobile/src/modules/entry-content/ctx.ts +++ b/apps/mobile/src/modules/entry-content/ctx.ts @@ -3,6 +3,7 @@ import { createContext, useContext } from "react" interface EntryContentContextType { showAISummaryAtom: PrimitiveAtom + showAITranslationAtom: PrimitiveAtom showReadabilityAtom: PrimitiveAtom } export const EntryContentContext = createContext(null!) diff --git a/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx b/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx index 63e5d6024..2209e3f5d 100644 --- a/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx +++ b/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx @@ -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 /> {!!entry.title && ( - - {entry.title.trim()} - + )} {view !== FeedViewType.Notifications && !!entry.description && ( - - {entry.description} - + )} {view !== FeedViewType.Notifications && ( diff --git a/apps/mobile/src/modules/entry-list/templates/EntrySocialItem.tsx b/apps/mobile/src/modules/entry-list/templates/EntrySocialItem.tsx index f645a66f5..bb50c95ff 100644 --- a/apps/mobile/src/modules/entry-list/templates/EntrySocialItem.tsx +++ b/apps/mobile/src/modules/entry-list/templates/EntrySocialItem.tsx @@ -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 }) { - - {description} - + source={description} + target={translation?.description} + showTranslation={!!entry.settings?.translation} + /> {media && media.length > 0 && ( diff --git a/apps/mobile/src/modules/entry-list/templates/EntryTranslation.tsx b/apps/mobile/src/modules/entry-list/templates/EntryTranslation.tsx new file mode 100644 index 000000000..7d6568755 --- /dev/null +++ b/apps/mobile/src/modules/entry-list/templates/EntryTranslation.tsx @@ -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 ( + + {`${nextTarget ? `${nextTarget} ` : ""}${nextSource}`} + + ) + } + + return ( + + {nextTarget && ( + + {nextTarget} + + )} + + {nextSource} + + + ) +} diff --git a/apps/mobile/src/modules/settings/actions/constant.ts b/apps/mobile/src/modules/settings/actions/constant.ts index b477a78b0..b20c3de4e 100644 --- a/apps/mobile/src/modules/settings/actions/constant.ts +++ b/apps/mobile/src/modules/settings/actions/constant.ts @@ -102,7 +102,7 @@ export const availableActionList: Array<{ }, { value: "translation", - label: "Translate into", + label: "Translate", }, { value: "readability", diff --git a/apps/mobile/src/modules/settings/routes/Actions.tsx b/apps/mobile/src/modules/settings/routes/Actions.tsx index 3602ed48e..10ac73e8b 100644 --- a/apps/mobile/src/modules/settings/routes/Actions.tsx +++ b/apps/mobile/src/modules/settings/routes/Actions.tsx @@ -48,6 +48,7 @@ export const ActionsScreen = () => { ), [isDirty], )} + promptBeforeLeave={isDirty} /> diff --git a/apps/mobile/src/modules/settings/routes/General.tsx b/apps/mobile/src/modules/settings/routes/General.tsx index 611721e8a..86fcd7242 100644 --- a/apps/mobile/src/modules/settings/routes/General.tsx +++ b/apps/mobile/src/modules/settings/routes/General.tsx @@ -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 = () => { {(locales[0]?.languageTag, "English")} + + {/* Content Behavior */} + + + + { + setGeneralSetting("summary", value) + }} + /> + + + { + setGeneralSetting("translation", value) + }} + /> + - Action Language + Language