Merge remote-tracking branch 'origin/mobile-main' into release/mobile/0.3.0

# Conflicts:
#	apps/desktop/layer/main/src/updater/custom-github-provider.ts
#	apps/desktop/layer/renderer/src/modules/achievement/AchievementModalContent.tsx
#	apps/desktop/layer/renderer/src/modules/entry-column/EntrySubscriptionItem.tsx
#	apps/desktop/layer/renderer/src/modules/entry-column/components/EntryPlaneToolbar.tsx
#	apps/desktop/layer/renderer/src/modules/entry-column/components/EntrySubscriptionSkeleton.tsx
#	apps/desktop/layer/renderer/src/modules/entry-content/EntryContent.legacy.tsx
#	apps/desktop/layer/renderer/src/modules/new-user-guide/guide-modal-content.tsx
#	apps/desktop/layer/renderer/src/modules/settings/tabs/invitations.tsx
#	apps/desktop/layer/renderer/src/modules/settings/tabs/referral.tsx
#	apps/desktop/layer/renderer/src/modules/wallet/level.tsx
#	apps/mobile/src/modules/login/referral.tsx
#	apps/mobile/src/modules/settings/routes/Invitations.tsx
#	apps/mobile/src/modules/settings/routes/Referral.tsx
#	apps/mobile/src/screens/(modal)/InvitationScreen.tsx
This commit is contained in:
DIYgod 2026-02-18 23:53:40 +08:00
commit b548f5a39c
34 changed files with 1878 additions and 132 deletions

View File

@ -0,0 +1,22 @@
import log from "electron-log"
/**
* Logger for updater module with scoped prefix
* All logs are prefixed with [Updater] for easy identification
*/
export const updaterLogger = log.scope("updater")
/**
* Logger specifically for GitHub provider operations
*/
export const githubProviderLogger = log.scope("updater:github")
/**
* Helper to log object properties in a formatted way
*/
export function logObject(logger: typeof updaterLogger, prefix: string, obj: Record<string, any>) {
logger.info(`${prefix}:`)
for (const [key, value] of Object.entries(obj)) {
logger.info(` ${key}: ${value}`)
}
}

View File

@ -77,7 +77,6 @@ const ChatHeaderLayout = ({
const { isScrolledBeyondThreshold } = useAIRootState()
const isScrolledBeyondThresholdValue = useAtomValue(isScrolledBeyondThreshold)
return (
<div
className={cn(

View File

@ -304,6 +304,7 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
interface ChatInterfaceProps {
centerInputOnEmpty?: boolean
visualOffsetY?: string | number
}
export const ChatInterface = (props: ChatInterfaceProps) => (
<ErrorBoundary fallback={AIErrorFallback}>

View File

@ -34,6 +34,157 @@ const isWordChar = (char: string): boolean => {
return letterNumberUnderscorePattern.test(char)
}
// Detect custom inline reference tags
const mentionTagStartPattern = /<\s*mention-(?:entry|feed)\b/gi
const mentionTagCompletePattern = /^<\s*(mention-(?:entry|feed))/i
// Finds the end index of a mention tag (self-closing or paired) starting at `startIndex`.
// Returns the index of the closing `>` when found outside of quotes; otherwise -1.
const findMentionTagEnd = (text: string, startIndex: number): number => {
// Don't process if inside a complete code block
if (hasCompleteCodeBlock(text)) return -1
let inQuote: '"' | "'" | null = null
let openingTagEnd = -1
for (let i = startIndex; i < text.length; i++) {
const char = text[i]
if (inQuote) {
if (char === inQuote && text[i - 1] !== "\\") {
inQuote = null
}
continue
}
if (char === '"' || char === "'") {
inQuote = char
continue
}
if (char === "/" && text[i + 1] === ">") {
return i + 1 // index of '>' in `/>`
}
if (char === ">") {
openingTagEnd = i
break
}
}
if (openingTagEnd === -1) {
return -1
}
const openingTag = text.substring(startIndex, openingTagEnd + 1)
const tagNameMatch = openingTag.match(mentionTagCompletePattern)
if (!tagNameMatch) {
return -1
}
// If the tag is already self-closing (allow whitespace before `/`)
if (/\/\s*>$/.test(openingTag)) {
return openingTagEnd
}
const tagName = (tagNameMatch[1] ?? "").toLowerCase()
if (!tagName) {
return -1
}
const afterOpening = text.substring(openingTagEnd + 1)
const closingTagPattern = new RegExp(`<\\s*/\\s*${tagName}\\s*>`, "i")
const closingMatch = closingTagPattern.exec(afterOpening)
if (!closingMatch) {
return -1
}
return openingTagEnd + 1 + closingMatch.index + closingMatch[0].length - 1
}
// Trims trailing, incomplete `<mention-entry ...>` or `<mention-feed ...>` tags to avoid
// injecting broken raw HTML into markdown while streaming.
const handleIncompleteMentionTags = (text: string): string => {
// Don't process if inside a complete code block
if (hasCompleteCodeBlock(text)) {
return text
}
let cutIndex: number | null = null
let match: RegExpExecArray | null
mentionTagStartPattern.lastIndex = 0
while ((match = mentionTagStartPattern.exec(text))) {
const start = match.index
const end = findMentionTagEnd(text, start)
if (end === -1) {
cutIndex = start
break
} else {
// continue scanning after this complete tag
mentionTagStartPattern.lastIndex = end + 1
}
}
if (cutIndex !== null) {
const nextNewlineIndex = text.indexOf("\n", cutIndex)
if (nextNewlineIndex !== -1) {
// Remove only the incomplete tag segment and preserve following lines
return text.substring(0, cutIndex) + text.substring(nextNewlineIndex)
}
// No newline after the incomplete tag; drop the trailing incomplete segment
return text.substring(0, cutIndex)
}
return text
}
// Handles `<Use: ...>` wrappers that contain mention tags (self-closing or paired) by:
// - Replacing the whole wrapper with only the inner `<mention-...>` when complete
// - Trimming from `<Use:` if the inner mention tag is incomplete while streaming
const handleUseWrapper = (text: string): string => {
// Don't process if inside a complete code block
if (hasCompleteCodeBlock(text)) return text
const usePattern = /<\s*Use:\s*/gi
let result = text
let match: RegExpExecArray | null
usePattern.lastIndex = 0
// We rebuild iteratively in case of multiple occurrences
while ((match = usePattern.exec(result))) {
const useStart = match.index
const mentionStart = result.indexOf("<mention-", useStart)
if (mentionStart === -1) {
// Incomplete `<Use:` without a mention yet → remove only the incomplete segment
const nextNewlineIndex = result.indexOf("\n", useStart)
return nextNewlineIndex !== -1
? result.substring(0, useStart) + result.substring(nextNewlineIndex)
: result.substring(0, useStart)
}
// Ensure mention is the immediate content of the Use wrapper (allow whitespace)
const between = result.substring(useStart + match[0].length, mentionStart)
if (!/^\s*$/.test(between)) {
// Unexpected content between Use and mention → treat as plain text, continue
continue
}
const mentionEnd = findMentionTagEnd(result, mentionStart)
if (mentionEnd === -1) {
// Mention not finished yet → remove only the incomplete wrapper segment
const nextNewlineIndex = result.indexOf("\n", useStart)
return nextNewlineIndex !== -1
? result.substring(0, useStart) + result.substring(nextNewlineIndex)
: result.substring(0, useStart)
}
// Replace `<Use: <mention-...>` with `<mention-...>`
const before = result.substring(0, useStart)
const mentionTag = result.substring(mentionStart, mentionEnd + 1)
const after = result.substring(mentionEnd + 1)
result = before + mentionTag + after
// Reset the regex lastIndex to continue scanning after the replaced tag
usePattern.lastIndex = before.length + mentionTag.length
}
return result
}
// Helper function to check if we have a complete code block
const hasCompleteCodeBlock = (text: string): boolean => {
const tripleBackticks = (text.match(/```/g) || []).length
@ -611,6 +762,24 @@ const handleIncompleteStrikethrough = (text: string): string => {
return text
}
// Counts single dollar signs that are not part of double dollar signs and not escaped
const _countSingleDollarSigns = (text: string): number => {
return text.split("").reduce((acc, char, index) => {
if (char === "$") {
const prevChar = text[index - 1]
const nextChar = text[index + 1]
// Skip if escaped with backslash
if (prevChar === "\\") {
return acc
}
if (prevChar !== "$" && nextChar !== "$") {
return acc + 1
}
}
return acc
}, 0)
}
// Completes incomplete block KaTeX formatting ($$)
const handleIncompleteBlockKatex = (text: string): string => {
// Count all $$ pairs in the text
@ -717,6 +886,10 @@ export const parseIncompleteMarkdown = (text: string): string => {
// Handle various formatting completions
// Handle triple asterisks first (most specific)
result = handleIncompleteBoldItalic(result)
// Normalize and guard the `<Use:` wrapper first so inner tags are handled correctly
result = handleUseWrapper(result)
// Handle custom mention tags trimming before other single-character completions
result = handleIncompleteMentionTags(result)
result = handleIncompleteBold(result)
result = handleIncompleteDoubleUnderscoreItalic(result)
result = handleIncompleteSingleAsteriskItalic(result)

View File

@ -0,0 +1,304 @@
import { convertLexicalToMarkdown } from "@follow/components/ui/lexical-rich-editor/utils.js"
import { FeedViewType } from "@follow/constants"
import { DEFAULT_SUMMARIZE_TIMELINE_SHORTCUT_ID } from "@follow/shared/settings/defaults"
import { getCategoryFeedIds } from "@follow/store/subscription/getter"
import type { LexicalEditor } from "lexical"
import { $createParagraphNode, $getRoot, createEditor } from "lexical"
import { nanoid } from "nanoid"
import { useEffect, useMemo, useRef } from "react"
import { getShortcutEffectivePrompt, useAISettingValue } from "~/atoms/settings/ai"
import { useGeneralSettingKey } from "~/atoms/settings/general"
import { ROUTE_FEED_IN_FOLDER, ROUTE_FEED_PENDING } from "~/constants"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { AI_CHAT_SPECIAL_ID_PREFIX } from "../constants"
import { LexicalAIEditorNodes, ShortcutNode } from "../editor"
import { AIPersistService } from "../services"
import { useAIChatStore } from "../store/AIChatContext"
import { useBlockActions, useChatActions, useCurrentChatId } from "../store/hooks"
import { BlockSliceAction } from "../store/slices/block.slice"
import type { AIChatContextBlock, SendingUIMessage } from "../store/types"
import { isTimelineSummaryAutoContext } from "./useTimelineSummaryAutoContext"
const ONE_HOUR = 60 * 60 * 1000
const buildSummaryMessage = (
editor: LexicalEditor,
contextBlocks: AIChatContextBlock[],
messageId: string,
): SendingUIMessage => {
const parts: SendingUIMessage["parts"] = []
if (contextBlocks.length > 0) {
parts.push({
type: "data-block",
data: contextBlocks,
})
}
parts.push({
type: "data-rich-text",
data: {
state: JSON.stringify(editor.getEditorState().toJSON()),
text: convertLexicalToMarkdown(editor),
},
})
return {
id: messageId,
role: "user",
parts,
}
}
const buildTimelineSummaryChatId = ({
view,
feedId,
timelineId,
unreadOnly,
seed,
}: {
view: number
feedId: string
timelineId?: string | null
unreadOnly: boolean
seed: string
}) => {
const normalizedTimelineId = timelineId ?? "all"
const unreadSegment = unreadOnly ? "unread" : "all"
const prefix = AI_CHAT_SPECIAL_ID_PREFIX.TIMELINE_SUMMARY
return `${prefix}${view}:${feedId}:${normalizedTimelineId}:${unreadSegment}:${seed}`
}
export const useAutoTimelineSummaryShortcut = () => {
const aiSettings = useAISettingValue()
const unreadOnly = useGeneralSettingKey("unreadOnly")
const { view, feedId, entryId, timelineId } = useRouteParamsSelector((params) => ({
view: params.view,
feedId: params.feedId,
entryId: params.entryId,
timelineId: params.timelineId,
}))
const chatActions = useChatActions()
const blockActions = useBlockActions()
const currentChatId = useCurrentChatId()
const timelineSummaryManualOverride = useAIChatStore()(
(state) => state.timelineSummaryManualOverride,
)
const automationStateRef = useRef<{
contextKey: string | null
promise: Promise<void> | null
failed: boolean
}>({
contextKey: null,
promise: null,
failed: false,
})
const previousContextKeyRef = useRef<string | null>(null)
const isAllTimeline = isTimelineSummaryAutoContext({ view, entryId })
const defaultShortcut = useMemo(() => {
const shortcuts = aiSettings.shortcuts ?? []
return shortcuts.find(
(shortcut) => shortcut.id === DEFAULT_SUMMARIZE_TIMELINE_SHORTCUT_ID && shortcut.enabled,
)
}, [aiSettings.shortcuts])
const normalizedFeedId = feedId ?? ROUTE_FEED_PENDING
const contextKey = useMemo(() => {
if (!isAllTimeline) return null
const keyParts = [
`timeline:${timelineId ?? "all"}`,
`feed:${normalizedFeedId}`,
`unread:${unreadOnly ? "1" : "0"}`,
]
return keyParts.join("|")
}, [isAllTimeline, timelineId, normalizedFeedId, unreadOnly])
useEffect(() => {
if (previousContextKeyRef.current !== contextKey) {
chatActions.setTimelineSummaryManualOverride(false)
previousContextKeyRef.current = contextKey
}
}, [chatActions, contextKey])
const previousIsAllTimelineRef = useRef(isAllTimeline)
useEffect(() => {
const wasAllTimeline = previousIsAllTimelineRef.current
if (
wasAllTimeline &&
!isAllTimeline &&
currentChatId &&
currentChatId.startsWith(AI_CHAT_SPECIAL_ID_PREFIX.TIMELINE_SUMMARY)
) {
blockActions.clearBlocks({ keepSpecialTypes: true })
chatActions.newChat()
}
previousIsAllTimelineRef.current = isAllTimeline
}, [blockActions, chatActions, currentChatId, isAllTimeline])
const contextBlocks = useMemo<AIChatContextBlock[]>(() => {
if (!isAllTimeline) return []
const blocks: AIChatContextBlock[] = []
if (typeof view === "number") {
blocks.push({
id: BlockSliceAction.SPECIAL_TYPES.mainView,
type: "mainView",
value: `${view}`,
})
}
if (normalizedFeedId && normalizedFeedId !== ROUTE_FEED_PENDING) {
let value = normalizedFeedId
if (normalizedFeedId.startsWith(ROUTE_FEED_IN_FOLDER)) {
const categoryName = normalizedFeedId.slice(ROUTE_FEED_IN_FOLDER.length)
const ids = getCategoryFeedIds(categoryName, FeedViewType.All)
if (ids.length > 0) {
value = ids.join(",")
}
}
blocks.push({
id: BlockSliceAction.SPECIAL_TYPES.mainFeed,
type: "mainFeed",
value,
})
}
if (unreadOnly) {
blocks.push({
id: BlockSliceAction.SPECIAL_TYPES.unreadOnly,
type: "unreadOnly",
value: "true",
})
}
return blocks
}, [isAllTimeline, normalizedFeedId, unreadOnly, view])
useEffect(() => {
if (!contextKey || !defaultShortcut) {
if (!contextKey) {
automationStateRef.current = { contextKey: null, promise: null, failed: false }
}
return
}
if (automationStateRef.current.contextKey !== contextKey) {
automationStateRef.current = {
contextKey,
promise: null,
failed: false,
}
} else {
if (automationStateRef.current.promise) {
return
}
if (automationStateRef.current.failed) {
return
}
}
if (timelineSummaryManualOverride) {
return
}
const run = async () => {
try {
const prompt = getShortcutEffectivePrompt(defaultShortcut)
const { id, name } = defaultShortcut
const existingSession = await AIPersistService.findTimelineSummarySession({
view,
feedId: normalizedFeedId,
timelineId: timelineId ?? null,
unreadOnly,
})
const now = Date.now()
if (existingSession) {
const lastUpdatedAt = existingSession.updatedAt?.getTime?.() ?? existingSession.updatedAt
if (typeof lastUpdatedAt === "number" && now - lastUpdatedAt < ONE_HOUR) {
if (currentChatId !== existingSession.chatId) {
await chatActions.switchToChat(existingSession.chatId)
}
automationStateRef.current.failed = false
return
}
}
const timelineSummaryChatId = buildTimelineSummaryChatId({
view,
feedId: normalizedFeedId,
timelineId: timelineId ?? null,
unreadOnly,
seed: nanoid(6),
})
await AIPersistService.ensureSession(timelineSummaryChatId, {
title: "Timeline Summary",
})
await chatActions.switchToChat(timelineSummaryChatId)
blockActions.clearBlocks({ keepSpecialTypes: true })
const tempEditor = createEditor({
nodes: LexicalAIEditorNodes,
})
tempEditor.update(
() => {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
const shortcutNode = new ShortcutNode({ id, name, prompt })
paragraph.append(shortcutNode)
root.append(paragraph)
},
{
discrete: true,
},
)
const message = buildSummaryMessage(tempEditor, contextBlocks, nanoid())
await chatActions.sendMessage(message, {
body: { scene: "general" },
})
automationStateRef.current.failed = false
} catch (error) {
automationStateRef.current.failed = true
console.error("[AI Chat] Failed to auto-run timeline summary shortcut:", error)
} finally {
if (automationStateRef.current.contextKey === contextKey) {
automationStateRef.current.promise = null
}
}
}
const promise = run()
automationStateRef.current.promise = promise
}, [
blockActions,
chatActions,
contextBlocks,
contextKey,
currentChatId,
defaultShortcut,
normalizedFeedId,
timelineId,
unreadOnly,
view,
timelineSummaryManualOverride,
])
}

View File

@ -8,6 +8,7 @@ import { ELECTRON_BUILD } from "@follow/shared/constants"
import { springScrollTo } from "@follow/utils/scroller"
import { clsx, cn, getOS } from "@follow/utils/utils"
import { m } from "framer-motion"
import { LinearBlur } from "progressive-blur"
import { isValidElement, useCallback, useEffect, useRef, useState } from "react"
import { useHotkeys } from "react-hotkeys-hook"
import { useTranslation } from "react-i18next"

View File

@ -20,7 +20,6 @@ import { previewBackPath } from "~/atoms/preview"
import { useGeneralSettingKey } from "~/atoms/settings/general"
import { useSubscriptionColumnShow } from "~/atoms/sidebar"
import { ROUTE_ENTRY_PENDING } from "~/constants"
import { useFeature } from "~/hooks/biz/useFeature"
import { useFollow } from "~/hooks/biz/useFollow"
import { getRouteParams, useRouteParams } from "~/hooks/biz/useRouteParams"
import { useLoginModal } from "~/hooks/common"

View File

@ -0,0 +1,563 @@
import { Logo } from "@follow/components/icons/logo.jsx"
import { Button } from "@follow/components/ui/button/index.js"
import type { LexicalRichEditorRef } from "@follow/components/ui/lexical-rich-editor/index.js"
import {
convertLexicalToMarkdown,
getEditorStateJSONString,
} from "@follow/components/ui/lexical-rich-editor/utils.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
import { useIsDark } from "@follow/hooks"
import { tracker } from "@follow/tracker"
import { nextFrame } from "@follow/utils"
import { cn } from "@follow/utils/utils"
import { AnimatePresence } from "framer-motion"
import { useSetAtom } from "jotai"
import type { EditorState } from "lexical"
import { $getRoot, $getSelection, $isRangeSelection, createEditor } from "lexical"
import { nanoid } from "nanoid"
import type { RefObject } from "react"
import { Fragment, useEffect, useLayoutEffect, useMemo, 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 { RateLimitNotice } from "../ai-chat/components/layouts/RateLimitNotice"
import { AIChatWaitingIndicator } from "../ai-chat/components/message/AIChatMessage"
import { AIShortcutButton } from "../ai-chat/components/ui/AIShortcutButton"
import { LexicalAIEditorNodes } from "../ai-chat/editor"
import { isRateLimitError } from "../ai-chat/utils/error"
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 I18nKeys[]
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-background p-2 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="outline" 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 isDark = useIsDark()
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-xs font-medium uppercase text-text-secondary">
{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
const gradient = gradientByIndex(index, isDark)
return (
<AIShortcutButton
key={suggestionKey}
onClick={() => onClickSuggestion(suggestionText)}
animationDelay={index * 0.05}
className="font-normal text-text"
style={{ background: gradient }}
>
{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) =>
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 staticEditor = useMemo(() => {
return createEditor({
nodes: LexicalAIEditorNodes,
})
}, [])
const handleSendMessage = useEventCallback((message: string | EditorState) => {
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: "data-rich-text",
data: {
state: getEditorStateJSONString(message),
text: message,
},
})
} else {
staticEditor.setEditorState(message)
parts.push({
type: "data-rich-text",
data: {
state: JSON.stringify(message.toJSON()),
text: convertLexicalToMarkdown(staticEditor),
},
})
}
// 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
// Check if error is a rate limit error
const hasRateLimitError = useMemo(() => isRateLimitError(error), [error])
// Additional height for rate limit notice (~40px)
const rateLimitExtraHeight = hasRateLimitError ? 40 : 0
const messages = useMessages()
const setStep = useSetAtom(stepAtom)
const hasFeedsSelection = messages.some((msg) =>
msg.parts.some((p) => p.type === "tool-onboardingGetTrendingFeeds" && p.output),
)
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) + rateLimitExtraHeight,
},
}}
ref={setScrollAreaRef}
rootClassName="flex-1"
viewportProps={{
style: {
paddingBottom: Math.max(128, bottomPanelHeight) + rateLimitExtraHeight,
},
}}
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" &&
hasFeedsSelection && (
<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(
"group center flex size-8 items-center gap-2 rounded-full border backdrop-blur-background transition-all bg-mix-background/transparent-8/2",
"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={"px-6"}>
{hasRateLimitError && error && <RateLimitNotice error={error} />}
<ChatInput
ref={inputRef}
onSend={handleSendMessage}
variant={!hasMessages ? "minimal" : "default"}
/>
</div>
</div>
)
}
// Softer gradient colors based on ACCENT_COLOR_MAP
const GRADIENT_COLORS = [
{
light: { from: "#FF6B35", to: "#FFB088" },
dark: { from: "#FF5C00", to: "#FF8B4D" },
},
{
light: { from: "#4CD7A5", to: "#8FE8C7" },
dark: { from: "#1FA97A", to: "#4DCFA0" },
},
{
light: { from: "#F7B500", to: "#FFD966" },
dark: { from: "#D99800", to: "#F7C84D" },
},
{
light: { from: "#B07BEF", to: "#D4B4F7" },
dark: { from: "#8A3DCC", to: "#B07BEF" },
},
{
light: { from: "#F266A8", to: "#F9A1CA" },
dark: { from: "#C63C82", to: "#E86BAA" },
},
]
function gradientByIndex(index: number, isDark: boolean) {
const colors = GRADIENT_COLORS[index % GRADIENT_COLORS.length]!
const mode = isDark ? "dark" : "light"
return `linear-gradient(to right, ${colors[mode].from}, ${colors[mode].to})`
}

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,232 @@
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 { AISplineLoader } from "../ai-chat/components/3d-models/AISplineLoader"
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) =>
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) => m.parts?.some((p) => p.type === "tool-onboardingGetTrendingFeeds"))
?.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 absolute right-0 top-0 z-10 size-5 -translate-y-1/2 translate-x-1/2 cursor-pointer text-text-secondary transition-colors hover:text-text"
/>
</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-sm font-semibold text-text">{feed.title}</p>
<p className="text-xs text-text-secondary">{feed.url}</p>
</div>
</div>
</CardHeader>
<CardContent>
<CardDescription className="text-sm text-text-secondary">
{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 border border-zinc-200/50 bg-material-thin p-8 backdrop-blur-xl dark:border-zinc-800/50"
aria-hidden="true"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.5, ease: "easeOut" }}
>
{/* Grid background - consistent with app patterns */}
<div className="absolute inset-0 bg-[linear-gradient(rgba(0,0,0,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(0,0,0,0.03)_1px,transparent_1px)] bg-[size:64px_64px] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_50%,black,transparent)] dark:bg-[linear-gradient(rgba(255,255,255,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.05)_1px,transparent_1px)]" />
{/* Content */}
<div className="relative z-10 flex h-full flex-col items-center justify-center text-center">
{/* Icon - using app's existing icon library */}
<m.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.1, duration: 0.6, type: "spring" }}
className="mb-6"
>
<div className="mx-auto mb-4 flex items-center justify-center">
<AISplineLoader />
</div>
</m.div>
{/* Title - using app's gradient text pattern */}
<m.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.3, duration: 0.6, ease: "easeOut" }}
className="mb-4"
>
<h1 className="bg-gradient-to-r from-zinc-800 to-zinc-600 bg-clip-text text-4xl font-bold text-transparent dark:from-zinc-100 dark:to-zinc-300">
{t.app("new_user_guide.intro.title")}
</h1>
</m.div>
{/* Description text */}
<m.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.5, duration: 0.6, ease: "easeOut" }}
className="mb-8 max-w-sm"
>
<p className="text-lg leading-relaxed text-text-secondary">
{t.app("new_user_guide.intro.description")}
</p>
</m.div>
</div>
</m.div>
)
}

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: feed.analytics.view ?? FeedViewType.Articles,
category: null,
isPrivate: false,
hideFromTimeline: null,
title: title ?? null,
feedId: id,
listId: undefined,
})
} catch (error) {
if (!disposed) {
console.error("Failed to subscribe feed during onboarding", { feedId: id, error })
}
}
}
}
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

@ -0,0 +1,38 @@
import type { MediaModel } from "@follow/database/schemas/types"
import type { FeedViewType } from "@follow-app/client-sdk"
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
}[]
analytics: {
view: FeedViewType | 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

@ -10,7 +10,6 @@ import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
import { Trans } from "react-i18next"
@ -103,14 +102,6 @@ const Content: FC<{
const [scrollerAtTop, setScrollerAtTop] = useState(true)
const [scroller, setScroller] = useState<HTMLDivElement | null>(null)
const pendingSectionRef = useRef<string | null>(initialSection ?? null)
const hasAppliedInitialSectionRef = useRef(false)
useEffect(() => {
pendingSectionRef.current = initialSection ?? null
hasAppliedInitialSectionRef.current = false
}, [initialSection])
useLayoutEffect(() => {
if (scroller) {
scroller.scrollTop = 0

View File

@ -22,6 +22,11 @@ export const AI_SETTING_SECTION_IDS = {
timelinePrompt: "settings-ai-timeline-prompt",
} as const
export const AI_SETTING_SECTION_IDS = {
shortcuts: "settings-ai-shortcuts",
tasks: "settings-ai-tasks",
} as const
export const SettingAI = () => {
const { t } = useTranslation("ai")

View File

@ -15,7 +15,7 @@ export const Component = () => {
>
<AIChatRoot>
<ChatPageHeader />
<ChatInterface centerInputOnEmpty />
<ChatInterface centerInputOnEmpty visualOffsetY="clamp(-10vh, -8vh, -6vh)" />
</AIChatRoot>
</div>
)

View File

@ -52,3 +52,64 @@
top: calc(env(safe-area-inset-top, 0px) + 20px);
}
}
[data-sonner-toast] {
background-image: linear-gradient(
to bottom right,
rgba(var(--color-background) / 0.98),
rgba(var(--color-background) / 0.95)
) !important;
}
[data-sonner-toast]::before {
content: "";
position: absolute;
inset: 0;
border-radius: 1rem;
background: linear-gradient(
to bottom right,
rgba(255, 92, 0, 0.05),
transparent,
rgba(255, 92, 0, 0.05)
);
z-index: -1;
}
[data-sonner-toast][data-type="success"]::before {
background: linear-gradient(
to bottom right,
rgba(40, 205, 65, 0.05),
transparent,
rgba(40, 205, 65, 0.05)
);
}
[data-sonner-toast][data-type="error"]::before {
background: linear-gradient(
to bottom right,
rgba(255, 69, 58, 0.05),
transparent,
rgba(255, 69, 58, 0.05)
);
}
[data-sonner-toast][data-type="warning"]::before {
background: linear-gradient(
to bottom right,
rgba(255, 149, 0, 0.05),
transparent,
rgba(255, 149, 0, 0.05)
);
}
[data-sonner-toast][data-type="info"]::before {
background: linear-gradient(
to bottom right,
rgba(0, 122, 255, 0.05),
transparent,
rgba(0, 122, 255, 0.05)
);
}
[data-sonner-toast][data-type="loading"]::before {
background: linear-gradient(
to bottom right,
rgba(142, 142, 147, 0.05),
transparent,
rgba(142, 142, 147, 0.05)
);
}

View File

@ -0,0 +1,11 @@
# What's New in v0.2.10
## Shiny new things
## Improvements
## No longer broken
## Thanks
Special thanks to volunteer contributors @ for their valuable contributions

View File

@ -53,7 +53,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>137</string>
<string>138</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key>

View File

@ -11,7 +11,8 @@
"TabBarPortalModule",
"EnhancePagerViewModule",
"EnhancePageViewModule",
"ItemPressableModule"
"ItemPressableModule",
"TabBarBottomAccessoryModule"
]
},
"android": {

View File

@ -9,105 +9,102 @@ import UIKit
extension UIWindow {
static func findViewController<T: UIViewController>(ofType type: T.Type) -> T? {
static func findViewController<T: UIViewController>(ofType type: T.Type) -> T? {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first else {
return nil
}
if let rootVC = window.rootViewController {
return findViewControllerInHierarchy(rootVC, ofType: type)
}
return nil
}
static private func findViewControllerInHierarchy<T: UIViewController>(_ viewController: UIViewController, ofType type: T.Type) -> T? {
if let targetVC = viewController as? T {
return targetVC
}
if let navController = viewController as? UINavigationController {
if let visibleVC = navController.visibleViewController {
return findViewControllerInHierarchy(visibleVC, ofType: type)
}
}
if let tabController = viewController as? UITabBarController {
if let selectedVC = tabController.selectedViewController {
return findViewControllerInHierarchy(selectedVC, ofType: type)
}
}
for childVC in viewController.children {
if let foundVC = findViewControllerInHierarchy(childVC, ofType: type) {
return foundVC
}
}
if let presentedVC = viewController.presentedViewController {
return findViewControllerInHierarchy(presentedVC, ofType: type)
}
return nil
}
public static func findRNSNavigationController() -> UINavigationController? {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first else {
return nil
}
let rootViewController = window.rootViewController
if let navController = rootViewController as? UINavigationController,
NSStringFromClass(type(of: navController)).contains("RNSNavigationController")
{
return navController
}
return findRNSNavigationControllerInChildren(of: rootViewController)
let window = windowScene.windows.first
else {
return nil
}
private static func findRNSNavigationControllerInChildren(of viewController: UIViewController?)
-> UINavigationController?
if let rootVC = window.rootViewController {
return findViewControllerInHierarchy(rootVC, ofType: type)
}
return nil
}
static private func findViewControllerInHierarchy<T: UIViewController>(
_ viewController: UIViewController, ofType type: T.Type
) -> T? {
if let targetVC = viewController as? T {
return targetVC
}
if let navController = viewController as? UINavigationController {
if let visibleVC = navController.visibleViewController {
return findViewControllerInHierarchy(visibleVC, ofType: type)
}
}
if let tabController = viewController as? UITabBarController {
if let selectedVC = tabController.selectedViewController {
return findViewControllerInHierarchy(selectedVC, ofType: type)
}
}
for childVC in viewController.children {
if let foundVC = findViewControllerInHierarchy(childVC, ofType: type) {
return foundVC
}
}
if let presentedVC = viewController.presentedViewController {
return findViewControllerInHierarchy(presentedVC, ofType: type)
}
return nil
}
public static func findRNSNavigationController() -> UINavigationController? {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first
else {
return nil
}
let rootViewController = window.rootViewController
if let navController = rootViewController as? UINavigationController,
NSStringFromClass(type(of: navController)).contains("RNSNavigationController")
{
guard let viewController = viewController else {
return nil
}
if let presentedVC = viewController.presentedViewController {
if let navController = presentedVC as? UINavigationController,
NSStringFromClass(type(of: navController)).contains("RNSNavigationController")
{
return navController
}
if let result = findRNSNavigationControllerInChildren(of: presentedVC) {
return result
}
}
for childVC in viewController.children {
if let navController = childVC as? UINavigationController,
NSStringFromClass(type(of: navController)).contains("RNSNavigationController")
{
return navController
}
if let result = findRNSNavigationControllerInChildren(of: childVC) {
return result
}
}
return nil
return navController
}
return findRNSNavigationControllerInChildren(of: rootViewController)
}
private static func findRNSNavigationControllerInChildren(of viewController: UIViewController?)
-> UINavigationController?
{
guard let viewController = viewController else {
return nil
}
if let presentedVC = viewController.presentedViewController {
if let navController = presentedVC as? UINavigationController,
NSStringFromClass(type(of: navController)).contains("RNSNavigationController")
{
return navController
}
if let result = findRNSNavigationControllerInChildren(of: presentedVC) {
return result
}
}
for childVC in viewController.children {
if let navController = childVC as? UINavigationController,
NSStringFromClass(type(of: navController)).contains("RNSNavigationController")
{
return navController
}
if let result = findRNSNavigationControllerInChildren(of: childVC) {
return result
}
}
return nil
}
}

View File

@ -0,0 +1,73 @@
//
// TabBarBottomAccessoryModule.swift
// FollowNative
//
// Created by Innei on 2025-09-25
//
import ExpoModulesCore
import UIKit
public class TabBarBottomAccessoryModule: Module {
public func definition() -> ModuleDefinition {
Name("TabBarBottomAccessory")
View(TabBarBottomAccessoryView.self) {
}
}
}
class TabBarBottomAccessoryView: ExpoView {
private weak var attachedRoot: TabBarRootView?
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
}
deinit {
detachFromRoot()
}
override func didMoveToWindow() {
super.didMoveToWindow()
attachToNearestTabBarRoot()
}
override func willMove(toWindow newWindow: UIWindow?) {
if newWindow == nil {
detachFromRoot()
}
super.willMove(toWindow: newWindow)
}
#if RCT_NEW_ARCH_ENABLED
override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
attachToNearestTabBarRoot()
}
override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
detachFromRoot()
}
#endif
func attachToNearestTabBarRoot() {
if #available(iOS 26, *) {
guard window != nil else { return }
CustomTabbarController.tabBarController.bottomAccessory = .init(contentView: self)
}
}
func detachFromRoot() {
if #available(iOS 26, *) {
guard window != nil else { return }
CustomTabbarController.tabBarController.bottomAccessory = nil
}
}
}

View File

@ -10,8 +10,9 @@ import Foundation
import SnapKit
import UIKit
class TabBarRootView: ExpoView {
private lazy var tabBarController = {
@MainActor
enum CustomTabbarController {
static var tabBarController = {
let tabBarController = UITabBarController()
if #available(iOS 16.0, *), UIDevice.current.userInterfaceIdiom == .pad {
tabBarController.tabBar.isTranslucent = false
@ -27,19 +28,27 @@ class TabBarRootView: ExpoView {
if #available(iOS 26.0, *) {
tabBarController.isTabBarHidden = false
tabBarController.tabBarMinimizeBehavior = .onScrollDown
}
tabBarController.tabBar.tintColor = Utils.accentColor
return tabBarController
}()
}
class TabBarRootView: ExpoView {
private var tabBarController = CustomTabbarController.tabBarController
private let vc = UIViewController()
private var tabViewControllers: [UIViewController] = []
private var bottomAccessoryView: UIView?
private let onTabIndexChange = EventDispatcher()
private let onTabItemPress = EventDispatcher()
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
@ -118,6 +127,7 @@ class TabBarRootView: ExpoView {
let tabBarView = tabBarController.view!
tabBarView.addSubview(tabBarPortalView)
}
}
override func willRemoveSubview(_ subview: UIView) {
@ -129,6 +139,7 @@ class TabBarRootView: ExpoView {
tabBarController.viewControllers = tabViewControllers
tabBarController.didMove(toParent: vc)
}
}
}

View File

@ -16,9 +16,18 @@ import { isIos26 } from "@/src/lib/platform"
export const ThemedBlurView = ({
ref,
tint,
tintColor,
useGlass,
...rest
}: BlurViewProps & { ref?: React.Ref<BlurView | null>; useGlass?: boolean }) => {
}: BlurViewProps & {
ref?: React.Ref<BlurView | null>
useGlass?: boolean
/**
* The tint color of the glass view, only works when `useGlass` is true
*/
tintColor?: string
}) => {
const { colorScheme } = useColorScheme()
const background = useColor("systemBackground")
@ -26,15 +35,20 @@ export const ThemedBlurView = ({
const useBlurView = Platform.OS === "ios" || "experimentalBlurMethod" in rest
if (isIos26 && useGlass) {
return <GlassView style={rest.style} glassEffectStyle="regular" />
return <GlassView style={rest.style} glassEffectStyle="regular" tintColor={tintColor} />
}
return useBlurView ? (
<BlurView
ref={ref}
intensity={100}
tint={colorScheme === "light" ? "systemChromeMaterialLight" : "systemChromeMaterialDark"}
{...rest}
/>
<>
<BlurView
ref={ref}
intensity={100}
tint={colorScheme === "light" ? "systemChromeMaterialLight" : "systemChromeMaterialDark"}
{...rest}
/>
{tintColor && (
<View style={[StyleSheet.absoluteFillObject, { backgroundColor: tintColor }]} />
)}
</>
) : (
<View
ref={ref as any}
@ -42,7 +56,7 @@ export const ThemedBlurView = ({
style={StyleSheet.flatten([
rest.style,
{
backgroundColor: background,
backgroundColor: tintColor ?? background,
opacity: 1,
},
])}

View File

@ -4,6 +4,7 @@ import type { ViewProps } from "react-native"
import type { TabBarRootWrapperProps } from "./types"
export const TabBarPortalWrapper = requireNativeView<ViewProps>("TabBarPortal")
export const TabBarBottomAccessoryWrapper = requireNativeView<ViewProps>("TabBarBottomAccessory")
export type TabScreenNativeProps = ViewProps & { title?: string }

View File

@ -6,6 +6,7 @@ import type { IconNativeValues } from "@/src/constants/native-images"
import type { TabbarIconProps, TabBarRootWrapperProps } from "./types"
export { View as TabBarPortalWrapper } from "react-native"
export { View as TabBarBottomAccessoryWrapper } from "react-native"
export type TabScreenNativeProps = React.ComponentProps<typeof View> & {
title?: string
icon?: FC<TabbarIconProps> | IconNativeValues

View File

@ -25,7 +25,7 @@ import { accentColor, useColor } from "@/src/theme/colors"
import { MarkAllAsReadDialog } from "../dialogs/MarkAllAsReadDialog"
export const ActionGroup = ({ children, className }: PropsWithChildren<{ className?: string }>) => {
return <View className={cn("flex flex-row items-center gap-2", className)}>{children}</View>
return <View className={cn("flex flex-row items-center gap-1", className)}>{children}</View>
}
export function HomeLeftAction() {
@ -59,7 +59,7 @@ interface HeaderActionButtonProps {
variant?: "primary" | "secondary"
}
export const MarkAllAsReadActionButton = ({ variant = "primary" }: HeaderActionButtonProps) => {
export const MarkAllAsReadActionButton = ({ variant = "secondary" }: HeaderActionButtonProps) => {
const { t } = useTranslation()
const { size, color } = useButtonVariant({ variant })
@ -76,11 +76,11 @@ export const MarkAllAsReadActionButton = ({ variant = "primary" }: HeaderActionB
const useButtonVariant = ({ variant = "primary" }: HeaderActionButtonProps) => {
const label = useColor("label")
const size = 24
const size = 20
const color = variant === "primary" ? accentColor : label
return { size, color }
}
export const UnreadOnlyActionButton = ({ variant = "primary" }: HeaderActionButtonProps) => {
export const UnreadOnlyActionButton = ({ variant = "secondary" }: HeaderActionButtonProps) => {
const { t } = useTranslation()
const unreadOnly = useGeneralSettingKey("unreadOnly")
const { size, color } = useButtonVariant({ variant })
@ -110,7 +110,7 @@ export const UnreadOnlyActionButton = ({ variant = "primary" }: HeaderActionButt
export const FeedShareActionButton = ({
feedId,
variant = "primary",
variant = "secondary",
}: { feedId?: string } & HeaderActionButtonProps) => {
const { t } = useTranslation()
const { size, color } = useButtonVariant({ variant })

View File

@ -1,13 +1,14 @@
import { FeedViewType } from "@follow/constants"
import { useFeedById } from "@follow/store/feed/hooks"
import { useIsSubscribed } from "@follow/store/subscription/hooks"
import { isBizId } from "@follow/utils"
import { isBizId, withOpacity } from "@follow/utils"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { Pressable } from "react-native"
import { Pressable, StyleSheet } from "react-native"
import { RootSiblingParent } from "react-native-root-siblings"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { ThemedBlurView } from "@/src/components/common/ThemedBlurView"
import { BottomTabBarHeightContext } from "@/src/components/layouts/tabbar/contexts/BottomTabBarHeightContext"
import { Text } from "@/src/components/ui/typography/Text"
import { useNavigation } from "@/src/lib/navigation/hooks"
@ -16,6 +17,7 @@ import { EntryListSelector } from "@/src/modules/entry-list/EntryListSelector"
import { EntryListContext, useEntries, useSelectedView } from "@/src/modules/screen/atoms"
import { TimelineHeader } from "@/src/modules/screen/TimelineSelectorProvider"
import { FollowScreen } from "@/src/screens/(modal)/FollowScreen"
import { accentColor } from "@/src/theme/colors"
export const FeedScreen: NavigationControllerView<{
feedId: string
@ -45,6 +47,11 @@ export const FeedScreen: NavigationControllerView<{
})
}}
>
<ThemedBlurView
useGlass
style={StyleSheet.absoluteFillObject}
tintColor={withOpacity(accentColor, 0.6)}
/>
<Text className="font-bold text-white">{t("words.follow")}</Text>
</Pressable>
)}

View File

@ -319,6 +319,7 @@
"new_user_dialog.title": "Welcome to Folo",
"new_user_guide.actions.back": "Back",
"new_user_guide.actions.finish": "Finish",
"new_user_guide.actions.import_opml": "Import OPML",
"new_user_guide.actions.next": "Next",
"new_user_guide.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",

View File

@ -319,6 +319,7 @@
"new_user_dialog.title": "Folo へようこそ",
"new_user_guide.actions.back": "戻る",
"new_user_guide.actions.finish": "完了",
"new_user_guide.actions.import_opml": "OPML をインポート",
"new_user_guide.actions.next": "次へ",
"new_user_guide.ai_chat.intro": "Folo へようこそ。Folo は AI がインターネットを読み解いてくれるリーダーです。\n\nあなたについて教えてください。",
"new_user_guide.ai_chat.reroll": "入れ替える",

View File

@ -319,6 +319,7 @@
"new_user_dialog.title": "欢迎使用 Folo",
"new_user_guide.actions.back": "上一步",
"new_user_guide.actions.finish": "完成",
"new_user_guide.actions.import_opml": "导入 OPML",
"new_user_guide.actions.next": "下一步",
"new_user_guide.ai_chat.intro": "欢迎来到 Folo这款 AI 阅读器可以替你阅读整个互联网。\n\n告诉我一些关于你的事情。",
"new_user_guide.ai_chat.reroll": "换一批",

View File

@ -319,6 +319,7 @@
"new_user_dialog.title": "歡迎使用 Folo",
"new_user_guide.actions.back": "上一步",
"new_user_guide.actions.finish": "完成",
"new_user_guide.actions.import_opml": "匯入 OPML",
"new_user_guide.actions.next": "下一步",
"new_user_guide.ai_chat.intro": "歡迎來到 Folo這款 AI 閱讀器會為您讀遍整個網路。\n\n跟我分享一些關於您的事吧。",
"new_user_guide.ai_chat.reroll": "換一批",

View File

@ -208,6 +208,26 @@
[data-theme="dark"] .shadow-ai-chat-floating-panel {
--shadow-color: rgba(255, 255, 255, 0.04);
}
[data-theme="dark"] .shadow-context-menu {
box-shadow:
rgba(255, 255, 255, 0.07) 0px 3px 8px,
rgba(255, 255, 255, 0.05) 0px 2px 5px,
rgba(255, 255, 255, 0.04) 0px 1px 1px;
}
.shadow-ai-chat-floating-panel {
box-shadow:
-5px -6px 20px 0px rgba(0, 0, 0, 0.08),
10px -5px 20px 0px rgba(0, 0, 0, 0.08),
0px 10px 20px 0px rgba(0, 0, 0, 0.08);
}
[data-theme="dark"] .shadow-ai-chat-floating-panel {
box-shadow:
-5px -6px 20px 0px rgba(255, 255, 255, 0.04),
10px -5px 20px 0px rgba(255, 255, 255, 0.04),
0px 10px 20px 0px rgba(255, 255, 255, 0.04);
}
}
/* Link */

View File

@ -101,7 +101,7 @@
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": "19.0.0"
"react": "19.2.0"
},
"dependencies": {
"@follow-app/client-sdk": "catalog:",

View File

@ -7928,6 +7928,51 @@ packages:
'@webassemblyjs/wast-printer@1.14.1':
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
'@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
'@webassemblyjs/floating-point-hex-parser@1.13.2':
resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==}
'@webassemblyjs/helper-api-error@1.13.2':
resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==}
'@webassemblyjs/helper-buffer@1.14.1':
resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==}
'@webassemblyjs/helper-numbers@1.13.2':
resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==}
'@webassemblyjs/helper-wasm-bytecode@1.13.2':
resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==}
'@webassemblyjs/helper-wasm-section@1.14.1':
resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==}
'@webassemblyjs/ieee754@1.13.2':
resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==}
'@webassemblyjs/leb128@1.13.2':
resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==}
'@webassemblyjs/utf8@1.13.2':
resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==}
'@webassemblyjs/wasm-edit@1.14.1':
resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==}
'@webassemblyjs/wasm-gen@1.14.1':
resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==}
'@webassemblyjs/wasm-opt@1.14.1':
resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==}
'@webassemblyjs/wasm-parser@1.14.1':
resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==}
'@webassemblyjs/wast-printer@1.14.1':
resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==}
'@welldone-software/why-did-you-render@10.0.1':
resolution: {integrity: sha512-tMgGkt30iVYeLMUKExNmtm019QgyjLtA7lwB0QAizYNEuihlCG2eoAWBBaz/bDeI7LeqAJ9msC6hY3vX+JB97g==}
peerDependencies:
@ -8444,6 +8489,10 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.8.18:
resolution: {integrity: sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==}
hasBin: true
baseline-browser-mapping@2.8.6:
resolution: {integrity: sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==}
hasBin: true
@ -8712,6 +8761,9 @@ packages:
caniuse-lite@1.0.30001769:
resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==}
caniuse-lite@1.0.30001751:
resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==}
cardinal@2.1.1:
resolution: {integrity: sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==}
hasBin: true
@ -10375,6 +10427,9 @@ packages:
events-universal@1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
events-universal@1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
@ -10710,6 +10765,9 @@ packages:
exponential-backoff@3.1.3:
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
exponential-backoff@3.1.3:
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
exsolve@1.0.7:
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
@ -11746,6 +11804,10 @@ packages:
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
engines: {node: '>= 0.4'}
is-generator-function@1.1.2:
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
engines: {node: '>= 0.4'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
@ -11971,6 +12033,10 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jks-js@1.1.0:
resolution: {integrity: sha512-irWi8S2V029Vic63w0/TYa8NIZwXu9oeMtHQsX51JDIVBo0lrEaOoyM8ALEEh5PVKD6TrA26FixQK6TzT7dHqA==}
@ -14113,6 +14179,12 @@ packages:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
progressive-blur@1.0.0:
resolution: {integrity: sha512-BZkJJHksqsxt9eJ+bv5ptXyUDa+ZbpOzdGJNJVxFo2qF0u+Y38E1oKWidt6JIaHCM1xk9o8LSjf9TbtHpbx9WQ==}
peerDependencies:
react: 19.0.0
react-dom: 19.0.0
promise-inflight@1.0.1:
resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==}
peerDependencies:
@ -15071,8 +15143,12 @@ packages:
resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
engines: {node: '>=11.0.0'}
scheduler@0.26.0:
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
schema-utils@4.3.3:
resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
engines: {node: '>= 10.13.0'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@ -15655,6 +15731,10 @@ packages:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tailwind-api-utils@1.0.3:
resolution: {integrity: sha512-KpzUHkH1ug1sq4394SLJX38ZtpeTiqQ1RVyFTTSY2XuHsNSTWUkRo108KmyyrMWdDbQrLYkSHaNKj/a3bmA4sQ==}
peerDependencies:
@ -16204,6 +16284,10 @@ packages:
resolution: {integrity: sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==}
engines: {node: '>=18.17'}
undici@6.22.0:
resolution: {integrity: sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==}
engines: {node: '>=18.17'}
unicode-canonical-property-names-ecmascript@2.0.1:
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
engines: {node: '>=4'}
@ -25531,6 +25615,8 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.8.18: {}
baseline-browser-mapping@2.8.6: {}
baseline-browser-mapping@2.9.19: {}
@ -25849,6 +25935,8 @@ snapshots:
caniuse-lite@1.0.30001769: {}
caniuse-lite@1.0.30001751: {}
cardinal@2.1.1:
dependencies:
ansicolors: 0.3.2
@ -27965,6 +28053,12 @@ snapshots:
transitivePeerDependencies:
- bare-abort-controller
events-universal@1.0.1:
dependencies:
bare-events: 2.8.0
transitivePeerDependencies:
- bare-abort-controller
events@3.3.0: {}
eventsource-parser@3.0.6: {}
@ -28377,6 +28471,8 @@ snapshots:
exponential-backoff@3.1.3: {}
exponential-backoff@3.1.3: {}
exsolve@1.0.7: {}
ext-list@2.2.2:
@ -29706,6 +29802,14 @@ snapshots:
has-tostringtag: 1.0.2
safe-regex-test: 1.1.0
is-generator-function@1.1.2:
dependencies:
call-bound: 1.0.4
generator-function: 2.0.1
get-proto: 1.0.1
has-tostringtag: 1.0.2
safe-regex-test: 1.1.0
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
@ -29956,6 +30060,8 @@ snapshots:
jiti@2.6.1: {}
jiti@2.6.1: {}
jks-js@1.1.0:
dependencies:
node-forge: 1.3.1
@ -32275,6 +32381,11 @@ snapshots:
progress@2.0.3: {}
progressive-blur@1.0.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
promise-inflight@1.0.1: {}
promise-limit@2.7.0: {}
@ -33425,7 +33536,14 @@ snapshots:
sax@1.4.4: {}
scheduler@0.26.0: {}
scheduler@0.27.0: {}
schema-utils@4.3.3:
dependencies:
'@types/json-schema': 7.0.15
ajv: 8.17.1
ajv-formats: 2.1.1(ajv@8.17.1)
ajv-keywords: 5.1.0(ajv@8.17.1)
scheduler@0.27.0: {}
@ -34648,6 +34766,8 @@ snapshots:
undici@6.22.0: {}
undici@6.22.0: {}
unicode-canonical-property-names-ecmascript@2.0.1: {}
unicode-match-property-ecmascript@2.0.0: