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:
parent
248369afe6
commit
8ba5822a29
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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": "正在刷新",
|
||||
|
|
|
|||
Loading…
Reference in New Issue