feat: refine onboarding (#4708)

* feat(ai-onboarding): implement AI onboarding modal and related components

- Added AI onboarding modal with step-by-step guidance for new users.
- Introduced components for feed selection, import steps, and chat interface.
- Integrated animations and user interactions for a smoother onboarding experience.
- Updated localization files to support new onboarding dialog texts.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(app-tip): enhance onboarding experience with next step functionality

- Added 'hasNextStep' prop to AppTip and AppTipDialog components to manage step navigation.
- Updated button actions to reflect the new step logic, allowing users to proceed or finish the onboarding.
- Introduced new localization strings for onboarding descriptions and actions in multiple languages.
- Added a button in the settings about tab to trigger the AI onboarding experience.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor(app-tip): remove video placeholder and related constants for a cleaner onboarding experience

- Eliminated video placeholder text and associated constants from AppTipMediaPreview and constants.ts.
- Updated useAppTipController to remove media references, streamlining the onboarding steps.
- Revised localization strings to reflect the new onboarding narrative without video references.

Signed-off-by: Innei <tukon479@gmail.com>

* refactor(ai-onboarding): streamline onboarding flow and enhance UI components

- Removed the DiscoverImportStep and PreFinish components to simplify the onboarding process.
- Updated step management in the onboarding modal to focus on essential steps.
- Enhanced the AIChatPane and FeedsSelectionList components with improved layout and animations.
- Introduced new localization strings for empty feed selection scenarios.
- Updated modal transitions for a smoother user experience.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(app-tip): enhance media preview component to support image and video types

- Added support for distinguishing between video and image media types in AppTipMediaPreview.
- Updated rendering logic to conditionally display video or image elements based on media type.
- Introduced a fallback icon for error handling based on media type.
- Enhanced state management for video playback and replay functionality.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(renderer): add support for Markdown rendering in ArticleLayout

- Introduced EntryContentMarkdownRenderer for handling Markdown content.
- Updated ArticleLayout to conditionally render HTML or Markdown based on entry type.
- Refactored EntryContentHTMLRenderer to use a shared props type for better type safety.
- Added types for entry content renderer props to improve code clarity.

This enhancement allows for more flexible content rendering in the application.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(entry-actions): integrate onboarding entry checks into action configurations

- Added `isOnboardingEntryUrl` utility to identify onboarding entries based on their URL.
- Updated `useEntryActions` to disable specific actions when the entry is an onboarding entry.
- Enhanced `MoreActions` and `HeaderActions` components to support the disabled state for actions based on onboarding status.
- Refactored `ArticleLayout` to utilize the new onboarding entry check for rendering logic.

This update improves user experience by preventing certain actions on onboarding entries.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(app-tip): enhance onboarding experience with new user guide state management

- Introduced `useNewUserGuideState` for managing the state of the new user onboarding guide.
- Added `APP_TIP_DISMISS_EVENT` constant for handling dismissal events.
- Refactored `useAppTipController` to utilize the new state management, improving clarity and functionality.
- Updated `EntryColumnContent` to integrate new user guide navigation logic.

This update streamlines the onboarding process and enhances user experience by providing better state management for onboarding tips.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(app-tip): enhance media rendering in AppTipDialog and introduce OpmlAbstractGraphic

- Updated AppTipDialog to conditionally render a custom React node for media previews.
- Added reactNode property to AppTipStepMedia type for improved media handling.
- Introduced OpmlAbstractGraphic component for enhanced visual representation in onboarding tips.
- Updated useAppTipController to utilize the new OpmlAbstractGraphic in onboarding media.

These changes improve the onboarding experience by providing more dynamic media content.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(OpmlAbstractGraphic): implement animated paths and dynamic rendering

- Added a new OpmlAbstractGraphic component to visually represent RSS readers with animated paths.
- Introduced deterministic pseudo-random path generation for smoother animations.
- Enhanced component responsiveness with layout effects and resize handling.
- Implemented hand-drawn style connector lines to improve visual appeal.

These changes enhance the onboarding experience by providing a more engaging and dynamic visual representation of RSS readers.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(app-tip): add extra content support in AppTipDialog and enhance useAppTipController

- Updated AppTipDialog to render additional content through the new `extra` property in AppTipStep.
- Modified AppTipStep type to include an optional `extra` field for custom React nodes.
- Introduced AiSplineIndicatorToggle component in useAppTipController for toggling AI settings.

These changes improve the flexibility of the AppTipDialog and enhance the onboarding experience by allowing for dynamic content rendering.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(app-tip): implement useAppTipController for enhanced onboarding experience

- Introduced useAppTipController to manage the onboarding flow and user guide interactions.
- Added state management for active steps, dismissal handling, and AI guide toggling.
- Integrated AiSplineIndicatorToggle for dynamic AI settings within the onboarding process.
- Enhanced event handling for debugging onboarding steps through custom events.

These changes improve the onboarding experience by providing a structured and interactive guide for new users.

Signed-off-by: Innei <tukon479@gmail.com>

* feat(app-tip): introduce AICopilotMedia component for enhanced AI interaction

- Added AICopilotMedia component to facilitate dynamic AI-driven conversations and recommendations.
- Integrated chat script for user-assistant interactions, showcasing AI capabilities in real-time.
- Updated useAppTipController to include AICopilotMedia in the onboarding flow, enhancing user engagement.

These changes improve the onboarding experience by providing interactive AI content that guides users through relevant information and resources.

Signed-off-by: Innei <tukon479@gmail.com>

* fix: update hoverMarkUnread setting to false for consistency

* feat: implement onboarding feed and entry detection

- Added utility functions to identify onboarding feeds and entries.
- Updated various components to display onboarding feeds distinctly.
- Enhanced sorting logic to prioritize onboarding feeds in lists.
- Added localization strings for onboarding feed messages.

Signed-off-by: Innei <tukon479@gmail.com>

* feat: add OverviewMedia component for enhanced onboarding visuals

- Introduced a new OverviewMedia component to display animated connections between onboarding elements.
- Integrated OverviewMedia into the app tip controller to enrich the new user dialog experience.
- Implemented responsive path calculations and animations for a dynamic visual presentation.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
Co-authored-by: DIYgod <i@diygod.me>
This commit is contained in:
Innei 2025-11-18 01:16:40 +08:00 committed by GitHub
parent 18eb4559f0
commit c7d0165eb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 2177 additions and 327 deletions

View File

@ -11,6 +11,19 @@ import { useCurrentModal } from "./hooks"
export const PlainModal = ({ children }: PropsWithChildren) => children
export const PlainWithAnimationModal = ({ children }: PropsWithChildren) => {
return (
<m.div
initial={true}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={Spring.presets.smooth}
>
{children}
</m.div>
)
}
export { PlainModal as NoopChildren }
type ModalTemplateType = {

View File

@ -2,6 +2,7 @@ import { isMobile } from "@follow/components/hooks/useMobile.js"
import { FeedViewType, getView, UserRole } from "@follow/constants"
import { IN_ELECTRON } from "@follow/shared/constants"
import { useIsEntryStarred } from "@follow/store/collection/hooks"
import { isOnboardingEntryUrl } from "@follow/store/constants/onboarding"
import { useEntry } from "@follow/store/entry/hooks"
import { entrySyncServices } from "@follow/store/entry/store"
import type { EntryModel } from "@follow/store/entry/types"
@ -265,6 +266,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
const shortcuts = useCommandShortcuts()
const isCurrentVisitEntry = routeEntryId === entryId
const isOnboardingEntry = isOnboardingEntryUrl(entry?.url)
const actionConfigs: EntryActionItem[] = useMemo(() => {
if (!hasEntry) return []
@ -334,6 +336,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
onClick: runCmdFn(COMMAND_ID.entry.copyLink, [{ entryId }]),
hide: !entry.url,
shortcut: shortcuts[COMMAND_ID.entry.copyLink],
disabled: isOnboardingEntry,
entryId,
}),
new EntryActionMenuItem({
@ -346,6 +349,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
id: COMMAND_ID.entry.imageGallery,
hide: entry.imagesLength <= 5,
onClick: runCmdFn(COMMAND_ID.entry.imageGallery, [{ entryId }]),
disabled: isOnboardingEntry,
entryId,
}),
new EntryActionMenuItem({
@ -353,6 +357,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
hide: !entry.url,
onClick: runCmdFn(COMMAND_ID.entry.openInBrowser, [{ entryId }]),
shortcut: shortcuts[COMMAND_ID.entry.openInBrowser],
disabled: isOnboardingEntry,
entryId,
}),
new EntryActionMenuItem({
@ -362,6 +367,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
]),
hide: isMobile() || !entry.url,
active: isShowSourceContent,
disabled: isOnboardingEntry,
entryId,
}),
new EntryActionMenuItem({
@ -423,6 +429,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
hide: !!entry.readability || (view && getView(view)?.wideMode) || !entry.url,
active: isEntryInReadability,
notice: !entry.doesContentContainsHTMLTags && !isEntryInReadability,
disabled: isOnboardingEntry,
entryId,
}),
@ -492,6 +499,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view: Feed
isEntryInReadability,
integrationSettings.customIntegration,
integrationSettings.enableCustomIntegration,
isOnboardingEntry,
])
return actionConfigs

