feat: new onboarding

This commit is contained in:
songkeys 2025-10-09 20:57:26 +08:00
parent 40305da191
commit ecb7e5c1c6
22 changed files with 1034 additions and 592 deletions

View File

@ -7,13 +7,14 @@ import { cva } from "class-variance-authority"
import { noop } from "es-toolkit"
import type { EditorState, LexicalEditor } from "lexical"
import { $getRoot } from "lexical"
import { memo, use, useCallback, useRef, useState } from "react"
import type { Ref } from "react"
import { memo, use, useCallback, useImperativeHandle, useRef, useState } from "react"
import { AIChatContextBar } from "~/modules/ai-chat/components/layouts/AIChatContextBar"
import { FileUploadPlugin, MentionPlugin } from "../../editor"
import { AIPanelRefsContext } from "../../store/AIChatContext"
import { useChatActions, useChatStatus } from "../../store/hooks"
import { useChatActions, useChatScene, useChatStatus } from "../../store/hooks"
import { AIChatSendButton } from "./AIChatSendButton"
import { AIModelIndicator } from "./AIModelIndicator"
@ -38,9 +39,10 @@ const chatInputVariants = cva(
interface ChatInputProps extends VariantProps<typeof chatInputVariants> {
onSend: (message: EditorState | string, editor: LexicalEditor | null) => void
ref?: Ref<LexicalRichEditorRef | null>
}
export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => {
export const ChatInput = memo(({ onSend, variant, ref: forwardedRef }: ChatInputProps) => {
const status = useChatStatus()
const chatActions = useChatActions()
@ -48,12 +50,20 @@ export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => {
chatActions.stop()
}, [chatActions])
const editorRef = useRef<LexicalRichEditorRef>(null)
const editorRef = useRef<LexicalRichEditorRef | null>(null)
useImperativeHandle<LexicalRichEditorRef | null, LexicalRichEditorRef | null>(
forwardedRef,
() => editorRef.current,
// eslint-disable-next-line react-hooks/exhaustive-deps
[editorRef.current],
)
const aiPanelRefs = use(AIPanelRefsContext)
if (editorRef.current) {
aiPanelRefs.inputRef.current = editorRef.current
}
const [isEmpty, setIsEmpty] = useState(true)
const [currentEditor, setCurrentEditor] = useState<LexicalEditor | null>(null)
@ -91,6 +101,8 @@ export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => {
})
}, [])
const scene = useChatScene()
return (
<div className={cn(chatInputVariants({ variant }))}>
{/* Input Area */}
@ -98,12 +110,12 @@ export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => {
<ScrollArea rootClassName="mx-5 my-3.5 mr-14 flex-1 overflow-auto">
<LexicalRichEditor
ref={editorRef}
placeholder="Message, @ for context"
placeholder={scene === "onboarding" ? "Enter your message" : "Message, @ for context"}
className="w-full"
onChange={handleEditorChange}
onKeyDown={handleKeyDown}
autoFocus
plugins={[MentionPlugin, FileUploadPlugin]}
plugins={scene === "onboarding" ? [] : [MentionPlugin, FileUploadPlugin]}
namespace="AIChatRichEditor"
/>
</ScrollArea>
@ -117,22 +129,24 @@ export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => {
</div>
</div>
{/* Context Bar - Always shown, positioned below the input area */}
<div className="border-border/20 relative z-10 border-t bg-transparent">
<div className="flex items-center justify-between px-4 py-2.5">
<div className="min-w-0 flex-1 shrink">
<AIChatContextBar
className="border-0 bg-transparent p-0"
onSendShortcut={(prompt) => onSend(prompt, null)}
{/* Context Bar - only shown in non-onboarding scene, positioned below the input area */}
{scene !== "onboarding" && (
<div className="border-border/20 relative z-10 border-t bg-transparent">
<div className="flex items-center justify-between px-4 py-2.5">
<div className="min-w-0 flex-1 shrink">
<AIChatContextBar
className="border-0 bg-transparent p-0"
onSendShortcut={(prompt) => onSend(prompt, null)}
/>
</div>
<AIModelIndicator
className="-mr-1.5 ml-3 translate-y-[2px] self-start"
// Current not support switch model, will open this feature later
onModelChange={noop}
/>
</div>
<AIModelIndicator
className="-mr-1.5 ml-3 translate-y-[2px] self-start"
// Current not support switch model, will open this feature later
onModelChange={noop}
/>
</div>
</div>
)}
</div>
)
})

View File

@ -412,7 +412,7 @@ export const ChatInterface = (props: ChatInterfaceProps) => (
</ErrorBoundary>
)
const Messages: FC<{ contentRef?: RefObject<HTMLDivElement> }> = ({ contentRef }) => {
export const Messages: FC<{ contentRef?: RefObject<HTMLDivElement> }> = ({ contentRef }) => {
const messages = useMessages()
const fallbackRef = useRef<HTMLDivElement>(null)
const messageContainerWidth = useElementWidth(contentRef ?? fallbackRef)

View File

@ -5,7 +5,7 @@ import { AnimatePresence, m } from "motion/react"
import * as React from "react"
import { useEditingMessageId, useSetEditingMessageId } from "~/modules/ai-chat/atoms/session"
import { useChatActions, useChatStatus } from "~/modules/ai-chat/store/hooks"
import { useChatActions, useChatScene, useChatStatus } from "~/modules/ai-chat/store/hooks"
import type { AIChatContextBlock, BizUIMessage } from "~/modules/ai-chat/store/types"
import { convertLexicalToMarkdown } from "../../utils/lexical-markdown"
@ -91,11 +91,13 @@ export const UserChatMessage: React.FC<UserChatMessageProps> = React.memo(({ mes
chatActions.regenerate({ messageId })
}, [chatActions, messageId])
const scene = useChatScene()
return (
<AIMessageIdContext value={messageId}>
<div className="relative flex flex-col gap-3">
{/* Render data-block parts separately, outside the chat bubble */}
{dataBlockParts.length > 0 && (
{dataBlockParts.length > 0 && scene !== "onboarding" && dataBlockParts.length > 0 && (
<div ref={dataBlockRef} className="flex justify-end">
<div className="max-w-[calc(100%-1rem)]">
{dataBlockParts.map((part) => {

View File

@ -1,5 +1,6 @@
import { autoBindThis } from "@follow/utils/bind-this"
import type { ChatRequestOptions, ChatStatus } from "ai"
import { merge } from "es-toolkit/compat"
import { nanoid } from "nanoid"
import type { StateCreator } from "zustand"
@ -158,7 +159,13 @@ export class ChatSliceActions {
: (message as Parameters<typeof this.chatInstance.sendMessage>[0])
// Use the AI SDK's sendMessage method
const response = await this.chatInstance.sendMessage(messageObj, options)
const finalOptions = merge(
{
body: { scene: this.get().scene },
},
options,
)
const response = await this.chatInstance.sendMessage(messageObj, finalOptions)
return response
} catch (error) {
this.setError(error as Error)
@ -276,4 +283,8 @@ export class ChatSliceActions {
throw error
}
}
setScene = (scene: ChatSlice["scene"]) => {
this.set((state) => ({ ...state, scene }))
}
}

View File

@ -20,4 +20,7 @@ export interface ChatSlice {
// Actions
chatActions: ChatSliceActions
// Scene
scene: "general" | "onboarding" | "timeline-summary"
}

View File

@ -80,3 +80,11 @@ export const useChatError = () => {
const store = useAIChatStore()
return store((state) => state.error)
}
/**
* Hook to get the chat scene
*/
export const useChatScene = () => {
const store = useAIChatStore()
return store((state) => state.scene)
}

View File

@ -77,5 +77,6 @@ export const createChatSlice: (options: {
currentTitle: undefined,
chatInstance,
chatActions,
scene: "general",
}
}

View File

@ -166,7 +166,7 @@ export const DiscoverFeedCard: FC<DiscoverFeedCardProps> = memo(
},
)
const SearchResultContent: FC<{
export const SearchResultContent: FC<{
entry: NonUndefined<DiscoveryItem["entries"]>[number]
}> = memo(({ entry }) => {
const safeUrl = useFeedSafeUrl(entry.id)

View File

@ -0,0 +1,526 @@
import { Logo } from "@follow/components/icons/logo.jsx"
import { AIShortcutButton } from "@follow/components/ui/ai-shortcut-button/index.js"
import { Button } from "@follow/components/ui/button/index.js"
import type { LexicalRichEditorRef } from "@follow/components/ui/lexical-rich-editor/index.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
import { tracker } from "@follow/tracker"
import { nextFrame } from "@follow/utils"
import { cn } from "@follow/utils/utils"
import { AnimatePresence } from "framer-motion"
import { useSetAtom } from "jotai"
import type { EditorState, LexicalEditor } from "lexical"
import { $getRoot, $getSelection, $isRangeSelection } from "lexical"
import { nanoid } from "nanoid"
import type { RefObject } from "react"
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from "react"
import { useEventCallback } from "usehooks-ts"
import { useI18n } from "~/hooks/common"
import { ChatInput } from "~/modules/ai-chat/components/layouts/ChatInput"
import { Messages } from "~/modules/ai-chat/components/layouts/ChatInterface"
import { useAttachScrollBeyond } from "~/modules/ai-chat/hooks/useAttachScrollBeyond"
import { useAutoScroll } from "~/modules/ai-chat/hooks/useAutoScroll"
import {
useBlockActions,
useChatActions,
useChatError,
useChatStatus,
useCurrentChatId,
useHasMessages,
useMessages,
} from "~/modules/ai-chat/store/hooks"
import type { AIChatContextBlock, BizUIMessage } from "~/modules/ai-chat/store/types"
import { convertLexicalToMarkdown } from "~/modules/ai-chat/utils/lexical-markdown"
import { CollapsibleError } from "../ai-chat/components/layouts/CollapsibleError"
import { AIChatWaitingIndicator } from "../ai-chat/components/message/AIChatMessage"
import { stepAtom } from "./store"
const SUGGESTION_KEYS = [
"new_user_guide.ai_chat.suggestions.fashion_designer",
"new_user_guide.ai_chat.suggestions.nano_engineering_researcher",
"new_user_guide.ai_chat.suggestions.drug_delivery_student",
"new_user_guide.ai_chat.suggestions.investor_market_news",
"new_user_guide.ai_chat.suggestions.nasa_fan",
"new_user_guide.ai_chat.suggestions.climate_newsletter_writer",
"new_user_guide.ai_chat.suggestions.plant_based_cooking",
"new_user_guide.ai_chat.suggestions.cybersecurity_tracker",
"new_user_guide.ai_chat.suggestions.japan_trip_planner",
"new_user_guide.ai_chat.suggestions.podcast_summary_seeker",
"new_user_guide.ai_chat.suggestions.personal_finance_builder",
"new_user_guide.ai_chat.suggestions.robotics_coach",
"new_user_guide.ai_chat.suggestions.saas_marketing_manager",
"new_user_guide.ai_chat.suggestions.ai_regulation_learner",
] as const
const SUGGESTION_SAMPLE_SIZE = 5
type SuggestionKey = (typeof SUGGESTION_KEYS)[number]
function pickSuggestionKeys(previous?: readonly SuggestionKey[]): SuggestionKey[] {
const shuffle = (input: readonly SuggestionKey[]) => {
const pool = [...input] as SuggestionKey[]
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[pool[i], pool[j]] = [pool[j]!, pool[i]!]
}
return pool
}
if (!previous || previous.length === 0) {
return shuffle(SUGGESTION_KEYS).slice(0, SUGGESTION_SAMPLE_SIZE)
}
const previousSet = new Set(previous)
const available = SUGGESTION_KEYS.filter((key) => !previousSet.has(key))
if (available.length >= SUGGESTION_SAMPLE_SIZE) {
return shuffle(available).slice(0, SUGGESTION_SAMPLE_SIZE)
}
// When there aren't enough unique suggestions left, attempt to find a fully new batch.
const maxAttempts = 10
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const candidate = shuffle(SUGGESTION_KEYS).slice(0, SUGGESTION_SAMPLE_SIZE)
if (!candidate.some((key) => previousSet.has(key))) {
return candidate
}
}
return shuffle(SUGGESTION_KEYS).slice(0, SUGGESTION_SAMPLE_SIZE)
}
export function AIChatPane() {
return (
<div className="flex h-full flex-col justify-between gap-8 overflow-hidden bg-[#0f0f0f] p-2 text-white lg:col-span-6">
<AIChatPaneImpl />
</div>
)
}
function AIChatPaneImpl() {
const t = useI18n()
const setStep = useSetAtom(stepAtom)
const hasMessages = useHasMessages()
const chatInputRef = useRef<LexicalRichEditorRef | null>(null)
const appendSuggestionToInput = (suggestion: string) => {
const ref = chatInputRef.current
const editor = ref?.getEditor()
if (!editor) {
return
}
editor.focus()
editor.update(() => {
const root = $getRoot()
const currentText = root.getTextContent()
const needsLeadingSpace = currentText.length > 0 && !currentText.endsWith(" ")
const textToInsert = needsLeadingSpace ? ` ${suggestion}` : suggestion
root.selectEnd()
let selection = $getSelection()
if (!$isRangeSelection(selection)) {
root.selectEnd()
selection = $getSelection()
}
if ($isRangeSelection(selection)) {
selection.insertText(textToInsert)
}
})
}
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" />
<Button variant="ghost" onClick={() => setStep("manual-import")}>
{t.app("new_user_guide.actions.import_opml")}
</Button>
</header>
<AnimatePresence mode="popLayout">
{!hasMessages && <Welcome onSuggestionClick={appendSuggestionToInput} />}
</AnimatePresence>
<div className="flex-1 overflow-hidden">
<AIChatInterface inputRef={chatInputRef} />
</div>
</div>
)
}
interface WelcomeProps {
onSuggestionClick: (suggestion: string) => void
}
function Welcome({ onSuggestionClick }: WelcomeProps) {
const t = useI18n()
const [suggestionKeys, setSuggestionKeys] = useState<SuggestionKey[]>(() => pickSuggestionKeys())
const onClickSuggestion = useEventCallback((suggestion: string) => {
onSuggestionClick(suggestion)
})
const rerollSuggestions = useEventCallback(() => {
setSuggestionKeys((prev) => pickSuggestionKeys(prev))
})
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">
{(t.app("new_user_guide.ai_chat.intro") as string).split("\n").map((line) => (
<Fragment key={line}>
{line}
<br />
</Fragment>
))}
</p>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-text-secondary text-xs font-medium uppercase">
{t.app("new_user_guide.ai_chat.you_can_say")}
</p>
<Button variant="ghost" size="sm" onClick={rerollSuggestions}>
<i className="i-mgc-refresh-2-cute-re mr-2 text-sm" aria-hidden />
{t.app("new_user_guide.ai_chat.reroll")}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{suggestionKeys.map((suggestionKey, index) => {
const suggestionText = t.app(suggestionKey) as string
return (
<AIShortcutButton
key={suggestionKey}
onClick={() => onClickSuggestion(suggestionText)}
animationDelay={index * 0.05}
className={cn(
"font-normal text-black shadow-[0_12px_30px_rgba(15,15,15,0.35)] hover:text-black",
gradientByIndex(index),
)}
>
{suggestionText}
</AIShortcutButton>
)
})}
</div>
</div>
<FinishListener />
</div>
)
}
// if the chat response has `tool-onboardingGetTrendingFeedsTool`, set the step to pre-finish
function FinishListener() {
const chatMessages = useMessages()
const setStep = useSetAtom(stepAtom)
useEffect(() => {
const hasCalledConfirmTool = chatMessages.some((msg) =>
// @ts-expect-error TODO: fix this after version published
msg.parts.some((p) => p.type === "tool-onboardingGetTrendingFeeds"),
)
if (hasCalledConfirmTool) {
setStep("pre-finish")
}
}, [chatMessages, setStep])
return null
}
const SCROLL_BOTTOM_THRESHOLD = 100
interface AIChatInterfaceProps {
inputRef?: RefObject<LexicalRichEditorRef | null>
}
function AIChatInterface({ inputRef }: AIChatInterfaceProps) {
const hasMessages = useHasMessages()
const status = useChatStatus()
const chatActions = useChatActions()
const error = useChatError()
const t = useI18n()
useEffect(() => {
if (error) {
console.error("AIChat Error:", error)
}
}, [error])
// on init, set the scene to onboarding
useEffect(() => {
chatActions.setScene("onboarding")
return () => {
// reset the scene to general
chatActions.setScene("general")
}
}, [chatActions])
const currentChatId = useCurrentChatId()
const [scrollAreaRef, setScrollAreaRef] = useState<HTMLDivElement | null>(null)
const [isAtBottom, setIsAtBottom] = useState(true)
const [messageContainerMinHeight, setMessageContainerMinHeight] = useState<number | undefined>()
const previousMinHeightRef = useRef<number>(0)
const messagesContentRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
setIsAtBottom(true)
setMessageContainerMinHeight(undefined)
previousMinHeightRef.current = 0
}, [currentChatId])
const { resetScrollState } = useAutoScroll(scrollAreaRef, status === "streaming")
const { handleScroll } = useAttachScrollBeyond()
useEffect(() => {
const scrollElement = scrollAreaRef
if (!scrollElement) return
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollElement
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const atBottom = distanceFromBottom <= SCROLL_BOTTOM_THRESHOLD
setIsAtBottom(atBottom)
}
scrollElement.addEventListener("scroll", handleScroll, { passive: true })
handleScroll()
return () => {
scrollElement.removeEventListener("scroll", handleScroll)
}
}, [scrollAreaRef])
const blockActions = useBlockActions()
const scrollHeightBeforeSendingRef = useRef<number>(0)
const scrollContainerParentRef = useRef<HTMLDivElement | null>(null)
const handleScrollPositioning = useEventCallback(() => {
const $scrollContainerParent = scrollContainerParentRef.current
if (!scrollAreaRef || !$scrollContainerParent) return
const parentClientHeight = $scrollContainerParent.clientHeight
// Use actual content height captured before send (messages container height), not inflated by minHeight
const currentScrollHeight = scrollHeightBeforeSendingRef.current
// Calculate new minimum height based on actual content height
// Use previousMinHeightRef which tracks the real content height, not reserved space
const baseHeight = Math.max(previousMinHeightRef.current, currentScrollHeight)
const newMinHeight = baseHeight + parentClientHeight - 250
setMessageContainerMinHeight(newMinHeight)
// Scroll to the end immediately to position user message at top
nextFrame(() => {
scrollAreaRef.scrollTo({
top: scrollAreaRef.scrollHeight,
behavior: "instant",
})
})
})
const handleSendMessage = useEventCallback(
(message: string | EditorState, editor: LexicalEditor | null) => {
resetScrollState()
const blocks = [] as AIChatContextBlock[]
for (const block of blockActions.getBlocks()) {
if (block.type === "fileAttachment" && block.attachment.serverUrl) {
blocks.push({
...block,
attachment: {
id: block.attachment.id,
name: block.attachment.name,
type: block.attachment.type,
size: block.attachment.size,
serverUrl: block.attachment.serverUrl,
},
})
} else {
blocks.push(block)
}
}
const parts: BizUIMessage["parts"] = [
{
type: "data-block",
data: blocks,
},
]
if (typeof message === "string") {
parts.push({
type: "text",
text: message,
})
} else if (editor) {
parts.push({
type: "data-rich-text",
data: {
state: JSON.stringify(message.toJSON()),
text: convertLexicalToMarkdown(editor),
},
})
}
// Capture actual content height (messages container), not including reserved minHeight
scrollHeightBeforeSendingRef.current = messagesContentRef.current?.scrollHeight ?? 0
chatActions.sendMessage({
parts,
role: "user",
id: nanoid(),
})
tracker.aiChatMessageSent()
nextFrame(() => {
// Calculate and adjust scroll positioning immediately
handleScrollPositioning()
})
},
)
const [bottomPanelHeight, setBottomPanelHeight] = useState<number>(0)
const bottomPanelRef = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
if (!bottomPanelRef.current) {
return
}
setBottomPanelHeight(bottomPanelRef.current.offsetHeight)
const resizeObserver = new ResizeObserver(() => {
if (!bottomPanelRef.current) {
return
}
setBottomPanelHeight(bottomPanelRef.current.offsetHeight)
})
resizeObserver.observe(bottomPanelRef.current)
return () => {
resizeObserver.disconnect()
}
}, [])
useEffect(() => {
if (status === "submitted") {
resetScrollState()
}
// When AI response is complete, update the reference height but keep the container height unchanged
// This avoids CLS while ensuring next calculation is based on actual content
if (status === "ready" && scrollAreaRef && messagesContentRef.current) {
// Update the reference to actual content height for next calculation (use messages container)
previousMinHeightRef.current = messagesContentRef.current.scrollHeight
// Keep the current minHeight unchanged to avoid CLS
}
}, [status, resetScrollState, messageContainerMinHeight, scrollAreaRef])
const shouldShowScrollToBottom = hasMessages && !isAtBottom
const messages = useMessages()
const setStep = useSetAtom(stepAtom)
return (
<div className="flex h-full flex-1 flex-col" ref={scrollContainerParentRef}>
<ScrollArea
onScroll={handleScroll}
flex
scrollbarClassName="mt-12"
scrollbarProps={{
style: {
marginBottom: Math.max(160, bottomPanelHeight) + (error ? 64 : 0),
},
}}
ref={setScrollAreaRef}
rootClassName="flex-1"
viewportProps={{
style: {
paddingBottom: Math.max(128, bottomPanelHeight) + (error ? 64 : 0),
},
}}
viewportClassName={"pt-12"}
>
<div
className="mx-auto w-full px-6 py-8"
style={{
minHeight: messageContainerMinHeight ? `${messageContainerMinHeight}px` : undefined,
}}
>
<Messages contentRef={messagesContentRef as RefObject<HTMLDivElement>} />
{/* if the last message is from ai, show "Next Step" button */}
{messages.length > 0 && messages.at(-1)?.role === "assistant" && status === "ready" && (
<div>
<Button onClick={() => setStep("pre-finish")}>
{t.app("new_user_guide.actions.next")}
</Button>
</div>
)}
{(status === "submitted" || status === "streaming") && <AIChatWaitingIndicator />}
</div>
</ScrollArea>
{shouldShowScrollToBottom && (
<div className={cn("absolute right-1/2 z-40 translate-x-1/2", "bottom-32")}>
<button
type="button"
onClick={() => resetScrollState()}
className={cn(
"center bg-mix-background/transparent-8/2 backdrop-blur-background group flex size-8 items-center gap-2 rounded-full border transition-all",
"border-border",
"hover:border-border/60 active:scale-[0.98]",
)}
>
<i className="i-mingcute-arrow-down-line text-text/90" />
</button>
</div>
)}
<div
ref={bottomPanelRef}
className={cn(
"duration-500 ease-in-out",
hasMessages && "px-6 pb-6",
!hasMessages && "px-6 pb-6 duration-200",
)}
>
{error && <CollapsibleError error={error} />}
<ChatInput
ref={inputRef}
onSend={handleSendMessage}
variant={!hasMessages ? "minimal" : "default"}
/>
</div>
</div>
)
}
const GRADIENT_CLASSES = [
"bg-gradient-to-r from-[#ff8a00] to-[#ff5f2d]",
"bg-gradient-to-r from-[#0ccb8b] to-[#00a36c]",
"bg-gradient-to-r from-[#ffd700] to-[#ffa800]",
"bg-gradient-to-r from-[#9b5df5] to-[#7848ff]",
"bg-gradient-to-r from-[#ff5dc5] to-[#7a5cff]",
]
function gradientByIndex(index: number) {
return GRADIENT_CLASSES[index % GRADIENT_CLASSES.length]
}

View File

@ -0,0 +1,25 @@
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("finish")}>{t.app("new_user_guide.actions.finish")}</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,207 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@follow/components/ui/card/index.jsx"
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.js"
import { cn } from "@follow/utils/utils"
import type { PrimitiveAtom } from "jotai"
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"
import { AnimatePresence, m } from "motion/react"
import { useEffect, useMemo, useRef } from "react"
import { useI18n } from "~/hooks/common"
import { useMessages } from "../ai-chat/store/hooks"
import { SearchResultContent } from "../discover/DiscoverFeedCard"
import { FeedIcon } from "../feed/feed-icon"
import type { FeedSelection } from "./store"
import { feedSelectionAtomsAtom, selectedFeedSelectionAtomsAtom } from "./store"
type FeedToSelect = Omit<FeedSelection, "selected">
export function FeedsSelectionList() {
const chatMessages = useMessages()
const hasFeedsSelection = chatMessages.some((msg) =>
// @ts-expect-error TODO: fix this after version published
msg.parts.some((p) => p.type === "tool-onboardingGetTrendingFeeds" && p.output),
)
return (
<div className="col-span-4 h-full overflow-hidden">
<AnimatePresence mode="popLayout">
{hasFeedsSelection ? <FeedSelectionOperationScreen /> : <FeedSelectionFirstScreen />}
</AnimatePresence>
</div>
)
}
function FeedSelectionOperationScreen() {
const chatMessages = useMessages()
const feedsToSelect: FeedToSelect[] = useMemo(
() =>
// find the last message that has the tool
chatMessages
.findLast((m) =>
// @ts-expect-error TODO: fix this after version published
m.parts?.some((p) => p.type === "tool-onboardingGetTrendingFeeds"),
)
// @ts-expect-error TODO: fix this after version published
?.parts?.findLast((p) => p.type === "tool-onboardingGetTrendingFeeds")?.output ?? [],
[chatMessages],
)
const store = useStore()
const atomList = useAtomValue(feedSelectionAtomsAtom)
const dispatch = useSetAtom(feedSelectionAtomsAtom)
const lastKeyRef = useRef<string | null>(null)
const outputKey = useMemo(() => {
const ids = Array.from(new Set(feedsToSelect.map((f) => String(f.id))))
ids.sort()
return ids.join("|")
}, [feedsToSelect])
const existingIds = useMemo(
() => new Set(atomList.map((a) => String(store.get(a).id))),
[atomList, store],
)
useEffect(() => {
if (lastKeyRef.current === outputKey) return
lastKeyRef.current = outputKey
const seen = new Set(existingIds)
for (const feed of feedsToSelect) {
const id = String(feed.id)
if (seen.has(id)) continue
seen.add(id)
dispatch({
type: "insert",
value: { ...feed, selected: true },
})
}
}, [dispatch, feedsToSelect, existingIds, outputKey])
const selectedAtoms = useAtomValue(selectedFeedSelectionAtomsAtom)
const items = useMemo(
() => selectedAtoms.map((atom) => ({ atom, id: store.get(atom).id })),
[selectedAtoms, store],
)
return (
<ScrollArea flex rootClassName="h-full" viewportClassName="px-3 flex min-h-0 grow">
<div className="flex flex-col gap-5 py-5">
<AnimatePresence mode="popLayout">
{items.map(({ atom, id }) => (
<m.div
key={id}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
>
<FeedSelectionItem feedAtom={atom} />
</m.div>
))}
</AnimatePresence>
</div>
</ScrollArea>
)
}
function FeedSelectionItem({ feedAtom }: { feedAtom: PrimitiveAtom<FeedSelection> }) {
const [feed, setFeed] = useAtom(feedAtom)
const onRemove = () => {
setFeed((prev) => ({
...prev,
selected: false,
}))
}
return (
<div className="relative mr-4">
{/* remove button */}
<Tooltip>
<TooltipTrigger asChild>
<i
onClick={onRemove}
className="i-mingcute-minus-circle-fill text-text-secondary hover:text-text absolute right-0 top-0 z-10 size-5 -translate-y-1/2 translate-x-1/2 cursor-pointer transition-colors"
/>
</TooltipTrigger>
<TooltipContent>Remove</TooltipContent>
</Tooltip>
<Card
data-feed-id={feed.id}
className={cn(
"flex-shrink-0 select-text overflow-hidden border border-zinc-200/50 bg-white/80 backdrop-blur-xl transition-all duration-300 dark:border-zinc-800/50 dark:bg-neutral-800/50",
)}
>
<CardHeader className="pb-2">
<div className="flex items-center gap-1">
<FeedIcon
size={32}
target={{ type: "feed", ...feed }}
siteUrl={feed.url}
fallbackUrl={feed.image ?? undefined}
fallback
/>
<div className="flex flex-col gap-1">
<p className="text-text text-sm font-semibold">{feed.title}</p>
<p className="text-text-secondary text-xs">{feed.url}</p>
</div>
</div>
</CardHeader>
<CardContent>
<CardDescription className="text-text-secondary text-sm">
{feed.description}
</CardDescription>
<div className="pointer-events-none mt-5 grid grid-cols-4 gap-2">
{feed.entries?.map((entry) => (
<SearchResultContent key={entry.id} entry={entry as any} />
))}
</div>
</CardContent>
</Card>
</div>
)
}
function FeedSelectionFirstScreen() {
const t = useI18n()
return (
<m.div
className="relative mr-4 h-full overflow-hidden rounded-3xl bg-[#FF5C02] p-5"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
>
<h1 className="relative z-[1] text-[6rem] font-semibold leading-[6rem] text-white">
{t.app("new_user_guide.intro.title")}
</h1>
{/* some shapes */}
<div className="absolute left-10 top-10 z-0 h-40 w-[30rem] rounded-full bg-[#FF792E]" />
<div className="absolute left-80 top-64 z-0 h-40 w-[30rem] rounded-full bg-[#FF792E]" />
<div className="absolute left-12 top-[30rem] z-0 h-40 w-[30rem] rounded-full bg-[#FF792E]" />
{/* screenshot image */}
<div className="absolute -bottom-12 -left-36 z-[1] h-[30rem] w-[40rem] rotate-[-25deg] rounded-3xl border border-white">
{/* TODO: add image here */}
</div>
</m.div>
)
}

View File

@ -1,359 +1,47 @@
import { Logo } from "@follow/components/icons/logo.jsx"
import { Button } from "@follow/components/ui/button/index.js"
import { Kbd } from "@follow/components/ui/kbd/Kbd.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { tracker } from "@follow/tracker"
import { cn } from "@follow/utils/utils"
import { AnimatePresence, m } from "motion/react"
import type { ComponentProps, FunctionComponentElement } from "react"
import { createElement, useEffect, useMemo, useRef, useState } from "react"
import { Trans, useTranslation } from "react-i18next"
import { useAtomValue } from "jotai"
import { useEffect } from "react"
import { mountLottie } from "~/components/ui/lottie-container"
import { Markdown } from "~/components/ui/markdown/Markdown"
import { useI18n } from "~/hooks/common"
import confettiUrl from "~/lottie/confetti.lottie?url"
import { settings } from "~/queries/settings"
import { AIChatRoot } from "~/modules/ai-chat/components/layouts/AIChatRoot"
import { DiscoverImport } from "../discover/DiscoverImport"
import { ProfileSettingForm } from "../profile/profile-setting-form"
import { settingSyncQueue } from "../settings/helper/sync-queue"
import { LanguageSelector } from "../settings/tabs/general"
import { Trending } from "../trending"
import { BehaviorGuide } from "./steps/behavior"
const containerWidth = 600
const variants = {
enter: (direction: number) => {
return {
x: direction > 0 ? containerWidth : -containerWidth,
opacity: 0,
}
},
center: {
zIndex: 1,
x: 0,
opacity: 1,
},
exit: (direction: number) => {
return {
zIndex: 0,
x: direction < 0 ? containerWidth : -containerWidth,
opacity: 0,
}
},
}
function Intro() {
const { t } = useTranslation("app")
return (
<div className="max-w-[50ch] space-y-8 text-balance text-center">
<Logo className="mx-auto size-20" />
<p className="mt-5 text-2xl font-bold">{t("new_user_guide.intro.title")}</p>
<p className="text-lg">{t("new_user_guide.intro.description")}</p>
<LanguageSelector contentClassName="z-10" showDescription={false} />
</div>
)
}
function Outtro() {
const { t } = useTranslation("app")
return (
<div className="max-w-[50ch] space-y-8 text-balance text-center">
<Logo className="mx-auto size-20" />
<p className="mt-5 text-2xl font-semibold">{t("new_user_guide.outro.title")}</p>
<p className="text-lg">{t("new_user_guide.outro.description")}</p>
<div className="space-y-2 text-sm opacity-80">
<p>Tip: {t("new_user_guide.step.shortcuts.description1")}</p>
<p>
<Trans
i18nKey="new_user_guide.step.shortcuts.description2"
components={{
kbd: <Kbd>?</Kbd>,
}}
/>
</p>
</div>
</div>
)
}
const absoluteConfettiUrl = new URL(confettiUrl, import.meta.url).href
import { AIChatPane } from "./ai-chat-pane"
import { DiscoverImportStep } from "./discover-import-step"
import { FeedsSelectionList } from "./feeds-selection-list"
import { PreFinish } from "./pre-finish"
import { stepAtom } from "./store"
export function GuideModalContent({ onClose }: { onClose: () => void }) {
const t = useI18n()
const [step, setStep] = useState(0)
const [direction, setDirection] = useState(1)
const guideSteps = useMemo(
() =>
[
{
title: t.app("new_user_guide.step.migrate.title"),
description: t.app("new_user_guide.step.migrate.description"),
content: createElement(DiscoverImport),
icon: "i-mgc-file-import-cute-re",
},
{
title: t.app("new_user_guide.step.discover.title"),
description: t.app("new_user_guide.step.discover.description"),
content: (
<div className="overflow-scroll">
<Trending center limit={50} />
</div>
),
icon: "i-mgc-emoji-2-cute-re",
},
{
title: t.app("new_user_guide.step.profile.title"),
description: t.app("new_user_guide.step.profile.description"),
content: (
<ProfileSettingForm
className="w-[500px] max-w-full"
buttonClassName="text-center !mt-8"
hideAvatar={true}
/>
),
icon: "i-mgc-user-setting-cute-re",
},
{
title: t.app("new_user_guide.step.behavior.unread_question.content"),
description: t.app("new_user_guide.step.profile.description"),
content: createElement(BehaviorGuide),
icon: tw`i-mgc-cursor-3-cute-re`,
},
].filter((i) => !!i) as {
title: string
icon: React.ReactNode
content: FunctionComponentElement<object>
description?: string
}[],
[t],
)
const totalSteps = useMemo(() => guideSteps.length, [guideSteps])
const status = useMemo(
() => (step === 0 ? "initial" : step > 0 && step <= totalSteps ? "active" : "complete"),
[step, totalSteps],
)
const step = useAtomValue(stepAtom)
useEffect(() => {
tracker.onBoarding({
step,
done: status === "complete",
done: step === "finish",
})
}, [status, step])
const title = useMemo(() => guideSteps[step - 1]?.title, [guideSteps, step])
}, [step])
const [isLottieAnimating, setIsLottieAnimating] = useState(false)
const finishGuide = useRef(() => {
settingSyncQueue.replaceRemote().then(() => {
settings.get().invalidate()
})
}).current
useEffect(() => {
if (step === "finish") {
onClose()
}
}, [onClose, step])
return (
<div className="bg-theme-background center relative flex size-full flex-col items-center justify-center overflow-hidden pb-14 sm:size-4/5 sm:rounded-xl sm:shadow-xl">
<div className="relative mx-auto flex max-h-full w-full justify-center">
<AnimatePresence initial={false} custom={direction} mode="popLayout">
<m.div
key={step - 1}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: "spring", stiffness: 300, damping: 30 },
opacity: { duration: 0.1 },
}}
className="flex min-w-0 flex-col px-6 sm:mt-12"
>
{!!title && (
<div className="mb-8">
<h1 className="mb-2 flex w-full items-center justify-center gap-2 text-2xl font-bold">
{typeof guideSteps[step - 1]!.icon === "string" ? (
<i className={cn(guideSteps[step - 1]!.icon, "text-accent")} />
) : (
guideSteps[step - 1]!.icon
)}
{title}
</h1>
{!!guideSteps[step - 1]!.description && (
<div className="text-text-secondary mx-auto mt-4 flex max-w-prose justify-center text-center text-sm">
<Markdown className="prose max-w-[100ch] text-left text-sm">
{guideSteps[step - 1]!.description!}
</Markdown>
</div>
)}
</div>
)}
<ScrollArea.ScrollArea viewportClassName="px-6">
{status === "initial" ? (
<Intro />
) : status === "active" ? (
guideSteps[step - 1]!.content
) : status === "complete" ? (
<Outtro />
) : null}
</ScrollArea.ScrollArea>
</m.div>
</AnimatePresence>
</div>
<div
className={cn(
"absolute left-4 top-4 flex h-fit gap-3 sm:hidden",
step === 0 && "invisible",
)}
>
{Array.from({ length: totalSteps }, (_, i) => i + 1).map((i) => (
<Step key={i} step={i} currentStep={step} />
))}
</div>
<div className="absolute inset-x-0 bottom-8 z-[1] flex w-full items-center justify-between px-8">
<div className={cn("flex h-fit gap-3 max-sm:hidden", step === 0 && "invisible")}>
{Array.from({ length: totalSteps }, (_, i) => i + 1).map((i) => (
<Step key={i} step={i} currentStep={step} />
))}
</div>
<div className="grow" />
<div className="flex gap-2">
{step !== 0 && (
<Button
size="lg"
onClick={() => {
if (step > 0) {
setStep((prev) => prev - 1)
setDirection(-1)
}
}}
variant={"outline"}
>
{t.app("new_user_guide.actions.back")}
</Button>
)}
<Button
size="lg"
disabled={isLottieAnimating}
onClick={(e) => {
if (step <= totalSteps) {
setStep((prev) => prev + 1)
setDirection(1)
} else {
finishGuide()
const target = e.target as HTMLElement
const { x, y } = target.getBoundingClientRect()
setIsLottieAnimating(true)
mountLottie(absoluteConfettiUrl, {
x: x - 40,
y: y - 80,
height: 120,
width: 120,
speed: 2,
onComplete() {
setIsLottieAnimating(false)
},
})
setTimeout(() => {
onClose()
}, 50)
}
}}
>
{step <= totalSteps
? t.app("new_user_guide.actions.next")
: t.app("new_user_guide.actions.finish")}
</Button>
<AIChatRoot>
<div className="bg-theme-background flex h-screen w-screen flex-col items-center justify-center overflow-hidden">
<div className="mx-auto flex flex-col gap-8">
{step === "manual-import" ? (
<DiscoverImportStep />
) : step === "intro" || step === "selecting-feeds" ? (
<div className="grid h-screen w-screen grid-cols-1 divide-x overflow-hidden p-5 lg:grid-cols-10">
<FeedsSelectionList />
<AIChatPane />
</div>
) : step === "pre-finish" ? (
<PreFinish />
) : null}
</div>
</div>
</div>
)
}
function Step({ step, currentStep }: { step: number; currentStep: number }) {
const status = currentStep === step ? "active" : currentStep < step ? "inactive" : "complete"
return (
<m.div animate={status} className="relative">
<m.div
variants={{
active: {
scale: 1,
transition: {
delay: 0,
duration: 0.2,
},
},
complete: {
scale: 1.25,
},
}}
transition={{
duration: 0.6,
delay: 0.2,
type: "tween",
ease: "circOut",
}}
className="bg-accent/20 absolute inset-0 rounded-full"
/>
<m.div
initial={false}
variants={{
inactive: {
backgroundColor: "var(--fo-background)",
borderColor: "hsl(var(--border) / 0.5)",
color: "#bbb",
},
active: {
backgroundColor: "var(--fo-background)",
borderColor: "hsl(var(--fo-a) / 1)",
color: "hsl(var(--fo-a) / 1)",
},
complete: {
backgroundColor: "hsl(var(--fo-a) / 1)",
borderColor: "hsl(var(--fo-a) / 1)",
color: "hsl(var(--fo-a) / 1)",
},
}}
transition={{ duration: 0.2 }}
className="relative flex size-7 items-center justify-center rounded-full border text-xs font-semibold"
>
<div className="flex items-center justify-center">
{status === "complete" ? (
<AnimatedCheckIcon className="size-4 text-white" />
) : (
<span>{step}</span>
)}
</div>
</m.div>
</m.div>
)
}
function AnimatedCheckIcon(props: ComponentProps<"svg">) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" {...props}>
<m.path
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{
delay: 0.2,
type: "tween",
ease: "easeOut",
duration: 0.3,
}}
strokeWidth={2}
d="M3.514 11.83a22.927 22.927 0 0 1 5.657 5.656c2.75-5.025 6.289-8.563 11.314-11.314"
/>
</svg>
</AIChatRoot>
)
}

View File

@ -0,0 +1,72 @@
import { FeedViewType } from "@follow/constants"
import { subscriptionSyncService } from "@follow/store/subscription/store"
import Spline from "@splinetool/react-spline"
import { useAtomValue, useSetAtom } from "jotai"
import { useEffect, useMemo } 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 setStep = useSetAtom(stepAtom)
const selectedFeeds = useMemo(
() => feedSelections.filter((feed) => feed.selected),
[feedSelections],
)
useEffect(() => {
let disposed = false
const subscribeSelectedFeeds = async () => {
for (const feed of selectedFeeds) {
if (disposed) break
const { url, id, title } = feed
try {
await subscriptionSyncService.subscribe({
url,
view: FeedViewType.All,
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 })
}
}
}
}
const run = async () => {
const tasks: Promise<unknown>[] = [sleep(WAIT_DURATION_MS)]
if (selectedFeeds.length > 0) {
tasks.push(subscribeSelectedFeeds())
}
await Promise.allSettled(tasks)
if (!disposed) {
setStep("finish")
}
}
run()
return () => {
disposed = true
}
}, [selectedFeeds, setStep])
return (
<div className="h-[100vh] w-screen">
<Spline scene="https://prod.spline.design/07pKu5Ohpb-J2VPw/scene.splinecode" />
</div>
)
}

View File

@ -1,66 +0,0 @@
import { RadioGroup } from "@follow/components/ui/radio-group/index.js"
import { RadioCard } from "@follow/components/ui/radio-group/RadioCard.js"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { setGeneralSetting } from "~/atoms/settings/general"
type Behavior = "radical" | "balanced" | "conservative"
export function BehaviorGuide() {
const [value, setValue] = useState<Behavior>("balanced")
const { t } = useTranslation("app")
const updateSettings = (behavior: Behavior) => {
switch (behavior) {
case "radical": {
setGeneralSetting("hoverMarkUnread", true)
setGeneralSetting("scrollMarkUnread", true)
setGeneralSetting("renderMarkUnread", true)
break
}
case "balanced": {
setGeneralSetting("hoverMarkUnread", true)
setGeneralSetting("scrollMarkUnread", true)
setGeneralSetting("renderMarkUnread", false)
break
}
case "conservative": {
setGeneralSetting("hoverMarkUnread", false)
setGeneralSetting("scrollMarkUnread", false)
setGeneralSetting("renderMarkUnread", false)
break
}
}
}
return (
<div className="space-y-8">
<div className="flex flex-col gap-4">
<RadioGroup
value={value}
onValueChange={(value) => {
setValue(value as Behavior)
updateSettings(value as Behavior)
}}
>
<RadioCard
wrapperClassName="has-[:checked]:bg-accent has-[:checked]:font-normal rounded-lg border p-3 transition-colors has-[:checked]:text-white"
label={t("new_user_guide.step.behavior.unread_question.option1")}
value="radical"
/>
<RadioCard
wrapperClassName="has-[:checked]:bg-accent has-[:checked]:font-normal rounded-lg border p-3 transition-colors has-[:checked]:text-white"
label={t("new_user_guide.step.behavior.unread_question.option2")}
value="balanced"
/>
<RadioCard
wrapperClassName="has-[:checked]:bg-accent has-[:checked]:font-normal rounded-lg border p-3 transition-colors has-[:checked]:text-white"
label={t("new_user_guide.step.behavior.unread_question.option3")}
value="conservative"
/>
</RadioGroup>
</div>
</div>
)
}

