feat(ai): add ai mention plugin (#4305)

* init

* refactor

* implement mention plugin for chat input

- Updated pnpm-lock.yaml and pnpm-workspace.yaml to include new versions of @floating-ui packages.
- Added MentionPlugin to the chat input component, enabling mention functionality.
- Created necessary components, hooks, and utilities for mention handling, including MentionNode, MentionDropdown, and related styles.
- Refactored existing code to integrate mention features seamlessly into the chat interface.

This update enhances the chat experience by allowing users to mention others easily, improving interactivity and engagement.

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

* Enhance mention functionality in chat editor

- Integrated MentionNode into AIRichTextMessage for improved mention handling.
- Updated MentionPlugin to utilize new hooks for mention search and insertion.
- Added useMentionIntegration and useMentionSearchService hooks for better mention management.
- Created contextBarSearchService and feedEntrySearchService for unified search functionality.
- Refactored LexicalRichEditor to support custom nodes from plugins.

This update improves the chat experience by streamlining mention interactions and enhancing search capabilities.

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

* Enhance mention integration and synchronization in chat editor

- Updated ChatInput layout for improved button positioning.
- Added select-none class to mention styles for better user experience.
- Refactored MentionDropdown to improve the display of mention types and content.
- Introduced useMentionBlockSync hook for bidirectional synchronization between mention nodes and context blocks.
- Enhanced useMentionIntegration to handle mention insertion with node key tracking.
- Implemented useMentionSearchService for streamlined mention search functionality.

These changes improve the overall mention handling and user interaction within the chat editor, making it more intuitive and efficient.

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

* Refactor ChatInput layout to include ScrollArea for improved scrolling experience

- Wrapped LexicalRichEditor in a ScrollArea component to enhance the input area’s usability.
- Adjusted styles in LexicalRichEditor for better layout consistency.

These changes aim to improve user interaction within the chat input by providing a more flexible scrolling interface.

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

* Remove deprecated mention functionality and update MentionNode for markdown export

- Deleted mention-related files and types as functionality has been moved to the desktop app.
- Updated MentionNode to change the text content format for markdown export.
- Refactored useMentionBlockSync to remove unused helper functions and streamline dependencies.
- Adjusted LexicalRichEditor placeholder styles for better alignment.

These changes clean up the codebase by removing obsolete mention features and enhancing the current mention handling in the chat editor.

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

* Implement AI chat message tracking functionality

- Added tracking for AI chat messages sent by integrating the tracker module into the ChatInterface component.
- Introduced a new tracker point for AIChatMessageSent in the TrackerMapper enum.
- Removed deprecated entry tracking methods to streamline the tracking process.

These changes enhance the analytics capabilities of the chat interface by monitoring AI interactions more effectively.

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

* Update VSCode settings for Tailwind CSS and i18n-ally configuration

- Streamlined the tailwindCSS.experimental.classRegex by removing unnecessary line breaks for better readability.
- Consolidated the tailwindCSS.experimental.configFile and i18n-ally settings into single-line arrays for consistency.
- Ensured proper formatting and alignment of settings to enhance maintainability.

These changes improve the clarity and organization of the VSCode settings file.

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

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-08-04 23:06:15 +08:00 committed by GitHub
parent 4205d49b08
commit c4ac1b1875
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1942 additions and 145 deletions

View File

@ -1,28 +0,0 @@
{
"permissions": {
"allow": [
"Bash(pnpm run lint:*)",
"Bash(rg:*)",
"Bash(grep:*)",
"Bash(pnpm run:*)",
"Bash(find:*)",
"Bash(mkdir:*)",
"Bash(rm:*)",
"Bash(pnpm install:*)",
"Bash(mv:*)",
"Bash(node:*)",
"Bash(npx eslint:*)",
"Bash(pnpm typecheck:*)",
"Bash(chmod:*)",
"Bash(./rename_i18n_keys.sh:*)",
"mcp__context7__resolve-library-id",
"mcp__context7__get-library-docs",
"Bash(pnpm add:*)",
"Bash(pnpm -w run typecheck)",
"Bash(pnpm --filter @follow/components run typecheck)",
"Bash(pnpm -w run lint:fix)",
"Bash(pnpm exec tsc:*)"
],
"deny": []
}
}

View File

@ -1,5 +1,6 @@
import type { LexicalRichEditorRef } from "@follow/components/ui/lexical-rich-editor/index.js"
import { LexicalRichEditor } from "@follow/components/ui/lexical-rich-editor/index.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
import { cn, stopPropagation } from "@follow/utils"
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
@ -10,6 +11,7 @@ import { memo, useCallback, useRef, useState } from "react"
import { AIChatContextBar } from "~/modules/ai/chat/components/AIChatContextBar"
import { useChatActions, useChatError, useChatStatus } from "../../__internal__/hooks"
import { MentionPlugin } from "../../editor"
import { AIChatSendButton } from "./AIChatSendButton"
import { CollapsibleError } from "./CollapsibleError"
@ -91,16 +93,19 @@ export const ChatInput = memo(({ onSend, variant }: ChatInputProps) => {
<div className={cn(chatInputVariants({ variant }))}>
{/* Input Area */}
<div className="relative z-10 flex items-end" onContextMenu={stopPropagation}>
<LexicalRichEditor
ref={editorRef}
placeholder="Message AI assistant..."
className="w-full"
onChange={handleEditorChange}
onKeyDown={handleKeyDown}
autoFocus
namespace="AIChatRichEditor"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<ScrollArea rootClassName="mx-5 my-3.5 mr-14 flex-1 overflow-auto">
<LexicalRichEditor
ref={editorRef}
placeholder="Message AI assistant..."
className="w-full"
onChange={handleEditorChange}
onKeyDown={handleKeyDown}
autoFocus
plugins={[MentionPlugin]}
namespace="AIChatRichEditor"
/>
</ScrollArea>
<div className="absolute right-3 top-3">
<AIChatSendButton
onClick={isProcessing ? stop : handleSend}
disabled={!isProcessing && isEmpty}

View File

@ -1,4 +1,5 @@
import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js"
import { tracker } from "@follow/tracker"
import { cn, nextFrame } from "@follow/utils"
import { springScrollTo } from "@follow/utils/scroller"
import type { BizUIMessage } from "@folo-services/ai-tools"
@ -125,6 +126,7 @@ export const ChatInterface = () => {
role: "user",
id: nanoid(),
})
tracker.aiChatMessageSent()
},
)

View File

@ -9,6 +9,8 @@ import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
import type { SerializedEditorState } from "lexical"
import * as React from "react"
import { MentionNode } from "../../editor/plugins/mention/MentionNode"
function onError(error: Error) {
console.error("Lexical Read-Only Editor Error:", error)
}
@ -29,7 +31,7 @@ export const AIRichTextMessage: React.FC<AIRichTextMessageProps> = React.memo(
onError,
editable: false, // Read-only mode
editorState: JSON.stringify(data.state),
nodes: LexicalRichEditorNodes,
nodes: [...LexicalRichEditorNodes, MentionNode],
}
return (

View File

@ -0,0 +1 @@
export * from "./plugins"

View File

@ -0,0 +1 @@
export * from "./mention"

View File

@ -0,0 +1,171 @@
import type {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalEditor,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from "lexical"
import { $applyNodeReplacement, DecoratorNode } from "lexical"
import * as React from "react"
import type { MentionData } from "./types"
export type SerializedMentionNode = Spread<
{
mentionData: MentionData
},
SerializedLexicalNode
>
const MentionComponent = React.lazy(() =>
import("./components/MentionComponent").then((module) => ({
default: module.MentionComponent,
})),
)
export class MentionNode extends DecoratorNode<React.JSX.Element> {
__mentionData: MentionData
static override getType(): string {
return "mention"
}
static override clone(node: MentionNode): MentionNode {
return new MentionNode(node.__mentionData, node.__key)
}
constructor(mentionData: MentionData, key?: NodeKey) {
super(key)
this.__mentionData = mentionData
}
override createDOM(config: EditorConfig): HTMLElement {
const dom = document.createElement("span")
dom.className = config.theme.mention || "mention-node"
dom.dataset.lexicalMention = "true"
dom.dataset.mentionType = this.__mentionData.type
dom.dataset.mentionId = this.__mentionData.id
return dom
}
override updateDOM(): false {
return false
}
static override importDOM(): DOMConversionMap | null {
return {
span: (domNode: HTMLElement) => {
if (!Object.hasOwn(domNode.dataset, "lexicalMention")) {
return null
}
return {
conversion: convertMentionElement,
priority: 1,
}
},
}
}
static override importJSON(serializedNode: SerializedMentionNode): MentionNode {
const { mentionData } = serializedNode
const node = $createMentionNode(mentionData)
return node
}
override exportDOM(): DOMExportOutput {
const element = document.createElement("span")
element.dataset.lexicalMention = "true"
element.dataset.mentionType = this.__mentionData.type
element.dataset.mentionId = this.__mentionData.id
element.textContent = `@${this.__mentionData.name}`
element.className = "mention-node"
return { element }
}
override exportJSON(): SerializedMentionNode {
return {
mentionData: this.__mentionData,
type: "mention",
version: 1,
}
}
/**
* For export markdown conversion
*/
override getTextContent(): string {
return `[[ref:${this.__mentionData.type}:${this.__mentionData.value}]]`
}
override decorate(_editor: LexicalEditor): React.JSX.Element {
return (
<React.Suspense fallback={<span>@{this.__mentionData.name}</span>}>
<MentionComponent mentionData={this.__mentionData} />
</React.Suspense>
)
}
override isInline(): boolean {
return true
}
override isKeyboardSelectable(): boolean {
return false
}
canInsertTextBefore(): boolean {
return false
}
canInsertTextAfter(): boolean {
return true
}
canBeEmpty(): boolean {
return false
}
isSegmented(): boolean {
return true
}
extractWithChild(): boolean {
return false
}
}
function convertMentionElement(domNode: HTMLElement): DOMConversionOutput {
const mentionType = domNode.dataset.mentionType as MentionData["type"] | null
const { mentionId } = domNode.dataset
const textContent = domNode.textContent || ""
if (!mentionType || !mentionId) {
return { node: null }
}
// Extract name from text content (remove @ prefix)
const name = textContent.startsWith("@") ? textContent.slice(1) : textContent
const mentionData: MentionData = {
id: mentionId,
name,
type: mentionType,
value: null,
}
const node = $createMentionNode(mentionData)
return { node }
}
export function $createMentionNode(mentionData: MentionData): MentionNode {
const mentionNode = new MentionNode(mentionData)
return $applyNodeReplacement(mentionNode)
}
export function $isMentionNode(node: LexicalNode | null | undefined): node is MentionNode {
return node instanceof MentionNode
}

View File

@ -0,0 +1,133 @@
import * as React from "react"
import { Suspense, useMemo } from "react"
import { MentionDropdown } from "./components/MentionDropdown"
import { DEFAULT_MAX_SUGGESTIONS } from "./constants"
import { useMentionIntegration } from "./hooks/useMentionIntegration"
import { useMentionKeyboard } from "./hooks/useMentionKeyboard"
import { useMentionSearch } from "./hooks/useMentionSearch"
import { useMentionSelection } from "./hooks/useMentionSelection"
import { useMentionTrigger } from "./hooks/useMentionTrigger"
import { MentionNode } from "./MentionNode"
import { defaultTriggerFn } from "./utils/triggerDetection"
export function MentionPlugin() {
// Get integrated search and context block handling
const { searchMentions, handleMentionInsert } = useMentionIntegration()
const { maxSuggestions, triggerFn } = {
maxSuggestions: DEFAULT_MAX_SUGGESTIONS,
triggerFn: defaultTriggerFn,
}
// Hook for detecting mention triggers
const { mentionMatch, isActive, clearMentionMatch } = useMentionTrigger({
triggerFn,
})
// Hook for searching mentions
const {
suggestions,
selectedIndex,
isLoading,
searchMentions: performSearch,
clearSuggestions,
setSelectedIndex,
hasResults,
} = useMentionSearch({
onSearch: searchMentions,
maxSuggestions,
})
// Hook for handling mention selection
const { selectMention } = useMentionSelection({
mentionMatch,
onMentionInsert: handleMentionInsert,
onSelectionComplete: () => {
clearMentionMatch()
clearSuggestions()
},
})
// Hook for keyboard navigation
const handleArrowKey = React.useCallback(
(isUp: boolean) => {
if (!hasResults) return
const newIndex = isUp
? selectedIndex <= 0
? suggestions.length - 1
: selectedIndex - 1
: selectedIndex >= suggestions.length - 1
? 0
: selectedIndex + 1
setSelectedIndex(newIndex)
},
[hasResults, suggestions.length, selectedIndex, setSelectedIndex],
)
const handleEnterKey = React.useCallback(() => {
if (hasResults && selectedIndex >= 0 && selectedIndex < suggestions.length) {
const mention = suggestions[selectedIndex]
if (mention) {
selectMention(mention)
}
}
}, [hasResults, selectedIndex, suggestions, selectMention])
const handleEscapeKey = React.useCallback(() => {
clearMentionMatch()
clearSuggestions()
}, [clearMentionMatch, clearSuggestions])
useMentionKeyboard({
isActive,
suggestions,
selectedIndex,
onArrowKey: handleArrowKey,
onEnterKey: handleEnterKey,
onEscapeKey: handleEscapeKey,
})
// Search when mention match changes
React.useEffect(() => {
if (mentionMatch) {
performSearch(mentionMatch.matchingString)
} else {
clearSuggestions()
}
}, [mentionMatch, performSearch, clearSuggestions])
// Calculate dropdown props
const dropdownProps = useMemo(() => {
if (!isActive || !hasResults) return null
return {
isVisible: true,
suggestions,
selectedIndex,
isLoading,
onSelect: selectMention,
onClose: handleEscapeKey,
query: mentionMatch?.matchingString || "",
}
}, [
isActive,
hasResults,
suggestions,
selectedIndex,
isLoading,
selectMention,
handleEscapeKey,
mentionMatch,
])
return dropdownProps ? (
<Suspense fallback={null}>
<MentionDropdown {...dropdownProps} />
</Suspense>
) : null
}
MentionPlugin.id = "mention"
MentionPlugin.nodes = [MentionNode]

View File

@ -0,0 +1,88 @@
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipRoot,
TooltipTrigger,
} from "@follow/components/ui/tooltip/index.js"
import { cn } from "@follow/utils"
import * as React from "react"
import type { MentionData } from "../types"
import { MentionTypeIcon } from "./shared/MentionTypeIcon"
interface MentionComponentProps {
mentionData: MentionData
className?: string
}
const MentionTooltipContent = ({ mentionData }: { mentionData: MentionData }) => (
<div className="flex items-center gap-2">
<div className="bg-fill border-fill-secondary flex size-6 flex-shrink-0 items-center justify-center rounded-full border">
<MentionTypeIcon type={mentionData.type} />
</div>
<div className="flex flex-col gap-1">
<p className="text-text text-sm font-medium">@{mentionData.name}</p>
<span
className={cn(
"rounded px-1.5 py-0.5 text-xs font-medium",
mentionData.type === "entry" && "bg-blue text-black",
mentionData.type === "feed" && "bg-orange text-black",
)}
>
{mentionData.type}
</span>
</div>
</div>
)
const getMentionStyles = (type: MentionData["type"]) => {
const baseStyles = tw`
inline-flex items-center gap-1 px-2 py-0.5 rounded-md
font-medium text-sm cursor-pointer select-none
`
switch (type) {
case "entry": {
return cn(
baseStyles,
"bg-blue/10 text-blue border-blue/20",
"hover:bg-blue/20 hover:border-blue/30",
)
}
case "feed": {
return cn(
baseStyles,
"bg-orange/10 text-orange border-orange/20",
"hover:bg-orange/20 hover:border-orange/30",
)
}
}
}
export const MentionComponent: React.FC<MentionComponentProps> = ({ mentionData, className }) => {
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
// Handle mention click - could navigate to user profile, topic page, etc.
// TODO: Implement navigation logic for mentions
}
return (
<Tooltip>
<TooltipRoot>
<TooltipTrigger asChild>
<span className={cn(getMentionStyles(mentionData.type), className)} onClick={handleClick}>
<MentionTypeIcon type={mentionData.type} />
<span>@{mentionData.name}</span>
</span>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="top" className="max-w-[300px]">
<MentionTooltipContent mentionData={mentionData} />
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</Tooltip>
)
}
MentionComponent.displayName = "MentionComponent"

View File

@ -0,0 +1,266 @@
import {
autoUpdate,
flip,
offset,
shift,
useDismiss,
useFloating,
useInteractions,
useRole,
} from "@floating-ui/react"
import { RootPortal } from "@follow/components/ui/portal/index.js"
import { cn, thenable } from "@follow/utils"
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import * as React from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import type { MentionData } from "../types"
import { calculateDropdownPosition } from "../utils/positioning"
import { MentionTypeIcon } from "./shared/MentionTypeIcon"
interface MentionDropdownProps {
isVisible: boolean
suggestions: MentionData[]
selectedIndex: number
isLoading: boolean
onSelect: (mention: MentionData) => void
onClose: () => void
query: string
anchor?: HTMLElement | null
}
const MentionSuggestionItem = React.memo(
({
mention,
isSelected,
onClick,
query,
}: {
mention: MentionData
isSelected: boolean
onClick: (mention: MentionData) => void
query: string
}) => {
const handleClick = useCallback(() => {
onClick(mention)
}, [mention, onClick])
// Highlight matching text
const highlightText = (text: string, query: string) => {
const cleanQuery = query.replace("@", "").toLowerCase()
if (!cleanQuery) return text
const parts = text.split(new RegExp(`(${cleanQuery})`, "gi"))
return parts.map((part, partIndex) => {
const isMatch = part.toLowerCase() === cleanQuery
// Create a more unique key that doesn't rely solely on index
const uniqueKey = `${mention.id}-${mention.type}-${part}-${partIndex}-${part.length}`
return (
<span key={uniqueKey} className={isMatch ? "text-text-vibrant font-semibold" : ""}>
{part}
</span>
)
})
}
return (
<div
className={cn(
"cursor-menu relative flex select-none items-center rounded-[5px] px-2.5 py-1 outline-none",
"focus-within:outline-transparent",
"data-[highlighted]:bg-theme-selection-hover focus:bg-theme-selection-active focus:text-theme-selection-foreground data-[highlighted]:text-theme-selection-foreground",
"h-[28px]",
isSelected && "bg-theme-selection-active text-theme-selection-foreground",
)}
onClick={handleClick}
role="option"
aria-selected={isSelected}
>
{/* Icon */}
<span
className={cn(
"mr-1.5 inline-flex size-4 items-center justify-center",
mention.type === "entry" && "text-blue-500",
mention.type === "feed" && "text-orange-500",
)}
>
<MentionTypeIcon type={mention.type} />
</span>
{/* Content */}
<span className="flex-1 truncate">{highlightText(mention.name, query)}</span>
{/* Selection Indicator */}
{isSelected && (
<span className="ml-1.5 inline-flex size-4 items-center justify-center">
<i className="i-mgc-check-cute-re size-3" />
</span>
)}
</div>
)
},
)
MentionSuggestionItem.displayName = "MentionSuggestionItem"
export const MentionDropdown: React.FC<MentionDropdownProps> = ({
isVisible,
suggestions,
selectedIndex,
isLoading,
onSelect,
onClose,
query,
}) => {
if (!isVisible) throw thenable
const [editor] = useLexicalComposerContext()
const dropdownRef = useRef<HTMLDivElement>(null)
const [referenceWidth, setReferenceWidth] = useState<number>(320)
// Create virtual reference element based on cursor position
const virtualReference = useRef({
getBoundingClientRect: () => {
const position = calculateDropdownPosition(editor)
const editorElement = editor.getRootElement()
if (!position || !editorElement) {
// Fallback to editor element
return (
editorElement?.getBoundingClientRect() || {
top: 0,
left: 0,
bottom: 0,
right: 0,
width: 0,
height: 0,
x: 0,
y: 0,
}
)
}
const editorRect = editorElement.getBoundingClientRect()
return {
top: editorRect.top + position.top,
left: editorRect.left + position.left,
bottom: editorRect.top + position.top,
right: editorRect.left + position.left,
width: 0,
height: 0,
x: editorRect.left + position.left,
y: editorRect.top + position.top,
}
},
})
const { refs, floatingStyles, context } = useFloating({
open: isVisible,
onOpenChange: (open) => {
if (!open) onClose()
},
middleware: [
offset(8),
flip({ fallbackPlacements: ["bottom-start", "top-start", "bottom-end", "top-end"] }),
shift({ padding: 8 }),
],
whileElementsMounted: autoUpdate,
placement: "bottom-start",
})
const dismiss = useDismiss(context, {
enabled: isVisible,
})
const role = useRole(context, {
role: "listbox",
})
const { getFloatingProps } = useInteractions([dismiss, role])
// Handle scroll to keep selected item in view
useEffect(() => {
if (isVisible && dropdownRef.current && selectedIndex >= 0) {
const listContainer = dropdownRef.current.querySelector('[role="listbox"]')
if (listContainer) {
const selectedElement = listContainer.children[selectedIndex] as HTMLElement
if (selectedElement) {
selectedElement.scrollIntoView({
block: "nearest",
behavior: "smooth",
})
}
}
}
}, [selectedIndex, isVisible])
// Set virtual reference element based on cursor position and calculate width
useEffect(() => {
if (isVisible) {
refs.setReference(virtualReference.current)
const editorElement = editor.getRootElement()
if (editorElement) {
const rect = editorElement.getBoundingClientRect()
setReferenceWidth(rect.width || 320)
}
}
}, [editor, refs, isVisible, query]) // Add query as dependency to update position when typing
return (
<RootPortal>
{isVisible && (
<div
ref={refs.setFloating}
style={floatingStyles}
className="z-[1000]"
{...getFloatingProps()}
>
<div
ref={dropdownRef}
className={cn(
"bg-material-medium backdrop-blur-background text-text shadow-context-menu",
"min-w-32 overflow-hidden rounded-[6px] border p-1",
"text-body",
)}
style={{
width: Math.max(referenceWidth, 200),
maxWidth: 320,
}}
>
{isLoading ? (
<div className="text-text-secondary flex items-center gap-2 px-2.5 py-1.5">
<i className="i-mgc-loading-3-cute-re size-4 animate-spin" />
<span className="text-sm">Searching...</span>
</div>
) : suggestions.length === 0 ? (
<div className="text-text-tertiary px-2.5 py-1.5 text-center">
<span className="text-sm">No matches found</span>
{query && (
<div className="text-text-quaternary mt-1 text-xs">
Try a different search term
</div>
)}
</div>
) : (
<div role="listbox" aria-label="Mention suggestions">
{suggestions.map((mention, index) => (
<MentionSuggestionItem
key={`${mention.type}-${mention.id}`}
mention={mention}
isSelected={index === selectedIndex}
onClick={onSelect}
query={query}
/>
))}
</div>
)}
</div>
</div>
)}
</RootPortal>
)
}
MentionDropdown.displayName = "MentionDropdown"

View File

@ -0,0 +1,24 @@
import * as React from "react"
import type { MentionType } from "../../types"
interface MentionTypeIconProps {
type: MentionType
className?: string
}
export const MentionTypeIcon: React.FC<MentionTypeIconProps> = ({ type, className = "size-3" }) => {
switch (type) {
case "entry": {
return <i className={`i-mgc-paper-cute-fi ${className}`} />
}
case "feed": {
return <i className={`i-mgc-rss-cute-fi ${className}`} />
}
default: {
return <i className={`i-mgc-ai-cute-re ${className}`} />
}
}
}
MentionTypeIcon.displayName = "MentionTypeIcon"

View File

@ -0,0 +1,16 @@
import { createCommand } from "lexical"
import type { MentionData } from "./types"
// Commands
export const MENTION_COMMAND = createCommand<MentionData>("MENTION_COMMAND")
export const MENTION_TYPEAHEAD_COMMAND = createCommand<string>("MENTION_TYPEAHEAD_COMMAND")
// Default configuration
export const DEFAULT_MAX_SUGGESTIONS = 10
// Trigger patterns
export const MENTION_TRIGGER_PATTERN = /(?:^|\s)(@[\w-]*)$/
export const FEED_MENTION_PATTERN = /(?:^|\s)(@#[\w-]*)$/
export const ENTRY_MENTION_PATTERN = /(?:^|\s)(@\+[\w-]*)$/

View File

@ -0,0 +1,249 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { $getNodeByKey } from "lexical"
import { useCallback, useEffect, useRef } from "react"
import { useAIChatStore } from "~/modules/ai/chat/__internal__/AIChatContext"
import { useChatBlockActions } from "~/modules/ai/chat/__internal__/hooks"
import type { AIChatContextBlock } from "~/modules/ai/chat/__internal__/types"
import { $isMentionNode, MentionNode } from "../MentionNode"
import type { MentionData } from "../types"
interface MentionBlockReference {
mentionNodeKey: string
blockId: string
resourceId: string // `${type}:${value}`
mentionData: MentionData
}
const getResourceId = (type: string, value: string) => `${type}:${value}`
const getBlockType = (mentionType: string): AIChatContextBlock["type"] => {
return mentionType === "feed" ? "referFeed" : "referEntry"
}
/**
* Hook that manages bidirectional synchronization between mention nodes and context blocks
* - When a mention is added, corresponding block is created
* - When a block is removed, corresponding mentions are removed
* - When a mention is removed, corresponding block is removed (if no other mentions reference it)
*/
export const useMentionBlockSync = () => {
const [editor] = useLexicalComposerContext()
const blockActions = useChatBlockActions()
const blocks = useAIChatStore()((state) => state.blocks)
// Reference tracking maps
const mentionToBlockRef = useRef(new Map<string, MentionBlockReference>()) // mentionNodeKey -> reference
const resourceToMentionsRef = useRef(new Map<string, Set<string>>()) // resourceId -> Set<mentionNodeKey>
const blockToResourceRef = useRef(new Map<string, string>()) // blockId -> resourceId
// Add mention-block reference
const addMentionReference = useCallback(
(mentionData: MentionData, mentionNodeKey: string, blockId: string) => {
const resourceId = getResourceId(mentionData.type, mentionData.value as string)
const reference: MentionBlockReference = {
mentionNodeKey,
blockId,
resourceId,
mentionData,
}
// Update tracking maps
mentionToBlockRef.current.set(mentionNodeKey, reference)
if (!resourceToMentionsRef.current.has(resourceId)) {
resourceToMentionsRef.current.set(resourceId, new Set())
}
resourceToMentionsRef.current.get(resourceId)!.add(mentionNodeKey)
blockToResourceRef.current.set(blockId, resourceId)
},
[],
)
// Remove mention reference
const removeMentionReference = useCallback((mentionNodeKey: string) => {
const reference = mentionToBlockRef.current.get(mentionNodeKey)
if (!reference) return null
const { resourceId, blockId } = reference
// Clean up tracking maps
mentionToBlockRef.current.delete(mentionNodeKey)
const mentionSet = resourceToMentionsRef.current.get(resourceId)
if (mentionSet) {
mentionSet.delete(mentionNodeKey)
if (mentionSet.size === 0) {
resourceToMentionsRef.current.delete(resourceId)
}
}
blockToResourceRef.current.delete(blockId)
return reference
}, [])
// Handle mention insertion - create block and track reference
const handleMentionInsert = useCallback(
(mentionData: MentionData, mentionNodeKey: string) => {
const resourceId = getResourceId(mentionData.type, mentionData.value as string)
// Check if block already exists for this resource
const existingBlock = blocks.find(
(block) => blockToResourceRef.current.get(block.id) === resourceId,
)
let blockId: string
if (existingBlock) {
// Use existing block
blockId = existingBlock.id
} else {
// Create new block
const blockType = getBlockType(mentionData.type)
// Generate block ID (mimicking the block slice logic)
const newBlock: Omit<AIChatContextBlock, "id"> = {
type: blockType,
value: mentionData.value as string,
}
blockActions.addBlock(newBlock)
// Use current blocks state directly from store instead of stale closure
const currentBlocks = blockActions.getBlocks()
const addedBlock = currentBlocks.find(
(block) => block.type === blockType && block.value === mentionData.value,
)
if (addedBlock) {
blockId = addedBlock.id
} else {
// Fallback to a predictable ID pattern if we can't find the block immediately
blockId = `${blockType}-${mentionData.value}-${mentionNodeKey}`
}
}
// Track the reference
addMentionReference(mentionData, mentionNodeKey, blockId)
},
[blocks, blockActions, addMentionReference],
)
// Handle mention removal - remove block if no other mentions reference it
const handleMentionRemove = useCallback(
(mentionNodeKey: string) => {
const reference = removeMentionReference(mentionNodeKey)
if (!reference) return
const { resourceId, blockId } = reference
// Check if any other mentions still reference this resource
const remainingMentions = resourceToMentionsRef.current.get(resourceId)
if (!remainingMentions || remainingMentions.size === 0) {
// No more mentions reference this resource, remove the block
blockActions.removeBlock(blockId)
}
},
[blockActions, removeMentionReference],
)
// Handle block removal - remove corresponding mentions
const handleBlockRemove = useCallback(
(blockId: string) => {
const resourceId = blockToResourceRef.current.get(blockId)
if (!resourceId) return
const mentionKeys = resourceToMentionsRef.current.get(resourceId)
if (!mentionKeys) return
// Remove all mention nodes for this resource
editor.update(() => {
Array.from(mentionKeys).forEach((mentionKey) => {
const node = $getNodeByKey(mentionKey)
if (node && $isMentionNode(node)) {
node.remove()
}
})
})
// Clean up references
Array.from(mentionKeys).forEach((mentionKey) => {
removeMentionReference(mentionKey)
})
},
[editor, removeMentionReference],
)
// Monitor block changes
useEffect(() => {
const currentBlockIds = new Set(blocks.map((block) => block.id))
const trackedBlockIds = new Set(blockToResourceRef.current.keys())
// Find removed blocks
for (const trackedBlockId of trackedBlockIds) {
if (!currentBlockIds.has(trackedBlockId)) {
handleBlockRemove(trackedBlockId)
}
}
}, [blocks, handleBlockRemove])
// Monitor mention node changes using mutation observer
useEffect(() => {
const removedMentionKeys = new Set<string>()
const unregisterMutationListener = editor.registerMutationListener(
MentionNode,
(mutatedNodes) => {
for (const [nodeKey, mutation] of mutatedNodes) {
// Only track destroyed mutations for mentions we're actually tracking
if (mutation === "destroyed" && mentionToBlockRef.current.has(nodeKey)) {
removedMentionKeys.add(nodeKey)
}
}
// Process removed mentions in next tick to avoid state conflicts
if (removedMentionKeys.size > 0) {
const timeoutId = setTimeout(() => {
Array.from(removedMentionKeys).forEach((mentionKey) => {
handleMentionRemove(mentionKey)
})
removedMentionKeys.clear()
}, 100) // Increase delay to avoid race conditions
// Store timeout ID for potential cleanup
return () => clearTimeout(timeoutId)
}
},
)
return unregisterMutationListener
}, [editor, handleMentionRemove])
// Cleanup on unmount
useEffect(() => {
const mentionToBlock = mentionToBlockRef.current
const resourceToMentions = resourceToMentionsRef.current
const blockToResource = blockToResourceRef.current
return () => {
mentionToBlock.clear()
resourceToMentions.clear()
blockToResource.clear()
}
}, [])
return {
handleMentionInsert,
handleMentionRemove,
// Debug helpers
getMentionReferences: () => ({
mentionToBlock: new Map(mentionToBlockRef.current),
resourceToMentions: new Map(resourceToMentionsRef.current),
blockToResource: new Map(blockToResourceRef.current),
}),
}
}

View File

@ -0,0 +1,29 @@
import { useCallback } from "react"
import type { MentionData } from "../types"
import { useMentionBlockSync } from "./useMentionBlockSync"
import { useMentionSearchService } from "./useMentionSearchService"
/**
* Hook that integrates mention search with context block management
* Provides search functionality and handles bidirectional synchronization
*/
export const useMentionIntegration = () => {
const { searchMentions } = useMentionSearchService()
const { handleMentionInsert: syncMentionInsert } = useMentionBlockSync()
// Handle mention insertion with node key tracking
const handleMentionInsert = useCallback(
(mention: MentionData, nodeKey?: string) => {
if (nodeKey) {
syncMentionInsert(mention, nodeKey)
}
},
[syncMentionInsert],
)
return {
searchMentions,
handleMentionInsert,
}
}

View File

@ -0,0 +1,161 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import {
COMMAND_PRIORITY_HIGH,
COMMAND_PRIORITY_LOW,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_UP_COMMAND,
KEY_ENTER_COMMAND,
KEY_ESCAPE_COMMAND,
KEY_TAB_COMMAND,
} from "lexical"
import { useCallback, useEffect } from "react"
import type { MentionData } from "../types"
interface UseMentionKeyboardOptions {
isActive: boolean
suggestions: MentionData[]
selectedIndex: number
onArrowKey: (isUp: boolean) => void
onEnterKey: () => void
onEscapeKey: () => void
}
export const useMentionKeyboard = ({
isActive,
suggestions,
selectedIndex,
onArrowKey,
onEnterKey,
onEscapeKey,
}: UseMentionKeyboardOptions) => {
const [editor] = useLexicalComposerContext()
// Handle keyboard navigation
const handleArrowKey = useCallback(
(isUp: boolean) => {
if (!isActive || suggestions.length === 0) return false
onArrowKey(isUp)
return true
},
[isActive, suggestions.length, onArrowKey],
)
// Handle enter key
const handleEnterKey = useCallback(() => {
if (
!isActive ||
suggestions.length === 0 ||
selectedIndex < 0 ||
selectedIndex >= suggestions.length
) {
return false
}
onEnterKey()
return true
}, [isActive, suggestions.length, selectedIndex, onEnterKey])
// Handle escape key
const handleEscapeKey = useCallback(() => {
if (!isActive) return false
onEscapeKey()
return true
}, [isActive, onEscapeKey])
// Register keyboard commands
useEffect(() => {
const removeCommands = [
// Arrow key navigation
editor.registerCommand(
KEY_ARROW_UP_COMMAND,
(event) => {
if (isActive && suggestions.length > 0) {
event.preventDefault()
return handleArrowKey(true)
}
return false
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ARROW_DOWN_COMMAND,
(event) => {
if (isActive && suggestions.length > 0) {
event.preventDefault()
return handleArrowKey(false)
}
return false
},
COMMAND_PRIORITY_LOW,
),
// Enter key
editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
if (
isActive &&
suggestions.length > 0 &&
selectedIndex >= 0 &&
selectedIndex < suggestions.length
) {
event?.preventDefault()
return handleEnterKey()
}
return false
},
COMMAND_PRIORITY_HIGH,
),
// Tab key (same as enter)
editor.registerCommand(
KEY_TAB_COMMAND,
(event) => {
if (
isActive &&
suggestions.length > 0 &&
selectedIndex >= 0 &&
selectedIndex < suggestions.length
) {
event.preventDefault()
return handleEnterKey()
}
return false
},
COMMAND_PRIORITY_HIGH,
),
// Escape key
editor.registerCommand(
KEY_ESCAPE_COMMAND,
(event) => {
if (isActive) {
event.preventDefault()
return handleEscapeKey()
}
return false
},
COMMAND_PRIORITY_LOW,
),
]
return () => {
removeCommands.forEach((remove) => remove())
}
}, [
editor,
isActive,
suggestions.length,
selectedIndex,
handleArrowKey,
handleEnterKey,
handleEscapeKey,
])
return {
handleArrowKey,
handleEnterKey,
handleEscapeKey,
}
}

View File

@ -0,0 +1,115 @@
import { useCallback, useRef, useState, useTransition } from "react"
import { DEFAULT_MAX_SUGGESTIONS } from "../constants"
import type { MentionData, MentionSearchState, MentionType } from "../types"
import { getMentionType, shouldTriggerMention } from "../utils/triggerDetection"
interface UseMentionSearchOptions {
onSearch?: (query: string, type: MentionType) => Promise<MentionData[]> | MentionData[]
maxSuggestions?: number
}
// Default search function
const defaultSearchFn = async (): Promise<MentionData[]> => []
export const useMentionSearch = ({
onSearch = defaultSearchFn,
maxSuggestions = DEFAULT_MAX_SUGGESTIONS,
}: UseMentionSearchOptions = {}) => {
const [searchState, setSearchState] = useState<MentionSearchState>({
suggestions: [],
selectedIndex: -1,
isLoading: false,
})
const [isPending, startTransition] = useTransition()
const onSearchRef = useRef(onSearch)
const maxSuggestionsRef = useRef(maxSuggestions)
const abortControllerRef = useRef<AbortController | null>(null)
// Update refs when props change to avoid stale closures
onSearchRef.current = onSearch
maxSuggestionsRef.current = maxSuggestions
const searchMentions = useCallback(
async (query: string) => {
// Cancel any pending search
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
if (!shouldTriggerMention(query)) {
setSearchState((prev) => ({
...prev,
suggestions: [],
selectedIndex: -1,
isLoading: false,
}))
return
}
// Create new abort controller for this search
const abortController = new AbortController()
abortControllerRef.current = abortController
// Use transition to defer search as low-priority update
startTransition(() => {
setSearchState((prev) => ({ ...prev, isLoading: true }))
})
try {
const mentionType = getMentionType(query)
const results = await onSearchRef.current(query, mentionType)
// Check if this search was aborted
if (abortController.signal.aborted) {
return
}
startTransition(() => {
setSearchState({
suggestions: results.slice(0, maxSuggestionsRef.current),
selectedIndex: results.length > 0 ? 0 : -1,
isLoading: false,
})
})
} catch (error) {
// Check if this search was aborted
if (abortController.signal.aborted) {
return
}
console.error("Error searching mentions:", error)
startTransition(() => {
setSearchState({
suggestions: [],
selectedIndex: -1,
isLoading: false,
})
})
}
},
[], // Empty deps array - we use refs to avoid dependency issues
)
const clearSuggestions = useCallback(() => {
setSearchState({
suggestions: [],
selectedIndex: -1,
isLoading: false,
})
}, [])
const setSelectedIndex = useCallback((index: number) => {
setSearchState((prev) => ({ ...prev, selectedIndex: index }))
}, [])
return {
...searchState,
searchMentions,
clearSuggestions,
setSelectedIndex,
hasResults: searchState.suggestions.length > 0,
isLoading: searchState.isLoading || isPending,
}
}

View File

@ -0,0 +1,34 @@
import { useMemo } from "react"
import { useFeedEntrySearchService } from "~/modules/ai/chat/services/feedEntrySearchService"
import type { MentionData, MentionType } from "../types"
/**
* Hook that provides search functionality for mentions
* Uses the shared feed/entry search service
*/
export const useMentionSearchService = () => {
const { search } = useFeedEntrySearchService({
maxRecentEntries: 50,
})
// Search function that converts search results to MentionData format
const searchMentions = useMemo(() => {
return async (query: string, type?: MentionType): Promise<MentionData[]> => {
const searchResults = search(query, type, 10)
// Convert to MentionData format
return searchResults.map(
(item): MentionData => ({
id: item.id,
name: item.title,
type: item.type as MentionType,
value: item.id,
}),
)
}
}, [search])
return { searchMentions }
}

View File

@ -0,0 +1,65 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { COMMAND_PRIORITY_LOW } from "lexical"
import { useCallback, useEffect } from "react"
import { MENTION_COMMAND } from "../constants"
import type { MentionData, MentionMatch } from "../types"
import { insertMentionNode } from "../utils/textReplacement"
interface UseMentionSelectionOptions {
mentionMatch: MentionMatch | null
onMentionInsert?: (mention: MentionData, nodeKey?: string) => void
onSelectionComplete?: () => void
}
export const useMentionSelection = ({
mentionMatch,
onMentionInsert,
onSelectionComplete,
}: UseMentionSelectionOptions) => {
const [editor] = useLexicalComposerContext()
const selectMention = useCallback(
(mentionData: MentionData) => {
if (!mentionMatch) return false
let result = { success: false, nodeKey: undefined as string | undefined }
editor.update(() => {
result = insertMentionNode(mentionData, mentionMatch)
if (result.success && result.nodeKey) {
// Call onMentionInsert immediately within the same editor update
// to ensure the node tracking happens before any mutations
onMentionInsert?.(mentionData, result.nodeKey)
}
})
// Call onSelectionComplete after the editor update is complete
if (result.success) {
setTimeout(() => {
onSelectionComplete?.()
}, 0)
}
return result.success
},
[editor, mentionMatch, onMentionInsert, onSelectionComplete],
)
// Register mention command
useEffect(() => {
const removeMentionCommand = editor.registerCommand(
MENTION_COMMAND,
(mentionData: MentionData) => {
return selectMention(mentionData)
},
COMMAND_PRIORITY_LOW,
)
return removeMentionCommand
}, [editor, selectMention])
return {
selectMention,
}
}

View File

@ -0,0 +1,65 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import type { LexicalEditor } from "lexical"
import { $getSelection, $isRangeSelection, $isTextNode } from "lexical"
import { useCallback, useEffect, useState } from "react"
import type { MentionMatch } from "../types"
import { defaultTriggerFn } from "../utils/triggerDetection"
interface UseMentionTriggerOptions {
triggerFn?: (text: string, editor: LexicalEditor) => MentionMatch | null
onTrigger?: (match: MentionMatch | null) => void
}
export const useMentionTrigger = ({
triggerFn = defaultTriggerFn,
onTrigger,
}: UseMentionTriggerOptions = {}) => {
const [editor] = useLexicalComposerContext()
const [mentionMatch, setMentionMatch] = useState<MentionMatch | null>(null)
const updateMentionMatch = useCallback(
(match: MentionMatch | null) => {
setMentionMatch(match)
onTrigger?.(match)
},
[onTrigger],
)
const clearMentionMatch = useCallback(() => {
updateMentionMatch(null)
}, [updateMentionMatch])
// Monitor text changes for mention triggers
useEffect(() => {
const removeUpdateListener = editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
updateMentionMatch(null)
return
}
const { anchor } = selection
const anchorNode = anchor.getNode()
if (!$isTextNode(anchorNode)) {
updateMentionMatch(null)
return
}
const textContent = anchorNode.getTextContent()
const match = triggerFn(textContent, editor)
updateMentionMatch(match)
})
})
return removeUpdateListener
}, [editor, triggerFn, updateMentionMatch])
return {
mentionMatch,
isActive: mentionMatch !== null,
clearMentionMatch,
}
}

