feat(desktop): resolve url with site url to make sure url is valid (#3511)

* feat(desktop): enhance entry actions and URL resolution with siteUrl support

* update

* update
This commit is contained in:
Konv Suu 2025-04-21 12:47:18 +08:00 committed by GitHub
parent fd74a0ed7e
commit 2bbb00c9fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 93 additions and 67 deletions

View File

@ -141,6 +141,7 @@ export const useEntryActions = ({
type: feed.type,
ownerUserId: feed.ownerUserId,
id: feed.id,
siteUrl: feed.siteUrl,
}
})
const listId = useRouteParamsSelector((s) => s.listId)
@ -242,7 +243,9 @@ export const useEntryActions = ({
}),
new EntryActionMenuItem({
id: COMMAND_ID.entry.viewSourceContent,
onClick: runCmdFn(COMMAND_ID.entry.viewSourceContent, [{ entryId }]),
onClick: runCmdFn(COMMAND_ID.entry.viewSourceContent, [
{ entryId, siteUrl: feed?.siteUrl },
]),
hide: isMobile() || !entry?.entries.url,
active: isShowSourceContent,
entryId,
@ -343,6 +346,7 @@ export const useEntryActions = ({
isEntryInReadability,
feed?.id,
feed?.ownerUserId,
feed?.siteUrl,
hasEntry,
imageLength,
inList,

View File

@ -0,0 +1,28 @@
import { resolveUrlWithBase } from "@follow/utils/utils"
import { useMemo } from "react"
import { useEntry } from "~/store/entry"
import { useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
export const useFeedSafeUrl = (entryId: string) => {
const entry = useEntry(entryId)
const feed = useFeedById(entry?.feedId)
const inbox = useInboxById(entry?.inboxId, (inbox) => inbox !== null)
return useMemo(() => {
if (inbox) return entry?.entries.authorUrl
const href = entry?.entries.url
if (!href) return "#"
if (href.startsWith("http")) {
const domain = new URL(href).hostname
if (domain === "localhost") return "#"
return href
}
const feedSiteUrl = feed?.type === "feed" ? feed.siteUrl : null
if (feedSiteUrl) return resolveUrlWithBase(href, feedSiteUrl)
return href
}, [entry?.entries.authorUrl, entry?.entries.url, feed?.type, inbox])
}

View File

@ -1,6 +1,6 @@
import { FeedViewType, UserRole } from "@follow/constants"
import { IN_ELECTRON } from "@follow/shared/constants"
import { cn } from "@follow/utils/utils"
import { cn, resolveUrlWithBase } from "@follow/utils/utils"
import { useMutation } from "@tanstack/react-query"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@ -234,7 +234,7 @@ export const useRegisterEntryCommands = () => {
id: COMMAND_ID.entry.viewSourceContent,
label: t("entry_actions.view_source_content"),
icon: <i className="i-mgc-web-cute-re" />,
run: ({ entryId }) => {
run: ({ entryId, siteUrl }) => {
if (!getShowSourceContent()) {
const entry = useEntryStore.getState().flatMapEntries[entryId]
if (!entry || !entry.entries.url) {
@ -250,7 +250,7 @@ export const useRegisterEntryCommands = () => {
if (viewPreviewInModal) {
showSourceContentModal({
title: entry.entries.title ?? undefined,
src: entry.entries.url,
src: siteUrl ? resolveUrlWithBase(entry?.entries.url, siteUrl) : entry?.entries.url,
})
return
}

View File

@ -42,7 +42,7 @@ export type OpenInBrowserCommand = Command<{
export type ViewSourceContentCommand = Command<{
id: typeof COMMAND_ID.entry.viewSourceContent
fn: (data: { entryId: string }) => void
fn: (data: { entryId: string; siteUrl?: string | null | undefined }) => void
}>
export type ShareCommand = Command<{

View File

@ -30,6 +30,7 @@ import { Media } from "~/components/ui/media"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useFollow } from "~/hooks/biz/useFollow"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
import { apiClient } from "~/lib/api-fetch"
import { UrlBuilder } from "~/lib/url-builder"
@ -365,42 +366,9 @@ const SearchCard: FC<{
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{item.entries
.filter((e) => !!e)
.map((entry) => {
const assertEntry = entry
return (
<a
key={assertEntry.id}
href={assertEntry.url || void 0}
target="_blank"
className="group relative flex flex-col overflow-hidden rounded-lg bg-zinc-50/50 shadow-zinc-100 transition-all duration-200 hover:-translate-y-px hover:shadow-md dark:bg-zinc-800/50 dark:shadow-neutral-700/50"
rel="noreferrer"
>
<div className="aspect-[3/2] w-full overflow-hidden">
<FeedCardMediaThumbnail entry={assertEntry} />
</div>
<div className="flex flex-1 flex-col justify-between p-3">
{assertEntry.title ? (
<div className="line-clamp-2 text-xs font-medium leading-4 text-zinc-900 group-hover:text-black dark:text-zinc-200 dark:group-hover:text-white">
{assertEntry.title}
</div>
) : (
<div className="flex items-center gap-1 text-xs text-zinc-500 dark:text-zinc-400">
<i className="i-mgc-link-cute-re shrink-0 translate-y-px self-start text-[14px]" />
<span className="line-clamp-2 break-all">
{assertEntry.url || "Untitled"}
</span>
</div>
)}
<div className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
<RelativeTime
date={assertEntry.publishedAt}
displayAbsoluteTimeAfterDay={Infinity}
/>
</div>
</div>
</a>
)
})}
.map((entry) => (
<SearchResultContent key={entry.id} entry={entry} />
))}
</div>
)}
</CardContent>
@ -516,3 +484,37 @@ const FeedCardMediaThumbnail: FC<{
</div>
)
}
const SearchResultContent: FC<{
entry: NonUndefined<DiscoverSearchData[number]["entries"]>[number]
}> = memo(({ entry }) => {
const safeUrl = useFeedSafeUrl(entry.id)
return (
<a
key={entry.id}
href={safeUrl ?? "#"}
target="_blank"
className="group relative flex flex-col overflow-hidden rounded-lg bg-zinc-50/50 shadow-zinc-100 transition-all duration-200 hover:-translate-y-px hover:shadow-md dark:bg-zinc-800/50 dark:shadow-neutral-700/50"
rel="noreferrer"
>
<div className="aspect-[3/2] w-full overflow-hidden">
<FeedCardMediaThumbnail entry={entry} />
</div>
<div className="flex flex-1 flex-col justify-between p-3">
{entry.title ? (
<div className="line-clamp-2 text-xs font-medium leading-4 text-zinc-900 group-hover:text-black dark:text-zinc-200 dark:group-hover:text-white">
{entry.title}
</div>
) : (
<div className="flex items-center gap-1 text-xs text-zinc-500 dark:text-zinc-400">
<i className="i-mgc-link-cute-re shrink-0 translate-y-px self-start text-[14px]" />
<span className="line-clamp-2 break-all">{entry.url || "Untitled"}</span>
</div>
)}
<div className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
<RelativeTime date={entry.publishedAt} displayAbsoluteTimeAfterDay={Infinity} />
</div>
</div>
</a>
)
})

View File

@ -5,6 +5,7 @@ import { useUISettingKey } from "~/atoms/settings/ui"
import { useWhoami } from "~/atoms/user"
import { RelativeTime } from "~/components/ui/datetime"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { useEntryTranslation } from "~/store/ai/hook"
import { useEntry, useEntryReadHistory } from "~/store/entry"
@ -18,37 +19,13 @@ interface EntryLinkProps {
compact?: boolean
}
const safeUrl = (url: string, baseUrl: string) => {
try {
return new URL(url, baseUrl).href
} catch {
return url
}
}
export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
const user = useWhoami()
const entry = useEntry(entryId)
const feed = useFeedById(entry?.feedId)
const inbox = useInboxById(entry?.inboxId)
const entryHistory = useEntryReadHistory(entryId)
const populatedFullHref = useMemo(() => {
if (inbox) return entry?.entries.authorUrl
const href = entry?.entries.url
if (!href) return "#"
if (href.startsWith("http")) {
const domain = new URL(href).hostname
if (domain === "localhost") return "#"
return href
}
const feedSiteUrl = feed?.type === "feed" ? feed.siteUrl : null
if (href.startsWith("/") && feedSiteUrl) return safeUrl(href, feedSiteUrl)
return href
}, [entry?.entries.authorUrl, entry?.entries.url, feed?.siteUrl, feed?.type, inbox])
const populatedFullHref = useFeedSafeUrl(entryId)
const translation = useEntryTranslation({ entry, extraFields: ["title"] })
const dateFormat = useUISettingKey("dateFormat")

View File

@ -18,6 +18,7 @@ import { ShadowDOM } from "~/components/common/ShadowDOM"
import { useInPeekModal } from "~/components/ui/modal/inspire/PeekModal"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useAuthQuery } from "~/hooks/common"
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
import { WrappedElementProvider } from "~/providers/wrapped-element-provider"
import { Queries } from "~/queries"
import { useEntryTranslation } from "~/store/ai/hook"
@ -88,6 +89,8 @@ export const EntryContent: Component<EntryContentProps> = ({
const contentLineHeight = useUISettingKey("contentLineHeight")
const contentFontSize = useUISettingKey("contentFontSize")
const safeUrl = useFeedSafeUrl(entryId)
const stableRenderStyle = useMemo(() => {
const css = {} as React.CSSProperties
if (readerFontFamily) {
@ -236,7 +239,7 @@ export const EntryContent: Component<EntryContentProps> = ({
</article>
</div>
</ScrollArea.ScrollArea>
<SourceContentPanel src={entry.entries.url} />
<SourceContentPanel src={safeUrl ?? "#"} />
</div>
</>
)

View File

@ -186,6 +186,18 @@ export const parseSafeUrl = (url: string) => {
return null
}
}
/**
* @deprecated Remove it in the future but not now
*/
export const resolveUrlWithBase = (url: string, baseUrl: string) => {
try {
return new URL(url, baseUrl).href
} catch {
return url
}
}
export const getUrlIcon = (url: string, fallback?: boolean | undefined) => {
let src: string
let fallbackUrl = ""