View File

@ -1,4 +1,4 @@
import { Logo } from "@follow/components/icons/logo.jsx"
import { Folo } from "@follow/components/icons/folo.js"
import { Button } from "@follow/components/ui/button/index.js"
import type { LexicalRichEditorRef } from "@follow/components/ui/lexical-rich-editor/index.js"
import {
@ -10,7 +10,7 @@ import { useIsDark } from "@follow/hooks"
import { tracker } from "@follow/tracker"
import { nextFrame } from "@follow/utils"
import { cn } from "@follow/utils/utils"
import { AnimatePresence } from "framer-motion"
import { AnimatePresence, m } from "framer-motion"
import { useSetAtom } from "jotai"
import type { EditorState } from "lexical"
import { $getRoot, $getSelection, $isRangeSelection, createEditor } from "lexical"
@ -19,7 +19,7 @@ import type { RefObject } from "react"
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import { useEventCallback } from "usehooks-ts"
import { useDialog } from "~/components/ui/modal/stacked/hooks"
import { GlassButton } from "~/components/ui/button/GlassButton"
import { useI18n } from "~/hooks/common"
import { ChatInput } from "~/modules/ai-chat/components/layouts/ChatInput"
import { useAttachScrollBeyond } from "~/modules/ai-chat/hooks/useAttachScrollBeyond"
@ -103,17 +103,14 @@ function pickSuggestionKeys(previous?: readonly SuggestionKey[]): SuggestionKey[
export function AIChatPane() {
return (
<div className="flex h-full flex-col justify-between gap-8 overflow-hidden bg-background p-2 lg:col-span-6">
<div className="flex h-full flex-col justify-between gap-8 overflow-hidden p-2">
<AIChatPaneImpl />
</div>
)
}
function AIChatPaneImpl() {
const t = useI18n()
const setStep = useSetAtom(stepAtom)
const dialog = useDialog()
const hasMessages = useHasMessages()
const chatInputRef = useRef<LexicalRichEditorRef | null>(null)
@ -148,32 +145,18 @@ function AIChatPaneImpl() {
}
const handleSkip = useEventCallback(async () => {
const confirmed = await dialog.ask({
title: t.app("new_user_guide.confirm_skip.title") as string,
message: t.app("new_user_guide.confirm_skip.message") as string,
confirmText: t.app("new_user_guide.actions.skip") as string,
})
if (!confirmed) {
return
}
setStep("skip-pre-finish")
setStep("finish")
})
return (
<div className="relative flex h-full flex-col">
<header className="flex w-full items-start justify-between px-5 pb-5">
<Logo className="size-12" />
<header className="flex w-full items-start justify-between px-4 pb-4 pt-2">
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={handleSkip}>
{t.app("new_user_guide.actions.skip")}
</Button>
<Button variant="outline" onClick={() => setStep("manual-import")}>
{t.app("new_user_guide.actions.import_opml")}
</Button>
<Folo className="size-9" /> <span className="text-xl font-semibold">AI</span>
</div>
<GlassButton onClick={handleSkip} variant="flat">
<i className="i-mgc-close-cute-re" />
</GlassButton>
</header>
<AnimatePresence mode="popLayout">
@ -205,10 +188,16 @@ function Welcome({ onSuggestionClick }: WelcomeProps) {
})
return (
<div className="flex flex-col items-start gap-5 p-5">
<div className="flex flex-col items-start gap-5">
<div className="space-y-4">
<p className="text-2xl leading-snug">
<m.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="flex flex-col items-start gap-4 px-4 py-3"
>
<div className="flex flex-col items-start gap-4">
<div className="space-y-3">
<p className="text-xl leading-snug">
{(t.app("new_user_guide.ai_chat.intro") as string).split("\n").map((line) => (
<Fragment key={line}>
{line}
@ -219,7 +208,7 @@ function Welcome({ onSuggestionClick }: WelcomeProps) {
</div>
</div>
<div className="space-y-3">
<div className="w-full space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs font-medium uppercase text-text-secondary">
{t.app("new_user_guide.ai_chat.you_can_say")}
@ -237,7 +226,7 @@ function Welcome({ onSuggestionClick }: WelcomeProps) {
<AIShortcutButton
key={suggestionKey}
onClick={() => 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) {
</div>
<FinishListener />
</div>
</m.div>
)
}
// 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 && (
<div>
<Button onClick={() => setStep("pre-finish")}>
{t.app("new_user_guide.actions.next")}
<Button onClick={() => setStep("finish")}>
{t.app("new_user_guide.actions.finish")}
</Button>
</div>
)}

View File

@ -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 (
<div className="grid h-screen w-screen grid-cols-1 divide-x overflow-hidden bg-theme-background p-5 lg:grid-cols-10">
<FeedsSelectionList />
<AIChatPane />
<div className="relative flex size-full flex-col overflow-hidden lg:flex-row">
{/* Left side - Feed Selection (45% width on large screens) */}
<div className="overflow-hidden lg:w-2/5">
<FeedsSelectionList />
</div>
{/* Gradient divider */}
<div
className="hidden w-px flex-shrink-0 lg:block"
style={{
background:
"linear-gradient(to bottom, transparent, rgba(255, 92, 0, 0.2), transparent)",
}}
/>
{/* Right side - AI Chat (55% width on large screens) */}
<div className="flex-1 overflow-hidden lg:w-[55%]">
<AIChatPane />
</div>
</div>
)
}
case "manual-import": {
return <DiscoverImportStep />
}
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 (
<AIChatRoot>
<div className="flex h-screen w-screen flex-col items-center justify-center overflow-hidden">
<div className="mx-auto flex flex-col gap-8">{content}</div>
<div className="absolute inset-8 flex flex-col overflow-hidden rounded-xl bg-background">
{content}
</div>
</AIChatRoot>
)

View File

@ -39,7 +39,7 @@ export function FeedsSelectionList() {
}, [hasFeedsSelection, setStep])
return (
<div className="col-span-4 h-full overflow-hidden">
<div className="h-full overflow-hidden">
<AnimatePresence mode="popLayout">
{hasFeedsSelection ? <FeedSelectionOperationScreen /> : <FeedSelectionFirstScreen />}
</AnimatePresence>
@ -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 (
<div className="flex h-full flex-col items-center justify-center px-8 text-center">
<i className="i-mgc-inbox-cute-re mb-4 text-6xl text-text-secondary" aria-hidden />
<p className="text-base font-semibold text-text">
{t.app("new_user_guide.selection.empty_title")}
</p>
<p className="mt-2 max-w-sm text-sm text-text-secondary">
{t.app("new_user_guide.selection.empty_description")}
</p>
</div>
)
}
return (
<ScrollArea flex rootClassName="h-full" viewportClassName="px-3 flex min-h-0 grow">
<div className="flex flex-col gap-5 py-5">
@ -187,11 +203,11 @@ function FeedSelectionFirstScreen() {
return (
<m.div
className="relative mr-4 h-full overflow-hidden rounded-3xl border border-zinc-200/50 bg-material-thin p-8 backdrop-blur-xl dark:border-zinc-800/50"
className="relative h-full overflow-hidden p-8"
aria-hidden="true"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.5, ease: "easeOut" }}
>
{/* Grid background - consistent with app patterns */}
@ -203,7 +219,7 @@ function FeedSelectionFirstScreen() {
<m.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.6, type: "spring" }}
transition={{ delay: 0.2, duration: 0.6, type: "spring" }}
className="mb-6"
>
<div className="mx-auto mb-4 flex items-center justify-center">
@ -215,7 +231,7 @@ function FeedSelectionFirstScreen() {
<m.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.3, duration: 0.6, ease: "easeOut" }}
transition={{ delay: 0.4, duration: 0.6, ease: "easeOut" }}
className="mb-4"
>
<h1 className="bg-gradient-to-r from-zinc-800 to-zinc-600 bg-clip-text text-4xl font-bold text-transparent dark:from-zinc-100 dark:to-zinc-300">
@ -227,8 +243,8 @@ function FeedSelectionFirstScreen() {
<m.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.5, duration: 0.6, ease: "easeOut" }}
className="mb-8 max-w-sm"
transition={{ delay: 0.6, duration: 0.6, ease: "easeOut" }}
className="mb-8 max-w-md"
>
<p className="text-lg leading-relaxed text-text-secondary">
{t.app("new_user_guide.intro.description")}

View File

@ -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 (
<div className="center h-full">
<m.div
initial={{ scale: 0.95, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.95, opacity: 0, y: 20 }}
transition={Spring.presets.smooth}
className="relative flex h-[85vh] w-[90vw] max-w-[1400px] flex-col overflow-hidden"
>
<div className="relative z-10 flex size-full flex-col overflow-hidden">{children}</div>
</m.div>
</div>
)
}
export const AiOnboardingModal = () => {
const [open, setOpen] = useState(true)
return (
<RootPortal>
<DeclarativeModal
id="ai-onboarding"
title="AI Onboarding"
CustomModalComponent={Modal}
modalContainerClassName="flex items-center justify-center"
open={open}
canClose={false}
clickOutsideToDismiss={false}
overlay
>
<AiOnboardingModalContent onClose={() => setOpen(false)} />
</DeclarativeModal>
</RootPortal>
)
}

View File

@ -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

View File

@ -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() {
</main>
</EntriesProvider>
<NewUserGuide />
<AppTip />
{isAuthFail && !user && (
<RootPortal>
@ -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 }) => (
<m.GuideModalContent
<m.AiOnboardingModalContent
onClose={() => {
dismiss()
}}

View File

@ -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 ? (
<Suspense>
<LazyNewUserGuideModal />
</Suspense>
) : null
}

View File

@ -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 — Ive 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 (
<div className="flex size-full flex-col gap-4 p-6">
<Header />
<ScrollArea
flex
rootClassName="-mx-6 -mb-6 flex min-h-0 flex-1"
viewportClassName="flex-col gap-4 pb-6 px-6"
>
<ChatPreview
messages={messagesToRender}
streamingId={lastVisible?.id}
showRecommendations={showRecommendations}
/>
</ScrollArea>
</div>
)
}
const Header: React.FC = () => {
return (
<m.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={Spring.smooth(0.4)}
className="relative"
>
<div className="relative flex items-center justify-between">
<div className="flex items-center gap-3">
{/* AI Icon with glow effect */}
<m.div
className="relative"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={Spring.bouncy(0.5, 0.1)}
>
<AISpline className="size-8" />
</m.div>
{/* Text content */}
<m.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={Spring.snappy(0.4)}
className="flex flex-col"
>
<div className="flex items-center gap-2">
<Folo className="size-7" />
<span className="text-sm font-semibold text-text">AI</span>
</div>
<span className="text-xs text-text-secondary">
Summarize, search, and curate for you
</span>
</m.div>
</div>
</div>
</m.div>
)
}
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 (
<div className="relative flex min-h-[240px] flex-1 flex-col overflow-hidden">
<div className="relative flex-1">
<div className="flex flex-col gap-4">
{messages.map((message, index) => {
const isUser = message.role === "user"
const delay = index * 0.1
return (
<m.div
key={message.id}
initial={{ opacity: 0, y: 12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={Spring.snappy(0.5, 0.05)}
className={cn("flex", isUser ? "justify-end" : "justify-start")}
style={{
animationDelay: `${delay}s`,
}}
>
<div className={cn("flex gap-2", isUser ? "flex-row-reverse" : "flex-row")}>
{/* Message bubble */}
<m.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={Spring.smooth(0.4)}
className={cn(
"group relative max-w-[85%] overflow-hidden rounded-2xl",
isUser ? "border-fill bg-fill/50" : "border-fill/30 shadow-sm",
)}
>
<div className="relative px-3.5 py-2.5">
{isUser ? (
<UserMessageParts message={buildUserBizMessage(message.id, message.text)} />
) : (
<>
<AIMarkdownStreamingMessage
text={message.text}
isStreaming={message.id === streamingId}
className="text-text"
/>
</>
)}
</div>
</m.div>
</div>
</m.div>
)
})}
{/* Recommendations section - appears after conversation */}
{showRecommendations && (
<m.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={Spring.smooth(0.5)}
className="mt-2"
>
{/* AI Avatar + Recommendation Card Container */}
<div className="flex gap-2">
{/* Recommendation Cards */}
<div className="min-w-0 flex-1 overflow-hidden rounded-2xl border border-fill/30 shadow-sm">
{/* Header */}
<div className="border-b border-fill/20 bg-gradient-to-r from-orange/5 via-pink/5 to-purple/5 px-3 py-2.5">
<m.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={Spring.snappy(0.4)}
className="flex items-center gap-2"
>
<m.div
animate={{
rotate: [0, 10, -10, 0],
scale: [1, 1.1, 1],
}}
transition={{
repeat: Infinity,
duration: 3,
ease: "easeInOut",
}}
>
<i className="i-mingcute-sparkles-2-line size-4 text-orange" />
</m.div>
<span className="text-xs font-semibold text-text">Recommended Feeds</span>
</m.div>
</div>
{/* Feed cards */}
<div className="min-w-0 divide-y divide-fill/10 p-2">
{feeds.map((feed, index) => (
<m.div
key={feed.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={Spring.snappy(0.4, 0.05)}
style={{
animationDelay: `${index * 0.15}s`,
}}
whileHover={{ scale: 1.005 }}
className="group relative overflow-hidden rounded-lg p-2.5 transition-colors hover:bg-material-thin/50"
>
{/* Icon with gradient background */}
<div className="relative flex items-start gap-2.5">
<m.div
whileHover={{ rotate: [0, -5, 5, 0] }}
transition={{ duration: 0.5 }}
className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-fill to-fill-secondary ring-1 ring-fill/50"
>
<i className="i-mingcute-rss-line size-4 text-text" />
</m.div>
<div className="min-w-0 flex-1">
{/* Feed title */}
<h4 className="mb-0.5 truncate text-xs font-semibold text-text">
{feed.title}
</h4>
{/* Feed description */}
<p className="mb-1 line-clamp-2 text-[11px] leading-snug text-text-secondary">
{feed.description}
</p>
{/* URL hint */}
<div className="flex items-center gap-1 text-[10px] text-text-tertiary">
<i className="i-mgc-link-cute-re size-2.5" />
<span className="truncate">{feed.siteUrl}</span>
</div>
</div>
</div>
</m.div>
))}
</div>
</div>
</div>
</m.div>
)}
</div>
</div>
</div>
)
}

View File

@ -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 (
<>
<AppTipDialog
open={shouldShowDialog}
steps={steps}
activeStep={activeStepData}
activeStepIndex={activeStepIndex}
onSelectStep={(idx) => setActiveStep(idx)}
onDismiss={handleDismiss}
hasNextStep={activeStepIndex < steps.length - 1}
/>
{showAiGuide && (
<Suspense fallback={null}>
<LazyAppTipModal />
</Suspense>
)}
</>
)
}

View File

@ -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 (
<DeclarativeModal
id="ai-onboarding"
title={t("new_user_dialog.title")}
CustomModalComponent={PlainWithAnimationModal}
modalContainerClassName="flex items-center justify-center"
modalClassName="w-full max-w-5xl"
open={open}
overlay={false}
canClose={false}
clickOutsideToDismiss={false}
>
<section className="shadow-modal relative grid min-h-[500px] overflow-hidden rounded-lg border border-border bg-background text-text lg:grid-cols-[1.2fr,1fr]">
<div className="relative flex aspect-square items-center justify-center overflow-hidden bg-background">
{activeStep.media?.reactNode ? (
<div className="absolute inset-0 aspect-square w-full overflow-hidden bg-material-medium">
{activeStep.media?.reactNode}
</div>
) : (
<AppTipMediaPreview media={activeStep.media} />
)}
</div>
<div className="relative flex w-[500px] flex-col border-t border-border bg-background lg:border-l lg:border-t-0">
<GlassButton
onClick={onDismiss}
variant="flat"
className="absolute right-4 top-4 z-10"
aria-label={t("new_user_dialog.actions.close")}
>
<i className="i-mgc-close-cute-re" />
</GlassButton>
<div className="flex flex-1 flex-col gap-6 p-8">
<div className="flex flex-col gap-3">
<h2 className="text-2xl font-semibold leading-tight text-text">{activeStep.title}</h2>
<p className="text-sm leading-relaxed text-text-secondary">
{activeStep.description}
</p>
</div>
<ul className="space-y-2.5 text-sm text-text-secondary">
{activeStep.highlights.map((point, idx) => (
<li key={`${activeStep.id}-${idx}`} className="flex items-start gap-2.5">
<span className="mt-2 flex size-1.5 rounded-full bg-text-tertiary" />
<span className="leading-relaxed">{point}</span>
</li>
))}
</ul>
{activeStep.extra}
</div>
<div className="border-t border-border p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
{steps.map((step, idx) => (
<button
type="button"
key={step.id}
onClick={() => 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",
)}
/>
))}
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={"outline"}
buttonClassName="h-8"
onClick={activeStep.onPrimaryAction}
>
{activeStep.primaryActionLabel}
</Button>
<Button size="sm" buttonClassName="h-8" onClick={handleNextStep}>
{hasNextStep
? t("words.next", { ns: "common" })
: t("new_user_dialog.actions.finish")}
</Button>
</div>
</div>
</div>
</div>
</section>
</DeclarativeModal>
)
}

