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:
commit
b548f5a39c
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -77,7 +77,6 @@ const ChatHeaderLayout = ({
|
|||
|
||||
const { isScrolledBeyondThreshold } = useAIRootState()
|
||||
const isScrolledBeyondThresholdValue = useAtomValue(isScrolledBeyondThreshold)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -304,6 +304,7 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
|
|||
|
||||
interface ChatInterfaceProps {
|
||||
centerInputOnEmpty?: boolean
|
||||
visualOffsetY?: string | number
|
||||
}
|
||||
export const ChatInterface = (props: ChatInterfaceProps) => (
|
||||
<ErrorBoundary fallback={AIErrorFallback}>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
])
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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})`
|
||||
}
|
||||
|
|
@ -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,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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const Component = () => {
|
|||
>
|
||||
<AIChatRoot>
|
||||
<ChatPageHeader />
|
||||
<ChatInterface centerInputOnEmpty />
|
||||
<ChatInterface centerInputOnEmpty visualOffsetY="clamp(-10vh, -8vh, -6vh)" />
|
||||
</AIChatRoot>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>137</string>
|
||||
<string>138</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@
|
|||
"TabBarPortalModule",
|
||||
"EnhancePagerViewModule",
|
||||
"EnhancePageViewModule",
|
||||
"ItemPressableModule"
|
||||
"ItemPressableModule",
|
||||
"TabBarBottomAccessoryModule"
|
||||
]
|
||||
},
|
||||
"android": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
])}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "入れ替える",
|
||||
|
|
|
|||
|
|
@ -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": "换一批",
|
||||
|
|
|
|||
|
|
@ -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": "換一批",
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@
|
|||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "19.0.0"
|
||||
"react": "19.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@follow-app/client-sdk": "catalog:",
|
||||
|
|
|
|||
126
pnpm-lock.yaml
126
pnpm-lock.yaml
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue