feat(mobile): add feed or rsshub feed

- Add multiple new SVG icons in the mobile app's icons directory
- Create a new Overlay component in the UI components
- Update search module and other related components
- Refactor and clean up various mobile app files

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-02-14 21:31:17 +08:00
parent 9a32c87e02
commit 665cf95afb
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
10 changed files with 194 additions and 66 deletions

View File

@ -0,0 +1,15 @@
import type { FC } from "react"
import { FadeIn, FadeOut } from "react-native-reanimated"
import { ReAnimatedPressable } from "../../common/AnimatedComponents"
export const Overlay: FC<{ onPress: () => void }> = ({ onPress }) => {
return (
<ReAnimatedPressable
entering={FadeIn}
exiting={FadeOut}
className={"absolute inset-0 bg-black/50"}
onPress={onPress}
/>
)
}

View File

@ -0,0 +1,26 @@
import * as React from "react"
import Svg, { Path } from "react-native-svg"
interface Search2CuteReIconProps {
width?: number
height?: number
color?: string
}
export const Search2CuteReIcon = ({
width = 24,
height = 24,
color = "#10161F",
}: Search2CuteReIconProps) => {
return (
<Svg width={width} height={height} fill="none" viewBox="0 0 24 24">
<Path fill="#fff" fillOpacity={0.01} d="M24 0v24H0V0z" />
<Path
stroke={color}
strokeLinecap="round"
strokeWidth={2}
d="M14.5 14.5 20 20m-4-10a6 6 0 1 1-12 0 6 6 0 0 1 12 0Z"
/>
</Svg>
)
}

View File

@ -20,6 +20,7 @@ import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-cont
import { BlurEffect } from "@/src/components/common/BlurEffect"
import { TabBar } from "@/src/components/ui/tabview/TabBar"
import { Search2CuteReIcon } from "@/src/icons/search_2_cute_re"
import { Search3CuteReIcon } from "@/src/icons/search_3_cute_re"
import { accentColor, useColor } from "@/src/theme/colors"
@ -215,7 +216,7 @@ const SearchInput = () => {
}}
className="absolute inset-y-0 left-3 flex flex-row items-center justify-center"
>
<Search3CuteReIcon color={placeholderTextColor} height={18} width={18} />
<Search2CuteReIcon color={placeholderTextColor} height={18} width={18} />
{!searchValue && !tempSearchValue && (
<Text className="text-secondary-label ml-2" style={styles.searchPlaceholderText}>
Search
@ -251,7 +252,7 @@ const SearchInput = () => {
className="absolute inset-0 flex flex-row items-center justify-center"
pointerEvents="none"
>
<Search3CuteReIcon color={placeholderTextColor} height={18} width={18} />
<Search2CuteReIcon color={placeholderTextColor} height={18} width={18} />
<Text className="text-secondary-label ml-1" style={styles.searchPlaceholderText}>
Search
</Text>

View File

@ -1,7 +1,7 @@
import { FeedViewType } from "@follow/constants"
import { zodResolver } from "@hookform/resolvers/zod"
import { StackActions } from "@react-navigation/native"
import { router, Stack, useLocalSearchParams, useNavigation } from "expo-router"
import { router, Stack, useNavigation } from "expo-router"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { ActivityIndicator, ScrollView, Text, View } from "react-native"
@ -20,7 +20,7 @@ import { GroupedInsetListCard } from "@/src/components/ui/grouped/GroupedList"
import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
import { useIsRouteOnlyOne } from "@/src/hooks/useIsRouteOnlyOne"
import { FeedViewSelector } from "@/src/modules/feed/view-selector"
import { useFeed, usePrefetchFeed } from "@/src/store/feed/hooks"
import { useFeed, usePrefetchFeed, usePrefetchFeedByUrl } from "@/src/store/feed/hooks"
import { useSubscriptionByFeedId } from "@/src/store/subscription/hooks"
import { subscriptionSyncService } from "@/src/store/subscription/store"
import type { SubscriptionForm } from "@/src/store/subscription/types"
@ -45,10 +45,31 @@ export function FollowFeed(props: { id: string }) {
)
}
return <FollowImpl />
return <FollowImpl feedId={id} />
}
function FollowImpl() {
const { id } = useLocalSearchParams()
export function FollowUrl(props: { url: string }) {
const { url } = props
const { isLoading, data, error } = usePrefetchFeedByUrl(url)
if (isLoading) {
return (
<View className="mt-24 flex-1 flex-row items-start justify-center">
<ActivityIndicator />
</View>
)
}
if (!data) {
return <Text className="text-label">{error?.message}</Text>
}
return <FollowImpl feedId={data.id} />
}
function FollowImpl(props: { feedId: string }) {
const { feedId: id } = props
const feed = useFeed(id as string)!
const isSubscribed = useSubscriptionByFeedId(feed?.id || "")
@ -129,7 +150,7 @@ function FollowImpl() {
</View>
</GroupedInsetListCard>
{/* Group 2 */}
<GroupedInsetListCard className="gap-y-4 px-5 py-4">
<GroupedInsetListCard className="gap-y-4 p-4">
<FormProvider form={form}>
<View>
<Controller
@ -169,6 +190,7 @@ function FollowImpl() {
control={form.control}
render={({ field: { onChange, value } }) => (
<FormSwitch
size="sm"
value={value}
label="Private"
description="Private feeds are only visible to you."

View File

@ -1,11 +1,16 @@
import { Link } from "expo-router"
import { TouchableOpacity, View } from "react-native"
import { Portal } from "@gorhom/portal"
import { router } from "expo-router"
import { useRef, useState } from "react"
import { SafeAreaView, Text, TextInput, TouchableOpacity, View } from "react-native"
import Animated, { SlideInUp, SlideOutUp } from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { UserAvatar } from "@/src/components/ui/avatar/UserAvatar"
import { Overlay } from "@/src/components/ui/overlay/Overlay"
import { AddCuteReIcon } from "@/src/icons/add_cute_re"
import { LinkCuteReIcon } from "@/src/icons/link_cute_re"
import { useWhoami } from "@/src/store/user/hooks"
import { accentColor } from "@/src/theme/colors"
import { accentColor, useColor } from "@/src/theme/colors"
const useActionPadding = () => {
const insets = useSafeAreaInsets()
@ -28,11 +33,86 @@ export function HomeRightAction() {
return (
<View className="flex-row items-center" style={{ paddingRight: insets.paddingRight }}>
<Link asChild href="/add">
<TouchableOpacity className="size-6">
<AddCuteReIcon color={accentColor} />
</TouchableOpacity>
</Link>
<AddFeedButton />
</View>
)
}
const AddFeedButton = () => {
const [isOpen, setIsOpen] = useState(false)
const label = useColor("label")
const handleClose = () => setIsOpen(false)
const valueRef = useRef("")
const handleAdd = () => {
handleClose()
const value = valueRef.current
if (!value) return
router.push({
pathname: "/follow",
params: {
url: value,
type: "url",
},
})
}
return (
<>
<TouchableOpacity className="size-6" onPress={() => setIsOpen((s) => !s)}>
<AddCuteReIcon color={accentColor} />
</TouchableOpacity>
<Portal>
{isOpen && (
<>
<Overlay onPress={handleClose} />
<Animated.View
className="bg-secondary-system-background absolute inset-x-0 -top-8 pt-8"
entering={SlideInUp.springify().damping(15).stiffness(100)}
exiting={SlideOutUp.duration(200)}
>
<SafeAreaView className="bg-secondary-system-background">
<View className="mt-4 px-6 py-4">
<View className="flex-row items-center gap-2">
<LinkCuteReIcon color={label} height={20} width={20} />
<Text className="text-label text-base font-medium">
Enter Feed URL or RSSHub URL
</Text>
</View>
<TextInput
onChangeText={(text) => (valueRef.current = text)}
autoFocus
enterKeyHint="done"
cursorColor={accentColor}
selectionColor={accentColor}
onSubmitEditing={handleAdd}
className="bg-system-background dark:bg-secondary-system-fill/30 text-text my-3 rounded-xl"
placeholder="https:// or rsshub://"
/>
</View>
<View className="flex-row gap-4 px-6 pb-4">
<TouchableOpacity
className="bg-system-fill flex-1 items-center justify-center rounded-full px-6 py-3"
onPress={handleClose}
>
<Text className="text-label text-base font-medium">Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
className="flex-1 items-center justify-center rounded-full bg-accent px-6 py-3"
onPress={handleAdd}
>
<Text className="text-label text-base font-semibold">Add</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
</Animated.View>
</>
)}
</Portal>
</>
)
}

View File

@ -1,47 +0,0 @@
import { Stack } from "expo-router"
import { useState } from "react"
import { Text, TextInput, TouchableOpacity, View } from "react-native"
import { ModalHeaderCloseButton } from "@/src/components/common/ModalSharedComponents"
import { Search3CuteReIcon } from "@/src/icons/search_3_cute_re"
import { accentColor, useColor } from "@/src/theme/colors"
export default function Add() {
const [url, setUrl] = useState("")
const label = useColor("label")
// @ts-expect-error FIXME: use the right color
const disabled = useColor("disabled")
return (
<View>
<Stack.Screen
options={{
gestureEnabled: !url,
headerLeft: ModalHeaderCloseButton,
headerRight: () => (
<TouchableOpacity
onPress={() => {
if (!url) return
// TODO impl search
}}
>
<Search3CuteReIcon color={!url ? disabled : label} />
</TouchableOpacity>
),
}}
/>
<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"
autoFocus
className="bg-system-background dark:bg-secondary-system-fill/30 text-text rounded-xl p-3"
/>
</View>
</View>
)
}

View File

@ -1,10 +1,10 @@
import { useLocalSearchParams } from "expo-router"
import { FollowFeed } from "@/src/modules/feed/FollowFeed"
import { FollowFeed, FollowUrl } from "@/src/modules/feed/FollowFeed"
import { FollowList } from "@/src/modules/list/FollowList"
export default function Follow() {
const { id, type = "feed" } = useLocalSearchParams()
const { id, type = "feed", url } = useLocalSearchParams()
switch (type) {
case "feed": {
@ -13,6 +13,9 @@ export default function Follow() {
case "list": {
return <FollowList id={id as string} />
}
case "url": {
return <FollowUrl url={url as string} />
}
default: {
return <FollowFeed id={id as string} />
}

View File

@ -31,3 +31,11 @@ export const usePrefetchFeed = (id: string, options?: { enabled?: boolean }) =>
...options,
})
}
export const usePrefetchFeedByUrl = (url: string, options?: { enabled?: boolean }) => {
return useQuery({
queryKey: ["feed", url],
queryFn: () => feedSyncServices.fetchFeedByUrl({ url }),
...options,
})
}

View File

@ -39,7 +39,7 @@ class FeedActions {
})
tx.persist(async () => {
await FeedService.upsertMany(feeds)
await FeedService.upsertMany(feeds.filter((feed) => !("nonce" in feed)))
})
tx.run()
@ -78,6 +78,25 @@ class FeedSyncServices {
return !finalData.id ? { ...finalData, id: nonce } : finalData
}
async fetchFeedByUrl({ url }: FeedQueryParams) {
const res = await apiClient.feeds.$get({
query: {
url,
},
})
const nonce = Math.random().toString(36).slice(2, 15)
const finalData = res.data.feed as FeedModel
if (!finalData.id) {
finalData["nonce"] = nonce
finalData["id"] = nonce
}
feedActions.upsertMany([finalData])
return finalData
}
}
export const feedSyncServices = new FeedSyncServices()
export const feedActions = new FeedActions()

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path stroke="#10161F" stroke-linecap="round" stroke-width="2" d="M14.5 14.5 20 20m-4-10a6 6 0 1 1-12 0 6 6 0 0 1 12 0Z"/></svg>

After

Width:  |  Height:  |  Size: 259 B