View File

@ -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<HTMLVideoElement>(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 (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-fill px-6 py-10 text-center">
<i className={`${fallbackIcon} mb-3 text-2xl text-text-tertiary`} />
</div>
)
}
return (
<div className="absolute inset-0 aspect-square w-full overflow-hidden bg-material-medium">
{isVideo ? (
<>
<video
ref={videoRef}
key={media.src}
className="size-full object-cover"
src={media.src}
poster={media.poster}
playsInline
muted
autoPlay
preload="metadata"
onError={() => setHasError(true)}
/>
<AnimatePresence>
{showReplay && (
<m.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={Spring.presets.snappy}
onClick={handleReplay}
className="absolute right-3 top-3 flex size-10 items-center justify-center rounded-full bg-material-ultra-thick backdrop-blur-background transition-colors hover:bg-material-thick"
aria-label={t("new_user_dialog.replay_video")}
>
<i className="i-mgc-refresh-2-cute-re text-lg text-text" />
</m.button>
)}
</AnimatePresence>
</>
) : (
<img
key={media.src}
className="size-full object-cover"
src={media.src}
alt={media.caption ?? ""}
loading="lazy"
decoding="async"
onError={() => setHasError(true)}
/>
)}
</div>
)
}

View File

@ -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<HTMLDivElement | null>(null)
const logoRef = React.useRef<HTMLDivElement | null>(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<Set<string>>(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 (
<div
ref={containerRef}
className={cn("relative aspect-square w-full overflow-hidden bg-material-medium")}
>
{/* Grid background */}
<div
className="absolute inset-0"
style={{
background: `
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)
`,
backgroundSize: "40px 40px",
}}
/>
{/* Top centered Logo */}
<m.div
ref={logoRef}
className="absolute left-1/2 top-[20%] z-10 -translate-x-1/2"
animate={{ opacity: 1 }}
initial={{ opacity: 0 }}
onAnimationComplete={() => markAnimationDone("logo")}
>
<div className="relative">
{/* Logo glow effect */}
<div
className="absolute inset-0 -z-10 blur-2xl"
style={{
background: "radial-gradient(circle, rgba(255, 92, 0, 0.3) 0%, transparent 70%)",
}}
/>
<Logo className="size-20 drop-shadow-lg" />
</div>
</m.div>
{/* 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 (
<m.div
key={view.name}
className="absolute bottom-[20%] -translate-x-1/2"
style={{
left: `${xPosition}%`,
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
...Spring.presets.smooth,
delay: 0.1 + index * 0.08,
}}
onAnimationComplete={() => markAnimationDone(`icon-${index}`)}
>
{/* Icon container */}
<div
ref={(el) => {
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`,
}}
>
<div className={cn(view.className, "flex")}>{view.icon}</div>
</div>
</m.div>
)
})}
{/* Hand-drawn connector lines */}
<svg className="pointer-events-none absolute inset-0" width="100%" height="100%">
<defs>
{/* Slight wobble via displacement map to enhance sketch feeling (subtle) */}
<filter id="scribble-wobble-overview" x="-5%" y="-5%" width="110%" height="110%">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="1" seed="2" />
<feDisplacementMap in="SourceGraphic" scale="0.7" />
</filter>
</defs>
{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 (
<g key={p.d} filter="url(#scribble-wobble-overview)">
{/* Underlay shadow to suggest marker bleed */}
<m.path
d={p.d}
fill="none"
stroke={p.shadow}
strokeWidth={5}
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={ready ? { pathLength: 1, opacity: 1 } : { pathLength: 0, opacity: 0 }}
transition={{ ...Spring.presets.smooth, delay: revealDelay }}
/>
{/* Main line */}
<m.path
d={p.d}
fill="none"
stroke={p.color}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={ready ? { pathLength: 1, opacity: 1 } : { pathLength: 0, opacity: 0 }}
transition={{ ...Spring.presets.smooth, delay: revealDelay + 0.05 }}
/>
{/* A second, lighter stroke with slight dash to mimic hand-drawn */}
<m.path
d={p.d}
fill="none"
stroke={p.color}
strokeOpacity={0.7}
strokeWidth={1.4}
strokeDasharray="6 7"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={ready ? { pathLength: 1, opacity: 1 } : { pathLength: 0, opacity: 0 }}
transition={{ ...Spring.presets.smooth, delay: revealDelay + 0.1 }}
/>
</g>
)
})}
</svg>
{/* Ambient background glow */}
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(circle at 50% 50%, rgba(255, 92, 0, 0.08) 0%, transparent 50%)",
}}
/>
</div>
)
}

View File

@ -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"

View File

@ -0,0 +1,2 @@
export { AppTip } from "./AppTip"
export { APP_TIP_DEBUG_EVENT } from "./constants"

View File

@ -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
}

View File

@ -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<AppTipStep[]>(() => {
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<AppTipDebugOpenEventDetail>
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 (
<div className="border-t pt-4">
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label className="text-sm font-medium text-text">
{t("settings.showSplineButton.label")}
</Label>
<p className="text-xs leading-relaxed text-text-secondary">
{t("settings.showSplineButton.description")}
</p>
</div>
<Switch
checked={showSplineButton}
onCheckedChange={(v) => setAISetting("showSplineButton", v)}
/>
</div>
</div>
)
}

View File

@ -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<AppTipDismissChangeDetail>
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<AppTipDismissChangeDetail>(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
}
}

View File

@ -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<HTMLDivElement | null>(null)
const logoRef = React.useRef<HTMLDivElement | null>(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<Set<string>>(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 (
<div
ref={containerRef}
className={cn("relative aspect-square w-full overflow-hidden bg-material-medium", className)}
>
{/* Right side Logo */}
<m.div
ref={logoRef}
className="absolute right-[15%] top-1/2 z-10 -translate-y-1/2"
initial={{ scale: 0, opacity: 0, x: 50 }}
animate={{ scale: 1, opacity: 1, x: 0 }}
transition={{ ...Spring.presets.smooth, delay: 0.3 }}
onAnimationComplete={() => markAnimationDone("logo")}
>
<div className="relative">
{/* Logo glow effect */}
<div
className="absolute inset-0 -z-10 blur-2xl"
style={{
background: "radial-gradient(circle, rgba(255, 92, 0, 0.3) 0%, transparent 70%)",
}}
/>
<Logo className="size-20 drop-shadow-lg" />
</div>
</m.div>
{/* 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 (
<m.div
key={reader.name}
className="absolute left-[15%]"
style={{
top: `${yPosition}%`,
}}
initial={{ scale: 0, opacity: 0, x: -50 }}
animate={{ scale: 1, opacity: 1, x: 0 }}
transition={{
...Spring.presets.smooth,
delay: 0.1 + index * 0.08,
}}
onAnimationComplete={() => markAnimationDone(`icon-${index}`)}
>
{/* Icon container */}
<div
ref={(el) => {
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`,
}}
>
<i className={cn(reader.icon, "size-6")} style={{ color: reader.color }} />
</div>
</m.div>
)
})}
{/* Hand-drawn connector lines */}
<svg className="pointer-events-none absolute inset-0" width="100%" height="100%">
<defs>
{/* Slight wobble via displacement map to enhance sketch feeling (subtle) */}
<filter id="scribble-wobble" x="-5%" y="-5%" width="110%" height="110%">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="1" seed="2" />
<feDisplacementMap in="SourceGraphic" scale="0.7" />
</filter>
</defs>
{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 (
<g key={p.d} filter="url(#scribble-wobble)">
{/* Underlay shadow to suggest marker bleed */}
<m.path
d={p.d}
fill="none"
stroke={p.shadow}
strokeWidth={5}
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={ready ? { pathLength: 1, opacity: 1 } : { pathLength: 0, opacity: 0 }}
transition={{ ...Spring.presets.smooth, delay: revealDelay }}
/>
{/* Main line */}
<m.path
d={p.d}
fill="none"
stroke={p.color}
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={ready ? { pathLength: 1, opacity: 1 } : { pathLength: 0, opacity: 0 }}
transition={{ ...Spring.presets.smooth, delay: revealDelay + 0.05 }}
/>
{/* A second, lighter stroke with slight dash to mimic hand-drawn */}
<m.path
d={p.d}
fill="none"
stroke={p.color}
strokeOpacity={0.7}
strokeWidth={1.4}
strokeDasharray="6 7"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0, opacity: 0 }}
animate={ready ? { pathLength: 1, opacity: 1 } : { pathLength: 0, opacity: 0 }}
transition={{ ...Spring.presets.smooth, delay: revealDelay + 0.1 }}
/>
</g>
)
})}
</svg>
{/* Ambient background glow on the right */}
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(circle at 85% 50%, rgba(255, 92, 0, 0.08) 0%, transparent 50%)",
}}
/>
</div>
)
}

View File

@ -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,
])
}

View File

@ -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<Range[]>([])
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 (
<Focusable
scope={HotkeyScope.Timeline}

View File

@ -3,6 +3,7 @@ import { useMobile } from "@follow/components/hooks/useMobile.js"
import { getMousePosition } from "@follow/components/hooks/useMouse.js"
import { ActionButton } from "@follow/components/ui/button/action-button.js"
import { FeedViewType } from "@follow/constants"
import { isOnboardingEntryUrl } from "@follow/store/constants/onboarding"
import { useEntry } from "@follow/store/entry/hooks"
import { unreadSyncService } from "@follow/store/unread/store"
import { cn } from "@follow/utils/utils"
@ -43,6 +44,7 @@ export const EntryItemWrapper: FC<
const { id, url } = state
return { feedId, id, inboxId: inboxHandle, url }
})
const isOnboardingEntry = isOnboardingEntryUrl(entry?.url)
const actionConfigs = useEntryActions({ entryId, view })
const isMobile = useMobile()
@ -157,6 +159,7 @@ export const EntryItemWrapper: FC<
!isWide ? "rounded-none @[650px]:rounded-md" : "rounded-md",
isAll && "!rounded-none",
(isActive || isContextMenuOpen) && "!bg-theme-item-active",
isOnboardingEntry && "border-l-2 border-l-orange-500 bg-orange-500/10",
itemClassName,
)}
onClick={handleClick}

View File

@ -55,6 +55,7 @@ export const EntryHeaderActions = ({ entryId, view }: { entryId: string; view: F
commandId={child.id}
onClick={resolveClick(child)!}
active={child.active}
disabled={child.disabled}
/>
))}
</DropdownMenuContent>
@ -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}
</DropdownMenuItem>

View File

@ -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 (
<DropdownMenuSub key={config.id}>
<DropdownMenuSubTrigger>
<DropdownMenuSubTrigger disabled={config.disabled}>
<CommandDropdownMenuItem
commandId={config.id}
onClick={resolveClick(config)!}
active={config.active}
asSubTrigger
disabled={config.disabled}
/>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
@ -128,6 +130,7 @@ export const MoreActions = ({
commandId={child.id}
onClick={resolveClick(child)!}
active={child.active}
disabled={child.disabled}
/>
))}
</DropdownMenuSubContent>
@ -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}
/>
))}
</DropdownMenuContent>
@ -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 (
<DropdownMenuItem key={commandId} className="pl-3" onSelect={onClick} active={active}>
<DropdownMenuItem
key={commandId}
className="pl-3"
onSelect={disabled ? undefined : onClick}
active={active}
disabled={disabled}
>
{content}
</DropdownMenuItem>
)
@ -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}
</DropdownMenuItem>

View File

@ -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 (
<EntryContentHTMLRenderer
<ContentRenderer
view={view}
feedId={feedId}
entryId={entryId}
@ -170,6 +178,6 @@ const Renderer: React.FC<{
renderInlineStyle={readerRenderInlineStyle}
>
{translation?.content || content}
</EntryContentHTMLRenderer>
</ContentRenderer>
)
}

View File

@ -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 (
<div>
<DiscoverImport />
<div className="mt-4 flex items-center justify-end gap-2">
<Button variant="ghost" onClick={() => setStep("intro")}>
{t.app("new_user_guide.actions.back")}
</Button>
<Button onClick={() => setStep("manual-import-pre-finish")}>
{t.app("new_user_guide.actions.finish")}
</Button>
</div>
</div>
)
}

View File

@ -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 (
<RootPortal>
<DeclarativeModal
id="new-user-guide"
title="New User Guide"
CustomModalComponent={PlainModal}
modalContainerClassName="flex items-center justify-center"
open={open}
canClose={false}
clickOutsideToDismiss={false}
overlay
>
<GuideModalContent onClose={() => setOpen(false)} />
</DeclarativeModal>
</RootPortal>
)
}

View File

@ -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<unknown>[] = [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 (
<div className="relative h-[100vh] w-screen">
{showProgress ? (
<div className="pointer-events-none absolute bottom-12 left-1/2 flex w-full -translate-x-1/2 justify-center px-6">
<Progress value={progress} max={100} aria-label="Subscription progress" />
</div>
) : null}
<Spline scene="https://prod.spline.design/07pKu5Ohpb-J2VPw/scene.splinecode" />
</div>
)
}

View File

@ -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<AS extends keyof JSX.IntrinsicElements = "div">({
view,
@ -21,12 +22,7 @@ export function EntryContentHTMLRenderer<AS extends keyof JSX.IntrinsicElements
entryId,
children,
...props
}: {
view: FeedViewType
feedId: string
entryId: string
children: Nullable<string>
} & HTMLProps<AS>) {
}: EntryContentRendererProps & HTMLProps<AS>) {
const entry = useEntry(entryId, (state) => {
const images =
state.media?.reduce(

View File

@ -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<ComponentProps<typeof Markdown>, "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<string, MarkdownImage>,
) ?? {}
const { url } = state
return {
images,
url,
}
})
const images: Record<string, MarkdownImage> = 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
<MarkdownImageRecordContext.Provider value={images}>
<MarkdownRenderActionContext value={actions}>
<EntryInfoContext value={useMemo(() => ({ feedId, entryId }), [feedId, entryId])}>
<Markdown {...props}>{children ?? ""}</Markdown>
</EntryInfoContext>
</MarkdownRenderActionContext>
</MarkdownImageRecordContext.Provider>
)
}
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 (
<>
<TimeStamp time={firstPart} />
<span>{children.slice(firstPart.length)}</span>
</>
)
}
return false
}
function isValidTimeString(time: string): boolean {
const timeRegex = /^\d{1,2}:[0-5]\d(?::[0-5]\d)?$/
return timeRegex.test(time)
}

View File

@ -0,0 +1,8 @@
import type { FeedViewType } from "@follow-app/client-sdk"
export type EntryContentRendererProps = {
view: FeedViewType
feedId: string
entryId: string
children: Nullable<string>
}

View File