View File

@ -1,47 +0,0 @@
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { AppErrorBoundary } from "~/components/common/AppErrorBoundary"
import { ErrorComponentType } from "~/components/errors/enum"
import { useAuthQuery } from "~/hooks/common"
import { Recommendations } from "~/modules/discover/recommendations"
import { Queries } from "~/queries"
export function RSSHubGuide({ categories, lang }: { categories?: string; lang?: string }) {
const rsshubPopular = useAuthQuery(
Queries.discover.rsshubCategory({
category: "popular",
categories,
lang,
}),
{
meta: {
persist: true,
},
},
)
const { data } = rsshubPopular
if (rsshubPopular.isLoading) {
return null
}
if (!data) {
return null
}
return (
<AppErrorBoundary errorType={ErrorComponentType.RSSHubDiscoverError}>
<ScrollArea.ScrollArea
viewportClassName="h-[450px]"
scrollbarClassName="-mr-4"
flex
rootClassName="overflow-visible"
>
<div className="space-y-3">
<Recommendations />
</div>
</ScrollArea.ScrollArea>
</AppErrorBoundary>
)
}

View File

@ -0,0 +1,33 @@
import type { MediaModel } from "@follow/database/schemas/types"
import { atom } from "jotai"
import { splitAtom } from "jotai/utils"
export const stepAtom = atom<
"intro" | "selecting-feeds" | "manual-import" | "pre-finish" | "finish"
>("intro")
export type FeedSelection = {
description: string | null
id: string
image: string | null
title: string | null
url: string
selected?: boolean
entries: {
description: string | null
id: string
media: MediaModel[] | null
publishedAt: Date
title: string | null
url: string | null
}[]
}
export const feedSelectionsAtom = atom<FeedSelection[]>([])
export const feedSelectionAtomsAtom = splitAtom(feedSelectionsAtom)
export const selectedFeedSelectionAtomsAtom = atom((get) =>
get(feedSelectionAtomsAtom).filter((a) => get(a).selected),
)

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M11.28 2.024c-2.109.185-3.979.926-5.561 2.201-1.675 1.351-2.908 3.28-3.416 5.346-.216.881-.277 1.41-.277 2.429s.061 1.548.277 2.429c.886 3.607 3.839 6.502 7.457 7.311.844.189 1.287.236 2.24.236.953 0 1.396-.047 2.24-.236 3.618-.809 6.571-3.704 7.457-7.311.213-.869.276-1.413.278-2.409.001-.976-.043-1.404-.235-2.26-.458-2.049-1.658-4.025-3.26-5.369-1.824-1.531-3.915-2.321-6.26-2.368a15.89 15.89 0 0 0-.94.001m5.06 9.042c.369.126.66.538.66.934 0 .242-.119.521-.299.701-.317.317-.038.299-4.703.299-4.514 0-4.321.009-4.624-.222a1.19 1.19 0 0 1-.243-.289c-.095-.161-.111-.233-.111-.489s.016-.328.111-.489c.125-.213.318-.375.539-.454.122-.043.94-.054 4.313-.055 3.773-.002 4.181.004 4.357.064" fill="#10161F" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 836 B

