feat: new onboarding
This commit is contained in:
parent
40305da191
commit
ecb7e5c1c6
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,4 +20,7 @@ export interface ChatSlice {
|
|||
|
||||
// Actions
|
||||
chatActions: ChatSliceActions
|
||||
|
||||
// Scene
|
||||
scene: "general" | "onboarding" | "timeline-summary"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,5 +77,6 @@ export const createChatSlice: (options: {
|
|||
currentTitle: undefined,
|
||||
chatInstance,
|
||||
chatActions,
|
||||
scene: "general",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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 |
|
|
@ -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!",
|
||||
|
|
|
|||
|
|
@ -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}} は更新可能です!",
|
||||
|
|
|
|||
|
|
@ -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}} 已准备好更新!",
|
||||
|
|
|
|||
|
|
@ -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}} 已準備好更新!",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue