feat: server side readability (#3498)

This commit is contained in:
Stephen Zhou 2025-04-17 10:41:15 +08:00 committed by GitHub
parent 6d9af8aeb1
commit cf6d7567bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 994 additions and 151 deletions

View File

@ -57,6 +57,12 @@ export const useEntryIsInReadability = (entryId?: string) =>
[entryId],
)
export const useEntryIsInReadabilitySuccess = (entryId?: string) =>
useReadabilityStatusSelector(
(map) => (entryId ? map[entryId] === ReadabilityStatus.SUCCESS : false),
[entryId],
)
export const useEntryInReadabilityStatus = (entryId?: string) =>
useReadabilityStatusSelector(
(map) => (entryId ? map[entryId] || ReadabilityStatus.INITIAL : ReadabilityStatus.INITIAL),

View File

@ -6,22 +6,22 @@ import { useMemo } from "react"
import { useShowAISummaryAuto, useShowAISummaryOnce } from "~/atoms/ai-summary"
import { useShowAITranslationAuto, useShowAITranslationOnce } from "~/atoms/ai-translation"
import {
getReadabilityContent,
getReadabilityStatus,
isInReadability,
ReadabilityStatus,
setReadabilityContent,
setReadabilityStatus,
useEntryInReadabilityStatus,
useEntryIsInReadability,
} from "~/atoms/readability"
import { useShowSourceContent } from "~/atoms/source-content"
import { useUserRole, whoami } from "~/atoms/user"
import { shortcuts } from "~/constants/shortcuts"
import { tipcClient } from "~/lib/client"
import { apiClient } from "~/lib/api-fetch"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { useRunCommandFn } from "~/modules/command/hooks/use-command"
import type { FollowCommandId } from "~/modules/command/types"
import { useToolbarOrderMap } from "~/modules/customize-toolbar/hooks"
import { useEntry } from "~/store/entry"
import { getEntry, useEntry } from "~/store/entry"
import { useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
@ -35,24 +35,39 @@ export const toggleEntryReadability = async ({ id, url }: { id: string; url: str
setReadabilityStatus({
[id]: ReadabilityStatus.WAITING,
})
const result = await tipcClient
?.readability({
url,
})
.catch(() => {
setReadabilityStatus({
[id]: ReadabilityStatus.FAILURE,
})
})
try {
let data = getReadabilityContent()[id]
if (!data) {
const entry = getEntry(id)
if (
entry &&
"readabilityContent" in entry.entries &&
entry.entries.readabilityContent !== null
) {
data = { content: entry.entries.readabilityContent }
}
if (result) {
const status = getReadabilityStatus()[id]
if (status !== ReadabilityStatus.WAITING) return
if (!data) {
const result = await apiClient.entries.readability.$get({ query: { id } })
if (result.data) {
data = result.data
}
}
}
if (data) {
const status = getReadabilityStatus()[id]
if (status !== ReadabilityStatus.WAITING) return
setReadabilityStatus({
[id]: ReadabilityStatus.SUCCESS,
})
setReadabilityContent({
[id]: data,
})
}
} catch {
setReadabilityStatus({
[id]: ReadabilityStatus.SUCCESS,
})
setReadabilityContent({
[id]: result,
[id]: ReadabilityStatus.FAILURE,
})
}
} else {
@ -69,6 +84,12 @@ export type EntryActionItem = {
shortcut?: string
active?: boolean
disabled?: boolean
notice?: boolean
entryId: string
}
function hasHTMLTags(text?: string | null): boolean {
return /<[^>]+>/.test(text || "")
}
export const useEntryActions = ({
@ -81,7 +102,7 @@ export const useEntryActions = ({
compact?: boolean
}) => {
const entry = useEntry(entryId)
const entryReadabilityStatus = useEntryInReadabilityStatus(entry?.entries.id)
const isEntryInReadability = useEntryIsInReadability(entry?.entries.id)
const imageLength = entry?.entries.media?.filter((a) => a.type === "photo").length || 0
const feed = useFeedById(entry?.feedId, (feed) => {
return {
@ -94,6 +115,7 @@ export const useEntryActions = ({
const inList = !!listId
const inbox = useInboxById(entry?.inboxId)
const isInbox = !!inbox
const isContentContainsHTMLTags = hasHTMLTags(entry?.entries.content)
const isShowSourceContent = useShowSourceContent()
const isShowAISummaryAuto = useShowAISummaryAuto(entry)
@ -231,23 +253,36 @@ export const useEntryActions = ({
onClick: runCmdFn(COMMAND_ID.entry.readability, [
{ entryId, entryUrl: entry?.entries.url },
]),
hide: !IN_ELECTRON || compact || (view && views[view]!.wideMode) || !entry?.entries.url,
active: isInReadability(entryReadabilityStatus),
hide:
!!entry.settings?.readability ||
compact ||
(view && views[view]!.wideMode) ||
!entry?.entries.url,
active: isEntryInReadability,
notice: !isContentContainsHTMLTags && !isEntryInReadability,
},
{
id: COMMAND_ID.settings.customizeToolbar,
onClick: runCmdFn(COMMAND_ID.settings.customizeToolbar, []),
},
].filter((config) => !config.hide)
]
.filter((config) => !config.hide)
.map((config) => {
return {
...config,
entryId,
}
})
}, [
compact,
entry?.collections,
entry?.entries.content,
entry?.entries.url,
entry?.read,
entry?.settings?.readability,
entry?.view,
entryId,
entryReadabilityStatus,
isEntryInReadability,
feed?.id,
feed?.ownerUserId,
hasEntry,
@ -259,6 +294,7 @@ export const useEntryActions = ({
isShowAITranslationAuto,
isShowAITranslationOnce,
isShowSourceContent,
isContentContainsHTMLTags,
runCmdFn,
userRole,
view,

View File

@ -4,6 +4,7 @@ import type { SupportedActionLanguage } from "@follow/shared"
import { ACTION_LANGUAGE_MAP } from "@follow/shared"
import { franc } from "franc-min"
import { getReadabilityContent } from "~/atoms/readability"
import type { FlatEntryModel } from "~/store/entry"
import { apiClient } from "./api-fetch"
@ -57,6 +58,16 @@ export async function translate({
}
fields = fields.filter((field) => {
if (language && field === "readabilityContent") {
const content = getReadabilityContent()[entry.entries.id]?.content
if (!content) return false
const isLanguageMatch = checkLanguage({
content,
language,
})
return !isLanguageMatch
}
if (language && entry.entries[field]) {
const isLanguageMatch = checkLanguage({
content: entry.entries[field],

View File

@ -3,6 +3,7 @@ import { cn } from "@follow/utils/utils"
import { useTranslation } from "react-i18next"
import { useShowAISummary } from "~/atoms/ai-summary"
import { useEntryIsInReadabilitySuccess } from "~/atoms/readability"
import { useActionLanguage } from "~/atoms/settings/general"
import { CopyButton } from "~/components/ui/button/CopyButton"
import { useAuthQuery } from "~/hooks/common"
@ -12,12 +13,14 @@ import { useEntry } from "~/store/entry"
export function AISummary({ entryId }: { entryId: string }) {
const { t } = useTranslation()
const entry = useEntry(entryId)
const isInReadabilitySuccess = useEntryIsInReadabilitySuccess(entryId)
const showAISummary = useShowAISummary(entry)
const actionLanguage = useActionLanguage()
const summary = useAuthQuery(
Queries.ai.summary({
entryId,
language: actionLanguage,
target: isInReadabilitySuccess ? "readabilityContent" : "content",
}),
{
enabled: showAISummary,

View File

@ -39,6 +39,8 @@ export const EntryHeaderActions = ({
onClick={config.onClick}
shortcut={config.shortcut}
clickableDisabled={config.disabled}
tooltipDefaultOpen={config.notice}
id={`${config.entryId}/${config.id}`}
/>
)
})

View File

@ -2,14 +2,17 @@ import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDanger
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { useTitle } from "@follow/hooks"
import type { FeedModel, InboxModel } from "@follow/models/types"
import { IN_ELECTRON } from "@follow/shared/constants"
import { stopPropagation } from "@follow/utils/dom"
import { cn } from "@follow/utils/utils"
import { ErrorBoundary } from "@sentry/react"
import * as React from "react"
import { useEffect, useMemo, useRef } from "react"
import { useEntryIsInReadability } from "~/atoms/readability"
import {
useEntryIsInReadability,
useEntryIsInReadabilitySuccess,
useEntryReadabilityContent,
} from "~/atoms/readability"
import { useUISettingKey } from "~/atoms/settings/ui"
import { ShadowDOM } from "~/components/common/ShadowDOM"
import { useInPeekModal } from "~/components/ui/modal/inspire/PeekModal"
@ -35,7 +38,7 @@ import {
ContainerToc,
NoContent,
ReadabilityAutoToggleEffect,
ReadabilityContent,
ReadabilityNotice,
RenderError,
TitleMetaHandler,
ViewSourceContentAutoToggleEffect,
@ -62,11 +65,13 @@ export const EntryContent: Component<EntryContentProps> = ({
staleTime: 300_000,
},
)
const readabilityContent = useEntryReadabilityContent(entryId)
const readerFontFamily = useUISettingKey("readerFontFamily")
const view = useRouteParamsSelector((route) => route.view)
const isInReadabilityMode = useEntryIsInReadability(entryId)
const isReadabilitySuccess = useEntryIsInReadabilitySuccess(entryId)
const scrollerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
scrollerRef.current?.scrollTo(0, 0)
@ -114,14 +119,21 @@ export const EntryContent: Component<EntryContentProps> = ({
)
const customCSS = useUISettingKey("customCSS")
const contentTranslated = useEntryTranslation({ entry, extraFields: ["content"] })
const contentTranslated = useEntryTranslation({
entry,
extraFields: isReadabilitySuccess ? ["readabilityContent"] : ["content"],
})
const isInPeekModal = useInPeekModal()
if (!entry) return null
const entryContent = entry?.entries.content ?? data?.entries.content
const translatedContent = contentTranslated.data?.content
const entryContent = isInReadabilityMode
? readabilityContent?.content
: (entry?.entries.content ?? data?.entries.content)
const translatedContent = isInReadabilityMode
? contentTranslated.data?.readabilityContent
: contentTranslated.data?.content
const content = translatedContent || entryContent
const isInbox = !!inbox
@ -166,34 +178,31 @@ export const EntryContent: Component<EntryContentProps> = ({
<TitleMetaHandler entryId={entry.entries.id} />
<AISummary entryId={entry.entries.id} />
<ErrorBoundary fallback={RenderError}>
{!isInReadabilityMode ? (
<ShadowDOM injectHostStyles={!isInbox}>
{!!customCSS && (
<MemoedDangerousHTMLStyle>{customCSS}</MemoedDangerousHTMLStyle>
)}
<EntryContentHTMLRenderer
view={view}
feedId={feed?.id}
entryId={entryId}
mediaInfo={mediaInfo}
noMedia={noMedia}
accessory={contentAccessories}
as="article"
className="prose dark:prose-invert prose-h1:text-[1.6em] prose-h1:font-bold !max-w-full hyphens-auto"
style={stableRenderStyle}
renderInlineStyle={readerRenderInlineStyle}
>
{content}
</EntryContentHTMLRenderer>
</ShadowDOM>
) : (
<ReadabilityContent entryId={entryId} feedId={feed.id} />
)}
<ReadabilityNotice entryId={entryId} />
<ShadowDOM injectHostStyles={!isInbox}>
{!!customCSS && (
<MemoedDangerousHTMLStyle>{customCSS}</MemoedDangerousHTMLStyle>
)}
<EntryContentHTMLRenderer
view={view}
feedId={feed?.id}
entryId={entryId}
mediaInfo={mediaInfo}
noMedia={noMedia}
accessory={contentAccessories}
as="article"
className="prose dark:prose-invert prose-h1:text-[1.6em] prose-h1:font-bold !max-w-full hyphens-auto"
style={stableRenderStyle}
renderInlineStyle={readerRenderInlineStyle}
>
{content}
</EntryContentHTMLRenderer>
</ShadowDOM>
</ErrorBoundary>
</div>
</WrappedElementProvider>
{entry.settings?.readability && IN_ELECTRON && (
{entry.settings?.readability && (
<ReadabilityAutoToggleEffect id={entry.entries.id} url={entry.entries.url ?? ""} />
)}
{entry.settings?.sourceContent && <ViewSourceContentAutoToggleEffect />}

View File

@ -4,7 +4,7 @@ import { Button, MotionButtonBase } from "@follow/components/ui/button/index.js"
import { LoadingWithIcon } from "@follow/components/ui/loading/index.jsx"
import { RootPortal } from "@follow/components/ui/portal/index.jsx"
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import { IN_ELECTRON, WEB_BUILD } from "@follow/shared/constants"
import { WEB_BUILD } from "@follow/shared/constants"
import { EventBus } from "@follow/utils/event-bus"
import { springScrollTo } from "@follow/utils/scroller"
import { cn } from "@follow/utils/utils"
@ -17,12 +17,12 @@ import {
ReadabilityStatus,
setReadabilityStatus,
useEntryInReadabilityStatus,
useEntryIsInReadability,
useEntryReadabilityContent,
} from "~/atoms/readability"
import { enableShowSourceContent } from "~/atoms/source-content"
import { Toc } from "~/components/ui/markdown/components/Toc"
import { toggleEntryReadability } from "~/hooks/biz/useEntryActions"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { getNewIssueUrl } from "~/lib/issues"
import {
useIsSoFWrappedElement,
@ -33,7 +33,6 @@ import { useEntry } from "~/store/entry"
import { useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
import { EntryContentHTMLRenderer } from "../renderer/html"
import { setEntryContentScrollToTop, setEntryTitleMeta } from "./atoms"
export interface EntryContentProps {
@ -85,10 +84,13 @@ export const TitleMetaHandler: Component<{
return null
}
export const ReadabilityContent = ({ entryId, feedId }: { entryId: string; feedId: string }) => {
export const ReadabilityNotice = ({ entryId }: { entryId: string }) => {
const { t } = useTranslation()
const result = useEntryReadabilityContent(entryId)
const view = useRouteParamsSelector((route) => route.view)
const isInReadability = useEntryIsInReadability(entryId)
if (!isInReadability) {
return null
}
return (
<div className="grow">
@ -103,16 +105,6 @@ export const ReadabilityContent = ({ entryId, feedId }: { entryId: string; feedI
<span className="text-sm">{t("entry_content.fetching_content")}</span>
</div>
)}
<EntryContentHTMLRenderer
view={view}
feedId={feedId}
entryId={entryId}
as="article"
className="prose dark:prose-invert prose-h1:text-[1.6em] prose-h1:font-bold hyphens-auto"
>
{result?.content ?? ""}
</EntryContentHTMLRenderer>
</div>
)
}
@ -134,12 +126,7 @@ export const NoContent: FC<{
{(WEB_BUILD || status === ReadabilityStatus.FAILURE) && (
<span>{t("entry_content.no_content")}</span>
)}
{WEB_BUILD && (
<div>
<span>{t("entry_content.web_app_notice")}</span>
</div>
)}
{!sourceContent && url && IN_ELECTRON && <ReadabilityAutoToggleEffect url={url} id={id} />}
{!sourceContent && url && <ReadabilityAutoToggleEffect url={url} id={id} />}
</div>
</div>
)

View File

@ -22,12 +22,21 @@ export const ai = {
defineQuery(["translation", entry?.entries.id, view, language, extraFields, part], () =>
translate({ entry, view, language, extraFields, part }),
),
summary: ({ entryId, language }: { entryId: string; language?: SupportedLanguages }) =>
defineQuery(["summary", entryId, language], async () => {
summary: ({
entryId,
language,
target = "content",
}: {
entryId: string
language?: SupportedLanguages
target?: "content" | "readabilityContent"
}) =>
defineQuery(["summary", entryId, language, target], async () => {
const res = await apiClient.ai.summary.$get({
query: {
id: entryId,
language,
target,
},
})
return res.data

View File

@ -0,0 +1,2 @@
ALTER TABLE `summaries` ADD `readability_summary` text;--> statement-breakpoint
ALTER TABLE `translations` ADD `readability_content` text;

View File

@ -0,0 +1,708 @@
{
"version": "6",
"dialect": "sqlite",
"id": "493dc0df-ddca-4c5c-96bc-f74802251bc6",
"prevId": "0295d33b-ce34-41aa-a428-1dc63c6bb447",
"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
},
"readability_summary": {
"name": "readability_summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"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
},
"readability_content": {
"name": "readability_content",
"type": "text",
"primaryKey": false,
"notNull": false,
"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
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -141,6 +141,13 @@
"when": 1743153830369,
"tag": "0019_wonderful_shape",
"breakpoints": true
},
{
"idx": 20,
"version": "6",
"when": 1744793226628,
"tag": "0020_little_marauders",
"breakpoints": true
}
]
}

View File

@ -20,6 +20,7 @@ import m0016 from "./0016_curious_carnage.sql"
import m0017 from "./0017_talented_captain_cross.sql"
import m0018 from "./0018_dashing_the_fury.sql"
import m0019 from "./0019_wonderful_shape.sql"
import m0020 from "./0020_little_marauders.sql"
import journal from "./meta/_journal.json"
export default {
@ -45,5 +46,6 @@ export default {
m0017,
m0018,
m0019,
m0020,
},
}

View File

@ -66,21 +66,16 @@ export function EntryContentWebView(props: EntryContentWebViewProps) {
}, [codeThemeLight, codeThemeDark, mode])
const entryInWebview = React.useMemo(() => {
if (showReadability) {
return {
...entry,
content: entry.readabilityContent,
}
}
const entryContent = showReadability ? entry.readabilityContent : entry.content
const translatedContent = showReadability
? entry.translation?.readabilityContent
: entry.translation?.content
const content = showTranslation ? translatedContent || entryContent : entryContent
if (showTranslation) {
return {
...entry,
content: entry.translation?.content || entry.content,
}
return {
...entry,
content,
}
return entry
}, [entry, showReadability, showTranslation])
useEffect(() => {

View File

@ -109,12 +109,11 @@ export const summariesTable = sqliteTable(
{
entryId: text("entry_id").notNull().primaryKey(),
summary: text("summary").notNull(),
readabilitySummary: text("readability_summary"),
createdAt: text("created_at").$defaultFn(() => new Date().toISOString()),
language: text("language"),
},
(table) => ({
unq: uniqueIndex("unq").on(table.entryId, table.language),
}),
(t) => [uniqueIndex("unq").on(t.entryId, t.language)],
)
export const translationsTable = sqliteTable(
@ -125,6 +124,7 @@ export const translationsTable = sqliteTable(
title: t.text("title").notNull(),
description: t.text("description").notNull(),
content: t.text("content").notNull(),
readabilityContent: t.text("readability_content"),
createdAt: t
.text("created_at")
.notNull()

View File

@ -13,10 +13,13 @@ export const EntryAISummary: FC<{
entryId: string
}> = ({ entryId }) => {
const ctx = useEntryContentContext()
const showReadability = useAtomValue(ctx.showReadabilityAtom)
const showAISummaryOnce = useAtomValue(ctx.showAISummaryAtom)
const showAISummary = useGeneralSettingKey("summary") || showAISummaryOnce
const summary = useSummary(entryId)
usePrefetchSummary(entryId, { enabled: showAISummary })
usePrefetchSummary(entryId, showReadability ? "readabilityContent" : "content", {
enabled: showAISummary,
})
const status = useSummaryStore((state) => state.generatingStatus[entryId])
if (!showAISummary) return null
@ -24,7 +27,11 @@ export const EntryAISummary: FC<{
return (
<AISummary
className="my-3"
summary={summary?.summary || ""}
summary={
showReadability
? summary?.readabilitySummary || summary?.summary || ""
: summary?.summary || ""
}
pending={status === SummaryGeneratingStatus.Pending}
error={status === SummaryGeneratingStatus.Error ? "Failed to generate summary" : undefined}
/>

View File

@ -111,7 +111,10 @@ const HeaderRightActionsImpl = ({
if (hasSummary) return
const hideGlowEffect = showIntelligenceGlowEffect()
await summarySyncService.generateSummary(entryId)
await summarySyncService.generateSummary(
entryId,
showReadability ? "readabilityContent" : "content",
)
hideGlowEffect()
}
@ -127,6 +130,7 @@ const HeaderRightActionsImpl = ({
entryId,
language: getGeneralSettings().actionLanguage as SupportedLanguages,
withContent: true,
target: showReadability ? "readabilityContent" : "content",
})
setShowTranslation((prev) => !prev)
}
@ -173,7 +177,7 @@ const HeaderRightActionsImpl = ({
onPress: toggleReadability,
active: showReadability,
isCheckbox: true,
inMenu: true,
// inMenu: true,
},
!showAISummarySetting && {
key: "GenerateSummary",

View File

@ -27,7 +27,6 @@ export const EntryTitle = ({ title, entryId }: { title: string; entryId: string
className="text-label px-4 text-4xl font-bold leading-snug"
source={title}
target={translation?.title}
inline
/>
</View>
)

View File

@ -44,7 +44,7 @@ export const EntryListContentArticle = forwardRef<
useImperativeHandle(forwardRef, () => ref.current!)
usePrefetchEntryTranslation(active ? viewableItems.map((item) => item.key) : [])
usePrefetchEntryTranslation({ entryIds: active ? viewableItems.map((item) => item.key) : [] })
return (
<TimelineSelectorList

View File

@ -30,7 +30,7 @@ export const EntryListContentPicture = forwardRef<
onScroll: hackOnScroll,
})
usePrefetchEntryTranslation(active ? viewableItems.map((item) => item.key) : [])
usePrefetchEntryTranslation({ entryIds: active ? viewableItems.map((item) => item.key) : [] })
return (
<TimelineSelectorMasonryList

View File

@ -37,7 +37,7 @@ export const EntryListContentSocial = forwardRef<
onScroll: hackOnScroll,
})
usePrefetchEntryTranslation(active ? viewableItems.map((item) => item.key) : [])
usePrefetchEntryTranslation({ entryIds: active ? viewableItems.map((item) => item.key) : [] })
return (
<TimelineSelectorList

View File

@ -28,7 +28,7 @@ export const EntryListContentVideo = forwardRef<
onScroll: hackOnScroll,
})
usePrefetchEntryTranslation(active ? viewableItems.map((item) => item.key) : [])
usePrefetchEntryTranslation({ entryIds: active ? viewableItems.map((item) => item.key) : [] })
const ListFooterComponent = useMemo(
() =>

View File

@ -1,4 +1,3 @@
import { cn } from "@follow/utils"
import { useMemo } from "react"
import type { TextProps } from "react-native"
import { Text, View } from "react-native"
@ -47,19 +46,14 @@ export const EntryTranslation = ({
return (
<View>
{nextTarget && (
<>
<Text {...props} className={className}>
{nextTarget}
</Text>
<Text {...props} className={cn("my-2", className)}>
</Text>
</>
)}
<Text {...props} className={className}>
{nextSource}
</Text>
{nextTarget && (
<Text {...props} className={className}>
{nextTarget}
</Text>
)}
</View>
)
}

View File

@ -108,7 +108,8 @@ class Morph {
title: item.entries.title,
url: item.entries.url,
content: null,
readabilityContent: null,
readabilityContent:
"readabilityContent" in item.entries ? item.entries.readabilityContent : null,
description: item.entries.description,
guid: item.entries.guid,
author: item.entries.author,
@ -163,7 +164,8 @@ class Morph {
title: data.entries.title,
url: data.entries.url,
content: data.entries.content,
readabilityContent: null,
readabilityContent:
"readabilityContent" in data.entries ? data.entries.readabilityContent : null,
description: data.entries.description,
guid: data.entries.guid,
author: data.entries.author,

View File

@ -34,7 +34,6 @@ export const EntryDetailScreen: NavigationControllerView<{
view: FeedViewType
}> = ({ entryId, view: viewType }) => {
usePrefetchEntryDetail(entryId)
usePrefetchEntryTranslation([entryId], true)
useAutoMarkAsRead(entryId)
const entry = useEntry(entryId)
const translation = useEntryTranslation(entryId)
@ -110,6 +109,11 @@ const EntryContentWebViewWithContext = ({ entry }: { entry: EntryWithTranslation
const showReadability = useAtomValue(showReadabilityAtom)
const translationSetting = useGeneralSettingKey("translation")
const showTranslation = useAtomValue(showAITranslationAtom)
usePrefetchEntryTranslation({
entryIds: [entry.id],
withContent: true,
target: showReadability ? "readabilityContent" : "content",
})
return (
<EntryContentWebView
entry={entry}

View File

@ -6,6 +6,13 @@ import type { SummarySchema } from "../database/schemas/types"
class SummaryServiceStatic {
async insertSummary(data: Omit<SummarySchema, "createdAt">) {
const updateExceptEmpty = Object.fromEntries(
Object.entries({
summary: data.summary,
readabilitySummary: data.readabilitySummary,
}).filter(([_, value]) => !!value),
)
await db
.insert(summariesTable)
.values({
@ -14,9 +21,7 @@ class SummaryServiceStatic {
})
.onConflictDoUpdate({
target: [summariesTable.entryId, summariesTable.language],
set: {
summary: data.summary,
},
set: updateExceptEmpty,
})
}

View File

@ -21,6 +21,7 @@ class TranslationServiceStatic implements Hydratable, Resetable {
title: data.title,
description: data.description,
content: data.content,
readabilityContent: data.readabilityContent,
}).filter(([_, value]) => !!value),
)

View File

@ -1,5 +1,4 @@
import { FeedViewType } from "@follow/constants"
import { readability } from "@follow/utils"
import { debounce } from "es-toolkit/compat"
import { fetch as expoFetch } from "expo/fetch"
@ -436,8 +435,19 @@ class EntrySyncServices {
const entry = honoMorph.toEntry(res.data)
if (!currentEntry && entry) {
await entryActions.upsertMany([entry])
} else if (entry?.content && currentEntry?.content !== entry.content) {
await entryActions.updateEntryContent({ entryId, content: entry.content })
} else {
if (entry?.content && currentEntry?.content !== entry.content) {
await entryActions.updateEntryContent({ entryId, content: entry.content })
}
if (
entry?.readabilityContent &&
currentEntry?.readabilityContent !== entry.readabilityContent
) {
await entryActions.updateEntryContent({
entryId,
readabilityContent: entry.readabilityContent,
})
}
}
return entry
}
@ -445,8 +455,12 @@ class EntrySyncServices {
async fetchEntryReadabilityContent(entryId: EntryId) {
const entry = getEntry(entryId)
if (entry?.url && !entry?.readabilityContent) {
const contentByFetch = await readability(entry.url)
if (entry?.url && entry?.readabilityContent === null) {
const { data: contentByFetch } = await apiClient.entries.readability.$get({
query: {
id: entryId,
},
})
if (contentByFetch?.content && entry?.readabilityContent !== contentByFetch.content) {
await entryActions.updateEntryContent({
entryId,

View File

@ -12,11 +12,15 @@ export const useSummaryStatus = (entryId: string) => {
return status
}
export const usePrefetchSummary = (entryId: string, options?: { enabled?: boolean }) => {
export const usePrefetchSummary = (
entryId: string,
target: "content" | "readabilityContent",
options?: { enabled?: boolean },
) => {
return useQuery({
queryKey: ["summary", entryId],
queryKey: ["summary", entryId, target],
queryFn: () => {
return summarySyncService.generateSummary(entryId)
return summarySyncService.generateSummary(entryId, target)
},
enabled: options?.enabled,
staleTime: 1000 * 60 * 60 * 24,

View File

@ -13,6 +13,7 @@ type SummaryModel = Omit<SummarySchema, "createdAt">
interface SummaryData {
lang?: string
summary: string
readabilitySummary: string | null
lastAccessed: number
}
@ -41,7 +42,9 @@ class SummaryActions {
immerSet((state) => {
state.data[summary.entryId] = {
lang: summary.language ?? undefined,
summary: summary.summary,
summary: summary.summary || state.data[summary.entryId]?.summary || "",
readabilitySummary:
summary.readabilitySummary || state.data[summary.entryId]?.readabilitySummary || null,
lastAccessed: now,
}
})
@ -96,7 +99,7 @@ export const summaryActions = new SummaryActions()
class SummarySyncService {
private pendingPromises: Record<string, Promise<string>> = {}
async generateSummary(entryId: string) {
async generateSummary(entryId: string, target: "content" | "readabilityContent") {
const entry = getEntry(entryId)
if (!entry) return
@ -116,6 +119,7 @@ class SummarySyncService {
query: {
id: entryId,
language: actionLanguage as SupportedLanguages,
target,
},
})
.then((summary) => {
@ -127,7 +131,11 @@ class SummarySyncService {
state.data[entryId] = {
lang: actionLanguage,
summary: summary.data,
summary: target === "content" ? summary.data : state.data[entryId]?.summary || "",
readabilitySummary:
target === "readabilityContent"
? summary.data
: state.data[entryId]?.readabilitySummary || null,
lastAccessed: Date.now(),
}
state.generatingStatus[entryId] = SummaryGeneratingStatus.Success
@ -153,8 +161,9 @@ class SummarySyncService {
summaryActions.upsertMany([
{
entryId,
summary,
summary: target === "content" ? summary : "",
language: actionLanguage ?? null,
readabilitySummary: target === "readabilityContent" ? summary : null,
},
])
}

View File

@ -7,7 +7,15 @@ import type { SupportedLanguages } from "@/src/lib/language"
import { useEntryList } from "../entry/hooks"
import { translationSyncService, useTranslationStore } from "./store"
export const usePrefetchEntryTranslation = (entryIds: string[], withContent?: boolean) => {
export const usePrefetchEntryTranslation = ({
entryIds,
withContent,
target = "content",
}: {
entryIds: string[]
withContent?: boolean
target?: "content" | "readabilityContent"
}) => {
const translation = useGeneralSettingKey("translation")
const entryList =
useEntryList(entryIds)
@ -18,12 +26,13 @@ export const usePrefetchEntryTranslation = (entryIds: string[], withContent?: bo
return useQueries({
queries: entryList.map((entryId) => ({
queryKey: ["translation", entryId, actionLanguage, withContent],
queryKey: ["translation", entryId, actionLanguage, withContent, target],
queryFn: () =>
translationSyncService.generateTranslation({
entryId,
language: actionLanguage,
withContent,
target,
}),
})),
})

View File

@ -30,6 +30,7 @@ class TranslationActions {
title: translation.title,
description: translation.description,
content: translation.content,
readabilityContent: translation.readabilityContent,
}
if (!state.data[translation.entryId]) {
@ -61,17 +62,19 @@ class TranslationSyncService {
entryId,
language,
withContent,
target,
}: {
entryId: string
language: SupportedLanguages
withContent?: boolean
target: "content" | "readabilityContent"
}) {
const entry = getEntry(entryId)
if (!entry) return
const translationSession = translationActions.getTranslation(entryId, language)
const fields = (
["title", "description", ...(withContent ? ["content"] : [])] as Array<
["title", "description", ...(withContent ? [target] : [])] as Array<
"title" | "description" | "content"
>
).filter((field) => {
@ -100,6 +103,7 @@ class TranslationSyncService {
title: res.data.title || "",
description: res.data.description || "",
content: res.data.content || "",
readabilityContent: res.data.readabilityContent || "",
}
await translationActions.upsertMany([translation])

View File

@ -2,4 +2,5 @@ export interface EntryTranslation {
title: string
description: string
content: string
readabilityContent: string | null
}

View File

@ -7,12 +7,13 @@ import type { Options } from "react-hotkeys-hook"
import { useHotkeys } from "react-hotkeys-hook"
import { KbdCombined } from "../kbd/Kbd"
import { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } from "../tooltip"
import { Tooltip, TooltipContent, TooltipPortal, TooltipRoot, TooltipTrigger } from "../tooltip"
export interface ActionButtonProps {
icon?: React.ReactNode | ((props: { isActive?: boolean; className: string }) => React.ReactNode)
tooltip?: React.ReactNode
tooltipSide?: "top" | "bottom"
tooltipDefaultOpen?: boolean
active?: boolean
disabled?: boolean
clickableDisabled?: boolean
@ -20,6 +21,7 @@ export interface ActionButtonProps {
disableTriggerShortcut?: boolean
enableHoverableContent?: boolean
size?: "sm" | "base" | "lg"
id?: string
/**
* @description only trigger shortcut when focus with in `<Focusable />`
@ -43,10 +45,11 @@ export const ActionButton = React.forwardRef<
(
{
icon,
id,
tooltip,
className,
tooltipSide,
tooltipDefaultOpen,
children,
active,
shortcut,
@ -126,19 +129,24 @@ export const ActionButton = React.forwardRef<
)}
{tooltip ? (
<Tooltip disableHoverableContent={!enableHoverableContent}>
<TooltipTrigger aria-label={typeof tooltip === "string" ? tooltip : undefined} asChild>
{Trigger}
</TooltipTrigger>
<TooltipPortal>
<TooltipContent className="flex items-center gap-1" side={tooltipSide ?? "bottom"}>
{tooltip}
{!!finalShortcut && (
<div className="ml-1">
<KbdCombined className="text-foreground/80">{finalShortcut}</KbdCombined>
</div>
)}
</TooltipContent>
</TooltipPortal>
<TooltipRoot defaultOpen={tooltipDefaultOpen} key={id}>
<TooltipTrigger
aria-label={typeof tooltip === "string" ? tooltip : undefined}
asChild
>
{Trigger}
</TooltipTrigger>
<TooltipPortal>
<TooltipContent className="flex items-center gap-1" side={tooltipSide ?? "bottom"}>
{tooltip}
{!!finalShortcut && (
<div className="ml-1">
<KbdCombined className="text-foreground/80">{finalShortcut}</KbdCombined>
</div>
)}
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</Tooltip>
) : (
Trigger

View File

@ -6,6 +6,7 @@ import * as React from "react"
import { tooltipStyle } from "./styles"
const TooltipProvider = TooltipPrimitive.Provider
const TooltipRoot = TooltipPrimitive.Root
const Tooltip: typeof TooltipProvider = ({ children, ...props }) => (
<TooltipProvider {...props}>
@ -45,6 +46,6 @@ const TooltipContent = React.forwardRef<
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipContent, TooltipTrigger }
export { Tooltip, TooltipContent, TooltipRoot, TooltipTrigger }
export { RootPortal as TooltipPortal } from "../portal"