-
+
+
+
+
{(t.app("new_user_guide.ai_chat.intro") as string).split("\n").map((line) => (
{line}
@@ -219,7 +208,7 @@ function Welcome({ onSuggestionClick }: WelcomeProps) {
-
+
{t.app("new_user_guide.ai_chat.you_can_say")}
@@ -237,7 +226,7 @@ function Welcome({ onSuggestionClick }: WelcomeProps) {
onClickSuggestion(suggestionText)}
- animationDelay={index * 0.05}
+ animationDelay={index * 0.05 + 0.2}
className="font-normal text-text"
style={{ background: gradient }}
>
@@ -249,11 +238,11 @@ function Welcome({ onSuggestionClick }: WelcomeProps) {
-
+
)
}
-// if the chat response has `tool-onboardingGetTrendingFeedsTool`, set the step to pre-finish
+// if the chat response has `tool-onboardingGetTrendingFeedsTool`, mark the flow as finished
function FinishListener() {
const chatMessages = useMessages()
const setStep = useSetAtom(stepAtom)
@@ -262,7 +251,7 @@ function FinishListener() {
msg.parts.some((p) => p.type === "tool-onboardingGetTrendingFeeds"),
)
if (hasCalledConfirmTool) {
- setStep("pre-finish")
+ setStep("finish")
}
}, [chatMessages, setStep])
@@ -521,8 +510,8 @@ function AIChatInterface({ inputRef }: AIChatInterfaceProps) {
status === "ready" &&
hasFeedsSelection && (
- setStep("pre-finish")}>
- {t.app("new_user_guide.actions.next")}
+ setStep("finish")}>
+ {t.app("new_user_guide.actions.finish")}
)}
diff --git a/apps/desktop/layer/renderer/src/modules/new-user-guide/guide-modal-content.tsx b/apps/desktop/layer/renderer/src/modules/ai-onboarding/ai-onboarding-modal-content.tsx
similarity index 52%
rename from apps/desktop/layer/renderer/src/modules/new-user-guide/guide-modal-content.tsx
rename to apps/desktop/layer/renderer/src/modules/ai-onboarding/ai-onboarding-modal-content.tsx
index 1e597de5b..bd36e7b13 100644
--- a/apps/desktop/layer/renderer/src/modules/new-user-guide/guide-modal-content.tsx
+++ b/apps/desktop/layer/renderer/src/modules/ai-onboarding/ai-onboarding-modal-content.tsx
@@ -6,24 +6,21 @@ import { AIChatRoot } from "~/modules/ai-chat/components/layouts/AIChatRoot"
import { settingSyncQueue } from "../settings/helper/sync-queue"
import { AIChatPane } from "./ai-chat-pane"
-import { DiscoverImportStep } from "./discover-import-step"
import { FeedsSelectionList } from "./feeds-selection-list"
import { stepAtom } from "./store"
-export function GuideModalContent({ onClose }: { onClose: () => void }) {
+export function AiOnboardingModalContent({ onClose }: { onClose: () => void }) {
const step = useAtomValue(stepAtom)
useEffect(() => {
tracker.onBoarding({
stepV2: step,
- done: step === "finish" || step === "manual-import-finish" || step === "skip-finish",
+ done: step === "finish",
})
}, [step])
useEffect(() => {
- if (step !== "finish" && step !== "manual-import-finish") {
- return
- }
+ if (step !== "finish") return
const syncSettings = async () => {
try {
@@ -37,7 +34,7 @@ export function GuideModalContent({ onClose }: { onClose: () => void }) {
}, [step])
useEffect(() => {
- if (step === "finish" || step === "manual-import-finish" || step === "skip-finish") {
+ if (step === "finish") {
onClose()
}
}, [onClose, step])
@@ -47,25 +44,29 @@ export function GuideModalContent({ onClose }: { onClose: () => void }) {
case "intro":
case "selecting-feeds": {
return (
-
-
-
+
+ {/* Left side - Feed Selection (45% width on large screens) */}
+
+
+
+
+ {/* Gradient divider */}
+
+
+ {/* Right side - AI Chat (55% width on large screens) */}
+
)
}
- case "manual-import": {
- return
- }
- case "pre-finish":
- case "manual-import-pre-finish":
- case "skip-pre-finish": {
- return null
- }
- case "finish":
- case "manual-import-finish":
- case "skip-finish": {
- return null
- }
+
default: {
return null
}
@@ -76,8 +77,8 @@ export function GuideModalContent({ onClose }: { onClose: () => void }) {
return (
-
-
{content}
+
+ {content}
)
diff --git a/apps/desktop/layer/renderer/src/modules/new-user-guide/feeds-selection-list.tsx b/apps/desktop/layer/renderer/src/modules/ai-onboarding/feeds-selection-list.tsx
similarity index 88%
rename from apps/desktop/layer/renderer/src/modules/new-user-guide/feeds-selection-list.tsx
rename to apps/desktop/layer/renderer/src/modules/ai-onboarding/feeds-selection-list.tsx
index 13587538f..4ed2eaf35 100644
--- a/apps/desktop/layer/renderer/src/modules/new-user-guide/feeds-selection-list.tsx
+++ b/apps/desktop/layer/renderer/src/modules/ai-onboarding/feeds-selection-list.tsx
@@ -39,7 +39,7 @@ export function FeedsSelectionList() {
}, [hasFeedsSelection, setStep])
return (
-
+
{hasFeedsSelection ? : }
@@ -49,6 +49,7 @@ export function FeedsSelectionList() {
function FeedSelectionOperationScreen() {
const chatMessages = useMessages()
+ const t = useI18n()
const feedsToSelect: FeedToSelect[] = useMemo(() => {
// find the last message that has the tool
@@ -100,6 +101,21 @@ function FeedSelectionOperationScreen() {
[selectedAtoms, store],
)
+ if (items.length === 0) {
+ return (
+
+
+
+
+ {t.app("new_user_guide.selection.empty_title")}
+
+
+ {t.app("new_user_guide.selection.empty_description")}
+
+
+ )
+ }
+
return (
@@ -187,11 +203,11 @@ function FeedSelectionFirstScreen() {
return (
{/* Grid background - consistent with app patterns */}
@@ -203,7 +219,7 @@ function FeedSelectionFirstScreen() {
@@ -215,7 +231,7 @@ function FeedSelectionFirstScreen() {
@@ -227,8 +243,8 @@ function FeedSelectionFirstScreen() {
{t.app("new_user_guide.intro.description")}
diff --git a/apps/desktop/layer/renderer/src/modules/ai-onboarding/modal.tsx b/apps/desktop/layer/renderer/src/modules/ai-onboarding/modal.tsx
new file mode 100644
index 000000000..abba2d6ce
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/ai-onboarding/modal.tsx
@@ -0,0 +1,45 @@
+import { Spring } from "@follow/components/constants/spring.js"
+import { RootPortal } from "@follow/components/ui/portal/index.jsx"
+import { m } from "motion/react"
+import type { PropsWithChildren } from "react"
+import { useState } from "react"
+
+import { DeclarativeModal } from "~/components/ui/modal/stacked/declarative-modal"
+
+import { AiOnboardingModalContent } from "./ai-onboarding-modal-content"
+
+const Modal = ({ children }: PropsWithChildren) => {
+ return (
+
+ )
+}
+
+export const AiOnboardingModal = () => {
+ const [open, setOpen] = useState(true)
+ return (
+
+
+ setOpen(false)} />
+
+
+ )
+}
diff --git a/apps/desktop/layer/renderer/src/modules/new-user-guide/store.ts b/apps/desktop/layer/renderer/src/modules/ai-onboarding/store.ts
similarity index 78%
rename from apps/desktop/layer/renderer/src/modules/new-user-guide/store.ts
rename to apps/desktop/layer/renderer/src/modules/ai-onboarding/store.ts
index 3f68a6465..59fd2eac6 100644
--- a/apps/desktop/layer/renderer/src/modules/new-user-guide/store.ts
+++ b/apps/desktop/layer/renderer/src/modules/ai-onboarding/store.ts
@@ -3,17 +3,7 @@ import type { FeedViewType } from "@follow-app/client-sdk"
import { atom } from "jotai"
import { splitAtom } from "jotai/utils"
-export const stepAtom = atom<
- | "intro"
- | "selecting-feeds"
- | "pre-finish"
- | "finish"
- | "skip-pre-finish"
- | "skip-finish"
- | "manual-import"
- | "manual-import-pre-finish"
- | "manual-import-finish"
->("intro")
+export const stepAtom = atom<"intro" | "selecting-feeds" | "finish">("intro")
export type FeedSelection = {
description: string | null
diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/MainDestopLayout.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/MainDestopLayout.tsx
index c00b412e8..c44f1daaf 100644
--- a/apps/desktop/layer/renderer/src/modules/app-layout/MainDestopLayout.tsx
+++ b/apps/desktop/layer/renderer/src/modules/app-layout/MainDestopLayout.tsx
@@ -16,6 +16,7 @@ import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
import { DeclarativeModal } from "~/components/ui/modal/stacked/declarative-modal"
import { ROOT_CONTAINER_ID } from "~/constants/dom"
import { EnvironmentIndicator } from "~/modules/app/EnvironmentIndicator"
+import { APP_TIP_DEBUG_EVENT, AppTip } from "~/modules/app-tip"
import { LoginModalContent } from "~/modules/auth/LoginModalContent"
import { DebugRegistry } from "~/modules/debug/registry"
import { EntriesProvider } from "~/modules/entry-column/context/EntriesContext"
@@ -24,7 +25,6 @@ import { SearchCmdK } from "~/modules/panel/cmdk"
import { CmdNTrigger } from "~/modules/panel/cmdn"
import { AppNotificationContainer } from "~/modules/upgrade/lazy/index"
-import { NewUserGuide } from "./subscription-column/components/NewUserGuide"
import { SubscriptionColumnContainer } from "./subscription-column/SubscriptionColumn"
const errorTypes = [
@@ -166,7 +166,7 @@ export function MainDestopLayout() {
-
+
{isAuthFail && !user && (
@@ -233,12 +233,20 @@ const RootContainer = ({
)
}
-DebugRegistry.add("New User Guide", () => {
- import("~/modules/new-user-guide/guide-modal-content").then((m) => {
+DebugRegistry.add("App Tip Dialog", () => {
+ window.dispatchEvent(
+ new CustomEvent(APP_TIP_DEBUG_EVENT, {
+ detail: { step: 0 },
+ }),
+ )
+})
+
+DebugRegistry.add("AI Onboarding", () => {
+ import("~/modules/ai-onboarding/ai-onboarding-modal-content").then((m) => {
window.presentModal({
- title: "New User Guide",
+ title: "AI Onboarding",
content: ({ dismiss }) => (
- {
dismiss()
}}
diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/subscription-column/components/NewUserGuide.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/subscription-column/components/NewUserGuide.tsx
deleted file mode 100644
index 8414f96ce..000000000
--- a/apps/desktop/layer/renderer/src/modules/app-layout/subscription-column/components/NewUserGuide.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { useWhoami } from "@follow/store/user/hooks"
-import { lazy, Suspense } from "react"
-
-import { useAuthQuery } from "~/hooks/common/useBizQuery"
-import { settings } from "~/queries/settings"
-
-const LazyNewUserGuideModal = lazy(() =>
- import("~/modules/new-user-guide/modal").then((m) => ({ default: m.NewUserGuideModal })),
-)
-
-export function NewUserGuide() {
- const user = useWhoami()
- const { data: remoteSettings, isLoading } = useAuthQuery(settings.get(), {})
- const isNewUser =
- !isLoading && remoteSettings && Object.keys(remoteSettings.updated ?? {}).length === 0
-
- return user && isNewUser ? (
-
-
-
- ) : null
-}
diff --git a/apps/desktop/layer/renderer/src/modules/app-tip/AICopilotMedia.tsx b/apps/desktop/layer/renderer/src/modules/app-tip/AICopilotMedia.tsx
new file mode 100644
index 000000000..5d51e39e6
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/app-tip/AICopilotMedia.tsx
@@ -0,0 +1,340 @@
+import { Spring } from "@follow/components/constants/spring.js"
+import { Folo } from "@follow/components/icons/folo.js"
+import { getEditorStateJSONString } from "@follow/components/ui/lexical-rich-editor/utils.js"
+import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
+import { cn } from "@follow/utils"
+import { m } from "motion/react"
+import * as React from "react"
+
+import { AISpline } from "~/modules/ai-chat/components/3d-models/AISpline"
+import { AIMarkdownStreamingMessage } from "~/modules/ai-chat/components/message/AIMarkdownMessage"
+import { UserMessageParts } from "~/modules/ai-chat/components/message/UserMessageParts"
+import type { BizUIMessage } from "~/modules/ai-chat/store/types"
+
+type PreviewMessage = {
+ id: string
+ role: "user" | "assistant"
+ text: string
+}
+
+const buildUserBizMessage = (id: string, text: string): BizUIMessage => {
+ return {
+ id,
+ role: "user",
+ parts: [
+ {
+ type: "data-rich-text",
+ data: {
+ state: getEditorStateJSONString(text),
+ text,
+ },
+ },
+ ],
+ createdAt: new Date(),
+ }
+}
+
+const chatScript: PreviewMessage[] = [
+ {
+ id: "m1",
+ role: "user",
+ text: "Help me follow the latest AI trends for frontend.",
+ },
+ {
+ id: "m2",
+ role: "assistant",
+ text:
+ "Sure — here are key updates this week:\n\n" +
+ "- React 19 RC brings Actions and built-in async APIs.\n" +
+ "- Vite 6 improves SSR and dev server performance.\n" +
+ "- AI tooling: better model routers and eval kits.\n",
+ },
+ {
+ id: "m3",
+ role: "user",
+ text: "Great. Recommend some feeds to follow?",
+ },
+ {
+ id: "m4",
+ role: "assistant",
+ text:
+ "Absolutely — I’ve picked a few high-signal sources for you. " +
+ "You can follow them in one click below.",
+ },
+]
+
+export const AICopilotMedia: React.FC = () => {
+ const [visibleCount, setVisibleCount] = React.useState(0)
+ const [showRecommendations, setShowRecommendations] = React.useState(false)
+
+ React.useEffect(() => {
+ let disposed = false
+ const stepDelays = [0, 900, 1600, 2400] // stagger message reveals
+ const timers: number[] = []
+
+ const start = () => {
+ setVisibleCount(0)
+ setShowRecommendations(false)
+ for (let i = 0; i < chatScript.length; i++) {
+ timers.push(
+ window.setTimeout(() => {
+ if (disposed) return
+ setVisibleCount((v) => Math.min(v + 1, chatScript.length))
+ }, stepDelays[i]),
+ )
+ }
+ // Show recommendations after last message
+ const lastMessageDelay = stepDelays[chatScript.length - 1] || 2400
+ timers.push(
+ window.setTimeout(() => {
+ if (disposed) return
+ setShowRecommendations(true)
+ }, lastMessageDelay + 800),
+ )
+ }
+
+ start()
+ return () => {
+ disposed = true
+ for (const t of timers) {
+ clearTimeout(t)
+ }
+ }
+ }, [])
+
+ const messagesToRender = chatScript.slice(0, visibleCount)
+ const lastVisible = messagesToRender.at(-1)
+
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+const Header: React.FC = () => {
+ return (
+
+
+
+ {/* AI Icon with glow effect */}
+
+
+
+
+ {/* Text content */}
+
+
+
+ AI
+
+
+ Summarize, search, and curate for you
+
+
+
+
+
+ )
+}
+
+const ChatPreview: React.FC<{
+ messages: PreviewMessage[]
+ streamingId?: string
+ showRecommendations?: boolean
+}> = ({ messages, streamingId, showRecommendations }) => {
+ const feeds = React.useMemo(
+ () => [
+ {
+ id: "vercel-blog",
+ type: "feed" as const,
+ title: "Vercel Blog",
+ url: "https://vercel.com/blog",
+ siteUrl: "https://vercel.com",
+ description: "Frontend, AI, and infra updates.",
+ },
+ {
+ id: "react",
+ type: "feed" as const,
+ title: "React",
+ url: "https://react.dev/blog",
+ siteUrl: "https://react.dev",
+ description: "Official updates and releases.",
+ },
+ {
+ id: "ai-engineering",
+ type: "feed" as const,
+ title: "AI Engineering",
+ url: "https://aie.sh",
+ siteUrl: "https://aie.sh",
+ description: "Practical AI for builders.",
+ },
+ ],
+ [],
+ )
+
+ return (
+
+
+
+ {messages.map((message, index) => {
+ const isUser = message.role === "user"
+ const delay = index * 0.1
+
+ return (
+
+
+ {/* Message bubble */}
+
+
+ {isUser ? (
+
+ ) : (
+ <>
+
+ >
+ )}
+
+
+
+
+ )
+ })}
+
+ {/* Recommendations section - appears after conversation */}
+ {showRecommendations && (
+
+ {/* AI Avatar + Recommendation Card Container */}
+
+ {/* Recommendation Cards */}
+
+ {/* Header */}
+
+
+
+
+
+ Recommended Feeds
+
+
+
+ {/* Feed cards */}
+
+ {feeds.map((feed, index) => (
+
+ {/* Icon with gradient background */}
+
+
+
+
+
+
+ {/* Feed title */}
+
+ {feed.title}
+
+ {/* Feed description */}
+
+ {feed.description}
+
+ {/* URL hint */}
+
+
+ {feed.siteUrl}
+
+
+
+
+ ))}
+
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/desktop/layer/renderer/src/modules/app-tip/AppTip.tsx b/apps/desktop/layer/renderer/src/modules/app-tip/AppTip.tsx
new file mode 100644
index 000000000..0a36be2c4
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/app-tip/AppTip.tsx
@@ -0,0 +1,42 @@
+import { lazy, Suspense } from "react"
+
+import { AppTipDialog } from "./AppTipDialog"
+import { useAppTipController } from "./useAppTipController"
+
+const LazyAppTipModal = lazy(() =>
+ import("~/modules/ai-onboarding/modal").then((m) => ({ default: m.AiOnboardingModal })),
+)
+
+export function AppTip() {
+ const {
+ shouldShowDialog,
+ showAiGuide,
+ activeStepData,
+ steps,
+ activeStepIndex,
+ handleDismiss,
+ setActiveStep,
+ } = useAppTipController()
+
+ if (!activeStepData) return null
+
+ return (
+ <>
+ setActiveStep(idx)}
+ onDismiss={handleDismiss}
+ hasNextStep={activeStepIndex < steps.length - 1}
+ />
+
+ {showAiGuide && (
+
+
+
+ )}
+ >
+ )
+}
diff --git a/apps/desktop/layer/renderer/src/modules/app-tip/AppTipDialog.tsx b/apps/desktop/layer/renderer/src/modules/app-tip/AppTipDialog.tsx
new file mode 100644
index 000000000..e18616921
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/app-tip/AppTipDialog.tsx
@@ -0,0 +1,135 @@
+import { Button } from "@follow/components/ui/button/index.js"
+import { cn } from "@follow/utils/utils"
+import { useTranslation } from "react-i18next"
+
+import { GlassButton } from "~/components/ui/button/GlassButton"
+import { PlainWithAnimationModal } from "~/components/ui/modal/stacked/custom-modal"
+import { DeclarativeModal } from "~/components/ui/modal/stacked/declarative-modal"
+
+import { AppTipMediaPreview } from "./AppTipMediaPreview"
+import type { AppTipStep } from "./types"
+
+type AppTipDialogProps = {
+ hasNextStep: boolean
+ steps: AppTipStep[]
+ activeStep: AppTipStep
+ activeStepIndex: number
+ onSelectStep: (index: number) => void
+ onDismiss: () => void
+ open: boolean
+}
+
+export function AppTipDialog({
+ steps,
+ activeStep,
+ activeStepIndex,
+ onSelectStep,
+ onDismiss,
+ open,
+ hasNextStep,
+}: AppTipDialogProps) {
+ const { t } = useTranslation()
+
+ const handleNextStep = () => {
+ if (hasNextStep) {
+ onSelectStep(activeStepIndex + 1)
+ } else {
+ onDismiss()
+ }
+ }
+
+ return (
+
+
+
+ {activeStep.media?.reactNode ? (
+
+ {activeStep.media?.reactNode}
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
{activeStep.title}
+
+ {activeStep.description}
+
+
+
+
+ {activeStep.highlights.map((point, idx) => (
+
+
+ {point}
+
+ ))}
+
+
+ {activeStep.extra}
+
+
+
+
+
+ {steps.map((step, idx) => (
+ onSelectStep(idx)}
+ aria-label={step.title}
+ aria-current={idx === activeStepIndex}
+ className={cn(
+ "size-2 cursor-pointer rounded-full transition-colors",
+ idx === activeStepIndex
+ ? "bg-text"
+ : "bg-fill-tertiary hover:bg-fill-secondary",
+ )}
+ />
+ ))}
+
+
+
+
+ {activeStep.primaryActionLabel}
+
+
+
+ {hasNextStep
+ ? t("words.next", { ns: "common" })
+ : t("new_user_dialog.actions.finish")}
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/desktop/layer/renderer/src/modules/app-tip/AppTipMediaPreview.tsx b/apps/desktop/layer/renderer/src/modules/app-tip/AppTipMediaPreview.tsx
new file mode 100644
index 000000000..334341df9
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/app-tip/AppTipMediaPreview.tsx
@@ -0,0 +1,104 @@
+import { Spring } from "@follow/components/constants/spring.js"
+import { AnimatePresence, m } from "motion/react"
+import { useEffect, useRef, useState } from "react"
+import { useTranslation } from "react-i18next"
+
+import type { AppTipStepMedia } from "./types"
+
+type AppTipMediaPreviewProps = {
+ media?: AppTipStepMedia
+}
+
+export function AppTipMediaPreview({ media }: AppTipMediaPreviewProps) {
+ const [hasError, setHasError] = useState(false)
+ const [showReplay, setShowReplay] = useState(false)
+ const videoRef = useRef(null)
+ const { t } = useTranslation()
+ const mediaKind = media?.kind ?? "video"
+ const isVideo = mediaKind === "video"
+
+ useEffect(() => {
+ setHasError(false)
+ setShowReplay(false)
+ }, [media?.src, mediaKind])
+
+ useEffect(() => {
+ if (!isVideo) return
+ const video = videoRef.current
+ if (!video) return
+
+ const handleEnded = () => setShowReplay(true)
+ const handlePlay = () => setShowReplay(false)
+
+ video.addEventListener("ended", handleEnded)
+ video.addEventListener("play", handlePlay)
+
+ return () => {
+ video.removeEventListener("ended", handleEnded)
+ video.removeEventListener("play", handlePlay)
+ }
+ }, [isVideo, media?.src])
+
+ const handleReplay = () => {
+ if (isVideo && videoRef.current) {
+ videoRef.current.currentTime = 0
+ videoRef.current.play()
+ }
+ }
+
+ if (!media?.src || hasError) {
+ const fallbackIcon = isVideo ? "i-mgc-video-cute-re" : "i-mgc-photo-album-cute-re"
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ {isVideo ? (
+ <>
+
setHasError(true)}
+ />
+
+
+ {showReplay && (
+
+
+
+ )}
+
+ >
+ ) : (
+ setHasError(true)}
+ />
+ )}
+
+ )
+}
diff --git a/apps/desktop/layer/renderer/src/modules/app-tip/OverviewMedia.tsx b/apps/desktop/layer/renderer/src/modules/app-tip/OverviewMedia.tsx
new file mode 100644
index 000000000..091e4d7a2
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/app-tip/OverviewMedia.tsx
@@ -0,0 +1,280 @@
+import { Spring } from "@follow/components/constants/spring.js"
+import { Logo } from "@follow/components/icons/logo.jsx"
+import { getViewList } from "@follow/constants"
+import { cn } from "@follow/utils/utils"
+import { m } from "motion/react"
+import * as React from "react"
+
+const seededRandom = (seed: number) => {
+ // Mulberry32
+ let t = (seed + 0x6d2b79f5) | 0
+ t = Math.imul(t ^ (t >>> 15), t | 1)
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
+}
+
+export const OverviewMedia: React.FC = () => {
+ const containerRef = React.useRef(null)
+ const logoRef = React.useRef(null)
+ const iconRefs = React.useRef<(HTMLDivElement | null)[]>([])
+
+ const [paths, setPaths] = React.useState<{ d: string; color: string; shadow: string }[]>([])
+ const [ready, setReady] = React.useState(false)
+ const completedKeysRef = React.useRef>(new Set())
+
+ const views = React.useMemo(() => getViewList(), [])
+
+ const computePaths = React.useCallback(() => {
+ const container = containerRef.current
+ const logoEl = logoRef.current
+ if (!container || !logoEl) return
+
+ const containerRect = container.getBoundingClientRect()
+ const logoRect = logoEl.getBoundingClientRect()
+
+ const startX = logoRect.left - containerRect.left + logoRect.width / 2
+ const startY = logoRect.top - containerRect.top + logoRect.height / 2
+
+ const newPaths: { d: string; color: string; shadow: string }[] = []
+
+ iconRefs.current.forEach((el, idx) => {
+ if (!el) return
+ const r = el.getBoundingClientRect()
+ const endX = r.left - containerRect.left + r.width / 2
+ const endY = r.top - containerRect.top + r.height / 2
+
+ // Generate a slightly wobbly path between start and end
+ const dx = endX - startX
+ const dy = endY - startY
+ const distance = Math.hypot(dx, dy)
+ const segments = Math.max(6, Math.min(12, Math.round(distance / 70)))
+ const amplitude = Math.min(18, Math.max(6, distance * 0.06))
+
+ // Build points along the straight line and offset them by a seeded noise
+ const points: Array<{ x: number; y: number }> = [{ x: startX, y: startY }]
+ for (let i = 1; i < segments; i++) {
+ const t = i / segments
+ const baseX = startX + dx * t
+ const baseY = startY + dy * t
+ // Perpendicular vector
+ const px = -dy
+ const py = dx
+ const plen = Math.hypot(px, py) || 1
+ const nx = px / plen
+ const ny = py / plen
+ // Taper near the ends
+ const taper = Math.sin(Math.PI * t)
+ const rand = (seededRandom((idx + 1) * 9973 + i * 53) - 0.5) * 2 // [-1, 1]
+ const offset = rand * amplitude * taper
+ points.push({ x: baseX + nx * offset, y: baseY + ny * offset })
+ }
+ points.push({ x: endX, y: endY })
+
+ // Convert to a smooth path using quadratic curves
+ let d = `M ${points[0]!.x} ${points[0]!.y}`
+ for (let i = 1; i < points.length - 1; i++) {
+ const p1 = points[i]!
+ const p2 = points[i + 1]!
+ // Midpoint smoothing
+ const cx = p1.x
+ const cy = p1.y
+ const mx = (p1.x + p2.x) / 2
+ const my = (p1.y + p2.y) / 2
+ d += ` Q ${cx} ${cy} ${mx} ${my}`
+ }
+
+ // Use view's active color
+ const view = views[idx]
+ const color = view?.activeColor ?? "#999999"
+ const shadow = `${color}40`
+
+ newPaths.push({ d, color, shadow })
+ })
+
+ setPaths(newPaths)
+ }, [views])
+
+ // Compute when animations are completed and on resize thereafter
+ React.useLayoutEffect(() => {
+ if (!ready) return
+ // Two rafs to ensure transforms are fully flushed
+ const id = requestAnimationFrame(() => {
+ const id2 = requestAnimationFrame(() => {
+ computePaths()
+ })
+ return () => cancelAnimationFrame(id2)
+ })
+ return () => cancelAnimationFrame(id)
+ }, [ready, computePaths])
+
+ React.useEffect(() => {
+ if (!ready) return
+ const onResize = () => computePaths()
+ window.addEventListener("resize", onResize)
+ return () => window.removeEventListener("resize", onResize)
+ }, [ready, computePaths])
+
+ const markAnimationDone = React.useCallback(
+ (key: string) => {
+ const set = completedKeysRef.current
+ if (set.has(key)) return
+ set.add(key)
+ if (set.size >= views.length + 1) {
+ setReady(true)
+ }
+ },
+ [views.length],
+ )
+
+ return (
+
+ {/* Grid background */}
+
+
+ {/* Top centered Logo */}
+
markAnimationDone("logo")}
+ >
+
+ {/* Logo glow effect */}
+
+
+
+
+
+ {/* Bottom view icons */}
+ {views.map((view, index) => {
+ const totalViews = views.length
+ // Distribute icons evenly with equal margins on both sides
+ const margin = 10 // Margin from edges (10%)
+ const startPosition = margin // First icon center position
+ const endPosition = 100 - margin // Last icon center position
+ const totalWidth = endPosition - startPosition // Available width
+ const spacing = totalViews > 1 ? totalWidth / (totalViews - 1) : 0
+ const xPosition = startPosition + spacing * index // Evenly spaced, symmetric
+
+ return (
+
markAnimationDone(`icon-${index}`)}
+ >
+ {/* Icon container */}
+ {
+ iconRefs.current[index] = el
+ }}
+ className="relative flex size-12 items-center justify-center rounded-xl backdrop-blur-sm"
+ style={{
+ backgroundColor: `${view.activeColor}20`,
+ borderWidth: "1px",
+ borderStyle: "solid",
+ borderColor: `${view.activeColor}40`,
+ boxShadow: `0 4px 12px ${view.activeColor}20`,
+ }}
+ >
+
{view.icon}
+
+
+ )
+ })}
+
+ {/* Hand-drawn connector lines */}
+
+
+ {/* Slight wobble via displacement map to enhance sketch feeling (subtle) */}
+
+
+
+
+
+ {paths.map((p, index) => {
+ // Keep reveal order in sync with icon animations
+ const iconDelay = index * 0.08
+ const revealDelay = iconDelay + 0.1 // start after icon settles a bit
+ return (
+
+ {/* Underlay shadow to suggest marker bleed */}
+
+ {/* Main line */}
+
+ {/* A second, lighter stroke with slight dash to mimic hand-drawn */}
+
+
+ )
+ })}
+
+
+ {/* Ambient background glow */}
+
+
+ )
+}
diff --git a/apps/desktop/layer/renderer/src/modules/app-tip/constants.ts b/apps/desktop/layer/renderer/src/modules/app-tip/constants.ts
new file mode 100644
index 000000000..9f5fe34a4
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/app-tip/constants.ts
@@ -0,0 +1,5 @@
+export const APP_TIP_STORAGE_PREFIX = "follow:ai-onboarding:dismissed"
+
+export const APP_TIP_DEBUG_EVENT = "follow:ai-onboarding:debug-open"
+
+export const APP_TIP_DISMISS_EVENT = "follow:ai-onboarding:dismiss-change"
diff --git a/apps/desktop/layer/renderer/src/modules/app-tip/index.ts b/apps/desktop/layer/renderer/src/modules/app-tip/index.ts
new file mode 100644
index 000000000..2d6fdb4cb
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/app-tip/index.ts
@@ -0,0 +1,2 @@
+export { AppTip } from "./AppTip"
+export { APP_TIP_DEBUG_EVENT } from "./constants"
diff --git a/apps/desktop/layer/renderer/src/modules/app-tip/types.ts b/apps/desktop/layer/renderer/src/modules/app-tip/types.ts
new file mode 100644
index 000000000..ddfca3ee2
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/app-tip/types.ts
@@ -0,0 +1,26 @@
+export type AppTipDebugOpenEventDetail = {
+ step?: number
+ openAiGuide?: boolean
+}
+
+export type AppTipStepMedia = {
+ src?: string
+ poster?: string
+ caption?: string
+ kind?: "video" | "image"
+
+ reactNode?: React.ReactNode
+}
+
+export type AppTipStep = {
+ id: string
+ title: string
+ description: string
+ highlights: string[]
+ media?: AppTipStepMedia
+ primaryActionLabel: string
+ onPrimaryAction: () => void
+ secondaryActionLabel?: string
+ onSecondaryAction?: () => void
+ extra?: React.ReactNode
+}
diff --git a/apps/desktop/layer/renderer/src/modules/app-tip/useAppTipController.tsx b/apps/desktop/layer/renderer/src/modules/app-tip/useAppTipController.tsx
new file mode 100644
index 000000000..edc2bc8af
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/app-tip/useAppTipController.tsx
@@ -0,0 +1,190 @@
+import { Label } from "@follow/components/ui/label/index.jsx"
+import { Switch } from "@follow/components/ui/switch/index.jsx"
+import { useCallback, useEffect, useMemo, useState } from "react"
+import { jsx } from "react/jsx-runtime"
+import { useTranslation } from "react-i18next"
+import { useNavigate } from "react-router"
+
+import { setAISetting, useAISettingKey } from "~/atoms/settings/ai"
+
+import { OpmlAbstractGraphic } from "../discover/OpmlAbstractGraphic"
+import { AICopilotMedia } from "./AICopilotMedia"
+import { APP_TIP_DEBUG_EVENT } from "./constants"
+import { OverviewMedia } from "./OverviewMedia"
+import type { AppTipDebugOpenEventDetail, AppTipStep } from "./types"
+import { useNewUserGuideState } from "./useNewUserGuideState"
+
+export function useAppTipController() {
+ const {
+ eligibleForGuide,
+ hasDismissed,
+ setHasDismissed,
+ persistDismissState,
+ shouldShowNewUserGuide,
+ } = useNewUserGuideState()
+ const navigate = useNavigate()
+ const { t } = useTranslation()
+
+ const [activeStep, setActiveStep] = useState(0)
+ const [showAiGuide, setShowAiGuide] = useState(false)
+ const [forceOpen, setForceOpen] = useState(false)
+
+ const shouldShowDialog = shouldShowNewUserGuide || forceOpen
+
+ useEffect(() => {
+ if (eligibleForGuide && !hasDismissed) {
+ setActiveStep(0)
+ }
+ }, [eligibleForGuide, hasDismissed])
+
+ const completeOnboarding = useCallback(
+ (options?: { skipPersistence?: boolean }) => {
+ if (!options?.skipPersistence) {
+ setHasDismissed(true)
+ persistDismissState(true)
+ }
+ setForceOpen(false)
+ },
+ [persistDismissState, setHasDismissed],
+ )
+
+ const handleDismiss = useCallback(() => {
+ if (forceOpen) {
+ setForceOpen(false)
+ setShowAiGuide(false)
+ return
+ }
+ completeOnboarding()
+ }, [completeOnboarding, forceOpen, setShowAiGuide])
+
+ const handleNavigateAndClose = useCallback(
+ (path: string) => {
+ if (forceOpen) {
+ setForceOpen(false)
+ } else {
+ completeOnboarding()
+ }
+ navigate(path)
+ },
+ [completeOnboarding, forceOpen, navigate],
+ )
+
+ const handleLaunchAiGuide = useCallback(() => {
+ if (forceOpen) {
+ setForceOpen(false)
+ } else {
+ completeOnboarding()
+ }
+ setShowAiGuide(true)
+ }, [completeOnboarding, forceOpen, setShowAiGuide])
+
+ const steps = useMemo(() => {
+ return [
+ {
+ id: "overview",
+ title: t("new_user_dialog.overview.title"),
+ description: t("new_user_dialog.overview.description"),
+ highlights: [
+ t("new_user_dialog.overview.highlight_1"),
+ t("new_user_dialog.overview.highlight_2"),
+ t("new_user_dialog.overview.highlight_3"),
+ ],
+ media: {
+ reactNode: jsx(OverviewMedia, {}),
+ },
+ primaryActionLabel: t("new_user_dialog.overview.primary"),
+ onPrimaryAction: () => handleNavigateAndClose("/discover?type=search"),
+ },
+ {
+ id: "ai",
+ title: t("new_user_dialog.ai.title"),
+ description: t("new_user_dialog.ai.description"),
+ highlights: [
+ t("new_user_dialog.ai.highlight_1"),
+ t("new_user_dialog.ai.highlight_2"),
+ t("new_user_dialog.ai.highlight_3"),
+ ],
+
+ media: {
+ reactNode: jsx(AICopilotMedia, {}),
+ },
+
+ primaryActionLabel: t("new_user_dialog.ai.primary"),
+ onPrimaryAction: handleLaunchAiGuide,
+ extra: jsx(AiSplineIndicatorToggle, {}),
+ },
+ {
+ id: "import",
+ title: t("new_user_dialog.import.title"),
+ description: t("new_user_dialog.import.description"),
+ highlights: [
+ t("new_user_dialog.import.highlight_1"),
+ t("new_user_dialog.import.highlight_2"),
+ t("new_user_dialog.import.highlight_3"),
+ ],
+ media: {
+ reactNode: jsx(OpmlAbstractGraphic, {}),
+ },
+
+ primaryActionLabel: t("new_user_dialog.import.primary"),
+ onPrimaryAction: () => handleNavigateAndClose("/discover?type=import"),
+ },
+ ]
+ }, [handleLaunchAiGuide, handleNavigateAndClose, t])
+
+ useEffect(() => {
+ const listener: EventListener = (event) => {
+ const { detail } = event as CustomEvent
+ setForceOpen(true)
+ setHasDismissed(false)
+ setShowAiGuide(Boolean(detail?.openAiGuide))
+ if (steps.length > 0 && typeof detail?.step === "number") {
+ const boundedIndex = Math.min(Math.max(detail.step, 0), steps.length - 1)
+ setActiveStep(boundedIndex)
+ } else {
+ setActiveStep(0)
+ }
+ }
+ window.addEventListener(APP_TIP_DEBUG_EVENT, listener)
+ return () => {
+ window.removeEventListener(APP_TIP_DEBUG_EVENT, listener)
+ }
+ }, [setHasDismissed, steps.length])
+
+ const activeStepData = steps[activeStep] ?? steps[0] ?? null
+
+ return {
+ shouldShowDialog,
+ showAiGuide,
+ steps,
+ activeStepData,
+ activeStepIndex: activeStep,
+ handleDismiss,
+ setActiveStep,
+ }
+}
+
+const AiSplineIndicatorToggle = () => {
+ const { t } = useTranslation("ai")
+ const showSplineButton = useAISettingKey("showSplineButton")
+
+ return (
+
+
+
+
+ {t("settings.showSplineButton.label")}
+
+
+ {t("settings.showSplineButton.description")}
+
+
+
+
setAISetting("showSplineButton", v)}
+ />
+
+
+ )
+}
diff --git a/apps/desktop/layer/renderer/src/modules/app-tip/useNewUserGuideState.ts b/apps/desktop/layer/renderer/src/modules/app-tip/useNewUserGuideState.ts
new file mode 100644
index 000000000..889943139
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/app-tip/useNewUserGuideState.ts
@@ -0,0 +1,83 @@
+import { useWhoami } from "@follow/store/user/hooks"
+import { useCallback, useEffect, useMemo, useState } from "react"
+
+import { useAuthQuery } from "~/hooks/common/useBizQuery"
+import { settings } from "~/queries/settings"
+
+import { APP_TIP_DISMISS_EVENT, APP_TIP_STORAGE_PREFIX } from "./constants"
+
+export type AppTipDismissChangeDetail = {
+ key: string
+ dismissed: boolean
+}
+
+export const useNewUserGuideState = () => {
+ const user = useWhoami()
+ const { data: remoteSettings, isLoading } = useAuthQuery(settings.get(), {})
+
+ const dismissKey = useMemo(() => (user ? `${APP_TIP_STORAGE_PREFIX}:${user.id}` : null), [user])
+ const [hasDismissed, setHasDismissed] = useState(() => readDismissed(dismissKey))
+
+ useEffect(() => {
+ setHasDismissed(readDismissed(dismissKey))
+ }, [dismissKey])
+
+ useEffect(() => {
+ if (!dismissKey || typeof window === "undefined") return
+ const listener: EventListener = (event) => {
+ const { detail } = event as CustomEvent
+ if (!detail || detail.key !== dismissKey) {
+ return
+ }
+ setHasDismissed(detail.dismissed)
+ }
+ window.addEventListener(APP_TIP_DISMISS_EVENT, listener)
+ return () => {
+ window.removeEventListener(APP_TIP_DISMISS_EVENT, listener)
+ }
+ }, [dismissKey])
+
+ const persistDismissState = useCallback(
+ (next: boolean) => {
+ if (!dismissKey || typeof window === "undefined") return
+
+ if (next) {
+ window.localStorage.setItem(dismissKey, "1")
+ } else {
+ window.localStorage.removeItem(dismissKey)
+ }
+ window.dispatchEvent(
+ new CustomEvent(APP_TIP_DISMISS_EVENT, {
+ detail: { key: dismissKey, dismissed: next },
+ }),
+ )
+ },
+ [dismissKey],
+ )
+
+ const isNewUser =
+ !isLoading && remoteSettings && Object.keys(remoteSettings.updated ?? {}).length === 0
+ const eligibleForGuide = Boolean(user && isNewUser)
+ const shouldShowNewUserGuide = eligibleForGuide && !hasDismissed
+
+ return {
+ user,
+ isNewUser,
+ eligibleForGuide,
+ shouldShowNewUserGuide,
+ hasDismissed,
+ setHasDismissed,
+ persistDismissState,
+ dismissKey,
+ isLoading,
+ }
+}
+
+function readDismissed(key: string | null) {
+ if (!key) return false
+ try {
+ return window.localStorage.getItem(key) === "1"
+ } catch {
+ return false
+ }
+}
diff --git a/apps/desktop/layer/renderer/src/modules/discover/OpmlAbstractGraphic.tsx b/apps/desktop/layer/renderer/src/modules/discover/OpmlAbstractGraphic.tsx
new file mode 100644
index 000000000..8550b2430
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/discover/OpmlAbstractGraphic.tsx
@@ -0,0 +1,273 @@
+import { Spring } from "@follow/components/constants/spring.js"
+import { Logo } from "@follow/components/icons/logo.jsx"
+import { cn } from "@follow/utils/utils"
+import { m } from "motion/react"
+import * as React from "react"
+
+// Popular RSS reader services
+const RSS_READERS = [
+ { icon: "i-simple-icons-feedly", name: "Feedly", color: "#2BB24C" },
+ { icon: "i-simple-icons-inoreader", name: "Inoreader", color: "#007BC5" },
+ { icon: "i-simple-icons-freshrss", name: "FreshRSS", color: "#FF9800" },
+]
+const seededRandom = (seed: number) => {
+ // Mulberry32
+ let t = (seed + 0x6d2b79f5) | 0
+ t = Math.imul(t ^ (t >>> 15), t | 1)
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
+}
+
+export function OpmlAbstractGraphic({ className }: { className?: string }) {
+ const containerRef = React.useRef(null)
+ const logoRef = React.useRef(null)
+ const iconRefs = React.useRef<(HTMLDivElement | null)[]>([])
+
+ const [paths, setPaths] = React.useState<{ d: string; color: string; shadow: string }[]>([])
+ const [ready, setReady] = React.useState(false)
+ const completedKeysRef = React.useRef>(new Set())
+
+ // Deterministic pseudo-random with seed
+
+ const computePaths = React.useCallback(() => {
+ const container = containerRef.current
+ const logoEl = logoRef.current
+ if (!container || !logoEl) return
+
+ const containerRect = container.getBoundingClientRect()
+ const logoRect = logoEl.getBoundingClientRect()
+
+ const endX = logoRect.left - containerRect.left + logoRect.width / 2
+ const endY = logoRect.top - containerRect.top + logoRect.height / 2
+
+ const newPaths: { d: string; color: string; shadow: string }[] = []
+
+ iconRefs.current.forEach((el, idx) => {
+ if (!el) return
+ const r = el.getBoundingClientRect()
+ const startX = r.left - containerRect.left + r.width / 2
+ const startY = r.top - containerRect.top + r.height / 2
+
+ // Generate a slightly wobbly path between start and end
+ const dx = endX - startX
+ const dy = endY - startY
+ const distance = Math.hypot(dx, dy)
+ const segments = Math.max(6, Math.min(12, Math.round(distance / 70)))
+ const amplitude = Math.min(18, Math.max(6, distance * 0.06))
+
+ // Build points along the straight line and offset them by a seeded noise
+ const points: Array<{ x: number; y: number }> = [{ x: startX, y: startY }]
+ for (let i = 1; i < segments; i++) {
+ const t = i / segments
+ const baseX = startX + dx * t
+ const baseY = startY + dy * t
+ // Perpendicular vector
+ const px = -dy
+ const py = dx
+ const plen = Math.hypot(px, py) || 1
+ const nx = px / plen
+ const ny = py / plen
+ // Taper near the ends
+ const taper = Math.sin(Math.PI * t)
+ const rand = (seededRandom((idx + 1) * 9973 + i * 53) - 0.5) * 2 // [-1, 1]
+ const offset = rand * amplitude * taper
+ points.push({ x: baseX + nx * offset, y: baseY + ny * offset })
+ }
+ points.push({ x: endX, y: endY })
+
+ // Convert to a smooth path using quadratic curves
+ let d = `M ${points[0]!.x} ${points[0]!.y}`
+ for (let i = 1; i < points.length - 1; i++) {
+ const p1 = points[i]!
+ const p2 = points[i + 1]!
+ // Midpoint smoothing
+ const cx = p1.x
+ const cy = p1.y
+ const mx = (p1.x + p2.x) / 2
+ const my = (p1.y + p2.y) / 2
+ d += ` Q ${cx} ${cy} ${mx} ${my}`
+ }
+
+ // Accent shadow color
+ const color = RSS_READERS[idx]?.color ?? "#999999"
+ const shadow =
+ idx === 0
+ ? "rgba(43,178,76,0.25)"
+ : idx === 1
+ ? "rgba(0,123,197,0.25)"
+ : "rgba(255,152,0,0.25)"
+
+ newPaths.push({ d, color, shadow })
+ })
+
+ setPaths(newPaths)
+ }, [])
+
+ // Compute when animations are completed and on resize thereafter
+ React.useLayoutEffect(() => {
+ if (!ready) return
+ // Two rafs to ensure transforms are fully flushed
+ const id = requestAnimationFrame(() => {
+ const id2 = requestAnimationFrame(() => {
+ computePaths()
+ })
+ return () => cancelAnimationFrame(id2)
+ })
+ return () => cancelAnimationFrame(id)
+ }, [ready, computePaths])
+
+ React.useEffect(() => {
+ if (!ready) return
+ const onResize = () => computePaths()
+ window.addEventListener("resize", onResize)
+ return () => window.removeEventListener("resize", onResize)
+ }, [ready, computePaths])
+
+ const markAnimationDone = React.useCallback(
+ (key: string) => {
+ const set = completedKeysRef.current
+ if (set.has(key)) return
+ set.add(key)
+ if (set.size >= RSS_READERS.length + 1) {
+ setReady(true)
+ }
+ },
+ [setReady],
+ )
+
+ return (
+
+ {/* Right side Logo */}
+
markAnimationDone("logo")}
+ >
+
+ {/* Logo glow effect */}
+
+
+
+
+
+ {/* Left side RSS Reader Icons in vertical layout */}
+ {RSS_READERS.map((reader, index) => {
+ const totalReaders = RSS_READERS.length
+ const spacing = 70 / (totalReaders + 1) // Distribute vertically within 70% of height
+ const yPosition = 15 + spacing * (index + 1) // Start at 15%, space evenly
+
+ return (
+
markAnimationDone(`icon-${index}`)}
+ >
+ {/* Icon container */}
+ {
+ iconRefs.current[index] = el
+ }}
+ className="relative flex size-12 items-center justify-center rounded-xl backdrop-blur-sm"
+ style={{
+ backgroundColor: `${reader.color}20`,
+ borderWidth: "1px",
+ borderStyle: "solid",
+ borderColor: `${reader.color}40`,
+ boxShadow: `0 4px 12px ${reader.color}20`,
+ }}
+ >
+
+
+
+ )
+ })}
+
+ {/* Hand-drawn connector lines */}
+
+
+ {/* Slight wobble via displacement map to enhance sketch feeling (subtle) */}
+
+
+
+
+
+ {paths.map((p, index) => {
+ // Keep reveal order in sync with icon animations
+ const iconDelay = index * 0.08
+ const revealDelay = iconDelay + 0.1 // start after icon settles a bit
+ return (
+
+ {/* Underlay shadow to suggest marker bleed */}
+
+ {/* Main line */}
+
+ {/* A second, lighter stroke with slight dash to mimic hand-drawn */}
+
+
+ )
+ })}
+
+
+ {/* Ambient background glow on the right */}
+
+
+ )
+}
diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useNavigateFirstEntry.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useNavigateFirstEntry.tsx
new file mode 100644
index 000000000..54dc511fd
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useNavigateFirstEntry.tsx
@@ -0,0 +1,46 @@
+import type { FeedViewType } from "@follow-app/client-sdk"
+import { useEffect, useRef } from "react"
+
+import { ROUTE_ENTRY_PENDING } from "~/constants"
+import type { NavigateEntryOptions } from "~/hooks/biz/useNavigateEntry"
+import { useNewUserGuideState } from "~/modules/app-tip/useNewUserGuideState"
+
+import { useEntriesState } from "../context/EntriesContext"
+
+export const useNavigateFirstEntry = (
+ entriesIds: string[],
+ activeEntryId: string | undefined,
+ view: FeedViewType,
+ navigate: (options: NavigateEntryOptions) => void,
+) => {
+ const state = useEntriesState()
+ const isRemoteSource = state.type === "remote"
+ const hasAutoNavigatedRef = useRef(false)
+ const { shouldShowNewUserGuide } = useNewUserGuideState()
+ useEffect(() => {
+ if (!shouldShowNewUserGuide) return
+ if (!isRemoteSource) return
+ if (hasAutoNavigatedRef.current) return
+ if (state.isLoading || state.isFetching) return
+ if (entriesIds.length === 0) return
+ if (activeEntryId && activeEntryId !== ROUTE_ENTRY_PENDING) return
+
+ const firstEntryId = entriesIds[0]
+ if (!firstEntryId) return
+
+ hasAutoNavigatedRef.current = true
+ navigate({
+ view,
+ entryId: firstEntryId,
+ })
+ }, [
+ activeEntryId,
+ entriesIds,
+ navigate,
+ shouldShowNewUserGuide,
+ state.isFetching,
+ state.isLoading,
+ view,
+ isRemoteSource,
+ ])
+}
diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx
index c8c5a9e3d..fd0a1ae87 100644
--- a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx
+++ b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx
@@ -27,6 +27,7 @@ import { EntryColumnGrid } from "./grid"
import { useAttachScrollBeyond } from "./hooks/useAttachScrollBeyond"
import { useSnapEntryIdList } from "./hooks/useEntryIdListSnap"
import { useEntryMarkReadHandler } from "./hooks/useEntryMarkReadHandler"
+import { useNavigateFirstEntry } from "./hooks/useNavigateFirstEntry"
import { EntryListHeader } from "./layouts/EntryListHeader"
import { EntryEmptyList, EntryList } from "./list"
import { EntryRootStateContext } from "./store/EntryColumnContext"
@@ -107,6 +108,7 @@ function EntryColumnContent() {
)
const navigate = useNavigateEntry()
+
const rangeQueueRef = useRef([])
const isRefreshing = state.isFetching && !state.isFetchingNextPage
const renderAsRead = useGeneralSettingKey("renderMarkUnread")
@@ -139,6 +141,8 @@ function EntryColumnContent() {
const ListComponent = getView(view)?.gridMode ? EntryColumnGrid : EntryList
+ useNavigateFirstEntry(entriesIds, activeEntryId, view, navigate)
+
return (
))}
@@ -76,10 +77,12 @@ const CommandDropdownMenuItem = memo(
commandId,
onClick,
active,
+ disabled,
}: {
commandId: FollowCommandId
onClick: () => void
active?: boolean
+ disabled?: boolean
}) => {
const command = useCommand(commandId)
@@ -90,8 +93,9 @@ const CommandDropdownMenuItem = memo(
key={command.id}
className="pl-3"
icon={command.icon}
- onSelect={onClick}
+ onSelect={disabled ? undefined : onClick}
active={active}
+ disabled={disabled}
>
{command.label.title}
diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/actions/more-actions.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/actions/more-actions.tsx
index 2cd55ddd4..839b25e2a 100644
--- a/apps/desktop/layer/renderer/src/modules/entry-content/actions/more-actions.tsx
+++ b/apps/desktop/layer/renderer/src/modules/entry-content/actions/more-actions.tsx
@@ -101,6 +101,7 @@ export const MoreActions = ({
commandId={config.id}
onClick={handler!}
active={config.active}
+ disabled={config.disabled}
/>
)
})}
@@ -113,12 +114,13 @@ export const MoreActions = ({
if (config instanceof EntryActionDropdownItem && config.hasChildren) {
return (
-
+
@@ -128,6 +130,7 @@ export const MoreActions = ({
commandId={child.id}
onClick={resolveClick(child)!}
active={child.active}
+ disabled={child.disabled}
/>
))}
@@ -144,6 +147,7 @@ export const MoreActions = ({
commandId={config.id}
onClick={handler!}
active={config.active}
+ disabled={config.disabled}
/>
)
}
@@ -159,6 +163,7 @@ export const MoreActions = ({
commandId={config.id}
onClick={resolveClick(config)!}
active={config.active}
+ disabled={config.disabled}
/>
))}
@@ -172,11 +177,13 @@ export const CommandDropdownMenuItem = ({
onClick,
active,
asSubTrigger = false,
+ disabled = false,
}: {
commandId: FollowCommandId | string
onClick: () => void
active?: boolean
asSubTrigger?: boolean
+ disabled?: boolean
}) => {
const command = useCommand(commandId as any)
@@ -194,7 +201,13 @@ export const CommandDropdownMenuItem = ({
}
return (
-
+
{content}
)
@@ -218,8 +231,9 @@ export const CommandDropdownMenuItem = ({
key={command.id}
className="pl-3"
icon={command.icon}
- onSelect={onClick}
+ onSelect={disabled ? undefined : onClick}
active={active}
+ disabled={disabled}
>
{command.label.title}
diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx
index 8929ccc6b..db9b1ceb8 100644
--- a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx
+++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx
@@ -1,5 +1,6 @@
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js"
import { FeedViewType } from "@follow/constants"
+import { isOnboardingEntry } from "@follow/store/constants/onboarding"
import { useEntry } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { useIsInbox } from "@follow/store/inbox/hooks"
@@ -18,6 +19,7 @@ import type { TextSelectionEvent } from "~/lib/simple-text-selection"
import { useBlockActions } from "~/modules/ai-chat/store/hooks"
import { BlockSliceAction } from "~/modules/ai-chat/store/slices/block.slice"
import { EntryContentHTMLRenderer } from "~/modules/renderer/html"
+import { EntryContentMarkdownRenderer } from "~/modules/renderer/markdown"
import { WrappedElementProvider } from "~/providers/wrapped-element-provider"
import { useEntryContent, useEntryMediaInfo } from "../../hooks"
@@ -140,6 +142,9 @@ const Renderer: React.FC<{
textSelectionEnabled?: boolean
}> = ({ entryId, view, feedId, noMedia = false, content = "", translation }) => {
const mediaInfo = useEntryMediaInfo(entryId)
+ const isMarkdownEntry = useMemo(() => {
+ return isOnboardingEntry(entryId)
+ }, [entryId])
const readerRenderInlineStyle = useUISettingKey("readerRenderInlineStyle")
const stableRenderStyle = useRenderStyle()
const isInPeekModal = useInPeekModal()
@@ -156,8 +161,11 @@ const Renderer: React.FC<{
}
}, [content, tocRef])
+ const ContentRenderer = useMemo(() => {
+ return isMarkdownEntry ? EntryContentMarkdownRenderer : EntryContentHTMLRenderer
+ }, [isMarkdownEntry])
return (
-
{translation?.content || content}
-
+
)
}
diff --git a/apps/desktop/layer/renderer/src/modules/new-user-guide/discover-import-step.tsx b/apps/desktop/layer/renderer/src/modules/new-user-guide/discover-import-step.tsx
deleted file mode 100644
index dca794a69..000000000
--- a/apps/desktop/layer/renderer/src/modules/new-user-guide/discover-import-step.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { Button } from "@follow/components/ui/button/index.js"
-import { useSetAtom } from "jotai"
-
-import { useI18n } from "~/hooks/common"
-
-import { DiscoverImport } from "../discover/DiscoverImport"
-import { stepAtom } from "./store"
-
-export function DiscoverImportStep() {
- const t = useI18n()
- const setStep = useSetAtom(stepAtom)
- return (
-
-
-
-
- setStep("intro")}>
- {t.app("new_user_guide.actions.back")}
-
-
- setStep("manual-import-pre-finish")}>
- {t.app("new_user_guide.actions.finish")}
-
-
-
- )
-}
diff --git a/apps/desktop/layer/renderer/src/modules/new-user-guide/modal.tsx b/apps/desktop/layer/renderer/src/modules/new-user-guide/modal.tsx
deleted file mode 100644
index 7d13fe72c..000000000
--- a/apps/desktop/layer/renderer/src/modules/new-user-guide/modal.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { RootPortal } from "@follow/components/ui/portal/index.jsx"
-import { useState } from "react"
-
-import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
-import { DeclarativeModal } from "~/components/ui/modal/stacked/declarative-modal"
-
-import { GuideModalContent } from "./guide-modal-content"
-
-export const NewUserGuideModal = () => {
- const [open, setOpen] = useState(true)
- return (
-
-
- setOpen(false)} />
-
-
- )
-}
diff --git a/apps/desktop/layer/renderer/src/modules/new-user-guide/pre-finish.tsx b/apps/desktop/layer/renderer/src/modules/new-user-guide/pre-finish.tsx
deleted file mode 100644
index ec6852ca0..000000000
--- a/apps/desktop/layer/renderer/src/modules/new-user-guide/pre-finish.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-import { Progress } from "@follow/components/ui/progress/index.js"
-import { FeedViewType } from "@follow/constants"
-import { subscriptionSyncService } from "@follow/store/subscription/store"
-import Spline from "@splinetool/react-spline"
-import { useAtom, useAtomValue } from "jotai"
-import { useEffect, useMemo, useState } from "react"
-
-import { feedSelectionsAtom, stepAtom } from "./store"
-
-const WAIT_DURATION_MS = 5000
-
-const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
-
-export function PreFinish() {
- const feedSelections = useAtomValue(feedSelectionsAtom)
- const [step, setStep] = useAtom(stepAtom)
- const selectedFeeds = useMemo(
- () => feedSelections.filter((feed) => feed.selected),
- [feedSelections],
- )
- const [progress, setProgress] = useState(selectedFeeds.length === 0 ? 100 : 0)
- const [showProgress, setShowProgress] = useState(false)
- const isSkipFlow = step === "skip-pre-finish"
-
- useEffect(() => {
- setProgress(isSkipFlow || selectedFeeds.length === 0 ? 100 : 0)
- setShowProgress(false)
-
- const timer = window.setTimeout(() => setShowProgress(true), WAIT_DURATION_MS)
-
- return () => {
- window.clearTimeout(timer)
- }
- }, [isSkipFlow, selectedFeeds])
-
- useEffect(() => {
- let disposed = false
-
- const subscribeSelectedFeeds = async () => {
- if (selectedFeeds.length === 0) {
- if (!disposed) {
- setProgress(100)
- }
- return
- }
-
- let completed = 0
-
- for (const feed of selectedFeeds) {
- if (disposed) break
- const { url, id, title } = feed
-
- try {
- await subscriptionSyncService.subscribe({
- url,
- view: feed.analytics.view ?? FeedViewType.Articles,
- category: null,
- isPrivate: false,
- hideFromTimeline: null,
- title: title ?? null,
- feedId: id,
- listId: undefined,
- })
- } catch (error) {
- if (!disposed) {
- console.error("Failed to subscribe feed during onboarding", { feedId: id, error })
- }
- } finally {
- completed += 1
- if (!disposed) {
- setProgress(Math.round((completed / selectedFeeds.length) * 100))
- }
- }
- }
- }
-
- const run = async () => {
- const tasks: Promise[] = [sleep(WAIT_DURATION_MS)]
- if (!isSkipFlow && selectedFeeds.length > 0) {
- tasks.push(subscribeSelectedFeeds())
- }
- await Promise.allSettled(tasks)
-
- if (!disposed) {
- if (step === "manual-import-pre-finish") {
- setStep("manual-import-finish")
- } else if (step === "skip-pre-finish") {
- setStep("skip-finish")
- } else {
- setStep("finish")
- }
- }
- }
-
- run()
-
- return () => {
- disposed = true
- }
- }, [isSkipFlow, selectedFeeds, setStep, step])
-
- return (
-
- {showProgress ? (
-
- ) : null}
-
-
- )
-}
diff --git a/apps/desktop/layer/renderer/src/modules/renderer/html.tsx b/apps/desktop/layer/renderer/src/modules/renderer/html.tsx
index cb6ffe56a..099edc01d 100644
--- a/apps/desktop/layer/renderer/src/modules/renderer/html.tsx
+++ b/apps/desktop/layer/renderer/src/modules/renderer/html.tsx
@@ -14,6 +14,7 @@ import type { MarkdownImage, MarkdownRenderActions } from "~/components/ui/markd
import { TimeStamp } from "./components/TimeStamp"
import { EntryInfoContext } from "./context"
+import type { EntryContentRendererProps } from "./types"
export function EntryContentHTMLRenderer({
view,
@@ -21,12 +22,7 @@ export function EntryContentHTMLRenderer
-} & HTMLProps) {
+}: EntryContentRendererProps & HTMLProps) {
const entry = useEntry(entryId, (state) => {
const images =
state.media?.reduce(
diff --git a/apps/desktop/layer/renderer/src/modules/renderer/markdown.tsx b/apps/desktop/layer/renderer/src/modules/renderer/markdown.tsx
new file mode 100644
index 000000000..e7f153905
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/renderer/markdown.tsx
@@ -0,0 +1,107 @@
+import { FeedViewType } from "@follow/constants"
+import { useEntry } from "@follow/store/entry/hooks"
+import { getFeedById } from "@follow/store/feed/getter"
+import type { ComponentProps } from "react"
+import { useMemo } from "react"
+
+import {
+ MarkdownImageRecordContext,
+ MarkdownRenderActionContext,
+} from "~/components/ui/markdown/context"
+import { Markdown } from "~/components/ui/markdown/Markdown"
+import type { MarkdownImage, MarkdownRenderActions } from "~/components/ui/markdown/types"
+
+import { TimeStamp } from "./components/TimeStamp"
+import { EntryInfoContext } from "./context"
+import type { EntryContentRendererProps } from "./types"
+
+type MarkdownProps = Omit, "children">
+
+export function EntryContentMarkdownRenderer({
+ view,
+ feedId,
+ entryId,
+ children,
+ ...props
+}: EntryContentRendererProps & MarkdownProps) {
+ const entry = useEntry(entryId, (state) => {
+ const images =
+ state.media?.reduce(
+ (acc, media) => {
+ if (media.height && media.width) {
+ acc[media.url] = media
+ }
+ return acc
+ },
+ {} as Record,
+ ) ?? {}
+
+ const { url } = state
+
+ return {
+ images,
+ url,
+ }
+ })
+
+ const images: Record = useMemo(() => entry?.images ?? {}, [entry])
+ const actions: MarkdownRenderActions = useMemo(() => {
+ return {
+ isAudio() {
+ return view === FeedViewType.Audios
+ },
+ transformUrl(url) {
+ if (!url || url.startsWith("http")) return url
+
+ const feed = getFeedById(feedId)
+ if (url.startsWith("/") && feed?.siteUrl) return safeUrl(url, feed.siteUrl)
+
+ if (url?.startsWith(".") && entry?.url) return safeUrl(url, entry?.url)
+
+ return url
+ },
+ ensureAndRenderTimeStamp,
+ }
+ }, [entry, feedId, view])
+
+ return (
+ // eslint-disable-next-line @eslint-react/no-context-provider
+
+
+ ({ feedId, entryId }), [feedId, entryId])}>
+ {children ?? ""}
+
+
+
+ )
+}
+
+const safeUrl = (url: string, baseUrl: string) => {
+ try {
+ return new URL(url, baseUrl).href
+ } catch {
+ return url
+ }
+}
+
+const ensureAndRenderTimeStamp = (children: string) => {
+ const firstPart = children.replace(" ", " ").split(" ")[0]
+ // 00:00 , 00:00:00
+ if (!firstPart) {
+ return
+ }
+ const isTime = isValidTimeString(firstPart.trim())
+ if (isTime) {
+ return (
+ <>
+
+ {children.slice(firstPart.length)}
+ >
+ )
+ }
+ return false
+}
+function isValidTimeString(time: string): boolean {
+ const timeRegex = /^\d{1,2}:[0-5]\d(?::[0-5]\d)?$/
+ return timeRegex.test(time)
+}
diff --git a/apps/desktop/layer/renderer/src/modules/renderer/types.ts b/apps/desktop/layer/renderer/src/modules/renderer/types.ts
new file mode 100644
index 000000000..f780d6e18
--- /dev/null
+++ b/apps/desktop/layer/renderer/src/modules/renderer/types.ts
@@ -0,0 +1,8 @@
+import type { FeedViewType } from "@follow-app/client-sdk"
+
+export type EntryContentRendererProps = {
+ view: FeedViewType
+ feedId: string
+ entryId: string
+ children: Nullable
+}
diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/about.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/about.tsx
index 0e5dd3abd..81a38d5e3 100644
--- a/apps/desktop/layer/renderer/src/modules/settings/tabs/about.tsx
+++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/about.tsx
@@ -15,6 +15,7 @@ import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { ipcServices } from "~/lib/client"
import { getNewIssueUrl } from "~/lib/issues"
import { EnvironmentDebugModalContent } from "~/modules/app/EnvironmentIndicator"
+import { APP_TIP_DEBUG_EVENT } from "~/modules/app-tip"
export const SettingAbout = () => {
const { t } = useTranslation("settings")
@@ -101,6 +102,14 @@ export const SettingAbout = () => {
window.open(`https://folo.is/${path}`, "_blank")
}
+ const handleOpenAiOnboarding = () => {
+ window.dispatchEvent(
+ new CustomEvent(APP_TIP_DEBUG_EVENT, {
+ detail: { step: 0 },
+ }),
+ )
+ }
+
return (
{/* Header Section */}
@@ -188,6 +197,17 @@ export const SettingAbout = () => {
+
+
+
{t("about.appTip")}
+
{t("about.appTipDescription")}
+
+
+
{/* Legal Section */}
diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/CategoryRemoveDialogContent.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/CategoryRemoveDialogContent.tsx
index 2fbc32809..363c597f9 100644
--- a/apps/desktop/layer/renderer/src/modules/subscription-column/CategoryRemoveDialogContent.tsx
+++ b/apps/desktop/layer/renderer/src/modules/subscription-column/CategoryRemoveDialogContent.tsx
@@ -28,7 +28,7 @@ export function CategoryRemoveDialogContent({
const { dismiss } = useCurrentModal()
return (
-
+
This operation will delete your category, but the feeds it contains will be retained and
diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/CategoryUnsubscribeDialogContent.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/CategoryUnsubscribeDialogContent.tsx
index 1833ef478..0d698d0c6 100644
--- a/apps/desktop/layer/renderer/src/modules/subscription-column/CategoryUnsubscribeDialogContent.tsx
+++ b/apps/desktop/layer/renderer/src/modules/subscription-column/CategoryUnsubscribeDialogContent.tsx
@@ -36,7 +36,7 @@ export function CategoryUnsubscribeDialogContent({
const { dismiss } = useCurrentModal()
return (
-
+
{t("sidebar.category_unsubscribe_dialog.description", {
category,
diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx
index 3769556a3..c50576a04 100644
--- a/apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx
+++ b/apps/desktop/layer/renderer/src/modules/subscription-column/FeedCategory.tsx
@@ -3,9 +3,17 @@ import { useMobile } from "@follow/components/hooks/useMobile.js"
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
import { LoadingCircle } from "@follow/components/ui/loading/index.jsx"
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipPortal,
+ TooltipTrigger,
+} from "@follow/components/ui/tooltip/index.jsx"
import type { FeedViewType } from "@follow/constants"
import { getViewList } from "@follow/constants"
import { useRefValue } from "@follow/hooks"
+import { isOnboardingFeedUrl } from "@follow/store/constants/onboarding"
+import { useFeedsByIds } from "@follow/store/feed/hooks"
import { useOwnedListByView } from "@follow/store/list/hooks"
import {
useSubscriptionByFeedId,
@@ -178,6 +186,10 @@ function FeedCategoryImpl({
const subscriptionCategoryExist = useSubscriptionCategoryExist(folderName)
const isAutoGroupedCategory = !!folderName && !subscriptionCategoryExist
+ // Check if any feed in this category is an onboarding feed
+ const feeds = useFeedsByIds(ids, (feed) => feed.url)
+ const hasOnboardingFeed = useMemo(() => feeds.some((url) => isOnboardingFeedUrl(url)), [feeds])
+
const { isOver, setNodeRef } = useDroppable({
id: `category-${folderName}`,
disabled: isAutoGroupedCategory,
@@ -299,7 +311,8 @@ function FeedCategoryImpl({
ref={setNodeRef}
data-active={isActive || isContextMenuOpen}
className={cn(
- isOver && "border-orange-400 bg-orange-400/60",
+ isOver && "border-folo bg-folo/60",
+
"my-px px-2.5",
feedColumnStyles.item,
)}
@@ -312,7 +325,10 @@ function FeedCategoryImpl({
}}
{...contextMenuProps}
>
-
+
{folderName}
-
+ {hasOnboardingFeed && (
+
+
+
+
+
+ {t("feed_category.onboarding_feed")}
+
+
+ )}
)}
diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/FeedItem.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/FeedItem.tsx
index f8c005e4e..2b771d401 100644
--- a/apps/desktop/layer/renderer/src/modules/subscription-column/FeedItem.tsx
+++ b/apps/desktop/layer/renderer/src/modules/subscription-column/FeedItem.tsx
@@ -10,6 +10,7 @@ import {
} from "@follow/components/ui/tooltip/index.jsx"
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
import type { FeedViewType } from "@follow/constants"
+import { isOnboardingFeedUrl } from "@follow/store/constants/onboarding"
import { useFeedById } from "@follow/store/feed/hooks"
import { useInboxById } from "@follow/store/inbox/hooks"
import { useListById } from "@follow/store/list/hooks"
@@ -171,6 +172,7 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) =>
if (!feed) return null
const isFeed = feed.type === "feed" || !feed.type
+ const isOnboardingFeed = isOnboardingFeedUrl(feed.url)
return (
feedColumnStyles.item,
isFeed ? "py-0.5" : "py-1.5",
"justify-between py-0.5",
+
className,
)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
{...contextMenuProps}
>
-
+
- {isFeed && (
+ {isOnboardingFeed && (
+
+
+
+
+
+ {t("feed_item.onboarding_feed")}
+
+
+ )}
+ {isFeed && !isOnboardingFeed && (
)}
- {subscription?.isPrivate && (
+ {subscription?.isPrivate && !isOnboardingFeed && (
diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/SortedFeedItems.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/SortedFeedItems.tsx
index 56a7426e8..4923ead9d 100644
--- a/apps/desktop/layer/renderer/src/modules/subscription-column/SortedFeedItems.tsx
+++ b/apps/desktop/layer/renderer/src/modules/subscription-column/SortedFeedItems.tsx
@@ -1,4 +1,5 @@
import type { FeedViewType } from "@follow/constants"
+import { isOnboardingFeedUrl } from "@follow/store/constants/onboarding"
import { useFeedStore } from "@follow/store/feed/store"
import { useSortedIdsByUnread } from "@follow/store/unread/hooks"
import { sortByAlphabet } from "@follow/utils/utils"
@@ -37,16 +38,31 @@ const SortByAlphabeticalList = (props: SortListProps) => {
const sortedFeedList = useFeedStore(
useCallback(
(state) => {
- const res = ids.sort((a, b) => {
- const feedTitleA = getPreferredTitle(state.feeds[a]) || ""
- const feedTitleB = getPreferredTitle(state.feeds[b]) || ""
- return sortByAlphabet(feedTitleA, feedTitleB)
- })
+ // Separate onboarding feeds and regular feeds
+ const onboardingFeeds: string[] = []
+ const regularFeeds: string[] = []
- if (isDesc) {
- return res
+ for (const id of ids) {
+ const feed = state.feeds[id]
+ if (feed && isOnboardingFeedUrl(feed.url)) {
+ onboardingFeeds.push(id)
+ } else {
+ regularFeeds.push(id)
+ }
}
- return res.reverse()
+
+ // Sort each group
+ const sortFeeds = (feedIds: string[]) => {
+ const sorted = feedIds.sort((a, b) => {
+ const feedTitleA = getPreferredTitle(state.feeds[a]) || ""
+ const feedTitleB = getPreferredTitle(state.feeds[b]) || ""
+ return sortByAlphabet(feedTitleA, feedTitleB)
+ })
+ return isDesc ? sorted : sorted.reverse()
+ }
+
+ // Return onboarding feeds first, then regular feeds
+ return [...sortFeeds(onboardingFeeds), ...sortFeeds(regularFeeds)]
},
[ids, isDesc],
),
@@ -69,9 +85,31 @@ const SortByUnreadList = ({ ids, showCollapse, view }: SortListProps) => {
const isDesc = useFeedListSortSelector((s) => s.order === "desc")
const sortByUnreadFeedList = useSortedIdsByUnread(ids, isDesc)
+ // Separate onboarding feeds and regular feeds, then merge with onboarding first
+ const sortedList = useFeedStore(
+ useCallback(
+ (state) => {
+ const onboardingFeeds: string[] = []
+ const regularFeeds: string[] = []
+
+ for (const id of sortByUnreadFeedList) {
+ const feed = state.feeds[id]
+ if (feed && isOnboardingFeedUrl(feed.url)) {
+ onboardingFeeds.push(id)
+ } else {
+ regularFeeds.push(id)
+ }
+ }
+
+ return [...onboardingFeeds, ...regularFeeds]
+ },
+ [sortByUnreadFeedList],
+ ),
+ )
+
return (
- {sortByUnreadFeedList.map((feedId) => (
+ {sortedList.map((feedId) => (
s.order === "desc")
- let sortedByAlphabetical = Object.keys(data).sort((a, b) => {
- const nameA = categoryName2RealDisplayNameMap[a]
- const nameB = categoryName2RealDisplayNameMap[b]
- if (typeof nameA !== "string" || typeof nameB !== "string") {
- return 0
- }
- return sortByAlphabet(nameA, nameB)
- })
- if (!isDesc) {
- sortedByAlphabetical = sortedByAlphabetical.reverse()
- }
+ // Separate categories with onboarding feeds and regular categories
+ const sortedByAlphabetical = useFeedStore(
+ useCallback(
+ (state) => {
+ const onboardingCategories: string[] = []
+ const regularCategories: string[] = []
+
+ // First, separate categories
+ for (const category of Object.keys(data)) {
+ const ids = data[category]!
+ const hasOnboardingFeed = ids.some((id) => {
+ const feed = state.feeds[id]
+ return feed && isOnboardingFeedUrl(feed.url)
+ })
+
+ if (hasOnboardingFeed) {
+ onboardingCategories.push(category)
+ } else {
+ regularCategories.push(category)
+ }
+ }
+
+ // Sort each group alphabetically
+ const sortCategories = (categories: string[]) => {
+ const sorted = categories.sort((a, b) => {
+ const nameA = categoryName2RealDisplayNameMap[a]
+ const nameB = categoryName2RealDisplayNameMap[b]
+ if (typeof nameA !== "string" || typeof nameB !== "string") {
+ return 0
+ }
+ return sortByAlphabet(nameA, nameB)
+ })
+ return isDesc ? sorted : sorted.reverse()
+ }
+
+ // Return onboarding categories first, then regular categories
+ return [...sortCategories(onboardingCategories), ...sortCategories(regularCategories)]
+ },
+ [data, categoryName2RealDisplayNameMap, isDesc],
+ ),
+ )
return (
diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/sort-by/SortByUnreadList.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/sort-by/SortByUnreadList.tsx
index 67f76243b..b0ae9a8c8 100644
--- a/apps/desktop/layer/renderer/src/modules/subscription-column/sort-by/SortByUnreadList.tsx
+++ b/apps/desktop/layer/renderer/src/modules/subscription-column/sort-by/SortByUnreadList.tsx
@@ -1,5 +1,7 @@
+import { isOnboardingFeedUrl } from "@follow/store/constants/onboarding"
+import { useFeedStore } from "@follow/store/feed/store"
import { useSortedCategoriesByUnread } from "@follow/store/unread/hooks"
-import { Fragment } from "react"
+import { Fragment, useCallback } from "react"
import { useFeedListSortSelector } from "../atom"
import { FeedCategoryAutoHideUnread } from "../FeedCategory"
@@ -9,9 +11,36 @@ export const SortByUnreadFeedList = ({ view, data, categoryOpenStateData }: Feed
const isDesc = useFeedListSortSelector((s) => s.order === "desc")
const sortedByUnread = useSortedCategoriesByUnread(data, isDesc)
+ // Separate categories with onboarding feeds and regular categories
+ const sortedList = useFeedStore(
+ useCallback(
+ (state) => {
+ if (!sortedByUnread) return []
+ const onboardingCategories: [string, string[]][] = []
+ const regularCategories: [string, string[]][] = []
+
+ for (const [category, ids] of sortedByUnread) {
+ const hasOnboardingFeed = ids.some((id) => {
+ const feed = state.feeds[id]
+ return feed && isOnboardingFeedUrl(feed.url)
+ })
+
+ if (hasOnboardingFeed) {
+ onboardingCategories.push([category, ids])
+ } else {
+ regularCategories.push([category, ids])
+ }
+ }
+
+ return [...onboardingCategories, ...regularCategories]
+ },
+ [sortedByUnread],
+ ),
+ )
+
return (
- {sortedByUnread?.map(([category, ids]) => (
+ {sortedList.map(([category, ids]) => (
as read",
"mark_all_read_button.undo": "Undo",
+ "new_user_dialog.actions.close": "Close dialog",
+ "new_user_dialog.actions.finish": "Explore!",
+ "new_user_dialog.ai.description": "Answer a few prompts and Folo's AI curates feeds, lists, and reading plans for you.",
+ "new_user_dialog.ai.highlight_1": "Describe what you need in plain language.",
+ "new_user_dialog.ai.highlight_2": "Receive recommended sources with context for why they matter.",
+ "new_user_dialog.ai.highlight_3": "Keep chatting to refine summaries, tone, and frequency.",
+ "new_user_dialog.ai.primary": "Launch AI onboarding",
+ "new_user_dialog.ai.title": "Let the AI Copilot build your starter stack",
+ "new_user_dialog.import.description": "Import OPML exports from any reader and preview every feed before subscribing.",
+ "new_user_dialog.import.highlight_1": "Upload OPML files from Feedly, Inoreader, or any RSS reader.",
+ "new_user_dialog.import.highlight_2": "Preview and choose which feeds to keep.",
+ "new_user_dialog.import.highlight_3": "Organize imported feeds into folders and lists immediately.",
+ "new_user_dialog.import.primary": "Import OPML",
+ "new_user_dialog.import.title": "Bring everything you already follow",
+ "new_user_dialog.overview.description": "Folo is an AI RSS reader that brings feeds, newsletters, podcasts, and social updates into one focused home.",
+ "new_user_dialog.overview.highlight_1": "Pin feeds, lists, and topics to switch contexts instantly.",
+ "new_user_dialog.overview.highlight_2": "Use the two-column layout to skim updates while keeping the current entry open.",
+ "new_user_dialog.overview.highlight_3": "Keyboard shortcuts and filters keep you in flow.",
+ "new_user_dialog.overview.primary": "Browse Discover",
+ "new_user_dialog.overview.title": "See everything you care about in one calm home view",
+ "new_user_dialog.replay_video": "Replay video",
+ "new_user_dialog.step_label.ai": "Step 2 · AI Copilot",
+ "new_user_dialog.step_label.import": "Step 3 · Import",
+ "new_user_dialog.step_label.overview": "Step 1 · Overview",
+ "new_user_dialog.title": "Welcome to Folo",
"new_user_guide.actions.back": "Back",
"new_user_guide.actions.finish": "Finish",
- "new_user_guide.actions.import_opml": "Import OPML",
"new_user_guide.actions.next": "Next",
- "new_user_guide.actions.skip": "Skip",
"new_user_guide.ai_chat.intro": "Welcome to Folo, the AI reader that reads the Internet for you.\n\nTell me something about you.",
"new_user_guide.ai_chat.reroll": "Reroll",
"new_user_guide.ai_chat.suggestions.ai_regulation_learner": "I'm learning about AI regulation in the European Union",
@@ -293,6 +318,8 @@
"new_user_guide.confirm_skip.title": "Skip onboarding?",
"new_user_guide.intro.description": "This guide will help you get started with the app.",
"new_user_guide.intro.title": "Vibe Reading with AI",
+ "new_user_guide.selection.empty_description": "Describe what you're looking for in the AI chat and we'll recommend feeds for you.",
+ "new_user_guide.selection.empty_title": "No feeds selected yet",
"not_logged_in_notice": "You are not logged in. Please sign in to sync your subscriptions and settings across devices.",
"notify.store.default": "the store",
"notify.store.mas": "App Store",
diff --git a/locales/app/ja.json b/locales/app/ja.json
index 3642293e1..98d06190b 100644
--- a/locales/app/ja.json
+++ b/locales/app/ja.json
@@ -193,6 +193,7 @@
"feed.followsAndFeeds": "{{subscriptionCount}} {{subscriptionNoun}} と {{feedsCount}} {{feedsNoun}} on {{appName}}",
"feed.read_one": "読む",
"feed.read_other": "読む",
+ "feed_category.onboarding_feed": "このカテゴリーにはオンボーディングフィードが含まれています",
"feed_claim_modal.choose_verification_method": "選択肢は 3 つあります。そのうち 1 つを選んで確認してください。",
"feed_claim_modal.claim_button": "クレーム",
"feed_claim_modal.content_instructions": "以下の内容をコピーして、最新の RSS フィードに投稿してください。",
@@ -239,6 +240,7 @@
"feed_item.claimed_list": "クレームされたリスト",
"feed_item.error_since": "エラー発生時刻",
"feed_item.not_publicly_visible": "プロフィールページに公開されていません",
+ "feed_item.onboarding_feed": "これはオンボーディングフィードです",
"login.agree_to": " 続行することで、あなたは私たちの",
"login.back": "戻る",
"login.confirm_password.label": "パスワードの確認",
@@ -263,11 +265,33 @@
"mark_all_read_button.mark_all_as_read": "すべてを既読にする",
"mark_all_read_button.mark_as_read": " を既読にする",
"mark_all_read_button.undo": "元に戻す",
+ "new_user_dialog.actions.close": "閉じる",
+ "new_user_dialog.ai.description": "いくつかの質問に答えるだけで、Folo の AI がフィードやリスト、読み方を提案します。",
+ "new_user_dialog.ai.highlight_1": "知りたいことを自然な言葉で伝えるだけ。",
+ "new_user_dialog.ai.highlight_2": "おすすめの情報源とその理由が返ってきます。",
+ "new_user_dialog.ai.highlight_3": "チャットを続けて要約のトーンや頻度を調整。",
+ "new_user_dialog.ai.primary": "AI オンボーディングを開始",
+ "new_user_dialog.ai.title": "AI コパイロットに初期セットアップを任せる",
+ "new_user_dialog.import.description": "どのリーダーからの OPML でもインポートし、追加前にプレビューできます。",
+ "new_user_dialog.import.highlight_1": "Feedly や Inoreader などの OPML ファイルに対応。",
+ "new_user_dialog.import.highlight_2": "インポート前に残したいフィードを選別。",
+ "new_user_dialog.import.highlight_3": "新しいフィードをすぐにフォルダーやリストへ整理。",
+ "new_user_dialog.import.primary": "OPML をインポート",
+ "new_user_dialog.import.title": "既存の購読をすべて持ち込む",
+ "new_user_dialog.overview.description": "Folo はフィードやニュースレター、ポッドキャスト、SNS をひとつの集中レイアウトにまとめる AI RSS リーダーです。",
+ "new_user_dialog.overview.highlight_1": "よく使うフィードやリストをピン留めして即座に切り替え。",
+ "new_user_dialog.overview.highlight_2": "2 カラム表示で記事を開いたまま最新更新を流し見できます。",
+ "new_user_dialog.overview.highlight_3": "キーボードショートカットとフィルターで集中をキープ。",
+ "new_user_dialog.overview.primary": "Discover を開く",
+ "new_user_dialog.overview.title": "落ち着いたホームで必要な情報をすべて確認",
+ "new_user_dialog.replay_video": "動画を再生",
+ "new_user_dialog.step_label.ai": "ステップ2 · AI コパイロット",
+ "new_user_dialog.step_label.import": "ステップ3 · インポート",
+ "new_user_dialog.step_label.overview": "ステップ1 · 概要",
+ "new_user_dialog.title": "Folo へようこそ",
"new_user_guide.actions.back": "戻る",
"new_user_guide.actions.finish": "完了",
- "new_user_guide.actions.import_opml": "OPML をインポート",
"new_user_guide.actions.next": "次へ",
- "new_user_guide.actions.skip": "スキップ",
"new_user_guide.ai_chat.intro": "Folo へようこそ。Folo は AI がインターネットを読み解いてくれるリーダーです。\n\nあなたについて教えてください。",
"new_user_guide.ai_chat.reroll": "入れ替える",
"new_user_guide.ai_chat.suggestions.ai_regulation_learner": "EUのAI規制について学んでいます",
@@ -289,6 +313,8 @@
"new_user_guide.confirm_skip.title": "オンボーディングをスキップしますか?",
"new_user_guide.intro.description": "このガイドはアプリを快適に始めるためのガイドです。",
"new_user_guide.intro.title": "AI と楽しむ読書体験",
+ "new_user_guide.selection.empty_description": "AI チャットで知りたいテーマを伝えると、おすすめのフィードを提案します。",
+ "new_user_guide.selection.empty_title": "まだフィードが選択されていません",
"not_logged_in_notice": "ログインしていません。 デバイス間で購読と設定を同期するにはサインイン してください。",
"notify.store.default": "ストア",
"notify.store.mas": "App Store",
diff --git a/locales/app/zh-CN.json b/locales/app/zh-CN.json
index 2a1a59e7e..613e28371 100644
--- a/locales/app/zh-CN.json
+++ b/locales/app/zh-CN.json
@@ -193,6 +193,7 @@
"feed.followsAndFeeds": "在 {{appName}} 上有 {{subscriptionCount}} 个{{subscriptionNoun}}和 {{feedsCount}} 个{{feedsNoun}}",
"feed.read_one": "阅读",
"feed.read_other": "阅读",
+ "feed_category.onboarding_feed": "此分类包含入门引导订阅源",
"feed_claim_modal.choose_verification_method": "有三种认证方式,可任选其中一种进行认证。",
"feed_claim_modal.claim_button": "认证",
"feed_claim_modal.content_instructions": "复制以下内容,发布到需要认证的订阅源。",
@@ -241,6 +242,7 @@
"feed_item.claimed_list": "已认证列表",
"feed_item.error_since": "源失效:",
"feed_item.not_publicly_visible": "在个人页面上隐藏",
+ "feed_item.onboarding_feed": "这是一个入门引导订阅源",
"login.agree_to": "继续即表示您同意我们的",
"login.back": "返回",
"login.confirm_password.label": "确认密码",
@@ -266,11 +268,34 @@
"mark_all_read_button.mark_all_as_read": "全部标记为已读",
"mark_all_read_button.mark_as_read": "标记 为已读",
"mark_all_read_button.undo": "撤销",
+ "new_user_dialog.actions.close": "关闭",
+ "new_user_dialog.actions.finish": "即刻体验",
+ "new_user_dialog.ai.description": "回答几个提示,Folo 的 AI 会为你挑选订阅、列表与阅读计划。",
+ "new_user_dialog.ai.highlight_1": "用自然语言描述你想了解的领域。",
+ "new_user_dialog.ai.highlight_2": "获得带有推荐理由的精选来源。",
+ "new_user_dialog.ai.highlight_3": "通过对话持续微调摘要风格与频率。",
+ "new_user_dialog.ai.primary": "启动 AI 引导",
+ "new_user_dialog.ai.title": "让 AI Copilot 为你搭建起点",
+ "new_user_dialog.import.description": "导入任何阅读器导出的 OPML,并在订阅前逐条预览。",
+ "new_user_dialog.import.highlight_1": "支持 Feedly、Inoreader 等任意 RSS 阅读器的 OPML 文件。",
+ "new_user_dialog.import.highlight_2": "导入前先预览与筛选想要保留的订阅。",
+ "new_user_dialog.import.highlight_3": "将新订阅快速整理进文件夹与列表。",
+ "new_user_dialog.import.primary": "导入 OPML",
+ "new_user_dialog.import.title": "带上你已经关注的所有内容",
+ "new_user_dialog.overview.description": "Folo 是一款 AI RSS 阅读器,把资讯流、新闻简报、播客和社交更新集中在同一个沉浸式阅读空间。",
+ "new_user_dialog.overview.highlight_1": "固定常用的订阅、列表与主题,随时切换上下文。",
+ "new_user_dialog.overview.highlight_2": "双栏布局让你一边阅读条目一边浏览最新动态。",
+ "new_user_dialog.overview.highlight_3": "键盘快捷键与筛选器让专注阅读不被打断。",
+ "new_user_dialog.overview.primary": "前往发现页",
+ "new_user_dialog.overview.title": "在同一个舒适空间看到一切",
+ "new_user_dialog.replay_video": "重播视频",
+ "new_user_dialog.step_label.ai": "步骤二 · AI 助手",
+ "new_user_dialog.step_label.import": "步骤三 · 导入",
+ "new_user_dialog.step_label.overview": "步骤一 · 概览",
+ "new_user_dialog.title": "欢迎使用 Folo",
"new_user_guide.actions.back": "上一步",
"new_user_guide.actions.finish": "完成",
- "new_user_guide.actions.import_opml": "导入 OPML",
"new_user_guide.actions.next": "下一步",
- "new_user_guide.actions.skip": "跳过",
"new_user_guide.ai_chat.intro": "欢迎来到 Folo,这款 AI 阅读器可以替你阅读整个互联网。\n\n告诉我一些关于你的事情。",
"new_user_guide.ai_chat.reroll": "换一批",
"new_user_guide.ai_chat.suggestions.ai_regulation_learner": "我在学习欧盟的 AI 监管动态",
@@ -292,6 +317,8 @@
"new_user_guide.confirm_skip.title": "跳过新手引导?",
"new_user_guide.intro.description": "本指南将帮助你快速上手这款应用。",
"new_user_guide.intro.title": "和 AI 一起沉浸式阅读",
+ "new_user_guide.selection.empty_description": "在 AI 对话里描述你的需求,我们会为你推荐来源。",
+ "new_user_guide.selection.empty_title": "还没有选择任何订阅源",
"not_logged_in_notice": "您尚未登录。 请登录 以同步您的订阅和设置到其他设备。",
"notify.store.default": "商店",
"notify.store.mas": "App Store",
diff --git a/locales/app/zh-TW.json b/locales/app/zh-TW.json
index 49c230959..448a76c31 100644
--- a/locales/app/zh-TW.json
+++ b/locales/app/zh-TW.json
@@ -264,11 +264,33 @@
"mark_all_read_button.mark_all_as_read": "全部標記為已讀",
"mark_all_read_button.mark_as_read": "標記 為已讀",
"mark_all_read_button.undo": "復原",
+ "new_user_dialog.actions.close": "關閉",
+ "new_user_dialog.ai.description": "回答幾個提示,Folo 的 AI 會幫你挑選訂閱、清單與閱讀計畫。",
+ "new_user_dialog.ai.highlight_1": "用自然語言描述你的需求。",
+ "new_user_dialog.ai.highlight_2": "收到附上推薦理由的精選來源。",
+ "new_user_dialog.ai.highlight_3": "透過對話不斷微調摘要風格與頻率。",
+ "new_user_dialog.ai.primary": "啟動 AI 引導",
+ "new_user_dialog.ai.title": "交給 AI Copilot 建立你的起手式",
+ "new_user_dialog.import.description": "匯入任何閱讀器輸出的 OPML,在訂閱前逐條預覽。",
+ "new_user_dialog.import.highlight_1": "支援 Feedly、Inoreader 等所有 RSS 閱讀器的 OPML 檔。",
+ "new_user_dialog.import.highlight_2": "匯入前先預覽並挑選想保留的訂閱。",
+ "new_user_dialog.import.highlight_3": "把新訂閱立即整理進資料夾與清單。",
+ "new_user_dialog.import.primary": "匯入 OPML",
+ "new_user_dialog.import.title": "帶上你原本追蹤的所有內容",
+ "new_user_dialog.overview.description": "Folo 是一款 AI RSS 閱讀器,把資訊流、電子報、Podcast 與社群更新集中在同一個沉浸式閱讀空間。",
+ "new_user_dialog.overview.highlight_1": "釘選常用訂閱、清單與主題,立即切換情境。",
+ "new_user_dialog.overview.highlight_2": "雙欄版面讓你邊閱讀內容邊瀏覽最新更新。",
+ "new_user_dialog.overview.highlight_3": "鍵盤快捷鍵與篩選讓專注力不被打斷。",
+ "new_user_dialog.overview.primary": "前往發現頁",
+ "new_user_dialog.overview.title": "在同一個舒適空間掌握所有資訊",
+ "new_user_dialog.replay_video": "重播影片",
+ "new_user_dialog.step_label.ai": "步驟二 · AI 助手",
+ "new_user_dialog.step_label.import": "步驟三 · 匯入",
+ "new_user_dialog.step_label.overview": "步驟一 · 概覽",
+ "new_user_dialog.title": "歡迎使用 Folo",
"new_user_guide.actions.back": "上一步",
"new_user_guide.actions.finish": "完成",
- "new_user_guide.actions.import_opml": "匯入 OPML",
"new_user_guide.actions.next": "下一步",
- "new_user_guide.actions.skip": "跳過",
"new_user_guide.ai_chat.intro": "歡迎來到 Folo,這款 AI 閱讀器會為您讀遍整個網路。\n\n跟我分享一些關於您的事吧。",
"new_user_guide.ai_chat.reroll": "換一批",
"new_user_guide.ai_chat.suggestions.ai_regulation_learner": "我在學習歐盟的 AI 監管最新動向",
@@ -290,6 +312,8 @@
"new_user_guide.confirm_skip.title": "要跳過新手引導嗎?",
"new_user_guide.intro.description": "這份指南將幫助您快速上手這款應用程式。",
"new_user_guide.intro.title": "和 AI 一起沉浸式閱讀",
+ "new_user_guide.selection.empty_description": "在 AI 對話中描述您的需求,我們會推薦合適的來源。",
+ "new_user_guide.selection.empty_title": "尚未選擇任何來源",
"notify.store.default": "商店",
"notify.store.mas": "App Store",
"notify.store.mss": "Microsoft Store",
diff --git a/locales/settings/en.json b/locales/settings/en.json
index 7c5e6184a..a263a961d 100644
--- a/locales/settings/en.json
+++ b/locales/settings/en.json
@@ -1,4 +1,7 @@
{
+ "about.aiOnboardingDescription": "Revisit the interactive onboarding tour.",
+ "about.appTip": "App Features",
+ "about.appTipDescription": "App features introduction and usage guide.",
"about.changelog": "Changelog",
"about.changelogDescription": "See what's new in each version",
"about.checkForUpdates": "Check for Updates",
diff --git a/locales/settings/ja.json b/locales/settings/ja.json
index 4f9d5ed35..345c96840 100644
--- a/locales/settings/ja.json
+++ b/locales/settings/ja.json
@@ -1,4 +1,7 @@
{
+ "about.aiOnboardingDescription": "インタラクティブなオンボーディングをもう一度体験できます。",
+ "about.appTip": "App 機能",
+ "about.appTipDescription": "App 機能紹介と使用ガイド。",
"about.changelog": "変更履歴",
"about.changelogDescription": "各バージョンの新機能を確認",
"about.checkForUpdates": "アップデートを確認",
diff --git a/locales/settings/zh-CN.json b/locales/settings/zh-CN.json
index f1009e089..dd9420ee2 100644
--- a/locales/settings/zh-CN.json
+++ b/locales/settings/zh-CN.json
@@ -1,4 +1,6 @@
{
+ "about.appTip": "App 功能",
+ "about.appTipDescription": "App 功能介绍与使用指南。",
"about.changelog": "更新日志",
"about.changelogDescription": "查看每个版本的新功能",
"about.checkForUpdates": "检查更新",
diff --git a/locales/settings/zh-TW.json b/locales/settings/zh-TW.json
index 431887dad..33d26427d 100644
--- a/locales/settings/zh-TW.json
+++ b/locales/settings/zh-TW.json
@@ -1,4 +1,7 @@
{
+ "about.aiOnboardingDescription": "重新體驗互動式新手導覽。",
+ "about.appTip": "App 功能",
+ "about.appTipDescription": "App 功能介紹與使用指南。",
"about.changelog": "更新日誌",
"about.changelogDescription": "查看每個版本的新功能",
"about.checkForUpdates": "檢查更新",
diff --git a/packages/internal/store/src/constants/onboarding.ts b/packages/internal/store/src/constants/onboarding.ts
new file mode 100644
index 000000000..041df4b20
--- /dev/null
+++ b/packages/internal/store/src/constants/onboarding.ts
@@ -0,0 +1,15 @@
+import { getEntry } from "../modules/entry/getter"
+
+const ONBOARDING_ENTRY_URL_PREFIX = "follow://onboarding"
+
+export const isOnboardingEntryUrl = (url?: string | null) => {
+ return typeof url === "string" && url.startsWith(ONBOARDING_ENTRY_URL_PREFIX)
+}
+
+export const isOnboardingEntry = (entryId: string) => {
+ return isOnboardingEntryUrl(getEntry(entryId)?.url)
+}
+
+export const isOnboardingFeedUrl = (url?: string | null) => {
+ return typeof url === "string" && url.startsWith(ONBOARDING_ENTRY_URL_PREFIX)
+}
diff --git a/packages/internal/store/src/modules/subscription/utils.ts b/packages/internal/store/src/modules/subscription/utils.ts
index e8fbc2193..3ad1a1a82 100644
--- a/packages/internal/store/src/modules/subscription/utils.ts
+++ b/packages/internal/store/src/modules/subscription/utils.ts
@@ -1,3 +1,4 @@
+import { isOnboardingFeedUrl } from "@follow/store/constants/onboarding"
import { capitalizeFirstLetter, parseUrl } from "@follow/utils/utils"
import { getFeedById } from "../feed/getter"
@@ -29,6 +30,11 @@ export const getDefaultCategory = (subscription?: SubscriptionModel) => {
if (!subscription) return null
const { feedId } = subscription
if (!feedId) return null
+
+ const feed = getFeedById(feedId)
+ if (!feed) return null
+ const isOnboardingFeed = isOnboardingFeedUrl(feed.url)
+ if (isOnboardingFeed) return "Onboarding Feeds"
const siteUrl = getFeedById(feedId)?.siteUrl
if (!siteUrl) return null
const parsed = parseUrl(siteUrl)