View File

@ -0,0 +1,19 @@
// Public API exports
export { MentionComponent } from "./components/MentionComponent"
export { MentionDropdown } from "./components/MentionDropdown"
export { $createMentionNode, $isMentionNode, MentionNode } from "./MentionNode"
export { MentionPlugin } from "./MentionPlugin"
// Commands
export { MENTION_COMMAND, MENTION_TYPEAHEAD_COMMAND } from "./constants"
// Types
export type {
MentionData,
MentionDropdownPosition,
MentionMatch,
MentionPluginProps,
MentionSearchState,
MentionTriggerState,
MentionType,
} from "./types"

View File

@ -0,0 +1,30 @@
export type MentionType = "entry" | "feed"
export interface MentionData {
id: string
name: string
type: MentionType
value: unknown
}
export interface MentionMatch {
leadOffset: number
matchingString: string
replaceableString: string
}
export interface MentionDropdownPosition {
top: number
left: number
}
export interface MentionSearchState {
suggestions: MentionData[]
selectedIndex: number
isLoading: boolean
}
export interface MentionTriggerState {
mentionMatch: MentionMatch | null
isActive: boolean
}

View File

@ -0,0 +1,38 @@
import type { LexicalEditor } from "lexical"
import type { MentionDropdownPosition } from "../types"
export const calculateDropdownPosition = (
editor: LexicalEditor,
): MentionDropdownPosition | null => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return null
const range = selection.getRangeAt(0)
const rect = range.getBoundingClientRect()
const editorElement = editor.getRootElement()
if (!editorElement) return null
const editorRect = editorElement.getBoundingClientRect()
return {
top: rect.bottom - editorRect.top + 8, // 8px offset below cursor
left: rect.left - editorRect.left,
}
}
export const scrollSelectedItemIntoView = (
containerRef: React.RefObject<HTMLElement>,
selectedIndex: number,
): void => {
if (!containerRef.current || selectedIndex < 0) return
const selectedElement = containerRef.current.children[selectedIndex] as HTMLElement
if (selectedElement) {
selectedElement.scrollIntoView({
block: "nearest",
behavior: "smooth",
})
}
}

View File

@ -0,0 +1,54 @@
import { $createTextNode, $getSelection, $isRangeSelection, $isTextNode } from "lexical"
import { $createMentionNode } from "../MentionNode"
import type { MentionData, MentionMatch } from "../types"
export const insertMentionNode = (
mentionData: MentionData,
mentionMatch: MentionMatch,
): { success: boolean; nodeKey?: string } => {
const selection = $getSelection()
if (!$isRangeSelection(selection) || !selection.isCollapsed()) return { success: false }
const { anchor } = selection
const anchorNode = anchor.getNode()
if (!$isTextNode(anchorNode)) return { success: false }
// Replace the mention text with the mention node
const textContent = anchorNode.getTextContent()
const { leadOffset, replaceableString } = mentionMatch
// Split the text node
const beforeText = textContent.slice(0, leadOffset)
const afterText = textContent.slice(leadOffset + replaceableString.length)
// Create new nodes
const beforeNode = beforeText ? $createTextNode(beforeText) : null
const mentionNode = $createMentionNode(mentionData)
const afterNode = afterText ? $createTextNode(afterText) : null
// Replace the current node
if (beforeNode) {
anchorNode.insertBefore(beforeNode)
}
anchorNode.insertBefore(mentionNode)
if (afterNode) {
anchorNode.insertBefore(afterNode)
}
// Remove the original node
anchorNode.remove()
// Position cursor after the mention
if (afterNode) {
afterNode.select(0, 0)
} else {
// Create a space after the mention if there's no following text
const spaceNode = $createTextNode(" ")
mentionNode.insertAfter(spaceNode)
spaceNode.select(1, 1)
}
return { success: true, nodeKey: mentionNode.getKey() }
}

View File

