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

307 lines
9.4 KiB
TypeScript

import { getViewport } from "@follow/components/hooks/useViewport.js"
import { CircleProgress } from "@follow/components/icons/Progress.js"
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 { WEB_BUILD } from "@follow/shared/constants"
import { springScrollTo } from "@follow/utils/scroller"
import { cn } from "@follow/utils/utils"
import type { FallbackRender } from "@sentry/react"
import { useStore } from "jotai"
import type { FC } from "react"
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import {
ReadabilityStatus,
setReadabilityStatus,
useEntryInReadabilityStatus,
useEntryIsInReadability,
useEntryReadabilityContent,
} from "~/atoms/readability"
import { enableShowSourceContent } from "~/atoms/source-content"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
import { Toc } from "~/components/ui/markdown/components/Toc"
import { enableEntryReadability } from "~/hooks/biz/useEntryActions"
import { getNewIssueUrl } from "~/lib/issues"
import {
useIsSoFWrappedElement,
useWrappedElement,
useWrappedElementSize,
} from "~/providers/wrapped-element-provider"
import { useEntry } from "~/store/entry"
import { useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
import { setEntryContentScrollToTop, setEntryTitleMeta } from "./atoms"
export interface EntryContentProps {
entryId: string
noMedia?: boolean
compact?: boolean
classNames?: EntryContentClassNames
}
export interface EntryContentClassNames {
header?: string
}
export const TitleMetaHandler: Component<{
entryId: string
}> = ({ entryId }) => {
const entry = useEntry(entryId, (state) => {
const { feedId, inboxId } = state
const { title } = state.entries
return { feedId, inboxId, title }
})
const feed = useFeedById(entry?.feedId)
const inbox = useInboxById(entry?.inboxId)
const feedTitle = feed?.title || inbox?.title
const atTop = useIsSoFWrappedElement()
useEffect(() => {
setEntryContentScrollToTop(true)
}, [entryId])
useLayoutEffect(() => {
setEntryContentScrollToTop(atTop)
}, [atTop])
useEffect(() => {
if (entry?.title && feedTitle) {
setEntryTitleMeta({ title: entry.title, description: feedTitle })
}
return () => {
setEntryTitleMeta(null)
}
}, [entryId, entry?.title, feedTitle])
return null
}
export const ReadabilityNotice = ({ entryId }: { entryId: string }) => {
const { t } = useTranslation()
const { t: T } = useTranslation("common")
const result = useEntryReadabilityContent(entryId)
const isInReadability = useEntryIsInReadability(entryId)
const status = useEntryInReadabilityStatus(entryId)
if (!isInReadability) {
return null
}
return (
<div className="grow">
{result ? (
<p className="mb-4 flex items-center gap-2 rounded-lg border border-blue-100 bg-blue-50/30 p-3 text-sm text-blue-700 shadow-sm dark:border-blue-800/30 dark:bg-blue-900/10 dark:text-blue-300">
<i className="i-mgc-information-cute-re self-baseline text-lg" />
{t("entry_content.readability_notice")}
</p>
) : (
<>
{status === ReadabilityStatus.FAILURE ? (
<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">
{t("entry_content.fetching_content_failed")}
</span>
<Button
variant="outline"
onClick={() => {
setReadabilityStatus({
[entryId]: ReadabilityStatus.INITIAL,
})
}}
>
{T("words.back")}
</Button>
</div>
) : (
<div className="center mt-32 flex flex-col gap-2">
<LoadingWithIcon size="large" icon={<i className="i-mgc-docment-cute-re" />} />
<span className="text-sm">{t("entry_content.fetching_content")}</span>
</div>
)}
</>
)}
</div>
)
}
export const NoContent: FC<{
id: string
url: string
sourceContent?: boolean
}> = ({ id, url, sourceContent }) => {
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">
{(WEB_BUILD || status === ReadabilityStatus.FAILURE) && (
<span>{t("entry_content.no_content")}</span>
)}
{!sourceContent && url && <ReadabilityAutoToggleEffect url={url} id={id} />}
</div>
</div>
)
}
export const ViewSourceContentAutoToggleEffect = () => {
const onceRef = useRef(false)
useEffect(() => {
if (!onceRef.current) {
onceRef.current = true
enableShowSourceContent()
}
}, [])
return null
}
export const ReadabilityAutoToggleEffect = ({ url, id }: { url: string; id: string }) => {
useEffect(() => {
enableEntryReadability({ id, url })
}, [id, url])
return null
}
export 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-red text-3xl" />
<span className="font-sans text-sm">
{t("entry_content.render_error")} {nextError.message}
</span>
<Button
variant={"outline"}
onClick={() => {
window.open(
getNewIssueUrl({
// body: [
// "### Error",
// "",
// nextError.message,
// "",
// "### Stack",
// "",
// "```",
// nextError.stack,
// "```",
// ].join("\n"),
// label: "bug",
// title: "Render error",
template: "bug_report.yml",
}),
)
}}
>
{t("entry_content.report_issue")}
</Button>
</div>
)
}
const useReadPercent = () => {
const y = 55
const { h } = useWrappedElementSize()
const scrollElement = useScrollViewElement()
const [scrollTop, setScrollTop] = useState(0)
useEffect(() => {
const handler = () => {
if (scrollElement) {
setScrollTop(scrollElement.scrollTop)
}
}
handler()
scrollElement?.addEventListener("scroll", handler)
return () => {
scrollElement?.removeEventListener("scroll", handler)
}
}, [scrollElement])
const store = useStore()
const readPercent = useMemo(() => {
const winHeight = getViewport(store).h
const deltaHeight = Math.min(scrollTop, winHeight)
return Math.floor(Math.min(Math.max(0, ((scrollTop - y + deltaHeight) / h) * 100), 100)) || 0
}, [store, scrollTop, h])
return [readPercent, scrollTop]
}
export const ContainerToc = memo(
({ ref, ..._ }: ComponentType & { ref?: React.Ref<TocRef | null> }) => {
const wrappedElement = useWrappedElement()
return (
<RootPortal to={wrappedElement!}>
<div
className="@[770px]:block group absolute right-[-130px] top-0 hidden h-full w-[100px]"
data-hide-in-print
>
<div className="sticky top-0">
<Toc
ref={ref}
className={cn(
"animate-in fade-in-0 slide-in-from-bottom-12 easing-spring spring-soft flex flex-col items-end",
"scrollbar-none max-h-[calc(100vh-100px)] overflow-auto",
"@[700px]:-translate-x-12 @[800px]:-translate-x-16 @[900px]:translate-x-0 @[900px]:items-start",
)}
/>
<BackTopIndicator
className={
"@[700px]:-translate-x-4 @[800px]:-translate-x-8 @[900px]:translate-x-0 @[900px]:items-start"
}
/>
</div>
</div>
</RootPortal>
)
},
)
const BackTopIndicator: Component = memo(({ className }) => {
const [readPercent] = useReadPercent()
const scrollElement = useScrollViewElement()
if (readPercent === 0) return
return (
<span
className={cn(
"mt-2 flex grow flex-col px-2 font-sans text-sm text-gray-800 dark:text-neutral-300",
className,
)}
>
<div className="flex items-center gap-2 tabular-nums">
<CircleProgress percent={readPercent!} size={14} strokeWidth={2} />
<span>{readPercent}%</span>
<br />
</div>
<MotionButtonBase
onClick={() => {
springScrollTo(0, scrollElement!)
}}
className={cn(
"mt-1 flex flex-nowrap items-center gap-2 opacity-50 transition-all duration-500 hover:opacity-100",
readPercent! > 10 ? "" : "pointer-events-none opacity-0",
)}
>
<i className="i-mingcute-arrow-up-circle-line" />
<span className="whitespace-nowrap">Back Top</span>
</MotionButtonBase>
</span>
)
})