View File

@ -264,36 +264,27 @@
"mark_all_read_button.undo": "Undo",
"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.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",
"new_user_guide.ai_chat.suggestions.climate_newsletter_writer": "I write a climate tech newsletter and need daily sources",
"new_user_guide.ai_chat.suggestions.cybersecurity_tracker": "I follow cybersecurity and open-source vulnerability news",
"new_user_guide.ai_chat.suggestions.drug_delivery_student": "I study drug delivery and want to learn about new FDA approvals",
"new_user_guide.ai_chat.suggestions.fashion_designer": "I'm a fashion designer",
"new_user_guide.ai_chat.suggestions.investor_market_news": "I'm an investor and need to keep up with stock market and company news",
"new_user_guide.ai_chat.suggestions.japan_trip_planner": "I'm planning a trip to Japan and want local tips",
"new_user_guide.ai_chat.suggestions.nano_engineering_researcher": "I research nano engineering",
"new_user_guide.ai_chat.suggestions.nasa_fan": "I'm a NASA fan",
"new_user_guide.ai_chat.suggestions.personal_finance_builder": "I'm building a personal finance tracker and need best practices",
"new_user_guide.ai_chat.suggestions.plant_based_cooking": "I'm exploring plant-based cooking trends",
"new_user_guide.ai_chat.suggestions.podcast_summary_seeker": "I need summaries for long podcasts about economics",
"new_user_guide.ai_chat.suggestions.robotics_coach": "I coach a high school robotics team and hunt for project ideas",
"new_user_guide.ai_chat.suggestions.saas_marketing_manager": "I'm a marketing manager launching a SaaS product",
"new_user_guide.ai_chat.you_can_say": "You can say...",
"new_user_guide.intro.description": "This guide will help you get started with the app.",
"new_user_guide.intro.title": "Welcome to Folo!",
"new_user_guide.outro.description": "You have completed the guide. Enjoy your journey!",
"new_user_guide.outro.title": "You're all set!",
"new_user_guide.step.automation.description": "- Folo leverages advanced AI to assist your operations.\n- Action rules allow you to automate various actions on sources that meet specific conditions.\n- Integrations allow you to save entries to other services.",
"new_user_guide.step.behavior.title": "Behavior",
"new_user_guide.step.behavior.unread_question.content": "Select how you would like to mark as read.",
"new_user_guide.step.behavior.unread_question.option1": "Radical: Automatically marked as read when displayed.",
"new_user_guide.step.behavior.unread_question.option2": "Balanced: Automatically marked as read when hovered over or scrolled out of view.",
"new_user_guide.step.behavior.unread_question.option3": "Conservative: Marked as read only when clicked.",
"new_user_guide.step.discover.description": "You can also find it later in Discover.",
"new_user_guide.step.discover.title": "Recommend for you",
"new_user_guide.step.features.actions.description": "Action rules allow you to perform different actions for different feeds.\n- Using AI to summarize or translate.\n- Configure how to read entries.\n- Enable notifications for new entries or silence them.\n- Rewrite or block specific entries.\n- Send new entries to a webhook address.",
"new_user_guide.step.features.integration.description": "Integrations allow you to save entries to other services. Currently supported services are:\n- Eagle\n- Readwise\n- Instapaper\n- Obsidian\n- Outline\n- Readeck.",
"new_user_guide.step.migrate.description": "You can also import them later in Discover.",
"new_user_guide.step.migrate.title": "Migrate from other RSS readers",
"new_user_guide.step.power.description": "Folo uses blockchain technology as an incentive mechanism for active users and outstanding creators. Users can obtain more services and benefits by holding and using Power Token. Creators can obtain more rewards by providing high-quality content and services.",
"new_user_guide.step.profile.description": "You can also set it up later in Preferences.",
"new_user_guide.step.profile.title": "Setup your profile",
"new_user_guide.step.shortcuts.description1": "Shortcut keys allow you to use Folo more conveniently and efficiently.",
"new_user_guide.step.shortcuts.description2": "Press <kbd /> to quickly view all shortcut keys at any time.",
"new_user_guide.step.shortcuts.title": "Shortcuts",
"new_user_guide.step.start_question.content": "Have you used other RSS readers before?",
"new_user_guide.step.start_question.option1": "Yes, I have used other RSS readers.",
"new_user_guide.step.start_question.option2": "No, this is my first time using an RSS reader.",
"new_user_guide.step.start_question.title": "Question",
"new_user_guide.step.trending.title": "Popular Feeds",
"new_user_guide.step.views.description": "Folo uses different views for various types of content to offer an experience equal to or better than the original platform.",
"new_user_guide.step.views.title": "Views",
"new_user_guide.intro.title": "Vibe Reading with AI",
"notify.unfollow_feed": "<FeedItem /> have been unfollowed.",
"notify.unfollow_feed_many": "All selected feeds have been unfollowed.",
"notify.update_info": "{{app_name}} is ready to update!",

View File

@ -260,36 +260,27 @@
"mark_all_read_button.undo": "元に戻す",
"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.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規制について学んでいます",
"new_user_guide.ai_chat.suggestions.climate_newsletter_writer": "私は気候テックのニュースレターを書いていて、毎日の情報源が必要です",
"new_user_guide.ai_chat.suggestions.cybersecurity_tracker": "私はサイバーセキュリティとオープンソースの脆弱性ニュースを追っています",
"new_user_guide.ai_chat.suggestions.drug_delivery_student": "私はドラッグデリバリーを学んでおり、最新のFDA承認を知りたいです",
"new_user_guide.ai_chat.suggestions.fashion_designer": "私はファッションデザイナーです",
"new_user_guide.ai_chat.suggestions.investor_market_news": "私は投資家で、株式市場と企業ニュースを追っています",
"new_user_guide.ai_chat.suggestions.japan_trip_planner": "日本旅行を計画しており、現地のヒントが欲しいです",
"new_user_guide.ai_chat.suggestions.nano_engineering_researcher": "私はナノ工学の研究をしています",
"new_user_guide.ai_chat.suggestions.nasa_fan": "私はNASAのファンです",
"new_user_guide.ai_chat.suggestions.personal_finance_builder": "個人資産管理トラッカーを作っており、ベストプラクティスを知りたいです",
"new_user_guide.ai_chat.suggestions.plant_based_cooking": "私はプラントベース料理のトレンドを探っています",
"new_user_guide.ai_chat.suggestions.podcast_summary_seeker": "経済に関する長尺ポッドキャストの要約が欲しいです",
"new_user_guide.ai_chat.suggestions.robotics_coach": "高校のロボティクスチームを指導しており、プロジェクトのアイデアを探しています",
"new_user_guide.ai_chat.suggestions.saas_marketing_manager": "SaaSプロダクトをローンチするマーケティングマネージャーです",
"new_user_guide.ai_chat.you_can_say": "こんなことを話してみてください...",
"new_user_guide.intro.description": "このガイドはアプリを快適に始めるためのガイドです。",
"new_user_guide.intro.title": "Folo へようこそ!",
"new_user_guide.outro.description": "ガイドを完了しました。快適な Folo 体験を!",
"new_user_guide.outro.title": "すべて完了しました!",
"new_user_guide.step.automation.description": "- Folo は高度なAIを活用し、お客様の業務を支援します。\n - アクションルールにより、特定の条件を満たしたソースに対して様々なアクションを自動化できます。",
"new_user_guide.step.behavior.title": "振る舞い",
"new_user_guide.step.behavior.unread_question.content": "既読時の振る舞いを選択してください",
"new_user_guide.step.behavior.unread_question.option1": "ラジカル: 表示したら自動で既読にします。",
"new_user_guide.step.behavior.unread_question.option2": "バランス: ホバーしたりスクロールが終わると自動で既読にします。",
"new_user_guide.step.behavior.unread_question.option3": "コンサバ: クリックしたときのみ既読にします。",
"new_user_guide.step.discover.description": "後で「発見」で見つけることもできます。",
"new_user_guide.step.discover.title": "あなたへのおすすめ",
"new_user_guide.step.features.actions.description": "アクションルールはフィードごとに違ったアクションを実行できます。\n - AI を使った要約や翻訳 \n - エントリーの読み方の設定 \n - 新着エントリーの通知の有効/無効 \n - 特定のエントリーの書き換えやブロック \n - 新着エントリーの Webhook アドレスへの送信など。",
"new_user_guide.step.features.integration.description": "統合により、他のサービスにエントリーを保存することができます。現在対応しているサービスは以下のとおりです:\n- Eagle \n- Readwise \n- Instapaper \n- Obsidian \n- Outline \n- Readeck。",
"new_user_guide.step.migrate.description": "あとで 発見 でもインポートできます。",
"new_user_guide.step.migrate.title": "OPML ファイルを構成する",
"new_user_guide.step.power.description": "Folo はアクティブなユーザーや優れたクリエイターに対するインセンティブ メカニズムとしてブロックチェーン技術を利用しています。ユーザーは Power トークンを保有・利用することで、より多くのサービスや特典を得ることができます。クリエイターは、質の高いコンテンツやサービスを提供することで、より多くの報酬を得ることができます。",
"new_user_guide.step.profile.description": "この設定は後で「設定」から行うこともできます。",
"new_user_guide.step.profile.title": "プロフィールの設定",
"new_user_guide.step.shortcuts.description1": "ショートカットキーを利用すると Folo をより便利に、より効率的に使えます。",
"new_user_guide.step.shortcuts.description2": " <kbd /> を押すと素早くすべてのショートカットキーにアクセスできます。",
"new_user_guide.step.shortcuts.title": "ショートカット",
"new_user_guide.step.start_question.content": "他の RSS リーダーを使ったことはありますか?",
"new_user_guide.step.start_question.option1": "はい、RSS リーダーを使ったことがあります",
"new_user_guide.step.start_question.option2": "いいえ、RSS リーダーを使うのは初めてです",
"new_user_guide.step.start_question.title": "質問",
"new_user_guide.step.trending.title": "人気のフィード",
"new_user_guide.step.views.description": "Folo は様々なタイプのコンテンツに異なる表示方法を使用し、オリジナルのプラットフォームと同等以上の体験を提供します。",
"new_user_guide.step.views.title": "表示",
"new_user_guide.intro.title": "AI と楽しむ読書体験",
"notify.unfollow_feed": "<FeedItem /> のフォローを解除しました",
"notify.unfollow_feed_many": "選択したすべてのフィードのフォローを解除しました。",
"notify.update_info": "{{app_name}} は更新可能です!",

View File