@ -0,0 +1,55 @@
import { $getSelection, $isRangeSelection, $isTextNode } from "lexical"
import { MENTION_TRIGGER_PATTERN } from "../constants"
import type { MentionMatch, MentionType } from "../types"
export const defaultTriggerFn = (): MentionMatch | null => {
const selection = $getSelection()
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
return null
}
const { anchor } = selection
const { focus } = selection
const anchorNode = anchor.getNode()
// Only trigger on text nodes
if (!$isTextNode(anchorNode) || anchor.key !== focus.key || anchor.offset !== focus.offset) {
return null
}
const textContent = anchorNode.getTextContent()
const cursorOffset = anchor.offset
// Look for @ symbol followed by text
const mentionMatch = textContent.slice(0, cursorOffset).match(MENTION_TRIGGER_PATTERN)
if (!mentionMatch) {
return null
}
const matchingString = mentionMatch[1] || ""
const replaceableString = matchingString
const leadOffset = (mentionMatch.index ?? 0) + (mentionMatch[0]?.startsWith(" ") ? 1 : 0)
return {
leadOffset,
matchingString,
replaceableString,
}
}
export const getMentionType = (query: string): MentionType => {
// Simple heuristic - could be enhanced with more sophisticated detection
if (query.startsWith("@#")) return "feed"
if (query.startsWith("@+")) return "entry"
return "feed"
}
export const cleanQuery = (query: string): string => {
return query.replace(/^@[#+]?/, "").toLowerCase()
}
export const shouldTriggerMention = (query: string): boolean => {
return query.startsWith("@") && query.length > 0
}

View File

@ -0,0 +1,69 @@
import { useMemo } from "react"
import type { SearchItem } from "./feedEntrySearchService"
import { useFeedEntrySearchService } from "./feedEntrySearchService"
/**
* Picker item interface for context bar compatibility
*/
export interface PickerItem {
id: string
title: string
}
/**
* Hook that provides search functionality for context bar
* Uses the shared feed/entry search service and converts results to PickerItem format
*/
export const useContextBarSearchService = () => {
const { search, feedItems, entryItems } = useFeedEntrySearchService({
maxRecentEntries: 50,
fuseOptions: {
keys: ["title", "id"],
threshold: 0.3,
},
})
// Convert search items to picker items
const convertToPickerItems = useMemo(() => {
return (items: SearchItem[]): PickerItem[] => {
return items.map((item) => ({
id: item.id,
title: item.title,
}))
}
}, [])
// Search function for feeds
const searchFeeds = useMemo(() => {
return (query: string): PickerItem[] => {
const results = search(query, "feed", 20)
return convertToPickerItems(results)
}
}, [search, convertToPickerItems])
// Search function for entries
const searchEntries = useMemo(() => {
return (query: string): PickerItem[] => {
const results = search(query, "entry", 20)
return convertToPickerItems(results)
}
}, [search, convertToPickerItems])
// Get all feeds as picker items
const allFeeds = useMemo(() => {
return convertToPickerItems(feedItems)
}, [feedItems, convertToPickerItems])
// Get all recent entries as picker items
const allRecentEntries = useMemo(() => {
return convertToPickerItems(entryItems)
}, [entryItems, convertToPickerItems])
return {
searchFeeds,
searchEntries,
allFeeds,
allRecentEntries,
}
}

View File

@ -0,0 +1,119 @@
import { useEntryIdsByView } from "@follow/store/entry/hooks"
import { useEntryStore } from "@follow/store/entry/store"
import { getFeedById } from "@follow/store/feed/getter"
import { useAllFeedSubscription } from "@follow/store/subscription/hooks"
import type { IFuseOptions } from "fuse.js"
import Fuse from "fuse.js"
import { useMemo } from "react"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
/**
* Generic search item interface
*/
export interface SearchItem {
id: string
title: string
type: "feed" | "entry"
}
/**
* Search service options
*/
export interface SearchServiceOptions {
/** Maximum number of recent entries to include */
maxRecentEntries?: number
/** Fuse.js search options */
fuseOptions?: IFuseOptions<SearchItem>
}
const defaultFuseOptions: IFuseOptions<SearchItem> = {
keys: ["title", "id"],
threshold: 0.3,
includeScore: true,
}
/**
* Hook that provides unified search functionality for feeds and entries
* Used by both context bar and mention plugin
*/
export const useFeedEntrySearchService = (options: SearchServiceOptions = {}) => {
const { maxRecentEntries = 50, fuseOptions = defaultFuseOptions } = options
// Get data sources
const allSubscriptions = useAllFeedSubscription()
const view = useRouteParamsSelector((route) => route.view)
const recentEntryIds = useEntryIdsByView(view, false)
const entryStore = useEntryStore((state) => state.data)
// Prepare feed items
const feedItems = useMemo(() => {
return allSubscriptions
.filter((subscription) => subscription.feedId)
.map((subscription) => {
const customTitle = subscription.title
if (!subscription.feedId) return null
const feed = getFeedById(subscription.feedId!)
return {
id: subscription.feedId!,
title: customTitle || feed?.title || `Feed ${subscription.feedId}`,
type: "feed" as const,
}
})
.filter(Boolean) as SearchItem[]
}, [allSubscriptions])
// Prepare entry items (recent entries, limited for performance)
const entryItems = useMemo(() => {
if (!recentEntryIds) return []
return recentEntryIds
.slice(0, maxRecentEntries)
.map((entryId) => {
const entry = entryStore[entryId]
return entry
? {
id: entryId,
title: entry.title || "Untitled",
type: "entry" as const,
}
: null
})
.filter(Boolean) as SearchItem[]
}, [recentEntryIds, entryStore, maxRecentEntries])
// Combine all search items
const allItems = useMemo(() => {
return [...feedItems, ...entryItems]
}, [feedItems, entryItems])
// Create Fuse instance for fuzzy search
const fuse = useMemo(() => {
return new Fuse(allItems, fuseOptions)
}, [allItems, JSON.stringify(fuseOptions)])
// Search function
const search = useMemo(() => {
return (query: string, type?: "feed" | "entry", maxResults = 10): SearchItem[] => {
if (!query.trim()) {
// If no query, return recent items of the specified type
const filteredItems = allItems.filter((item) => !type || item.type === type)
return filteredItems.slice(0, maxResults)
}
// Perform fuzzy search
const fuseResults = fuse.search(query)
return fuseResults
.map((result) => result.item)
.filter((item) => !type || item.type === type)
.slice(0, maxResults)
}
}, [allItems, fuse])
return {
search,
feedItems,
entryItems,
allItems,
}
}

1
icons/mgc/at_cute_re.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M11.2 2.04a9.993 9.993 0 0 0-9.038 8.252c-.643 3.731.879 7.523 3.918 9.754a10.003 10.003 0 0 0 7.88 1.75c1.03-.202 2.343-.688 2.697-.999.527-.463.416-1.285-.217-1.61-.317-.162-.613-.141-1.1.08-2.211 1.004-4.674.975-6.84-.081-1.595-.777-2.929-2.109-3.679-3.672-.818-1.706-1.031-3.587-.604-5.334.593-2.425 2.154-4.353 4.386-5.415 1.779-.847 3.872-.998 5.862-.423a7.728 7.728 0 0 1 4.954 4.356c.4.927.578 1.716.609 2.702.057 1.85-.48 3.194-1.477 3.694-.36.18-.75.282-.946.247-.334-.061-.576-.347-.627-.743-.014-.103.087-1.277.239-2.785.145-1.433.263-2.707.263-2.831a.855.855 0 0 0-.241-.637.994.994 0 0 0-.681-.335.96.96 0 0 0-.8.341l-.136.161-.241-.223c-.985-.913-2.492-1.401-3.861-1.25-2.392.263-4.231 2.085-4.48 4.439-.22 2.082.882 4.06 2.781 4.995.343.168.826.344 1.069.388.095.018.15.05.15.088 0 .053.107.058.83.041.912-.023 1.275-.077 1.843-.276a4.736 4.736 0 0 0 1.224-.641c.175-.126.333-.219.351-.208a.82.82 0 0 1 .118.188c.144.283.69.789 1.054.976.687.353 1.585.388 2.427.096 1.644-.571 2.676-1.956 3.042-4.085.118-.681.126-2.082.017-2.74-.358-2.157-1.291-3.978-2.798-5.458-1.536-1.51-3.492-2.453-5.688-2.742a13.715 13.715 0 0 0-2.26-.06m1.64 7.073c.368.112.542.192.845.388 1.032.666 1.563 1.905 1.321 3.076a2.993 2.993 0 0 1-1.626 2.105c-.484.238-.754.297-1.36.297-.432 0-.571-.016-.819-.094-1.536-.478-2.437-1.929-2.145-3.451.115-.594.351-1.04.798-1.505a3.04 3.04 0 0 1 1.547-.869c.355-.081 1.09-.053 1.439.053" fill="#10161F" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -23,6 +23,9 @@
},
"dependencies": {
"@essentials/request-timeout": "1.3.0",
"@floating-ui/core": "*",
"@floating-ui/react": "*",
"@floating-ui/react-dom": "*",
"@follow/hooks": "workspace:*",
"@follow/models": "workspace:*",
"@follow/types": "workspace:*",

View File

@ -18,12 +18,12 @@ import { useImperativeHandle, useRef, useState } from "react"
import { LexicalRichEditorNodes } from "./nodes"
import { KeyboardPlugin } from "./plugins"
import { defaultLexicalTheme } from "./theme"
import type { LexicalRichEditorProps, LexicalRichEditorRef } from "./types"
import type { BuiltInPlugins, LexicalRichEditorProps, LexicalRichEditorRef } from "./types"
function onError(error: Error) {
console.error("Lexical Editor Error:", error)
}
const defaultEnabledPlugins = {
const defaultEnabledPlugins: BuiltInPlugins = {
history: true,
markdown: true,
list: true,
@ -42,15 +42,22 @@ export const LexicalRichEditor = ({
theme = defaultLexicalTheme,
enabledPlugins = defaultEnabledPlugins,
initalEditorState,
plugins,
}: LexicalRichEditorProps & { ref?: React.RefObject<LexicalRichEditorRef | null> }) => {
const editorRef = useRef<LexicalEditor | null>(null)
const [isEmpty, setIsEmpty] = useState(true)
// Collect nodes from plugins
const pluginNodes = plugins?.flatMap((plugin) => plugin.nodes || []) || []
// Merge base nodes with custom nodes and plugin nodes
const allNodes = [...LexicalRichEditorNodes, ...pluginNodes]
const initialConfig: InitialConfigType = {
namespace,
theme,
onError,
nodes: LexicalRichEditorNodes,
nodes: allNodes,
editorState: initalEditorState,
}
@ -88,13 +95,13 @@ export const LexicalRichEditor = ({
contentEditable={
<ContentEditable
className={cn(
"scrollbar-none text-text placeholder:text-text-secondary",
"max-h-40 min-h-14 w-full resize-none bg-transparent px-5 py-3.5 pr-14",
"scrollbar-none text-text placeholder:text-text-secondary cursor-text",
"max-h-40 min-h-14 w-full resize-none bg-transparent",
"text-sm !outline-none transition-all duration-200 focus:outline-none",
)}
aria-placeholder={placeholder}
placeholder={
<div className="text-text-secondary pointer-events-none absolute left-5 top-3.5 text-sm">
<div className="text-text-secondary pointer-events-none absolute left-0 top-0 text-sm">
{placeholder}
</div>
}
@ -109,6 +116,10 @@ export const LexicalRichEditor = ({
{enabledPlugins.list && <ListPlugin />}
{enabledPlugins.link && <LinkPlugin />}
{plugins?.map((Plugin) => (
<Plugin key={Plugin.id} />
))}
<KeyboardPlugin onKeyDown={onKeyDown} />
{autoFocus && enabledPlugins.autoFocus && <AutoFocusPlugin />}
</div>

View File

@ -3,4 +3,4 @@ export { LexicalRichEditor } from "./LexicalRichEditor"
export { LexicalRichEditorNodes } from "./nodes"
export { KeyboardPlugin } from "./plugins"
export { defaultLexicalTheme } from "./theme"
export type { LexicalRichEditorProps, LexicalRichEditorRef } from "./types"
export type * from "./types"

View File

@ -5,6 +5,9 @@ import { MarkNode } from "@lexical/mark"
import { HeadingNode, QuoteNode } from "@lexical/rich-text"
import { ParagraphNode, TextNode } from "lexical"
// Mention functionality moved to desktop app, import should be handled there
// import { MentionNode } from "./plugins/mention"
export const LexicalRichEditorNodes = [
// Core nodes
ParagraphNode,

View File

@ -1,29 +0,0 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { useEffect } from "react"
interface KeyboardPluginProps {
onKeyDown?: (event: KeyboardEvent) => boolean
}
export function KeyboardPlugin({ onKeyDown }: KeyboardPluginProps) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!onKeyDown) return
const handleKeyDown = (event: KeyboardEvent) => {
onKeyDown(event)
}
return editor.registerRootListener((rootElement, prevRootElement) => {
if (prevRootElement !== null) {
prevRootElement.removeEventListener("keydown", handleKeyDown)
}
if (rootElement !== null) {
rootElement.addEventListener("keydown", handleKeyDown)
}
})
}, [editor, onKeyDown])
return null
}

View File

@ -1 +1 @@
export { KeyboardPlugin } from "./KeyboardPlugin"
export { KeyboardPlugin } from "./keyboard"

View File

@ -0,0 +1,55 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
import { COMMAND_PRIORITY_LOW, KEY_ENTER_COMMAND } from "lexical"
import { useEffect } from "react"
interface KeyboardPluginProps {
onKeyDown?: (event: KeyboardEvent) => boolean
}
export function KeyboardPlugin({ onKeyDown }: KeyboardPluginProps) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!onKeyDown) return
// Register a low-priority command that will only execute if no higher-priority commands handle the event
const removeEnterCommand = editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
// This will only be called if no higher-priority commands handled the Enter key
if (!event) return false
const handled = onKeyDown(event)
return handled
},
COMMAND_PRIORITY_LOW,
)
// For other keys, use DOM event listener
const handleKeyDown = (event: KeyboardEvent) => {
// Skip Enter key as it's handled by the command system
if (event.key === "Enter") return
const handled = onKeyDown(event)
if (handled) {
event.preventDefault()
event.stopPropagation()
}
}
const removeRootListener = editor.registerRootListener((rootElement, prevRootElement) => {
if (prevRootElement !== null) {
prevRootElement.removeEventListener("keydown", handleKeyDown)
}
if (rootElement !== null) {
rootElement.addEventListener("keydown", handleKeyDown)
}
})
return () => {
removeEnterCommand()
removeRootListener()
}
}, [editor, onKeyDown])
return null
}

View File

@ -1,4 +1,4 @@
import type { EditorState, LexicalEditor } from "lexical"
import type { EditorState, Klass, LexicalEditor, LexicalNode } from "lexical"
export interface LexicalRichEditorRef {
getEditor: () => LexicalEditor
@ -7,6 +7,13 @@ export interface LexicalRichEditorRef {
isEmpty: () => boolean
}
export interface BuiltInPlugins {
history?: boolean
markdown?: boolean
list?: boolean
link?: boolean
autoFocus?: boolean
}
export interface LexicalRichEditorProps {
placeholder?: string
className?: string
@ -15,12 +22,11 @@ export interface LexicalRichEditorProps {
autoFocus?: boolean
namespace?: string
theme?: any
enabledPlugins?: {
history?: boolean
markdown?: boolean
list?: boolean
link?: boolean
autoFocus?: boolean
}
enabledPlugins?: BuiltInPlugins
initalEditorState?: EditorState
plugins?: LexicalPluginFC[]
}
export type LexicalPluginFC<T = unknown> = React.FC<T> & {
id: string
nodes?: ReadonlyArray<Klass<LexicalNode>>
}

View File

@ -25,7 +25,7 @@ export enum TrackerMapper {
Register = 3000,
OnBoarding = 3001,
Subscribe = 3002,
EntryRead = 3003,
EntryAction = 3004,
ViewAction = 3005,
// AI
AIChatMessageSent = 4000,
}

View File

@ -114,16 +114,8 @@ export class TrackerPoints {
this.track(TrackerMapper.Subscribe, props)
}
entryRead(props: { entryId: string }) {
this.track(TrackerMapper.EntryRead, props)
}
entryAction(props: { entryId: string; action: string }) {
this.track(TrackerMapper.EntryAction, props)
}
viewAction(props: { view: string; action: string }) {
this.track(TrackerMapper.ViewAction, props)
aiChatMessageSent() {
this.track(TrackerMapper.AIChatMessageSent)
}
private track(code: TrackerMapper, properties?: Record<string, unknown>) {

View File

@ -22,6 +22,10 @@ overrides:
react-dom: 19.0.0
react-native-ios-context-menu: 3.1.1
react-native-ios-utilities: 5.1.5
'@floating-ui/core': 1.7.2
'@floating-ui/dom': 1.7.2
'@floating-ui/react-dom': 2.1.4
'@floating-ui/react': 0.27.14
patchedDependencies:
'@microflash/remark-callout-directives':
@ -1444,6 +1448,15 @@ importers:
'@essentials/request-timeout':
specifier: 1.3.0
version: 1.3.0
'@floating-ui/core':
specifier: 1.7.2
version: 1.7.2
'@floating-ui/react':
specifier: 0.27.14
version: 0.27.14(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@floating-ui/react-dom':
specifier: 2.1.4
version: 2.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@follow/hooks':
specifier: workspace:*
version: link:../hooks
@ -4051,36 +4064,18 @@ packages:
'@firebase/webchannel-wrapper@1.0.3':
resolution: {integrity: sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==}
'@floating-ui/core@1.7.0':
resolution: {integrity: sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==}
'@floating-ui/core@1.7.2':
resolution: {integrity: sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==}
'@floating-ui/dom@1.7.0':
resolution: {integrity: sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==}
'@floating-ui/dom@1.7.2':
resolution: {integrity: sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==}
'@floating-ui/react-dom@2.1.2':
resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==}
peerDependencies:
react: 19.0.0
react-dom: 19.0.0
'@floating-ui/react-dom@2.1.4':
resolution: {integrity: sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==}
peerDependencies:
react: 19.0.0
react-dom: 19.0.0
'@floating-ui/react@0.26.28':
resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
peerDependencies:
react: 19.0.0
react-dom: 19.0.0
'@floating-ui/react@0.27.14':
resolution: {integrity: sha512-aSf9JXfyXpRQWMbtuW+CJQrnhzHu4Hg1Th9AkvR1o+wSW/vCUVMrtgXaRY5ToV5Fh5w3I7lXJdvlKVvYrQrppw==}
peerDependencies:
@ -4090,9 +4085,6 @@ packages:
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@floating-ui/utils@0.2.9':
resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==}
'@follow-app/client-sdk@0.3.25':
resolution: {integrity: sha512-+x9x3bvcCedkqH2ypw1ae2zYySzT70FniC9LH8/TdqpYKIUzQZaMSZxGNIA9KaAQT1EtvL9e7lO07/zpQiEPSw==}
@ -19285,44 +19277,21 @@ snapshots:
'@firebase/webchannel-wrapper@1.0.3': {}
'@floating-ui/core@1.7.0':
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/core@1.7.2':
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.0':
dependencies:
'@floating-ui/core': 1.7.0
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.2':
dependencies:
'@floating-ui/core': 1.7.2
'@floating-ui/utils': 0.2.10
'@floating-ui/react-dom@2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/dom': 1.7.0
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@floating-ui/react-dom@2.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/dom': 1.7.2
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@floating-ui/react@0.26.28(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@floating-ui/utils': 0.2.9
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
tabbable: 6.2.0
'@floating-ui/react@0.27.14(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/react-dom': 2.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -19333,8 +19302,6 @@ snapshots:
'@floating-ui/utils@0.2.10': {}
'@floating-ui/utils@0.2.9': {}
'@follow-app/client-sdk@0.3.25(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(expo-sqlite@15.2.12(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(kysely@0.28.2)(pg@8.16.3)':
dependencies:
'@folo-services/constants': 0.1.15
@ -19582,7 +19549,7 @@ snapshots:
'@headlessui/react@2.2.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/react': 0.26.28(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@floating-ui/react': 0.27.14(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@react-aria/focus': 3.20.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@react-aria/interactions': 3.25.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@tanstack/react-virtual': 3.13.12(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -21027,7 +20994,7 @@ snapshots:
'@radix-ui/react-popper@1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@floating-ui/react-dom': 2.1.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.0.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.0.0)

View File

@ -51,6 +51,11 @@ overrides:
react-native-ios-context-menu: "3.1.1"
react-native-ios-utilities: "5.1.5"
"@floating-ui/core": "1.7.2"
"@floating-ui/dom": "1.7.2"
"@floating-ui/react-dom": "2.1.4"
"@floating-ui/react": "0.27.14"
catalog:
typescript: "5.8.3"
"@follow-app/client-sdk": "0.3.25"