Folo/apps/renderer/src/modules/entry-content/index.tsx

414 lines
13 KiB
TypeScript

import type { FallbackRender } from "@sentry/react"
import { ErrorBoundary } from "@sentry/react"
import type { FC } from "react"
import { memo, useEffect, useLayoutEffect, useMemo, useRef } from "react"
import { useHotkeys } from "react-hotkeys-hook"
import { useTranslation } from "react-i18next"
import {
ReadabilityStatus,
setReadabilityStatus,
useEntryInReadabilityStatus,
useEntryIsInReadability,
useEntryReadabilityContent,
} from "~/atoms/readability"
import { useUISettingKey } from "~/atoms/settings/ui"
import { m } from "~/components/common/Motion"
import { ShadowDOM } from "~/components/common/ShadowDOM"
import { AutoResizeHeight } from "~/components/ui/auto-resize-height"
import { HTML } from "~/components/ui/markdown"
import { Toc } from "~/components/ui/markdown/components/Toc"
import { useInPeekModal } from "~/components/ui/modal/inspire/PeekModal"
import { RootPortal } from "~/components/ui/portal"
import { ScrollArea } from "~/components/ui/scroll-area"
import { isWebBuild, ROUTE_FEED_PENDING } from "~/constants"
import { shortcuts } from "~/constants/shortcuts"
import { useEntryReadabilityToggle } from "~/hooks/biz/useEntryActions"
import { useRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useAuthQuery, useTitle } from "~/hooks/common"
import { stopPropagation } from "~/lib/dom"
import { FeedViewType } from "~/lib/enum"
import { getNewIssueUrl } from "~/lib/issues"
import { cn } from "~/lib/utils"
import type { ActiveEntryId, FeedModel } from "~/models"
import {
useIsSoFWrappedElement,
useWrappedElement,
WrappedElementProvider,
} from "~/providers/wrapped-element-provider"
import { Queries } from "~/queries"
import { useEntry } from "~/store/entry"
import { useFeedById, useFeedHeaderTitle } from "~/store/feed"
import { LoadingWithIcon } from "../../components/ui/loading"
import { EntryPlaceholderDaily } from "../ai/ai-daily/EntryPlaceholderDaily"
import { setEntryContentScrollToTop, setEntryTitleMeta } from "./atoms"
import { EntryPlaceholderLogo } from "./components/EntryPlaceholderLogo"
import { EntryTitle } from "./components/EntryTitle"
import { SupportCreator } from "./components/SupportCreator"
import { EntryHeader } from "./header"
import { EntryContentLoading } from "./loading"
import { EntryContentProvider } from "./provider"
export const EntryContent = ({
entryId,
noMedia,
compact,
}: {
entryId: ActiveEntryId
noMedia?: boolean
compact?: boolean
}) => {
const title = useFeedHeaderTitle()
const { feedId, view } = useRouteParams()
useTitle(title)
if (!entryId) {
return (
<m.div
className="center size-full flex-col"
initial={{ opacity: 0.01, y: 300 }}
animate={{ opacity: 1, y: 0 }}
>
<EntryPlaceholderLogo />
{feedId === ROUTE_FEED_PENDING && view === FeedViewType.Articles && (
<EntryPlaceholderDaily view={view} />
)}
</m.div>
)
}
return <EntryContentRender entryId={entryId} noMedia={noMedia} compact={compact} />
}
export const EntryContentRender: Component<{
entryId: string
noMedia?: boolean
compact?: boolean
}> = ({ entryId, noMedia, className, compact }) => {
const { t } = useTranslation()
const { error, data, isPending } = useAuthQuery(Queries.entries.byId(entryId), {
staleTime: 300_000,
})
const entry = useEntry(entryId)
useTitle(entry?.entries.title)
const feed = useFeedById(entry?.feedId) as FeedModel
const readerRenderInlineStyle = useUISettingKey("readerRenderInlineStyle")
const summary = useAuthQuery(
Queries.ai.summary({
entryId,
language: entry?.settings?.translation,
}),
{
enabled: !!entry?.settings?.summary,
refetchOnMount: false,
refetchOnWindowFocus: false,
meta: {
persist: true,
},
},
)
const readerFontFamily = useUISettingKey("readerFontFamily")
const view = useRouteParamsSelector((route) => route.view)
const isInReadabilityMode = useEntryIsInReadability(entryId)
const scrollerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
scrollerRef.current?.scrollTo(0, 0)
}, [entryId])
useHotkeys(shortcuts.entry.scrollDown.key, () => {
scrollerRef.current?.scrollBy(0, window.innerHeight / 2)
})
useHotkeys(shortcuts.entry.scrollUp.key, () => {
scrollerRef.current?.scrollBy(0, -window.innerHeight / 2)
})
const isPeekModal = useInPeekModal()
const contentAccessories = useMemo(
() => (isPeekModal ? undefined : <ContainerToc key={entryId} />),
[entryId, isPeekModal],
)
const stableRenderStyle = useMemo(
() =>
readerFontFamily
? {
fontFamily: readerFontFamily,
}
: undefined,
[readerFontFamily],
)
if (!entry) return null
const content = entry?.entries.content ?? data?.entries.content
return (
<EntryContentProvider
entryId={entry.entries.id}
feedId={entry.feedId}
audioSrc={entry.entries?.attachments?.[0].url}
view={view}
>
<EntryHeader
entryId={entry.entries.id}
view={0}
className="h-[55px] shrink-0 px-3 @container"
compact={compact}
/>
<ScrollArea.ScrollArea
mask={false}
rootClassName={cn("h-0 min-w-0 grow overflow-y-auto @container", className)}
scrollbarClassName="mr-[1.5px]"
viewportClassName="p-5"
ref={scrollerRef}
>
<div
style={stableRenderStyle}
className="duration-200 ease-in-out animate-in fade-in slide-in-from-bottom-24 f-motion-reduce:fade-in-0 f-motion-reduce:slide-in-from-bottom-0"
key={entry.entries.id}
>
<article
data-testid="entry-render"
onContextMenu={stopPropagation}
className="relative m-auto min-w-0 max-w-[550px] @3xl:max-w-[70ch]"
>
<EntryTitle entryId={entryId} compact={compact} />
<WrappedElementProvider boundingDetection>
<div className="mx-auto mb-32 mt-8 max-w-full cursor-auto select-text break-all text-[0.94rem]">
<TitleMetaHandler entryId={entry.entries.id} />
{(summary.isLoading || summary.data) && (
<div className="my-8 space-y-1 rounded-lg border px-4 py-3">
<div className="flex items-center gap-2 font-medium text-zinc-800 dark:text-neutral-400">
<i className="i-mgc-magic-2-cute-re align-middle" />
<span>{t("entry_content.ai_summary")}</span>
</div>
<AutoResizeHeight spring className="text-sm leading-relaxed">
{summary.isLoading ? SummaryLoadingSkeleton : summary.data}
</AutoResizeHeight>
</div>
)}
<ErrorBoundary fallback={RenderError}>
{!isInReadabilityMode ? (
<ShadowDOM>
<HTML
noMedia={noMedia}
accessory={contentAccessories}
as="article"
className="prose !max-w-full dark:prose-invert prose-h1:text-[1.6em]"
style={stableRenderStyle}
renderInlineStyle={readerRenderInlineStyle}
>
{content}
</HTML>
</ShadowDOM>
) : (
<ReadabilityContent entryId={entryId} />
)}
</ErrorBoundary>
</div>
</WrappedElementProvider>
{entry.settings?.readability && (
<ReadabilityAutoToggle id={entry.entries.id} url={entry.entries.url ?? ""} />
)}
{!content && (
<div className="center mt-16 min-w-0">
{isPending ? (
<EntryContentLoading icon={feed?.siteUrl!} />
) : error ? (
<div className="center flex min-w-0 flex-col gap-2">
<i className="i-mgc-close-cute-re text-3xl text-red-500" />
<span className="font-sans text-sm">Network Error</span>
<pre className="mt-6 w-full overflow-auto whitespace-pre-wrap break-all">
{error.message}
</pre>
</div>
) : (
<NoContent id={entry.entries.id} url={entry.entries.url ?? ""} />
)}
</div>
)}
{feed?.ownerUserId && <SupportCreator entryId={entryId} />}
</article>
</div>
</ScrollArea.ScrollArea>
</EntryContentProvider>
)
}
const SummaryLoadingSkeleton = (
<div className="space-y-2">
<span className="block h-3 w-full animate-pulse rounded-xl bg-zinc-200 dark:bg-neutral-800" />
<span className="block h-3 w-full animate-pulse rounded-xl bg-zinc-200 dark:bg-neutral-800" />
<span className="block h-3 w-full animate-pulse rounded-xl bg-zinc-200 dark:bg-neutral-800" />
</div>
)
const TitleMetaHandler: Component<{
entryId: string
}> = ({ entryId }) => {
const {
entries: { title: entryTitle },
feedId,
} = useEntry(entryId)!
const { title: feedTitle } = useFeedById(feedId)!
const atTop = useIsSoFWrappedElement()
useEffect(() => {
setEntryContentScrollToTop(true)
}, [entryId])
useLayoutEffect(() => {
setEntryContentScrollToTop(atTop)
}, [atTop])
useEffect(() => {
if (entryTitle && feedTitle) {
setEntryTitleMeta({ title: entryTitle, description: feedTitle })
}
return () => {
setEntryTitleMeta(null)
}
}, [entryId, entryTitle, feedTitle])
return null
}
const ReadabilityContent = ({ entryId }: { entryId: string }) => {
const { t } = useTranslation()
const result = useEntryReadabilityContent(entryId)
return (
<div className="grow">
{result ? (
<p className="rounded-xl border p-3 text-sm opacity-80">
<i className="i-mgc-information-cute-re mr-1 translate-y-[2px]" />
{t("entry_content.readability_notice")}
</p>
) : (
<div className="center mt-16 flex flex-col gap-2">
<LoadingWithIcon size="large" icon={<i className="i-mgc-sparkles-2-cute-re" />} />
<span className="text-sm">{t("entry_content.fetching_content")}</span>
</div>
)}
<HTML as="article" className="prose dark:prose-invert prose-h1:text-[1.6em]">
{result?.content ?? ""}
</HTML>
</div>
)
}
const NoContent: FC<{
id: string
url: string
}> = ({ id, url }) => {
const status = useEntryInReadabilityStatus(id)
const { t } = useTranslation("app")
if (status !== ReadabilityStatus.INITIAL && status !== ReadabilityStatus.FAILURE) {
return null
}
return (
<div className="center">
<div className="space-y-2 text-balance text-center text-sm text-zinc-400">
{(isWebBuild || status === ReadabilityStatus.FAILURE) && (
<span>{t("entry_content.no_content")}</span>
)}
{isWebBuild && (
<div>
<span>{t("entry_content.web_app_notice")}</span>
</div>
)}
{url && window.electron && <ReadabilityAutoToggle url={url} id={id} />}
</div>
</div>
)
}
const ReadabilityAutoToggle = ({ url, id }: { url: string; id: string }) => {
const toggle = useEntryReadabilityToggle({
id,
url,
})
const onceRef = useRef(false)
useEffect(() => {
if (!onceRef.current) {
onceRef.current = true
setReadabilityStatus({
[id]: ReadabilityStatus.INITIAL,
})
toggle()
}
}, [toggle])
return null
}
const RenderError: FallbackRender = ({ error }) => {
const { t } = useTranslation()
const nextError = typeof error === "string" ? new Error(error) : (error as Error)
return (
<div className="center mt-16 flex flex-col gap-2">
<i className="i-mgc-close-cute-re text-3xl text-red-500" />
<span className="font-sans text-sm">
{t("entry_content.render_error")} {nextError.message}
</span>
<a
href={getNewIssueUrl({
body: [
"### Error",
"",
nextError.message,
"",
"### Stack",
"",
"```",
nextError.stack,
"```",
].join("\n"),
label: "bug",
title: "Render error",
})}
target="_blank"
rel="noreferrer"
>
{t("entry_content.report_issue")}
</a>
</div>
)
}
const ContainerToc: FC = memo(() => {
const wrappedElement = useWrappedElement()
return (
<RootPortal to={wrappedElement!}>
<div className="group absolute right-[-130px] top-0 h-full w-[100px]">
<div className="sticky top-0">
<Toc
className={cn(
"flex flex-col items-end animate-in fade-in-0 slide-in-from-bottom-12 easing-spring spring-soft",
"max-h-[calc(100vh-100px)] overflow-auto scrollbar-none",
"@[500px]:-translate-x-12",
"@[700px]:-translate-x-12 @[800px]:-translate-x-16 @[900px]:translate-x-0 @[900px]:items-start",
)}
/>
</div>
</div>
</RootPortal>
)
})