feat(mobile): feed summary component

This commit is contained in:
DIYgod 2025-05-15 17:42:07 +08:00
parent 75102f4e12
commit 64d44ec61b
No known key found for this signature in database
4 changed files with 218 additions and 186 deletions

View File

@ -0,0 +1,137 @@
import { FeedViewType } from "@follow/constants"
import { getBackgroundGradient } from "@follow/utils"
import { LinearGradient } from "expo-linear-gradient"
import { Text, View } from "react-native"
import { ScrollView } from "react-native-gesture-handler"
import { RelativeDateTime } from "@/src/components/ui/datetime/RelativeDateTime"
import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
import { Image } from "@/src/components/ui/image/Image"
import { ItemPressableStyle } from "@/src/components/ui/pressable/enum"
import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
import type { apiClient } from "@/src/lib/api-fetch"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { FollowScreen } from "@/src/screens/(modal)/FollowScreen"
type SearchResultItem = Awaited<ReturnType<typeof apiClient.discover.$post>>["data"][number]
export const FeedSummary = ({
item,
children,
className,
}: {
item: SearchResultItem
children?: React.ReactNode
className?: string
}) => {
const navigation = useNavigation()
return (
<ItemPressable
itemStyle={ItemPressableStyle.Plain}
onPress={() => {
if (item.feed?.id) {
navigation.presentControllerView(FollowScreen, {
id: item.feed.id,
type: "feed",
})
} else if (item.feed?.url) {
navigation.presentControllerView(FollowScreen, {
url: item.feed.url,
type: "url",
})
}
}}
className={className}
>
{/* Headline */}
<View className="flex-row items-center gap-2 pr-2">
<View className="size-[32px] overflow-hidden rounded-lg">
<FeedIcon
size={32}
feed={
item.feed
? {
id: item.feed?.id!,
title: item.feed?.title!,
url: item.feed?.url!,
image: item.feed?.image!,
ownerUserId: item.feed?.ownerUserId!,
siteUrl: item.feed?.siteUrl!,
type: FeedViewType.Articles,
}
: undefined
}
/>
</View>
<View className="flex-1">
<Text
className="text-text text-lg font-semibold"
ellipsizeMode="middle"
numberOfLines={1}
>
{item.feed?.title}
</Text>
<Text className="text-text text-sm leading-tight opacity-60">{item.feed?.url}</Text>
</View>
</View>
{!!item.feed?.description && (
<Text className="mt-3 pl-[39] pr-2 text-sm" ellipsizeMode="tail" numberOfLines={2}>
{item.feed?.description}
</Text>
)}
{/* Preview */}
<View className="mt-4">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerClassName="flex flex-row gap-4"
>
{item.entries?.map((entry) => <PreviewItem entry={entry} key={entry.id || entry.guid} />)}
</ScrollView>
</View>
{children}
</ItemPressable>
)
}
const PreviewItem = ({ entry }: { entry: NonNullable<SearchResultItem["entries"]>[number] }) => {
const firstMedia = entry.media?.[0]
const [, , , bgAccent, bgAccentLight] = getBackgroundGradient(
entry.title || entry.url || "Untitled",
)
return (
<View className="bg-secondary-system-background w-[112] flex-col overflow-hidden rounded-lg">
{firstMedia ? (
<Image
source={{ uri: firstMedia.url }}
className="w-full"
placeholder={{
blurhash: firstMedia.blurhash,
}}
aspectRatio={112 / 63}
proxy={{
width: 112,
height: 63,
}}
/>
) : (
<LinearGradient
className="flex h-[63] w-[112] items-center justify-center"
colors={[bgAccentLight!, bgAccent!]}
locations={[0, 1]}
>
<Text className="text-lg font-bold text-white">{entry.title?.[0]}</Text>
</LinearGradient>
)}
<View className="flex flex-1 justify-between p-2">
<Text className="text-text text-sm" ellipsizeMode="tail" numberOfLines={2}>
{entry.title}
</Text>
<RelativeDateTime className="text-text/60 text-sm" date={new Date(entry.publishedAt)} />
</View>
</View>
)
}

View File

