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:
parent
9a32c87e02
commit
665cf95afb
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 |
Loading…
Reference in New Issue