Skip to content

Commit

Permalink
feat(rn-search): impl search feeds and list view
Browse files Browse the repository at this point in the history
Signed-off-by: Innei <[email protected]>
  • Loading branch information
Innei committed Jan 10, 2025
1 parent 61d3ed4 commit f747882
Show file tree
Hide file tree
Showing 7 changed files with 189 additions and 15 deletions.
3 changes: 3 additions & 0 deletions apps/mobile/src/components/ui/form/TextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { forwardRef } from "react"
import type { StyleProp, TextInputProps, ViewStyle } from "react-native"
import { StyleSheet, Text, TextInput, View } from "react-native"

import { accentColor } from "@/src/theme/colors"

import { FormLabel } from "./Label"

interface TextFieldProps {
Expand Down Expand Up @@ -35,6 +37,7 @@ export const TextField = forwardRef<TextInput, TextInputProps & TextFieldProps>(
style={wrapperStyle}
>
<TextInput
selectionColor={accentColor}
ref={ref}
className={cn("text-text placeholder:text-placeholder-text w-full flex-1", className)}
style={StyleSheet.flatten([styles.textField, style])}
Expand Down
129 changes: 126 additions & 3 deletions apps/mobile/src/modules/discover/search-tabs/SearchFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { FeedViewType } from "@follow/constants"
import { useQuery } from "@tanstack/react-query"
import { Image } from "expo-image"
import { router } from "expo-router"
import { useAtomValue } from "jotai"
import { memo } from "react"
import { Text } from "react-native"
import { Text, useWindowDimensions, View } from "react-native"
import { ScrollView } from "react-native-gesture-handler"
import Animated, { FadeInUp } from "react-native-reanimated"

import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
import { LoadingIndicator } from "@/src/components/ui/loading"
import { ItemPressable } from "@/src/components/ui/pressable/item-pressable"
import { apiClient } from "@/src/lib/api-fetch"
import { useSubscriptionByFeedId } from "@/src/store/subscription/hooks"

import { useSearchPageContext } from "../ctx"
import { BaseSearchPageFlatList, BaseSearchPageRootView, BaseSearchPageScrollView } from "./__base"
Expand All @@ -15,7 +23,7 @@ export const SearchFeed = () => {
const { searchValueAtom } = useSearchPageContext()
const searchValue = useAtomValue(searchValueAtom)

const { data, isLoading } = useQuery({
const { data, isLoading, refetch } = useQuery({
queryKey: ["searchFeed", searchValue],
queryFn: () => {
return apiClient.discover.$post({
Expand All @@ -38,6 +46,8 @@ export const SearchFeed = () => {

return (
<BaseSearchPageFlatList
refreshing={isLoading}
onRefresh={refetch}
keyExtractor={keyExtractor}
renderScrollComponent={(props) => <BaseSearchPageScrollView {...props} />}
data={data?.data}
Expand All @@ -52,5 +62,118 @@ const renderItem = ({ item }: { item: SearchResultItem }) => (
)

const SearchFeedItem = memo(({ item }: { item: SearchResultItem }) => {
return <Text className="text-text">{item.feed?.title}</Text>
const isSubscribed = useSubscriptionByFeedId(item.feed?.id ?? "")
return (
<Animated.View entering={FadeInUp}>
<ItemPressable
className="py-2"
onPress={() => {
if (item.feed?.id) {
router.push(`/follow?id=${item.feed.id}`)
}
}}
>
{/* 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-full px-2 py-1">
<Text className="text-gray-2 text-sm font-medium">Subscribed</Text>
</View>
</View>
)}
</View>

{/* Preview */}
<View className="mt-3">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerClassName="flex flex-row gap-4 pl-14 pr-2"
>
{item.entries?.map((entry) => (
// <View key={entry.id} className="bg-green h-[60px] w-[3/4]">
// <Text>{entry.title}</Text>
// </View>
<PreviewItem entry={entry} key={entry.id} />
))}
</ScrollView>
</View>
</ItemPressable>
</Animated.View>
)
})
const formatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
const PreviewItem = ({ entry }: { entry: NonNullable<SearchResultItem["entries"]>[number] }) => {
const { width } = useWindowDimensions()
const firstMedia = entry.media?.[0]

return (
<View
className="border-gray-5 bg-system-background h-[68px] flex-row rounded-lg border p-2"
style={{ width: (width / 4) * 3 }}
>
{/* Left */}
<View className="flex-1">
<Text className="text-text" ellipsizeMode="tail" numberOfLines={2}>
{entry.title}
</Text>
<Text className="text-text/60 text-sm" ellipsizeMode="tail" numberOfLines={1}>
{formatter.format(new Date(entry.publishedAt))}
</Text>
</View>

{/* Right */}
{!!firstMedia && (
<View className="bg-gray-6 ml-auto size-[52px] shrink-0 overflow-hidden rounded-lg">
<Image
source={firstMedia.url}
className="size-full rounded-lg"
contentFit="cover"
transition={300}
placeholder={{
blurHash: firstMedia.blurhash,
}}
/>
</View>
)}
</View>
)
}
17 changes: 14 additions & 3 deletions apps/mobile/src/modules/discover/search-tabs/__base.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { forwardRef } from "react"
import type { ScrollViewProps } from "react-native"
import { ScrollView, useWindowDimensions, View } from "react-native"
import { RefreshControl, ScrollView, useWindowDimensions, View } from "react-native"
import type { FlatListPropsWithLayout } from "react-native-reanimated"
import Animated, { LinearTransition } from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
Expand Down Expand Up @@ -41,7 +41,11 @@ export const BaseSearchPageRootView = ({ children }: { children: React.ReactNode
)
}

export function BaseSearchPageFlatList<T>({ ...props }: FlatListPropsWithLayout<T>) {
export function BaseSearchPageFlatList<T>({
refreshing,
onRefresh,
...props
}: FlatListPropsWithLayout<T> & { refreshing: boolean; onRefresh: () => void }) {
const insets = useSafeAreaInsets()
const searchBarHeight = useSearchBarHeight()
const offsetTop = searchBarHeight - insets.top
Expand All @@ -51,10 +55,17 @@ export function BaseSearchPageFlatList<T>({ ...props }: FlatListPropsWithLayout<
itemLayoutAnimation={LinearTransition}
className="flex-1"
style={{ width: windowWidth }}
contentContainerStyle={{ paddingTop: offsetTop }}
contentContainerStyle={{ paddingTop: offsetTop + 8 }}
scrollIndicatorInsets={{ bottom: insets.bottom, top: offsetTop }}
automaticallyAdjustContentInsets
contentInsetAdjustmentBehavior="always"
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
progressViewOffset={offsetTop}
/>
}
{...props}
/>
)
Expand Down
9 changes: 6 additions & 3 deletions apps/mobile/src/modules/discover/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ const DiscoverHeaderImpl = () => {
const headerHeight = getDefaultHeaderHeight(frame, false, insets.top)

return (
<View style={{ height: headerHeight, paddingTop: insets.top }} className="relative">
<View
style={{ height: headerHeight, paddingTop: insets.top }}
className="border-b-separator relative border-b"
>
<BlurEffect />
<View style={styles.header}>
<PlaceholerSearchBar />
Expand All @@ -70,7 +73,7 @@ const PlaceholerSearchBar = () => {
return (
<Pressable
style={styles.searchbar}
className="dark:bg-gray-6 bg-gray-5"
className="bg-gray-5/60"
onPress={() => {
router.push("/search")
}}
Expand Down Expand Up @@ -183,7 +186,7 @@ const SearchInput = () => {
}, [isFocused])

return (
<View style={styles.searchbar} className="dark:bg-gray-6 bg-gray-5">
<View style={styles.searchbar} className="bg-gray-5/60">
{focusOrHasValue && (
<Animated.View
style={{
Expand Down
10 changes: 9 additions & 1 deletion apps/mobile/src/modules/login/email.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { z } from "zod"

import { ThemedText } from "@/src/components/common/ThemedText"
import { signIn } from "@/src/lib/auth"
import { accentColor } from "@/src/theme/colors"

const formSchema = z.object({
email: z.string().email(),
Expand Down Expand Up @@ -35,7 +36,14 @@ function Input({
control,
name,
})
return <TextInput {...rest} value={field.value} onChangeText={field.onChange} />
return (
<TextInput
selectionColor={accentColor}
{...rest}
value={field.value}
onChangeText={field.onChange}
/>
)
}

export function EmailLogin() {
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/screens/(modal)/add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Text, TextInput, TouchableOpacity, View } from "react-native"

import { ModalHeaderCloseButton } from "@/src/components/common/ModalSharedComponents"
import { Search2CuteReIcon } from "@/src/icons/search_2_cute_re"
import { useColor } from "@/src/theme/colors"
import { accentColor, useColor } from "@/src/theme/colors"

export default function Add() {
const [url, setUrl] = useState("")
Expand Down Expand Up @@ -33,6 +33,7 @@ export default function Add() {
<View className="mx-3 mt-6">
<Text className="text-label mb-1 ml-3 text-base font-medium">Feed URL</Text>
<TextInput
cursorColor={accentColor}
value={url}
onChangeText={setUrl}
placeholder="Enter the URL of the feed"
Expand Down
33 changes: 29 additions & 4 deletions apps/mobile/src/screens/(modal)/follow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FeedViewType } from "@follow/constants"
import { zodResolver } from "@hookform/resolvers/zod"
import { StackActions } from "@react-navigation/native"
import { useQuery } from "@tanstack/react-query"
import { router, Stack, useLocalSearchParams, useNavigation } from "expo-router"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
Expand All @@ -16,9 +17,12 @@ 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 { FeedIcon } from "@/src/components/ui/icon/feed-icon"
import { LoadingIndicator } from "@/src/components/ui/loading"
import { useIsRouteOnlyOne } from "@/src/hooks/useIsRouteOnlyOne"
import { FeedViewSelector } from "@/src/modules/feed/view-selector"
import { useFeed } from "@/src/store/feed/hooks"
import { feedSyncServices } from "@/src/store/feed/store"
import { useSubscriptionByFeedId } from "@/src/store/subscription/hooks"
import { subscriptionSyncService } from "@/src/store/subscription/store"
import type { SubscriptionForm } from "@/src/store/subscription/types"

Expand All @@ -29,13 +33,30 @@ const formSchema = z.object({
title: z.string().optional(),
})
const defaultValues = { view: FeedViewType.Articles.toString() }

export default function Follow() {
const { id } = useLocalSearchParams()
const feed = useFeed(id as string)
const { isLoading } = useQuery({
queryKey: ["feed", id],
queryFn: () => feedSyncServices.fetchFeedById({ id: id as string }),
enabled: !feed,
})

if (isLoading) {
return (
<View className="mt-24 flex-1 flex-row items-start justify-center">
<LoadingIndicator size={36} />
</View>
)
}

return <FollowImpl />
}
function FollowImpl() {
const { id } = useLocalSearchParams()

const feed = useFeed(id as string)
// const hasSub = useSubscriptionByFeedId(feed?.id || "")
// const isSubscribed = !!feedQuery.data?.subscription || hasSub
const isSubscribed = useSubscriptionByFeedId(feed?.id || "")

const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
Expand Down Expand Up @@ -73,11 +94,15 @@ export default function Follow() {

const { isValid, isDirty } = form.formState

if (!feed?.id) {
return <Text className="text-text">Feed ({id}) not found</Text>
}

return (
<ScrollView contentContainerClassName="px-2 pt-4 gap-y-4">
<Stack.Screen
options={{
title: `Follow - ${feed?.title}`,
title: `${isSubscribed ? "Edit" : "Follow"} - ${feed?.title}`,
headerLeft: ModalHeaderCloseButton,
gestureEnabled: !isDirty,
headerRight: () => (
Expand Down

0 comments on commit f747882

Please sign in to comment.