@ -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 (
<div className="mx-auto mt-6 max-w-3xl space-y-8">
{/* Header Section */}
@ -188,6 +197,17 @@ export const SettingAbout = () => {
</div>
<i className="i-mgc-external-link-cute-re text-base text-text-tertiary transition-all group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-accent" />
</button>
<button
type="button"
onClick={handleOpenAiOnboarding}
className="group flex w-full items-center justify-between rounded-lg p-3 text-left transition-all hover:bg-fill-secondary hover:shadow-sm"
>
<div>
<div className="text-sm font-medium">{t("about.appTip")}</div>
<div className="text-xs text-text-tertiary">{t("about.appTipDescription")}</div>
</div>
<i className="i-mingcute-sparkles-2-line text-base text-text-tertiary transition-all group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-accent" />
</button>
</div>
{/* Legal Section */}

View File

@ -28,7 +28,7 @@ export function CategoryRemoveDialogContent({
const { dismiss } = useCurrentModal()
return (
<div className="flex w-[65ch] max-w-full flex-col gap-4">
<div className="flex w-[45ch] max-w-full flex-col gap-4">
<Trans i18nKey="sidebar.category_remove_dialog.description">
<p>
This operation will delete your category, but the feeds it contains will be retained and

View File

@ -36,7 +36,7 @@ export function CategoryUnsubscribeDialogContent({
const { dismiss } = useCurrentModal()
return (
<div className="flex w-[65ch] max-w-full flex-col gap-4">
<div className="flex w-[45ch] max-w-full flex-col gap-4">
<p className="text-text">
{t("sidebar.category_unsubscribe_dialog.description", {
category,

View File

@ -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}
>
<div className="flex w-full min-w-0 items-center" onDoubleClick={toggleCategoryOpenState}>
<div
className={cn("flex w-full min-w-0 items-center", hasOnboardingFeed && "text-folo")}
onDoubleClick={toggleCategoryOpenState}
>
<button
data-type="collapse"
type="button"
@ -349,7 +365,16 @@ function FeedCategoryImpl({
) : (
<Fragment>
<span className="grow truncate">{folderName}</span>
{hasOnboardingFeed && (
<Tooltip delayDuration={300}>
<TooltipTrigger>
<i className="i-mingcute-sparkles-line ml-1 shrink-0 text-base text-folo" />
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t("feed_category.onboarding_feed")}</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
<UnreadNumber unread={unread} className="ml-2" />
</Fragment>
)}

View File

@ -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 (
<DraggableItemWrapper
@ -186,21 +188,38 @@ const FeedItemImpl = ({ view, feedId, className, isPreview }: FeedItemProps) =>
feedColumnStyles.item,
isFeed ? "py-0.5" : "py-1.5",
"justify-between py-0.5",
className,
)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
{...contextMenuProps}
>
<div className={cn("flex min-w-0 items-center", isFeed && feed.errorAt && "text-red")}>
<div
className={cn(
"flex min-w-0 items-center",
isFeed && feed.errorAt && !isOnboardingFeed && "text-red",
isOnboardingFeed && "text-folo",
)}
>
<FeedIcon fallback target={feed} size={16} />
<FeedTitle feed={feed} />
{isFeed && (
{isOnboardingFeed && (
<Tooltip delayDuration={300}>
<TooltipTrigger>
<i className="i-mingcute-sparkles-line ml-1 size-3 shrink-0 translate-y-0.5 text-base text-folo" />
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t("feed_item.onboarding_feed")}</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
{isFeed && !isOnboardingFeed && (
<ErrorTooltip errorAt={feed.errorAt} errorMessage={feed.errorMessage}>
<i className="i-mingcute-close-circle-fill ml-1 shrink-0 text-base" />
</ErrorTooltip>
)}
{subscription?.isPrivate && (
{subscription?.isPrivate && !isOnboardingFeed && (
<Tooltip delayDuration={300}>
<TooltipTrigger>
<OouiUserAnonymous className="ml-1 shrink-0 text-base" />

View File

@ -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 (
<Fragment>
{sortByUnreadFeedList.map((feedId) => (
{sortedList.map((feedId) => (
<FeedItemAutoHideUnread
key={feedId}
feedId={feedId}

View File

@ -1,3 +1,4 @@
import { isOnboardingFeedUrl } from "@follow/store/constants/onboarding"
import { useFeedStore } from "@follow/store/feed/store"
import { useSubscriptionStore } from "@follow/store/subscription/store"
import { getInboxHandleOrFeedIdFromFeedId } from "@follow/store/unread/utils"
@ -68,17 +69,47 @@ export const SortByAlphabeticalFeedList = ({
const isDesc = useFeedListSortSelector((s) => 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 (
<Fragment>

View File

@ -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 (
<Fragment>
{sortedByUnread?.map(([category, ids]) => (
{sortedList.map(([category, ids]) => (
<FeedCategoryAutoHideUnread
key={category}
data={ids}

View File

@ -194,6 +194,7 @@
"feed.followsAndFeeds": "{{subscriptionCount}} {{subscriptionNoun}} and {{feedsCount}} {{feedsNoun}} on {{appName}}",
"feed.read_one": "read",
"feed.read_other": "reads",
"feed_category.onboarding_feed": "This category contains onboarding feeds",
"feed_claim_modal.choose_verification_method": "There are three ways to choose from, you can choose one of them to verify.",
"feed_claim_modal.claim_button": "Claim",
"feed_claim_modal.content_instructions": "Copy the content below and post it to your latest RSS feed.",
@ -242,6 +243,7 @@
"feed_item.claimed_list": "Claimed List",
"feed_item.error_since": "Error since",
"feed_item.not_publicly_visible": "Not publicly visible on your profile page",
"feed_item.onboarding_feed": "This is an onboarding feed",
"login.agree_to": "By continuing, you agree to our",
"login.back": "Back",
"login.confirm_password.label": "Confirm Password",
@ -267,11 +269,34 @@
"mark_all_read_button.mark_all_as_read": "Mark all as read",
"mark_all_read_button.mark_as_read": "Mark <which /> 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. <br />Please <b>sign in</b> to sync your subscriptions and settings across devices.",
"notify.store.default": "the store",
"notify.store.mas": "App Store",

View File

@ -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": "<which />を既読にする",
"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": "ログインしていません。<br />デバイス間で購読と設定を同期するには<b>サインイン</b>してください。",
"notify.store.default": "ストア",
"notify.store.mas": "App Store",

View File

@ -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": "标记 <which /> 为已读",
"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": "您尚未登录。<br />请<b>登录</b>以同步您的订阅和设置到其他设备。",
"notify.store.default": "商店",
"notify.store.mas": "App Store",

View File

@ -264,11 +264,33 @@
"mark_all_read_button.mark_all_as_read": "全部標記為已讀",
"mark_all_read_button.mark_as_read": "標記 <which /> 為已讀",
"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",

View File

@ -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",

View File

@ -1,4 +1,7 @@
{
"about.aiOnboardingDescription": "インタラクティブなオンボーディングをもう一度体験できます。",
"about.appTip": "App 機能",
"about.appTipDescription": "App 機能紹介と使用ガイド。",
"about.changelog": "変更履歴",
"about.changelogDescription": "各バージョンの新機能を確認",
"about.checkForUpdates": "アップデートを確認",

View File

@ -1,4 +1,6 @@
{
"about.appTip": "App 功能",
"about.appTipDescription": "App 功能介绍与使用指南。",
"about.changelog": "更新日志",
"about.changelogDescription": "查看每个版本的新功能",
"about.checkForUpdates": "检查更新",

View File

@ -1,4 +1,7 @@
{
"about.aiOnboardingDescription": "重新體驗互動式新手導覽。",
"about.appTip": "App 功能",
"about.appTipDescription": "App 功能介紹與使用指南。",
"about.changelog": "更新日誌",
"about.changelogDescription": "查看每個版本的新功能",
"about.checkForUpdates": "檢查更新",

View File

@ -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)
}

View File

@ -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)