@ -262,36 +262,27 @@
"mark_all_read_button.undo": "撤销",
"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.ai_chat.intro": "欢迎来到 Folo这款 AI 阅读器可以替你阅读整个互联网。\n\n告诉我一些关于你的事情。",
"new_user_guide.ai_chat.reroll": "换一批",
"new_user_guide.ai_chat.suggestions.ai_regulation_learner": "我在学习欧盟的 AI 监管动态",
"new_user_guide.ai_chat.suggestions.climate_newsletter_writer": "我写一份气候科技通讯,需要每日素材",
"new_user_guide.ai_chat.suggestions.cybersecurity_tracker": "我关注网络安全和开源漏洞新闻",
"new_user_guide.ai_chat.suggestions.drug_delivery_student": "我学习药物递送,想了解最新的 FDA 批准",
"new_user_guide.ai_chat.suggestions.fashion_designer": "我是一名时装设计师",
"new_user_guide.ai_chat.suggestions.investor_market_news": "我是投资人,需要关注股市和公司新闻",
"new_user_guide.ai_chat.suggestions.japan_trip_planner": "我计划去日本旅行,想要本地建议",
"new_user_guide.ai_chat.suggestions.nano_engineering_researcher": "我做纳米工程研究",
"new_user_guide.ai_chat.suggestions.nasa_fan": "我是 NASA 的粉丝",
"new_user_guide.ai_chat.suggestions.personal_finance_builder": "我在搭建个人理财追踪器,需要最佳实践",
"new_user_guide.ai_chat.suggestions.plant_based_cooking": "我在探索植物性烹饪的新趋势",
"new_user_guide.ai_chat.suggestions.podcast_summary_seeker": "我需要经济类长播客的摘要",
"new_user_guide.ai_chat.suggestions.robotics_coach": "我指导高中机器人团队,寻找项目灵感",
"new_user_guide.ai_chat.suggestions.saas_marketing_manager": "我是 SaaS 产品的市场经理,准备发布",
"new_user_guide.ai_chat.you_can_say": "你可以这样说...",
"new_user_guide.intro.description": "本指南将帮助你快速上手这款应用。",
"new_user_guide.intro.title": "欢迎使用 Folo",
"new_user_guide.outro.description": "你已完成指南,祝你使用愉快!",
"new_user_guide.outro.title": "一切就绪!",
"new_user_guide.step.automation.description": "- Folo 利用先进的 AI 来协助你的操作\n- 自动化帮助你处理符合特定条件的来源\n- 集成帮助你将条目保存到其他服务中。",
"new_user_guide.step.behavior.title": "使用偏好",
"new_user_guide.step.behavior.unread_question.content": "希望如何标记为已读。",
"new_user_guide.step.behavior.unread_question.option1": "主动:显示时自动标记为已读。",
"new_user_guide.step.behavior.unread_question.option2": "平衡:悬停或滚出视野时自动标记为已读。",
"new_user_guide.step.behavior.unread_question.option3": "被动:仅在点击时标记为已读。",
"new_user_guide.step.discover.description": "你也可以稍后在\"发现\"中找到它们。",
"new_user_guide.step.discover.title": "为你推荐",
"new_user_guide.step.features.actions.description": "自动化规则允许你对不同的订阅执行不同的操作。\n- 使用 AI 进行总结或翻译\n- 配置阅读条目的方式\n- 启用新条目的通知或静音\n- 重写或屏蔽特定条目\n- 将新条目发送到 webhook 地址。",
"new_user_guide.step.features.integration.description": "集成允许你将条目保存到其他服务。目前支持的服务有:\n- Eagle\n- Readwise\n- Instapaper\n- Obsidian\n- Outline\n- Readeck。",
"new_user_guide.step.migrate.description": "您也可以稍后在\"发现\"中导入它们。",
"new_user_guide.step.migrate.title": "从其他 RSS 阅读器迁移",
"new_user_guide.step.power.description": "Folo 使用区块链技术作为活跃用户和优秀创作者的激励机制。用户可以通过持有和使用 Power 来获得更多服务和福利。创作者可以通过提供高质量的内容和服务来获得更多奖励。",
"new_user_guide.step.profile.description": "你也可以稍后在\"设置\"中设置它。",
"new_user_guide.step.profile.title": "设置你的个人资料",
"new_user_guide.step.shortcuts.description1": "快捷键让你更方便、高效地使用 Folo",
"new_user_guide.step.shortcuts.description2": "随时按 <kbd /> 快速查看所有快捷键。",
"new_user_guide.step.shortcuts.title": "快捷键",
"new_user_guide.step.start_question.content": "你以前使用过其它 RSS 阅读器吗?",
"new_user_guide.step.start_question.option1": "是的,我使用过其他 RSS 阅读器。",
"new_user_guide.step.start_question.option2": "不,这是我第一次使用 RSS 阅读器。",
"new_user_guide.step.start_question.title": "问题",
"new_user_guide.step.trending.title": "热门订阅源",
"new_user_guide.step.views.description": "Folo 针对不同类型的内容使用不同的视图,以提供与原平台相当或更好的体验。",
"new_user_guide.step.views.title": "视图",
"new_user_guide.intro.title": "和 AI 一起沉浸式阅读",
"notify.unfollow_feed": "已取消订阅 <FeedItem />",
"notify.unfollow_feed_many": "已取消订阅选中的订阅源。",
"notify.update_info": "{{app_name}} 已准备好更新!",

View File

@ -244,36 +244,27 @@
"mark_all_read_button.undo": "復原",
"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.ai_chat.intro": "歡迎來到 Folo這款 AI 閱讀器會為您讀遍整個網路。\n\n跟我分享一些關於您的事吧。",
"new_user_guide.ai_chat.reroll": "換一批",
"new_user_guide.ai_chat.suggestions.ai_regulation_learner": "我在學習歐盟的 AI 監管最新動向",
"new_user_guide.ai_chat.suggestions.climate_newsletter_writer": "我撰寫氣候科技電子報,需要每日素材",
"new_user_guide.ai_chat.suggestions.cybersecurity_tracker": "我關注資安與開源漏洞新聞",
"new_user_guide.ai_chat.suggestions.drug_delivery_student": "我學習藥物傳遞,想追蹤最新 FDA 核准",
"new_user_guide.ai_chat.suggestions.fashion_designer": "我是一名時裝設計師",
"new_user_guide.ai_chat.suggestions.investor_market_news": "我是投資人,需要掌握股市與公司新聞",
"new_user_guide.ai_chat.suggestions.japan_trip_planner": "我計畫去日本旅行,想要在地建議",
"new_user_guide.ai_chat.suggestions.nano_engineering_researcher": "我研究奈米工程",
"new_user_guide.ai_chat.suggestions.nasa_fan": "我是 NASA 的粉絲",
"new_user_guide.ai_chat.suggestions.personal_finance_builder": "我正在打造個人理財追蹤器,需要最佳實務",
"new_user_guide.ai_chat.suggestions.plant_based_cooking": "我在探索植物性料理的新趨勢",
"new_user_guide.ai_chat.suggestions.podcast_summary_seeker": "我需要經濟類長篇 Podcast 的摘要",
"new_user_guide.ai_chat.suggestions.robotics_coach": "我指導高中機器人隊,尋找專案靈感",
"new_user_guide.ai_chat.suggestions.saas_marketing_manager": "我是 SaaS 產品的行銷經理,準備發佈",
"new_user_guide.ai_chat.you_can_say": "您可以這樣說...",
"new_user_guide.intro.description": "這份指南將幫助您快速上手這款應用程式。",
"new_user_guide.intro.title": "歡迎來到 Folo",
"new_user_guide.outro.description": "您已完成指南,開始您的探索之旅吧!",
"new_user_guide.outro.title": "準備就緒!",
"new_user_guide.step.automation.description": "- Folo 利用 AI 技術協助您進行操作。\n- 指令允許您自動化對符合特定條件的來源執行各種操作。\n- 整合功能允許您將項目保存到其他服務。",
"new_user_guide.step.behavior.title": "行為設定",
"new_user_guide.step.behavior.unread_question.content": "選擇您想要的標記已讀方式。",
"new_user_guide.step.behavior.unread_question.option1": "主動:顯示時自動標記為已讀。",
"new_user_guide.step.behavior.unread_question.option2": "平衡:滑過或離開視圖時自動標記為已讀。",
"new_user_guide.step.behavior.unread_question.option3": "被動:僅在點擊時標記為已讀。",
"new_user_guide.step.discover.description": "您也可以稍後在“發現”中匯入它们。",
"new_user_guide.step.discover.title": "為你推薦",
"new_user_guide.step.features.actions.description": "自動化操作允許您為不同的 RSS 摘要設定操作。\n- 使用 AI 進行總結或翻譯。\n- 設定項目的閱讀方式。\n- 為新項目啟用通知或靜音。\n- 覆寫或封鎖特定項目。\n- 將新項目發送到 webhook。",
"new_user_guide.step.features.integration.description": "整合功能允許您將項目儲存到其他服務。目前支持的服務有:\n- Eagle\n- Readwise\n- Instapaper\n- Obsidian\n- Outline\n- Readeck。",
"new_user_guide.step.migrate.description": "您也可以稍後在“發現”中匯入它们。",
"new_user_guide.step.migrate.title": "從其他 RSS 閱讀器遷移",
"new_user_guide.step.power.description": "Folo 使用區塊鏈技術來獎勵活躍使用者和優秀創作者。使用者可以透過 power token 獲得更多服務和福利,創作者可以透過提供高品質的內容和服務獲得更多獎勵。",
"new_user_guide.step.profile.description": "你也可以稍後在“設定”中設定它。",
"new_user_guide.step.profile.title": "設定你的個人資料",
"new_user_guide.step.shortcuts.description1": "快捷鍵讓您更方便且有效率的使用 Folo。",
"new_user_guide.step.shortcuts.description2": "按 <kbd /> 隨時快速查看所有快捷鍵。",
"new_user_guide.step.shortcuts.title": "快捷鍵",
"new_user_guide.step.start_question.content": "您曾經使用過其他 RSS 閱讀器嗎?",
"new_user_guide.step.start_question.option1": "是的,我使用過其他 RSS 閱讀器。",
"new_user_guide.step.start_question.option2": "沒有,這是我第一次使用 RSS 閱讀器。",
"new_user_guide.step.start_question.title": "問卷",
"new_user_guide.step.trending.title": "熱門 RSS 摘要",
"new_user_guide.step.views.description": "Folo 針對不同類型的內容提供不同的視圖,讓您的使用體驗與原平台一樣出色,甚至更佳。",
"new_user_guide.step.views.title": "視圖",
"new_user_guide.intro.title": "和 AI 一起沉浸式閱讀",
"notify.unfollow_feed": "已取消跟隨 <FeedItem />",
"notify.unfollow_feed_many": "已取消跟隨正在選擇的 RSS 摘要。",
"notify.update_info": "{{app_name}} 已準備好更新!",

View File

@ -97,7 +97,7 @@ export class TrackerPoints {
this.track(TrackerMapper.Register, props)
}
onBoarding(props: { step: number; done: boolean }) {
onBoarding(props: { step: string | number; done: boolean }) {
this.track(TrackerMapper.OnBoarding, props)
}