diff --git a/apps/renderer/src/atoms/source-content.tsx b/apps/renderer/src/atoms/source-content.tsx new file mode 100644 index 000000000..e89de49b4 --- /dev/null +++ b/apps/renderer/src/atoms/source-content.tsx @@ -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(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: () => , + resizeable: true, + clickOutsideToDismiss: true, + // The number was picked arbitrarily + resizeDefaultSize: { width: 900, height: 700 }, + }) + }, + [present], + ) +} diff --git a/apps/renderer/src/hooks/biz/useEntryActions.tsx b/apps/renderer/src/hooks/biz/useEntryActions.tsx index ad1eda01c..52897d4ff 100644 --- a/apps/renderer/src/hooks/biz/useEntryActions.tsx +++ b/apps/renderer/src/hooks/biz/useEntryActions.tsx @@ -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, ]) diff --git a/apps/renderer/src/hooks/biz/useNavigateEntry.ts b/apps/renderer/src/hooks/biz/useNavigateEntry.ts index cd7f55f66..694ea88bf 100644 --- a/apps/renderer/src/hooks/biz/useNavigateEntry.ts +++ b/apps/renderer/src/hooks/biz/useNavigateEntry.ts @@ -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") diff --git a/apps/renderer/src/modules/entry-content/components/SourceContentView.tsx b/apps/renderer/src/modules/entry-content/components/SourceContentView.tsx new file mode 100644 index 000000000..fb10c85ad --- /dev/null +++ b/apps/renderer/src/modules/entry-content/components/SourceContentView.tsx @@ -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 ( +
+
+

Some websites can't be displayed here. Download desktop app to view it.

+
+
+ ) +} + +export const SourceContentView = ({ src }: { src: string }) => { + const showSourceContent = useShowSourceContent() + const [loading, setLoading] = useState(true) + const webviewRef = useRef(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 && } +
+ {loading && ( +
+ +
+ )} + + setLoading(false)} + /> + +
+ + ) +} + +export const SourceContentPanel = ({ src }: { src: string | null }) => { + const showSourceContent = useShowSourceContent() + return ( + + {showSourceContent && src && ( + + + + )} + + ) +} diff --git a/apps/renderer/src/modules/entry-content/index.tsx b/apps/renderer/src/modules/entry-content/index.tsx index 986967ab5..a2854264b 100644 --- a/apps/renderer/src/modules/entry-content/index.tsx +++ b/apps/renderer/src/modules/entry-content/index.tsx @@ -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} /> - -
+ -
- +
+ - -
- - {(summary.isLoading || summary.data) && ( -
-
- - {t("entry_content.ai_summary")} + +
+ + {(summary.isLoading || summary.data) && ( +
+
+ + {t("entry_content.ai_summary")} +
+ + {summary.isLoading ? SummaryLoadingSkeleton : summary.data} +
- - {summary.isLoading ? SummaryLoadingSkeleton : summary.data} - -
- )} - - {!isInReadabilityMode ? ( - - - {content} - - - ) : ( - )} - -
- + + {!isInReadabilityMode ? ( + + + {content} + + + ) : ( + + )} + +
+ - {entry.settings?.readability && ( - - )} + {entry.settings?.readability && ( + + )} - {!content && ( -
- {isPending ? ( - - ) : error ? ( -
- - Network Error + {!content && ( +
+ {isPending ? ( + + ) : error ? ( +
+ + Network Error -
-                      {error.message}
-                    
-
- ) : ( - - )} -
- )} +
+                        {error.message}
+                      
+
+ ) : ( + + )} +
+ )} - {feed?.ownerUserId && } -
-
-
+ {feed?.ownerUserId && } + + + + + ) } @@ -346,13 +350,13 @@ const NoContent: FC<{ {t("entry_content.web_app_notice")} )} - {url && window.electron && } + {url && window.electron && } ) } -const ReadabilityAutoToggle = ({ url, id }: { url: string; id: string }) => { +const ReadabilityAutoToggleEffect = ({ url, id }: { url: string; id: string }) => { const toggle = useEntryReadabilityToggle({ id, url, diff --git a/icons/mgc/world_2_cute_fi.svg b/icons/mgc/world_2_cute_fi.svg new file mode 100644 index 000000000..242779d70 --- /dev/null +++ b/icons/mgc/world_2_cute_fi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/locales/app/en.json b/locales/app/en.json index b9b289aa0..0fb119b6e 100644 --- a/locales/app/en.json +++ b/locales/app/en.json @@ -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...", diff --git a/locales/app/zh-CN.json b/locales/app/zh-CN.json index 60c090c08..12f0484d7 100644 --- a/locales/app/zh-CN.json +++ b/locales/app/zh-CN.json @@ -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": "正在刷新",