feat(mobile): feed summary component
This commit is contained in:
parent
75102f4e12
commit
64d44ec61b
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue