diff --git a/apps/mobile/src/icons/list_check_2_cute_re.tsx b/apps/mobile/src/icons/list_check_2_cute_re.tsx new file mode 100644 index 000000000..381178e7c --- /dev/null +++ b/apps/mobile/src/icons/list_check_2_cute_re.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface ListCheck2CuteReIconProps { + width?: number + height?: number + color?: string +} + +export const ListCheck2CuteReIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: ListCheck2CuteReIconProps) => { + return ( + + + + ) +} diff --git a/apps/mobile/src/icons/shuffle_2_cute_re.tsx b/apps/mobile/src/icons/shuffle_2_cute_re.tsx new file mode 100644 index 000000000..fd4ee82c0 --- /dev/null +++ b/apps/mobile/src/icons/shuffle_2_cute_re.tsx @@ -0,0 +1,31 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +interface Shuffle2CuteReIconProps { + width?: number + height?: number + color?: string +} + +export const Shuffle2CuteReIcon = ({ + width = 24, + height = 24, + color = "#10161F", +}: Shuffle2CuteReIconProps) => { + return ( + + + + + ) +} diff --git a/apps/mobile/src/modules/onboarding/hooks/use-reading-behavior.ts b/apps/mobile/src/modules/onboarding/hooks/use-reading-behavior.ts new file mode 100644 index 000000000..f12ebf93a --- /dev/null +++ b/apps/mobile/src/modules/onboarding/hooks/use-reading-behavior.ts @@ -0,0 +1,41 @@ +import { setGeneralSetting, useGeneralSettingKey } from "@/src/atoms/settings/general" + +type ReadingBehavior = "radical" | "balanced" | "conservative" + +export const useReadingBehavior = () => { + const markAsReadWhenScrolling = useGeneralSettingKey("scrollMarkUnread") + const markAsReadWhenInView = useGeneralSettingKey("renderMarkUnread") + + const behavior: ReadingBehavior = + markAsReadWhenInView && markAsReadWhenScrolling + ? "radical" + : !markAsReadWhenInView && !markAsReadWhenScrolling + ? "conservative" + : "balanced" + + const updateSettings = (behavior: ReadingBehavior) => { + switch (behavior) { + case "radical": { + setGeneralSetting("scrollMarkUnread", true) + setGeneralSetting("renderMarkUnread", true) + break + } + case "balanced": { + setGeneralSetting("scrollMarkUnread", true) + setGeneralSetting("renderMarkUnread", false) + break + } + case "conservative": { + setGeneralSetting("scrollMarkUnread", false) + setGeneralSetting("renderMarkUnread", false) + break + } + } + } + return { + behavior, + markAsReadWhenScrolling, + markAsReadWhenInView, + updateSettings, + } +} diff --git a/apps/mobile/src/modules/onboarding/preset.ts b/apps/mobile/src/modules/onboarding/preset.ts new file mode 100644 index 000000000..bfbafb49c --- /dev/null +++ b/apps/mobile/src/modules/onboarding/preset.ts @@ -0,0 +1,122 @@ +import { FeedViewType } from "@follow/constants" + +export type PresetFeedConfig = { + title: string + feedId: string + url: string + view: FeedViewType +} + +export const presetFeeds: PresetFeedConfig[] = [ + { + feedId: "41358761177015296", + title: "知乎热榜 - 全站", + url: "rsshub://zhihu/hot/total", + view: FeedViewType.Articles, + }, + { + feedId: "100020530265058357", + title: "阮一峰的网络日志", + url: "https://feeds.feedburner.com/ruanyifeng", + view: FeedViewType.Articles, + }, + + { + feedId: "41358830592746496", + title: "微博热搜榜", + url: "rsshub://weibo/search/hot", + view: FeedViewType.SocialMedia, + }, + { + feedId: "100411504863520768", + title: "Twitter @Elon Musk", + url: "rsshub://twitter/user/elonmusk", + view: FeedViewType.SocialMedia, + }, + { + feedId: "41324816676184077", + title: "Twitter @DIŸgöd ☀️", + url: "rsshub://twitter/user/DIYgod", + view: FeedViewType.SocialMedia, + }, + + { + feedId: "78806242632741888", + title: "bilibili 排行榜-全站", + url: "rsshub://bilibili/ranking/0", + view: FeedViewType.Videos, + }, + + { + feedId: "60338304723722240", + title: "实时财经快讯 - FastBull", + url: "rsshub://fastbull/express-news", + view: FeedViewType.Articles, + }, + { + feedId: "55611390687386624", + title: "格隆汇快讯-7x24小时市场快讯-财经市场热点", + url: "rsshub://gelonghui/live", + view: FeedViewType.Articles, + }, + { + feedId: "49375919416104960", + title: "深潮TechFlow - 快讯", + url: "rsshub://techflowpost/express", + view: FeedViewType.Articles, + }, + { + feedId: "55982073122828305", + title: "TED Talks Daily", + url: "https://feeds.acast.com/public/shows/67587e77c705e441797aff96", + view: FeedViewType.Articles, + }, + { + feedId: "72541715399995392", + title: "TheBlockBeats - 快讯", + url: "rsshub://theblockbeats/newsflash/0", + view: FeedViewType.Articles, + }, + { + feedId: "100184911354754055", + title: "小Lin说", + url: "https://www.youtube.com/feeds/videos.xml?channel_id=UCilwQlk62k1z7aUEZPOB6yw", + view: FeedViewType.Videos, + }, + { + feedId: "100185810923910148", + title: "张小珺Jùn|商业访谈录", + url: "https://feed.xyzfm.space/dk4yh3pkpjp3", + view: FeedViewType.Articles, + }, + { + feedId: "56584656988676096", + title: "迷因电波", + url: "rsshub://xiaoyuzhou/podcast/61d52b3bee197a3aac3dac44", + view: FeedViewType.Articles, + }, + { + feedId: "76051724651752448", + title: "极致音乐汇 的 bilibili 空间", + url: "rsshub://bilibili/user/video/1691501735", + view: FeedViewType.Videos, + }, + { + feedId: "66701376672681984", + title: "AP Top News - AP News", + url: "rsshub://apnews/api/apf-topnews", + view: FeedViewType.Articles, + }, + { + feedId: "44366244616936448", + title: "金十数据", + url: "rsshub://jin10", + view: FeedViewType.Articles, + }, + { + feedId: "52325519371718656", + title: "Hacker News", + url: "rsshub://hackernews", + view: FeedViewType.Articles, + }, +] diff --git a/apps/mobile/src/modules/onboarding/step-finished.tsx b/apps/mobile/src/modules/onboarding/step-finished.tsx new file mode 100644 index 000000000..3cea7b44c --- /dev/null +++ b/apps/mobile/src/modules/onboarding/step-finished.tsx @@ -0,0 +1,13 @@ +import { Text, View } from "react-native" + +import { Logo } from "@/src/components/ui/logo" + +export const StepFinished = () => ( + + + You're all set! + + You have completed the guide. Enjoy your journey! + + +) diff --git a/apps/mobile/src/modules/onboarding/step-interests.tsx b/apps/mobile/src/modules/onboarding/step-interests.tsx new file mode 100644 index 000000000..c7a44780d --- /dev/null +++ b/apps/mobile/src/modules/onboarding/step-interests.tsx @@ -0,0 +1,110 @@ +import { cn } from "@follow/utils" +import { useCallback, useState } from "react" +import { Text, TouchableOpacity, View } from "react-native" +import Animated, { FadeIn, FadeOut } from "react-native-reanimated" + +import { Search3CuteReIcon } from "@/src/icons/search_3_cute_re" +import { Shuffle2CuteReIcon } from "@/src/icons/shuffle_2_cute_re" +import { toast } from "@/src/lib/toast" +import { useSubscription } from "@/src/store/subscription/hooks" +import { subscriptionSyncService } from "@/src/store/subscription/store" +import { accentColor } from "@/src/theme/colors" + +import type { PresetFeedConfig } from "./preset" +import { presetFeeds } from "./preset" + +const subscribeFeed = async (config: PresetFeedConfig) => { + await subscriptionSyncService.subscribe({ + feedId: config.feedId, + title: config.title, + url: config.url, + view: config.view, + category: "", + isPrivate: false, + }) + + toast.success(`Subscribed to ${config.title}`, { + position: "bottom", + }) +} + +const unsubscribeFeed = async (feedId: string) => { + await subscriptionSyncService.unsubscribe(feedId) + toast.success(`Unsubscribed from feed`, { + position: "bottom", + }) +} + +export const StepInterests = () => { + const [displayFeeds, setDisplayFeeds] = useState(presetFeeds.slice(0, 7)) + + const shuffleFeeds = useCallback(() => { + const shuffled = [...presetFeeds].sort(() => Math.random() - 0.5).slice(0, 7) + setDisplayFeeds(shuffled) + }, []) + return ( + + + + Discover Interests + + Subscribe to feeds that match your interests. + + + + + + Suggestions feed + + + Shuffle + + + + + {displayFeeds.map((feed) => ( + + ))} + + + + ) +} + +const FeedChip = (feed: PresetFeedConfig) => { + const isSubscribed = useSubscription(feed.feedId) + + const handleSubscribe = useCallback( + async (feed: PresetFeedConfig) => { + if (isSubscribed) { + await unsubscribeFeed(feed.feedId) + return + } + await subscribeFeed(feed) + }, + [isSubscribed], + ) + + return ( + + handleSubscribe(feed)} + className={cn( + "flex rounded-full px-4 py-2", + isSubscribed ? "bg-accent" : "bg-secondary-system-fill", + )} + > + + {feed.title} + + + + ) +} diff --git a/apps/mobile/src/modules/onboarding/step-preferences.tsx b/apps/mobile/src/modules/onboarding/step-preferences.tsx new file mode 100644 index 000000000..c9349f3a4 --- /dev/null +++ b/apps/mobile/src/modules/onboarding/step-preferences.tsx @@ -0,0 +1,151 @@ +import { router } from "expo-router" +import type { PropsWithChildren } from "react" +import { ActivityIndicator, Pressable, Text, TouchableOpacity, View } from "react-native" +import { useColor } from "react-native-uikit-colors" + +import { UserAvatar } from "@/src/components/ui/avatar/UserAvatar" +import { GroupedInsetListNavigationLinkIcon } from "@/src/components/ui/grouped/GroupedList" +import { DocmentCuteReIcon } from "@/src/icons/docment_cute_re" +import { FileImportCuteReIcon } from "@/src/icons/file_import_cute_re" +import { ListCheck2CuteReIcon } from "@/src/icons/list_check_2_cute_re" +import { MingcuteRightLine } from "@/src/icons/mingcute_right_line" +import { Settings1CuteReIcon } from "@/src/icons/settings_1_cute_re" +import { useWhoami } from "@/src/store/user/hooks" +import { accentColor } from "@/src/theme/colors" + +import { importOpml, setAvatar } from "../settings/utils" +import { useReadingBehavior } from "./hooks/use-reading-behavior" + +export const StepPreferences = () => { + const { behavior } = useReadingBehavior() + + return ( + + + + + Personalize Your Experience + + + Set your preferences to make Follow work best for you. You can always change these later + in Settings. + + + + + + + + } + onPress={() => { + router.push("/onboarding/edit-profile") + }} + > + + Change your name, email, and profile picture + + + + {/* Reading Preferences Card */} + + + + } + onPress={() => { + router.push("/onboarding/select-reading-mode") + }} + > + {behavior === "radical" && ( + + Automatically mark entries as read when displayed + + )} + {behavior === "balanced" && ( + + Automatically mark entries as read when scrolled out of view + + )} + {behavior === "conservative" && ( + + Mark entries as read only when clicked + + )} + + + {/* Import Card */} + + + + } + onPress={importOpml} + > + + + If you have used RSS before, you can import an OPML file + + + + + + ) +} + +export const EditProfileSection = () => { + const whoami = useWhoami() + + if (!whoami) { + return ( + + + + ) + } + + return ( + + + + + Set Avatar + + + ) +} + +type PreferenceCardProps = PropsWithChildren<{ + title: string + icon?: React.ReactNode + onPress?: () => void +}> + +const PreferenceCard = ({ title, children, onPress, icon }: PreferenceCardProps) => { + const rightIconColor = useColor("tertiaryLabel") + + return ( + + {icon} + + {title} + {children} + + + + ) +} diff --git a/apps/mobile/src/modules/onboarding/step-welcome.tsx b/apps/mobile/src/modules/onboarding/step-welcome.tsx new file mode 100644 index 000000000..01af0007b --- /dev/null +++ b/apps/mobile/src/modules/onboarding/step-welcome.tsx @@ -0,0 +1,13 @@ +import { Text, View } from "react-native" + +import { Logo } from "@/src/components/ui/logo" + +export const StepWelcome = () => ( + + + Welcome to Follow! + + This guide will help you get started with the app. + + +) diff --git a/apps/mobile/src/modules/settings/routes/EditProfile.tsx b/apps/mobile/src/modules/settings/routes/EditProfile.tsx index 611477f56..4a440f723 100644 --- a/apps/mobile/src/modules/settings/routes/EditProfile.tsx +++ b/apps/mobile/src/modules/settings/routes/EditProfile.tsx @@ -2,7 +2,7 @@ import { withOpacity } from "@follow/utils/src/color" import { useMutation } from "@tanstack/react-query" import { router } from "expo-router" import type { FC } from "react" -import { useState } from "react" +import { useCallback, useState } from "react" import { ActivityIndicator, Text, @@ -13,6 +13,8 @@ import { import { KeyboardController } from "react-native-keyboard-controller" import { RotateableLoading } from "@/src/components/common/RotateableLoading" +import { ModalHeader } from "@/src/components/layouts/header/ModalHeader" +import { SafeModalScrollView } from "@/src/components/layouts/views/SafeModalScrollView" import { NavigationBlurEffectHeader, SafeNavigationScrollView, @@ -29,8 +31,6 @@ import { import { CheckCircleCuteReIcon } from "@/src/icons/check_circle_cute_re" import { CheckLineIcon } from "@/src/icons/check_line" import { CloseCircleFillIcon } from "@/src/icons/close_circle_fill" -import { apiClient, apiFetch, getBizFetchErrorMessage } from "@/src/lib/api-fetch" -import { pickImage } from "@/src/lib/native/picker" import { toast } from "@/src/lib/toast" import { useWhoami } from "@/src/store/user/hooks" import type { MeModel } from "@/src/store/user/store" @@ -38,6 +38,8 @@ import { userSyncService } from "@/src/store/user/store" import type { UserProfileEditable } from "@/src/store/user/types" import { accentColor, useColor } from "@/src/theme/colors" +import { setAvatar } from "../utils" + export const EditProfileScreen = () => { const whoami = useWhoami() @@ -57,6 +59,26 @@ export const EditProfileScreen = () => { ) } +export const EditProfileModal = () => { + const whoami = useWhoami() + + if (!whoami) { + return ( + + + + ) + } + + return ( + + + + + + ) +} + const AvatarSection: FC<{ whoami: MeModel }> = ({ whoami }) => { @@ -69,37 +91,7 @@ const AvatarSection: FC<{ className={!whoami?.name || !whoami.image ? "bg-system-background" : ""} /> - { - const result = await pickImage({ - fileName: "avatar.jpg", - maxSizeKB: 290, - }) - - if (!result) return - const { formData } = result - const res = await apiFetch<{ - url: string - }>(apiClient.upload.avatar.$url().toString(), { - method: "POST", - headers: { - "Content-Type": "multipart/form-data", - }, - body: formData, - }).catch((err) => { - toast.error(getBizFetchErrorMessage(err)) - throw err - }) - - const { url } = res - - userSyncService.updateProfile({ - image: url, - }) - }} - > + Set Avatar @@ -108,7 +100,8 @@ const AvatarSection: FC<{ const ProfileForm: FC<{ whoami: MeModel -}> = ({ whoami }) => { + layout?: "modal" | "screen" +}> = ({ whoami, layout = "screen" }) => { const [dirtyFields, setDirtyFields] = useState>({}) const { mutateAsync: updateProfile, isPending } = useMutation({ @@ -124,27 +117,36 @@ const ProfileForm: FC<{ }) const label = useColor("label") + const headerRight = useCallback( + () => ( + + ) : ( + + ) + } + onPress={() => { + updateProfile() + }} + /> + ), + [dirtyFields, isPending, label, updateProfile], + ) + + const Header = + layout === "modal" ? ( + + ) : ( + + ) + return ( - ( - - ) : ( - - ) - } - onPress={() => { - updateProfile() - }} - /> - )} - title="Edit Profile" - /> + {Header} { diff --git a/apps/mobile/src/modules/settings/utils.ts b/apps/mobile/src/modules/settings/utils.ts index 7b4942a1a..e9965a593 100644 --- a/apps/mobile/src/modules/settings/utils.ts +++ b/apps/mobile/src/modules/settings/utils.ts @@ -3,8 +3,38 @@ import * as FileSystem from "expo-file-system" import * as Sharing from "expo-sharing" import { getDbPath } from "@/src/database" -import { apiFetch, getBizFetchErrorMessage } from "@/src/lib/api-fetch" +import { apiClient, apiFetch, getBizFetchErrorMessage } from "@/src/lib/api-fetch" +import { pickImage } from "@/src/lib/native/picker" import { toast } from "@/src/lib/toast" +import { userSyncService } from "@/src/store/user/store" + +export const setAvatar = async () => { + const result = await pickImage({ + fileName: "avatar.jpg", + maxSizeKB: 290, + }) + + if (!result) return + const { formData } = result + const res = await apiFetch<{ + url: string + }>(apiClient.upload.avatar.$url().toString(), { + method: "POST", + headers: { + "Content-Type": "multipart/form-data", + }, + body: formData, + }).catch((err) => { + toast.error(getBizFetchErrorMessage(err)) + throw err + }) + + const { url } = res + + userSyncService.updateProfile({ + image: url, + }) +} type FeedResponseList = { id: string diff --git a/apps/mobile/src/screens/(modal)/onboarding/edit-profile.tsx b/apps/mobile/src/screens/(modal)/onboarding/edit-profile.tsx new file mode 100644 index 000000000..831dd6602 --- /dev/null +++ b/apps/mobile/src/screens/(modal)/onboarding/edit-profile.tsx @@ -0,0 +1 @@ +export { EditProfileModal as default } from "@/src/modules/settings/routes/EditProfile" diff --git a/apps/mobile/src/screens/(modal)/onboarding/select-reading-mode.tsx b/apps/mobile/src/screens/(modal)/onboarding/select-reading-mode.tsx new file mode 100644 index 000000000..6e91f5ad8 --- /dev/null +++ b/apps/mobile/src/screens/(modal)/onboarding/select-reading-mode.tsx @@ -0,0 +1,96 @@ +import { cn } from "@follow/utils" +import type { PropsWithChildren } from "react" +import { Pressable, Text, View } from "react-native" + +import { ModalHeader } from "@/src/components/layouts/header/ModalHeader" +import { SafeModalScrollView } from "@/src/components/layouts/views/SafeModalScrollView" +import { GroupedInsetListNavigationLinkIcon } from "@/src/components/ui/grouped/GroupedList" +import { Eye2CuteReIcon } from "@/src/icons/eye_2_cute_re" +import { Grid2CuteReIcon } from "@/src/icons/grid_2_cute_re" +import { PowerIcon } from "@/src/icons/power" +import { useReadingBehavior } from "@/src/modules/onboarding/hooks/use-reading-behavior" + +const SelectReadingModeScreen = () => { + const { behavior, updateSettings } = useReadingBehavior() + + return ( + + + + + + + + } + isSelected={behavior === "radical"} + onPress={() => { + updateSettings("radical") + }} + > + + Radical: Automatically mark entries as read when displayed + + + + + + + } + isSelected={behavior === "balanced"} + onPress={() => { + updateSettings("balanced") + }} + > + + Balanced: Automatically mark entries as read when scrolled out of view + + + + + + + } + isSelected={behavior === "conservative"} + onPress={() => { + updateSettings("conservative") + }} + > + Conservative: Mark entries as read only when clicked + + + + ) +} + +const Card = ({ + children, + onPress, + icon, + isSelected, +}: PropsWithChildren<{ + icon?: React.ReactNode + onPress?: () => void + isSelected?: boolean +}>) => { + return ( + + {icon} + {children} + + ) +} + +export default SelectReadingModeScreen diff --git a/apps/mobile/src/screens/_layout.tsx b/apps/mobile/src/screens/_layout.tsx index cb41687c5..0cefdc0e7 100644 --- a/apps/mobile/src/screens/_layout.tsx +++ b/apps/mobile/src/screens/_layout.tsx @@ -81,6 +81,17 @@ function AnimatedStack() { }, }} /> + ) } diff --git a/apps/mobile/src/screens/onboarding.tsx b/apps/mobile/src/screens/onboarding.tsx new file mode 100644 index 000000000..3702db1a0 --- /dev/null +++ b/apps/mobile/src/screens/onboarding.tsx @@ -0,0 +1,95 @@ +import { router } from "expo-router" +import { useCallback, useState } from "react" +import { Text, TouchableOpacity, View } from "react-native" +import { useSafeAreaInsets } from "react-native-safe-area-context" +import { SheetScreen } from "react-native-sheet-transitions" + +import { StepFinished } from "../modules/onboarding/step-finished" +import { StepInterests } from "../modules/onboarding/step-interests" +import { StepPreferences } from "../modules/onboarding/step-preferences" +import { StepWelcome } from "../modules/onboarding/step-welcome" + +export default function Onboarding() { + const insets = useSafeAreaInsets() + const [currentStep, setCurrentStep] = useState(1) + const totalSteps = 4 + + const handleNext = useCallback(() => { + if (currentStep < totalSteps) { + setCurrentStep(currentStep + 1) + } else { + // Complete onboarding + router.replace("/") + } + }, [currentStep]) + + return ( + { + router.back() + }} + > + + + + {/* Content */} + {currentStep === 1 && } + {currentStep === 2 && } + {currentStep === 3 && } + {currentStep === 4 && } + + {/* Navigation buttons */} + + + + {currentStep < totalSteps - 1 + ? "Next" + : currentStep === totalSteps - 1 + ? "Finish Setup" + : "Let's Go!"} + + + + + + ) +} + +export function ProgressIndicator({ + currentStep, + totalSteps, + setCurrentStep, +}: { + currentStep: number + totalSteps: number + setCurrentStep: (step: number) => void +}) { + return ( + + {Array.from({ length: totalSteps }).map((_, index) => ( + { + setCurrentStep(index + 1) + }} + > + = index + 1 ? "bg-accent" : "bg-gray-300" + }`} + /> + + ))} + + ) +} diff --git a/icons/mgc/list_check_2_cute_re.svg b/icons/mgc/list_check_2_cute_re.svg new file mode 100644 index 000000000..025162a48 --- /dev/null +++ b/icons/mgc/list_check_2_cute_re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/mgc/shuffle_2_cute_re.svg b/icons/mgc/shuffle_2_cute_re.svg new file mode 100644 index 000000000..24c77e7d1 --- /dev/null +++ b/icons/mgc/shuffle_2_cute_re.svg @@ -0,0 +1 @@ + \ No newline at end of file