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

378 lines
12 KiB
TypeScript

import {
Focusable,
useFocusable,
useFocusActions,
} from "@follow/components/common/Focusable/index.js"
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import type { FeedViewType } from "@follow/constants"
import { useTitle } from "@follow/hooks"
import type { FeedModel, InboxModel } from "@follow/models/types"
import { nextFrame, stopPropagation } from "@follow/utils/dom"
import { EventBus } from "@follow/utils/event-bus"
import { springScrollTo } from "@follow/utils/scroller"
import { cn, combineCleanupFunctions } from "@follow/utils/utils"
import { ErrorBoundary } from "@sentry/react"
import * as React from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import {
useEntryIsInReadability,
useEntryIsInReadabilitySuccess,
useEntryReadabilityContent,
} from "~/atoms/readability"
import { useUISettingKey } from "~/atoms/settings/ui"
import { ShadowDOM } from "~/components/common/ShadowDOM"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
import { useInPeekModal } from "~/components/ui/modal/inspire/InPeekModal"
import { HotkeyScope } from "~/constants"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useAuthQuery, useConditionalHotkeyScope } from "~/hooks/common"
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
import { useHotkeyScope } from "~/providers/hotkey-provider"
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 { COMMAND_ID } from "../command/commands/id"
import { useCommandBinding, useCommandHotkey } from "../command/hooks/use-register-hotkey"
import { EntryContentHTMLRenderer } from "../renderer/html"
import { AISummary } from "./AISummary"
import { EntryTimelineSidebar } from "./components/EntryTimelineSidebar"
import { EntryTitle } from "./components/EntryTitle"
import { SourceContentPanel } from "./components/SourceContentView"
import { SupportCreator } from "./components/SupportCreator"
import { EntryHeader } from "./header"
import type { EntryContentProps } from "./index.shared"
import {
ContainerToc,
NoContent,
ReadabilityAutoToggleEffect,
ReadabilityNotice,
RenderError,
TitleMetaHandler,
ViewSourceContentAutoToggleEffect,
} from "./index.shared"
import { EntryContentLoading } from "./loading"
export const EntryContent: Component<EntryContentProps> = ({
entryId,
noMedia,
className,
compact,
classNames,
}) => {
const entry = useEntry(entryId)
useTitle(entry?.entries.title)
const feed = useFeedById(entry?.feedId) as FeedModel | InboxModel
const inbox = useInboxById(entry?.inboxId, (inbox) => inbox !== null)
const { error, data, isPending } = useAuthQuery(
inbox ? Queries.entries.byInboxId(entryId) : Queries.entries.byId(entryId),
{
staleTime: 300_000,
},
)
const readabilityContent = useEntryReadabilityContent(entryId)
const view = useRouteParamsSelector((route) => route.view)
const isInReadabilityMode = useEntryIsInReadability(entryId)
const isReadabilitySuccess = useEntryIsInReadabilitySuccess(entryId)
const scrollerRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const scrollAndFocus = () => {
scrollerRef.current?.scrollTo(0, 0)
}
scrollAndFocus()
return combineCleanupFunctions(
EventBus.subscribe(COMMAND_ID.timeline.switchToNext, scrollAndFocus),
EventBus.subscribe(COMMAND_ID.timeline.switchToPrevious, scrollAndFocus),
)
}, [])
const safeUrl = useFeedSafeUrl(entryId)
const customCSS = useUISettingKey("customCSS")
const contentTranslated = useEntryTranslation({
entry,
extraFields: isReadabilitySuccess ? ["readabilityContent"] : ["content"],
})
const isInPeekModal = useInPeekModal()
const [isUserInteraction, setIsUserInteraction] = useState(false)
if (!entry) return null
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
return (
<>
{!isInPeekModal && (
<EntryHeader
entryId={entry.entries.id}
view={view}
className={cn("@container h-[55px] shrink-0 px-3", classNames?.header)}
compact={compact}
/>
)}
<Focusable
className="@container relative flex size-full flex-col overflow-hidden print:size-auto print:overflow-visible"
onFocus={() => setIsUserInteraction(true)}
>
<RegisterCommands
scrollerRef={scrollerRef}
isUserInteraction={isUserInteraction}
setIsUserInteraction={setIsUserInteraction}
/>
<EntryTimelineSidebar entryId={entry.entries.id} />
<EntryScrollArea className={className} scrollerRef={scrollerRef}>
<div
className="animate-in fade-in slide-in-from-bottom-24 f-motion-reduce:fade-in-0 f-motion-reduce:slide-in-from-bottom-0 select-text duration-200 ease-in-out"
key={entry.entries.id}
>
<article
tabIndex={-1}
onFocus={() => setIsUserInteraction(true)}
data-testid="entry-render"
onContextMenu={stopPropagation}
className="@[950px]:max-w-[70ch] @7xl:max-w-[80ch] relative m-auto min-w-0 max-w-[550px]"
>
<EntryTitle entryId={entryId} compact={compact} />
<WrappedElementProvider boundingDetection>
<div className="mx-auto mb-32 mt-8 max-w-full cursor-auto text-[0.94rem]">
<TitleMetaHandler entryId={entry.entries.id} />
<AISummary entryId={entry.entries.id} />
<ErrorBoundary fallback={RenderError}>
<ReadabilityNotice entryId={entryId} />
<ShadowDOM injectHostStyles={!isInbox}>
{!!customCSS && (
<MemoedDangerousHTMLStyle>{customCSS}</MemoedDangerousHTMLStyle>
)}
<Renderer
entryId={entryId}
view={view}
feedId={feed?.id}
noMedia={noMedia}
content={content}
/>
</ShadowDOM>
</ErrorBoundary>
</div>
</WrappedElementProvider>
{entry.settings?.readability && (
<ReadabilityAutoToggleEffect id={entry.entries.id} url={entry.entries.url ?? ""} />
)}
{entry.settings?.sourceContent && <ViewSourceContentAutoToggleEffect />}
{!content && !isInReadabilityMode && (
<div className="center mt-16 min-w-0">
{isPending ? (
<EntryContentLoading
icon={!isInbox ? (feed as FeedModel)?.siteUrl : undefined}
/>
) : error ? (
<div className="center mt-36 flex flex-col items-center gap-3">
<i className="i-mgc-warning-cute-re text-red text-4xl" />
<span className="text-balance text-center 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 ?? ""}
sourceContent={entry.settings?.sourceContent}
/>
)}
</div>
)}
<SupportCreator entryId={entryId} />
</article>
</div>
</EntryScrollArea>
<SourceContentPanel src={safeUrl ?? "#"} />
</Focusable>
</>
)
}
const EntryScrollArea: Component<{
scrollerRef: React.RefObject<HTMLDivElement | null>
}> = ({ children, className, scrollerRef }) => {
const isInPeekModal = useInPeekModal()
if (isInPeekModal) {
return <div className="p-5">{children}</div>
}
return (
<ScrollArea.ScrollArea
mask={false}
rootClassName={cn(
"h-0 min-w-0 grow overflow-y-auto print:h-auto print:overflow-visible",
className,
)}
scrollbarClassName="mr-[1.5px] print:hidden"
viewportClassName="p-5"
ref={scrollerRef}
>
{children}
</ScrollArea.ScrollArea>
)
}
const Renderer: React.FC<{
entryId: string
view: FeedViewType
feedId: string
noMedia?: boolean
content?: Nullable<string>
}> = React.memo(({ entryId, view, feedId, noMedia = false, content = "" }) => {
const mediaInfo = useEntry(entryId, (entry) =>
Object.fromEntries(
entry?.entries.media
?.filter((m) => m.type === "photo")
.map((cur) => [
cur.url,
{
width: cur.width,
height: cur.height,
},
]) ?? [],
),
)
const readerRenderInlineStyle = useUISettingKey("readerRenderInlineStyle")
const stableRenderStyle = useRenderStyle()
const isInPeekModal = useInPeekModal()
const tocRef = useRef<TocRef | null>(null)
const contentAccessories = useMemo(
() => (isInPeekModal ? undefined : <ContainerToc ref={tocRef} />),
[isInPeekModal],
)
useEffect(() => {
if (tocRef) {
tocRef.current?.refreshItems()
}
}, [content, tocRef])
return (
<EntryContentHTMLRenderer
view={view}
feedId={feedId}
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>
)
})
const RegisterCommands = ({
scrollerRef,
isUserInteraction,
setIsUserInteraction,
}: {
scrollerRef: React.RefObject<HTMLDivElement | null>
isUserInteraction: boolean
setIsUserInteraction: (isUserInteraction: boolean) => void
}) => {
const containerFocused = useFocusable()
useConditionalHotkeyScope(HotkeyScope.EntryRender, isUserInteraction && containerFocused, true)
const activeScope = useHotkeyScope()
const when = activeScope.includes(HotkeyScope.EntryRender)
useCommandBinding({
commandId: COMMAND_ID.entryRender.scrollUp,
when,
})
useCommandBinding({
commandId: COMMAND_ID.entryRender.scrollDown,
when,
})
useCommandBinding({
commandId: COMMAND_ID.entryRender.nextEntry,
when,
})
useCommandBinding({
commandId: COMMAND_ID.entryRender.previousEntry,
when,
})
useCommandHotkey({
commandId: COMMAND_ID.layout.focusToTimeline,
when,
shortcut: "Backspace",
})
const { highlightBoundary } = useFocusActions()
useEffect(() => {
return combineCleanupFunctions(
EventBus.subscribe(COMMAND_ID.entryRender.scrollUp, () => {
const currentScroll = scrollerRef.current?.scrollTop
const delta = window.innerHeight
if (typeof currentScroll === "number" && delta) {
springScrollTo(currentScroll - delta, scrollerRef.current!)
}
}),
EventBus.subscribe(COMMAND_ID.entryRender.scrollDown, () => {
const currentScroll = scrollerRef.current?.scrollTop
const delta = window.innerHeight
if (typeof currentScroll === "number" && delta) {
springScrollTo(currentScroll + delta, scrollerRef.current!)
}
}),
EventBus.subscribe(COMMAND_ID.timeline.enter, () => {
const $scroller = scrollerRef.current
if ($scroller) {
springScrollTo(0, $scroller)
$scroller.focus()
nextFrame(highlightBoundary)
setIsUserInteraction(true)
}
}),
)
}, [highlightBoundary, scrollerRef, setIsUserInteraction])
return null
}