feat: view source content in app (#655)

* feat: support source content view

* refactor: use context menu for toggling source content view

* refactor: update source content view entry

* refactor: show source content in modal in social media and video

* refactor: add SourceContentView component for displaying external content

* feat: add banner for unsupported websites in SourceContentView

* refactor: replace iframe with SourceContentView in source content modal

* chore: clean code

* chore: update i18n

* fix: overflow in image view

* chore: add fi icon

Signed-off-by: Innei <i@innei.in>

* fix: improve loading stability  in SourceContentView

---------

Signed-off-by: Innei <i@innei.in>
Co-authored-by: Innei <i@innei.in>
This commit is contained in:
Whitewater 2024-09-29 16:57:36 +08:00 committed by GitHub
parent 248369afe6
commit 8ba5822a29
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 250 additions and 76 deletions

View File

@ -0,0 +1,31 @@
import { atom } from "jotai"
import { useCallback } from "react"
import { useModalStack } from "~/components/ui/modal"
import { createAtomHooks } from "~/lib/jotai"
import { SourceContentView } from "~/modules/entry-content/components/SourceContentView"
export const [, , useShowSourceContent, , getShowSourceContent, setShowSourceContent] =
createAtomHooks(atom<boolean>(false))
export const toggleShowSourceContent = () => setShowSourceContent(!getShowSourceContent())
export const resetShowSourceContent = () => setShowSourceContent(false)
export const useSourceContentModal = () => {
const { present } = useModalStack()
return useCallback(
({ title, src }: { title?: string; src: string }) => {
present({
id: src,
title,
content: () => <SourceContentView src={src} />,
resizeable: true,
clickOutsideToDismiss: true,
// The number was picked arbitrarily
resizeDefaultSize: { width: 900, height: 700 },
})
},
[present],
)
}

View File

@ -13,6 +13,12 @@ import {
setReadabilityStatus,
} from "~/atoms/readability"
import { useIntegrationSettingKey } from "~/atoms/settings/integration"
import {
setShowSourceContent,
toggleShowSourceContent,
useShowSourceContent,
useSourceContentModal,
} from "~/atoms/source-content"
import { whoami } from "~/atoms/user"
import { mountLottie } from "~/components/ui/lottie-container"
import {
@ -23,6 +29,7 @@ import {
import { shortcuts } from "~/constants/shortcuts"
import { tipcClient } from "~/lib/client"
import { nextFrame } from "~/lib/dom"
import { FeedViewType } from "~/lib/enum"
import { getOS } from "~/lib/utils"
import StarAnimationUri from "~/lottie/star.lottie?url"
import type { CombinedEntryModel } from "~/models"
@ -31,6 +38,8 @@ import type { FlatEntryModel } from "~/store/entry"
import { entryActions } from "~/store/entry"
import { useFeedById } from "~/store/feed"
import { navigateEntry } from "./useNavigateEntry"
const absoluteStarAnimationUri = new URL(StarAnimationUri, import.meta.url).href
export const useEntryReadabilityToggle = ({ id, url }: { id: string; url: string }) =>
@ -148,6 +157,9 @@ export const useEntryActions = ({
entryId: populatedEntry?.entries.id ?? undefined,
})
const showSourceContent = useShowSourceContent()
const showSourceContentModal = useSourceContentModal()
const collect = useCollect(populatedEntry)
const uncollect = useUnCollect(populatedEntry)
const read = useRead()
@ -344,12 +356,36 @@ export const useEntryActions = ({
}),
shortcut: shortcuts.entry.openInBrowser.key,
className: "i-mgc-world-2-cute-re",
hide: !populatedEntry.entries.url,
hide: type === "toolbar" || !populatedEntry.entries.url,
onClick: () => {
if (!populatedEntry.entries.url) return
window.open(populatedEntry.entries.url, "_blank")
},
},
{
key: "viewSourceContent",
name: t("entry_actions.view_source_content"),
// shortcut: shortcuts.entry.openInBrowser.key,
className: !showSourceContent ? "i-mgc-world-2-cute-re" : tw`i-mgc-world-2-cute-fi`,
hide: !populatedEntry.entries.url,
active: showSourceContent,
onClick: () => {
if (!populatedEntry.entries.url) return
if (view === FeedViewType.SocialMedia || view === FeedViewType.Videos) {
showSourceContentModal({
title: populatedEntry.entries.title ?? undefined,
src: populatedEntry.entries.url,
})
return
}
if (type === "toolbar") {
toggleShowSourceContent()
return
}
navigateEntry({ entryId: populatedEntry.entries.id })
setShowSourceContent(true)
},
},
{
name: t("entry_actions.share"),
key: "share",
@ -406,9 +442,12 @@ export const useEntryActions = ({
instapaperPassword,
instapaperUsername,
feed?.ownerUserId,
type,
showSourceContent,
openTipModal,
collect,
uncollect,
showSourceContentModal,
read,
unread,
])

View File

@ -2,6 +2,7 @@ import { isUndefined } from "lodash-es"
import { getReadonlyRoute, getStableRouterNavigate } from "~/atoms/route"
import { setSidebarActiveView } from "~/atoms/sidebar"
import { resetShowSourceContent } from "~/atoms/source-content"
import { ROUTE_ENTRY_PENDING, ROUTE_FEED_IN_FOLDER, ROUTE_FEED_PENDING } from "~/constants"
import { FeedViewType } from "~/lib/enum"
@ -37,6 +38,7 @@ export const navigateEntry = (options: NavigateEntryOptions) => {
nextSearchParams.set("view", view.toString())
setSidebarActiveView(view)
}
resetShowSourceContent()
const finalView = nextSearchParams.get("view")

View File

@ -0,0 +1,95 @@
import { AnimatePresence } from "framer-motion"
import { useEffect, useRef, useState } from "react"
import { useShowSourceContent } from "~/atoms/source-content"
import { m } from "~/components/common/Motion"
import { softSpringPreset } from "~/components/ui/constants/spring"
import { EntryContentLoading } from "../loading"
const ViewTag = window.electron ? "webview" : "iframe"
const variants = {
hidden: { x: "100%" },
visible: { x: 0 },
exit: { x: "100%" },
}
const Banner = () => {
return (
<div className="z-50 w-full bg-yellow-600 p-3 text-white">
<div className="text-center">
<p>Some websites can't be displayed here. Download desktop app to view it.</p>
</div>
</div>
)
}
export const SourceContentView = ({ src }: { src: string }) => {
const showSourceContent = useShowSourceContent()
const [loading, setLoading] = useState(true)
const webviewRef = useRef<HTMLIFrameElement | null>(null)
useEffect(() => {
const abortController = new AbortController()
const webview = webviewRef.current
if (!webview) return
const handleDidStopLoading = () => setLoading(false)
// See https://www.electronjs.org/docs/latest/api/webview-tag#example
webview.addEventListener("did-start-loading", handleDidStopLoading, {
signal: abortController.signal,
})
return () => {
abortController.abort()
}
}, [src, showSourceContent])
return (
<>
{!window.electron && <Banner />}
<div className="relative flex size-full flex-col">
{loading && (
<div className="center mt-16 min-w-0">
<EntryContentLoading icon={src} />
</div>
)}
<m.div
className="size-full"
initial={{ opacity: 0 }}
animate={{ opacity: loading ? 0 : 1 }}
transition={softSpringPreset}
>
<ViewTag
ref={webviewRef}
className="size-full"
src={src}
sandbox="allow-scripts allow-same-origin"
// For iframe
onLoad={() => setLoading(false)}
/>
</m.div>
</div>
</>
)
}
export const SourceContentPanel = ({ src }: { src: string | null }) => {
const showSourceContent = useShowSourceContent()
return (
<AnimatePresence>
{showSourceContent && src && (
<m.div
className="absolute left-0 top-0 z-[1] size-full bg-theme-background"
initial="hidden"
animate="visible"
exit="exit"
variants={variants}
transition={softSpringPreset}
>
<SourceContentView src={src} />
</m.div>
)}
</AnimatePresence>
)
}

View File

@ -45,6 +45,7 @@ import { EntryPlaceholderDaily } from "../ai/ai-daily/EntryPlaceholderDaily"
import { setEntryContentScrollToTop, setEntryTitleMeta } from "./atoms"
import { EntryPlaceholderLogo } from "./components/EntryPlaceholderLogo"
import { EntryTitle } from "./components/EntryTitle"
import { SourceContentPanel } from "./components/SourceContentView"
import { SupportCreator } from "./components/SupportCreator"
import { EntryHeader } from "./header"
import { EntryContentLoading } from "./loading"
@ -174,88 +175,91 @@ export const EntryContentRender: Component<{
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}
<div className="relative flex size-full flex-col overflow-hidden">
<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}
>
<article
data-testid="entry-render"
onContextMenu={stopPropagation}
className="relative m-auto min-w-0 max-w-[550px] @3xl:max-w-[70ch]"
<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}
>
<EntryTitle entryId={entryId} compact={compact} />
<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>
<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>
<AutoResizeHeight spring className="text-sm leading-relaxed">
{summary.isLoading ? SummaryLoadingSkeleton : summary.data}
</AutoResizeHeight>
</div>
)}
<ErrorBoundary fallback={RenderError}>
{!isInReadabilityMode ? (
<ShadowDOM>
<HTML
mediaInfo={mediaInfo}
noMedia={noMedia}
accessory={contentAccessories}
as="article"
className="prose !max-w-full dark:prose-invert prose-h1:text-[1.6em] prose-h1:font-bold"
style={stableRenderStyle}
renderInlineStyle={readerRenderInlineStyle}
>
{content}
</HTML>
</ShadowDOM>
) : (
<ReadabilityContent entryId={entryId} />
)}
</ErrorBoundary>
</div>
</WrappedElementProvider>
<ErrorBoundary fallback={RenderError}>
{!isInReadabilityMode ? (
<ShadowDOM>
<HTML
mediaInfo={mediaInfo}
noMedia={noMedia}
accessory={contentAccessories}
as="article"
className="prose !max-w-full dark:prose-invert prose-h1:text-[1.6em] prose-h1:font-bold"
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 ?? ""} />
)}
{entry.settings?.readability && (
<ReadabilityAutoToggleEffect 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>
{!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>
)}
<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>
{feed?.ownerUserId && <SupportCreator entryId={entryId} />}
</article>
</div>
</ScrollArea.ScrollArea>
<SourceContentPanel src={entry.entries.url} />
</div>
</EntryContentProvider>
)
}
@ -346,13 +350,13 @@ const NoContent: FC<{
<span>{t("entry_content.web_app_notice")}</span>
</div>
)}
{url && window.electron && <ReadabilityAutoToggle url={url} id={id} />}
{url && window.electron && <ReadabilityAutoToggleEffect url={url} id={id} />}
</div>
</div>
)
}
const ReadabilityAutoToggle = ({ url, id }: { url: string; id: string }) => {
const ReadabilityAutoToggleEffect = ({ url, id }: { url: string; id: string }) => {
const toggle = useEntryReadabilityToggle({
id,
url,

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path fill="#10161F" fill-rule="evenodd" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10M7.995 10.957l-.16-.049A1.887 1.887 0 0 1 6.5 9.104v-2.59a2 2 0 0 1 .031-.353A7.972 7.972 0 0 1 12 4c.484 0 .957.043 1.418.125a1.664 1.664 0 0 1-.97 2.615l-.81.185a1.784 1.784 0 0 0-1.362 2.033 1.784 1.784 0 0 1-2.281 2m6.322 7.154a1.8 1.8 0 0 0 1.017 1.163 8.021 8.021 0 0 0 2.533-1.835 2.234 2.234 0 0 0-.006-.051l-.228-1.826a2 2 0 0 0-1.09-1.54l-1-.5a1.822 1.822 0 0 0-2.104 2.917l.194.195a2 2 0 0 1 .51.864z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 686 B

View File

@ -48,6 +48,7 @@
"entry_actions.tip": "Tip",
"entry_actions.unstar": "Unstar",
"entry_actions.unstarred": "Unstarred.",
"entry_actions.view_source_content": "View source content",
"entry_column.filtered_content_tip": "You have filtered content hidden.",
"entry_column.filtered_content_tip_2": "In addition to the entries shown above, there is also filtered content.",
"entry_column.refreshing": "Refreshing new entries...",

View File

@ -48,6 +48,7 @@
"entry_actions.tip": "打赏",
"entry_actions.unstar": "取消收藏",
"entry_actions.unstarred": "取消收藏",
"entry_actions.view_source_content": "查看原文",
"entry_column.filtered_content_tip": "部分内容已被过滤隐藏",
"entry_column.filtered_content_tip_2": "除了上面显示的内容外,还有一些被过滤的内容",
"entry_column.refreshing": "正在刷新",