@ -1,31 +1,13 @@
import { FeedViewType } from "@follow/constants"
import { getBackgroundGradient } from "@follow/utils"
import { useQuery } from "@tanstack/react-query"
import { LinearGradient } from "expo-linear-gradient"
import { useAtomValue } from "jotai"
import { Text, useWindowDimensions, View } from "react-native"
import { ScrollView } from "react-native-gesture-handler"
import { RelativeDateTime } from "@/src/components/ui/datetime/RelativeDateTime"
import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
import { Image } from "@/src/components/ui/image/Image"
import { ItemPressableStyle } from "@/src/components/ui/pressable/enum"
import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
import { RightCuteReIcon } from "@/src/icons/right_cute_re"
import { SafeAlertCuteReIcon } from "@/src/icons/safe_alert_cute_re"
import { SafetyCertificateCuteReIcon } from "@/src/icons/safety_certificate_cute_re"
import { User3CuteReIcon } from "@/src/icons/user_3_cute_re"
import { apiClient } from "@/src/lib/api-fetch"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { FollowScreen } from "@/src/screens/(modal)/FollowScreen"
import { useSubscriptionByFeedId } from "@/src/store/subscription/hooks"
import { useColor } from "@/src/theme/colors"
import { useSearchPageContext } from "../ctx"
import { ItemSeparator } from "./__base"
import { useDataSkeleton } from "./hooks"
type SearchResultItem = Awaited<ReturnType<typeof apiClient.discover.$post>>["data"][number]
import { SearchFeedCard } from "./SearchFeedCard"
export const SearchFeed = () => {
const { searchValueAtom } = useSearchPageContext()
@ -56,7 +38,7 @@ export const SearchFeed = () => {
<View>
{data.data?.map((item) => (
<View key={item.feed?.id || Math.random().toString()}>
<SearchFeedItem item={item} />
<SearchFeedCard item={item} />
<ItemSeparator />
</View>
))}
@ -64,158 +46,3 @@ export const SearchFeed = () => {
</View>
)
}
const SearchFeedItem = ({ item }: { item: SearchResultItem }) => {
const isSubscribed = useSubscriptionByFeedId(item.feed?.id ?? "")
const navigation = useNavigation()
const iconColor = useColor("text")
return (
<ItemPressable
itemStyle={ItemPressableStyle.Plain}
className="py-8"
onPress={() => {
if (item.feed?.id) {
navigation.presentControllerView(FollowScreen, {
id: item.feed.id,
type: "feed",
})
} else if (item.feed?.url) {
navigation.presentControllerView(FollowScreen, {
url: item.feed.url,
type: "url",
})
}
}}
>
{/* Headline */}
<View className="flex-row items-center gap-2 pl-4 pr-2">
<View className="size-[32px] overflow-hidden rounded-lg">
<FeedIcon
size={32}
feed={
item.feed
? {
id: item.feed?.id!,
title: item.feed?.title!,
url: item.feed?.url!,
image: item.feed?.image!,
ownerUserId: item.feed?.ownerUserId!,
siteUrl: item.feed?.siteUrl!,
type: FeedViewType.Articles,
}
: undefined
}
/>
</View>
<View className="flex-1">
<Text
className="text-text text-lg font-semibold"
ellipsizeMode="middle"
numberOfLines={1}
>
{item.feed?.title}
</Text>
{!!item.feed?.description && (
<Text className="text-text/60" ellipsizeMode="tail" numberOfLines={1}>
{item.feed?.description}
</Text>
)}
</View>
{/* Subscribe */}
{isSubscribed ? (
<View className="ml-auto">
<View className="bg-gray-5/60 rounded-lg px-3 py-2">
<Text className="text-gray-2 text-sm font-bold">Followed</Text>
</View>
</View>
) : (
<View className="ml-auto">
<View className="bg-accent rounded-lg px-3 py-2">
<Text className="text-sm font-bold text-white">Follow</Text>
</View>
</View>
)}
</View>
<View className="mt-3 flex-row items-center gap-1 pl-4 opacity-60">
<RightCuteReIcon width={16} height={16} />
<Text className="text-text">{item.feed?.url}</Text>
</View>
{/* Preview */}
<View className="mt-3">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerClassName="flex flex-row gap-4 pl-4"
>
{item.entries?.map((entry) => <PreviewItem entry={entry} key={entry.id || entry.guid} />)}
</ScrollView>
</View>
<View className="mt-4 flex-row items-center gap-6 pl-4 opacity-60">
<View className="flex-row items-center gap-2">
<User3CuteReIcon width={16} height={16} color={iconColor} />
<Text className="text-text">{item.subscriptionCount} followers</Text>
</View>
<View className="flex-row items-center gap-2">
{item.updatesPerWeek ? (
<>
<SafetyCertificateCuteReIcon width={16} height={16} color={iconColor} />
<Text className="text-text">{item.updatesPerWeek} entries/week</Text>
</>
) : item.entries?.[0]?.publishedAt ? (
<>
<SafeAlertCuteReIcon width={16} height={16} color={iconColor} />
<Text className="text-text">Updated</Text>
<RelativeDateTime
className="text-text"
date={new Date(item.entries[0].publishedAt)}
/>
</>
) : null}
</View>
</View>
</ItemPressable>
)
}
const PreviewItem = ({ entry }: { entry: NonNullable<SearchResultItem["entries"]>[number] }) => {
const firstMedia = entry.media?.[0]
const [, , , bgAccent, bgAccentLight] = getBackgroundGradient(
entry.title || entry.url || "Untitled",
)
return (
<View className="bg-secondary-system-background w-[112] flex-col overflow-hidden rounded-lg">
{firstMedia ? (
<Image
source={{ uri: firstMedia.url }}
className="w-full"
placeholder={{
blurhash: firstMedia.blurhash,
}}
aspectRatio={112 / 63}
proxy={{
width: 112,
height: 63,
}}
/>
) : (
<LinearGradient
className="flex h-[63] w-[112] items-center justify-center"
colors={[bgAccentLight!, bgAccent!]}
locations={[0, 1]}
>
<Text className="text-lg font-bold text-white">{entry.title?.[0]}</Text>
</LinearGradient>
)}
<View className="flex flex-1 justify-between p-2">
<Text className="text-text text-sm" ellipsizeMode="tail" numberOfLines={2}>
{entry.title}
</Text>
<RelativeDateTime className="text-text/60 text-sm" date={new Date(entry.publishedAt)} />
</View>
</View>
)
}

View File

@ -0,0 +1,68 @@
import { Text, View } from "react-native"
import { RelativeDateTime } from "@/src/components/ui/datetime/RelativeDateTime"
import { SafeAlertCuteReIcon } from "@/src/icons/safe_alert_cute_re"
import { SafetyCertificateCuteReIcon } from "@/src/icons/safety_certificate_cute_re"
import { User3CuteReIcon } from "@/src/icons/user_3_cute_re"
import type { apiClient } from "@/src/lib/api-fetch"
import { useSubscriptionByFeedId } from "@/src/store/subscription/hooks"
import { useColor } from "@/src/theme/colors"
import { FeedSummary } from "../FeedSummary"
type SearchResultItem = Awaited<ReturnType<typeof apiClient.discover.$post>>["data"][number]
export const SearchFeedCard = ({ item }: { item: SearchResultItem }) => {
const isSubscribed = useSubscriptionByFeedId(item.feed?.id ?? "")
const iconColor = useColor("text")
return (
<View>
<FeedSummary item={item} className="py-8 pl-4">
<>
<View className="mt-4 flex-row items-center gap-6 opacity-60">
<View className="flex-row items-center gap-1.5">
<User3CuteReIcon width={14} height={14} color={iconColor} />
<Text className="text-text text-sm">
{item.analytics?.subscriptionCount} followers
</Text>
</View>
<View className="flex-row items-center gap-1.5">
{item.analytics?.updatesPerWeek ? (
<>
<SafetyCertificateCuteReIcon width={14} height={14} color={iconColor} />
<Text className="text-text text-sm">
{item.analytics.updatesPerWeek} entries/week
</Text>
</>
) : item.analytics?.latestEntryPublishedAt ? (
<>
<SafeAlertCuteReIcon width={14} height={14} color={iconColor} />
<Text className="text-text text-sm">Updated</Text>
<RelativeDateTime
className="text-text text-sm"
date={new Date(item.analytics.latestEntryPublishedAt)}
/>
</>
) : null}
</View>
</View>
{/* Subscribe */}
{isSubscribed ? (
<View className="ml-auto mr-4 mt-1">
<View className="bg-gray-5/60 rounded-lg px-3 py-2">
<Text className="text-gray-2 text-sm font-bold">Followed</Text>
</View>
</View>
) : (
<View className="ml-auto mr-4 mt-1">
<View className="bg-accent rounded-lg px-3 py-2">
<Text className="text-sm font-bold text-white">Follow</Text>
</View>
</View>
)}
</>
</FeedSummary>
</View>
)
}

View File

@ -17,10 +17,10 @@ import { FormLabel } from "@/src/components/ui/form/Label"
import { FormSwitch } from "@/src/components/ui/form/Switch"
import { TextField } from "@/src/components/ui/form/TextField"
import { GroupedInsetListCard } from "@/src/components/ui/grouped/GroupedList"
import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
import { PlatformActivityIndicator } from "@/src/components/ui/loading/PlatformActivityIndicator"
import { useCanDismiss, useNavigation } from "@/src/lib/navigation/hooks"
import { useSetModalScreenOptions } from "@/src/lib/navigation/ScreenOptionsContext"
import { FeedSummary } from "@/src/modules/discover/FeedSummary"
import { FeedViewSelector } from "@/src/modules/feed/view-selector"
import { useFeed, usePrefetchFeed, usePrefetchFeedByUrl } from "@/src/store/feed/hooks"
import { useSubscriptionByFeedId } from "@/src/store/subscription/hooks"
@ -163,16 +163,16 @@ function FollowImpl(props: { feedId: string }) {
}
>
{/* Group 1 */}
<GroupedInsetListCard className="px-5 py-4">
<View className="flex flex-row gap-4">
<View className="size-[50px] overflow-hidden rounded-lg">
<FeedIcon feed={feed} size={50} />
</View>
<View className="flex-1 flex-col gap-y-1">
<Text className="text-text text-lg font-semibold">{feed?.title}</Text>
<Text className="text-secondary-label text-sm">{feed?.description}</Text>
</View>
</View>
<GroupedInsetListCard>
<FeedSummary
className="px-5 py-4"
item={{
feed: {
...feed,
type: "feed",
},
}}
/>
</GroupedInsetListCard>
{/* Group 2 */}
<GroupedInsetListCard className="gap-y-4 p-4">