diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index ec997af15..000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -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": [] - } -} \ No newline at end of file diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/components/layouts/ChatInput.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/components/layouts/ChatInput.tsx index ee8a9b79c..b8f399259 100644 --- a/apps/desktop/layer/renderer/src/modules/ai/chat/components/layouts/ChatInput.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/components/layouts/ChatInput.tsx @@ -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) => {
{/* Input Area */}
- -
+ + + +
{ role: "user", id: nanoid(), }) + tracker.aiChatMessageSent() }, ) diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/components/message/AIRichTextMessage.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/components/message/AIRichTextMessage.tsx index f4cef9c96..3227f698b 100644 --- a/apps/desktop/layer/renderer/src/modules/ai/chat/components/message/AIRichTextMessage.tsx +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/components/message/AIRichTextMessage.tsx @@ -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 = React.memo( onError, editable: false, // Read-only mode editorState: JSON.stringify(data.state), - nodes: LexicalRichEditorNodes, + nodes: [...LexicalRichEditorNodes, MentionNode], } return ( diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/index.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/index.ts new file mode 100644 index 000000000..8ea7bb21d --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/index.ts @@ -0,0 +1 @@ +export * from "./plugins" diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/index.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/index.tsx new file mode 100644 index 000000000..880d97c42 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/index.tsx @@ -0,0 +1 @@ +export * from "./mention" diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/MentionNode.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/MentionNode.tsx new file mode 100644 index 000000000..0c83d92d6 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/MentionNode.tsx @@ -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 { + __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 ( + @{this.__mentionData.name}}> + + + ) + } + + 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 +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/MentionPlugin.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/MentionPlugin.tsx new file mode 100644 index 000000000..a2b14a3e6 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/MentionPlugin.tsx @@ -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 ? ( + + + + ) : null +} + +MentionPlugin.id = "mention" +MentionPlugin.nodes = [MentionNode] diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/components/MentionComponent.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/components/MentionComponent.tsx new file mode 100644 index 000000000..296cf4d09 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/components/MentionComponent.tsx @@ -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 }) => ( +
+
+ +
+
+

@{mentionData.name}

+ + {mentionData.type} + +
+
+) + +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 = ({ 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 ( + + + + + + @{mentionData.name} + + + + + + + + + + ) +} + +MentionComponent.displayName = "MentionComponent" diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/components/MentionDropdown.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/components/MentionDropdown.tsx new file mode 100644 index 000000000..77f486337 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/components/MentionDropdown.tsx @@ -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 ( + + {part} + + ) + }) + } + + return ( +
+ {/* Icon */} + + + + + {/* Content */} + {highlightText(mention.name, query)} + + {/* Selection Indicator */} + {isSelected && ( + + + + )} +
+ ) + }, +) + +MentionSuggestionItem.displayName = "MentionSuggestionItem" + +export const MentionDropdown: React.FC = ({ + isVisible, + suggestions, + selectedIndex, + isLoading, + onSelect, + onClose, + query, +}) => { + if (!isVisible) throw thenable + + const [editor] = useLexicalComposerContext() + const dropdownRef = useRef(null) + const [referenceWidth, setReferenceWidth] = useState(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 ( + + {isVisible && ( +
+
+ {isLoading ? ( +
+ + Searching... +
+ ) : suggestions.length === 0 ? ( +
+ No matches found + {query && ( +
+ Try a different search term +
+ )} +
+ ) : ( +
+ {suggestions.map((mention, index) => ( + + ))} +
+ )} +
+
+ )} +
+ ) +} + +MentionDropdown.displayName = "MentionDropdown" diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/components/shared/MentionTypeIcon.tsx b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/components/shared/MentionTypeIcon.tsx new file mode 100644 index 000000000..c1e91dfdc --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/components/shared/MentionTypeIcon.tsx @@ -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 = ({ type, className = "size-3" }) => { + switch (type) { + case "entry": { + return + } + case "feed": { + return + } + default: { + return + } + } +} + +MentionTypeIcon.displayName = "MentionTypeIcon" diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/constants.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/constants.ts new file mode 100644 index 000000000..546c37f03 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/constants.ts @@ -0,0 +1,16 @@ +import { createCommand } from "lexical" + +import type { MentionData } from "./types" + +// Commands +export const MENTION_COMMAND = createCommand("MENTION_COMMAND") +export const MENTION_TYPEAHEAD_COMMAND = createCommand("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-]*)$/ diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionBlockSync.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionBlockSync.ts new file mode 100644 index 000000000..2b7c70f0a --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionBlockSync.ts @@ -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()) // mentionNodeKey -> reference + const resourceToMentionsRef = useRef(new Map>()) // resourceId -> Set + const blockToResourceRef = useRef(new Map()) // 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 = { + 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() + + 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), + }), + } +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionIntegration.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionIntegration.ts new file mode 100644 index 000000000..27f1e477d --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionIntegration.ts @@ -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, + } +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionKeyboard.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionKeyboard.ts new file mode 100644 index 000000000..e0efc9307 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionKeyboard.ts @@ -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, + } +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionSearch.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionSearch.ts new file mode 100644 index 000000000..40cc95451 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionSearch.ts @@ -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[] + maxSuggestions?: number +} + +// Default search function +const defaultSearchFn = async (): Promise => [] + +export const useMentionSearch = ({ + onSearch = defaultSearchFn, + maxSuggestions = DEFAULT_MAX_SUGGESTIONS, +}: UseMentionSearchOptions = {}) => { + const [searchState, setSearchState] = useState({ + suggestions: [], + selectedIndex: -1, + isLoading: false, + }) + + const [isPending, startTransition] = useTransition() + const onSearchRef = useRef(onSearch) + const maxSuggestionsRef = useRef(maxSuggestions) + const abortControllerRef = useRef(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, + } +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionSearchService.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionSearchService.ts new file mode 100644 index 000000000..bda95bcee --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionSearchService.ts @@ -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 => { + 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 } +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionSelection.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionSelection.ts new file mode 100644 index 000000000..bf72dae86 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionSelection.ts @@ -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, + } +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionTrigger.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionTrigger.ts new file mode 100644 index 000000000..7b35b8da1 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/hooks/useMentionTrigger.ts @@ -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(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, + } +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/index.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/index.ts new file mode 100644 index 000000000..698ba4a48 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/index.ts @@ -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" diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/types.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/types.ts new file mode 100644 index 000000000..582902b88 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/types.ts @@ -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 +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/utils/positioning.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/utils/positioning.ts new file mode 100644 index 000000000..96615def5 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/utils/positioning.ts @@ -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, + 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", + }) + } +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/utils/textReplacement.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/utils/textReplacement.ts new file mode 100644 index 000000000..a94735bbc --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/utils/textReplacement.ts @@ -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() } +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/utils/triggerDetection.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/utils/triggerDetection.ts new file mode 100644 index 000000000..1239c84a9 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/editor/plugins/mention/utils/triggerDetection.ts @@ -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 +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/services/contextBarSearchService.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/services/contextBarSearchService.ts new file mode 100644 index 000000000..22b4b92b4 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/services/contextBarSearchService.ts @@ -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, + } +} diff --git a/apps/desktop/layer/renderer/src/modules/ai/chat/services/feedEntrySearchService.ts b/apps/desktop/layer/renderer/src/modules/ai/chat/services/feedEntrySearchService.ts new file mode 100644 index 000000000..2909dc4e2 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/ai/chat/services/feedEntrySearchService.ts @@ -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 +} + +const defaultFuseOptions: IFuseOptions = { + 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, + } +} diff --git a/icons/mgc/at_cute_re.svg b/icons/mgc/at_cute_re.svg new file mode 100644 index 000000000..129ced2ee --- /dev/null +++ b/icons/mgc/at_cute_re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/internal/components/package.json b/packages/internal/components/package.json index 7259d6edf..70f4ff732 100644 --- a/packages/internal/components/package.json +++ b/packages/internal/components/package.json @@ -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:*", diff --git a/packages/internal/components/src/ui/lexical-rich-editor/LexicalRichEditor.tsx b/packages/internal/components/src/ui/lexical-rich-editor/LexicalRichEditor.tsx index dd41caa91..e20ccb1bb 100644 --- a/packages/internal/components/src/ui/lexical-rich-editor/LexicalRichEditor.tsx +++ b/packages/internal/components/src/ui/lexical-rich-editor/LexicalRichEditor.tsx @@ -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 }) => { const editorRef = useRef(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={ +
{placeholder}
} @@ -109,6 +116,10 @@ export const LexicalRichEditor = ({ {enabledPlugins.list && } {enabledPlugins.link && } + {plugins?.map((Plugin) => ( + + ))} + {autoFocus && enabledPlugins.autoFocus && }
diff --git a/packages/internal/components/src/ui/lexical-rich-editor/index.ts b/packages/internal/components/src/ui/lexical-rich-editor/index.ts index 21c187430..8eec0d013 100644 --- a/packages/internal/components/src/ui/lexical-rich-editor/index.ts +++ b/packages/internal/components/src/ui/lexical-rich-editor/index.ts @@ -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" diff --git a/packages/internal/components/src/ui/lexical-rich-editor/nodes.ts b/packages/internal/components/src/ui/lexical-rich-editor/nodes.ts index d84224e5c..7bf89baa3 100644 --- a/packages/internal/components/src/ui/lexical-rich-editor/nodes.ts +++ b/packages/internal/components/src/ui/lexical-rich-editor/nodes.ts @@ -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, diff --git a/packages/internal/components/src/ui/lexical-rich-editor/plugins/KeyboardPlugin.tsx b/packages/internal/components/src/ui/lexical-rich-editor/plugins/KeyboardPlugin.tsx deleted file mode 100644 index 58c07b4bc..000000000 --- a/packages/internal/components/src/ui/lexical-rich-editor/plugins/KeyboardPlugin.tsx +++ /dev/null @@ -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 -} diff --git a/packages/internal/components/src/ui/lexical-rich-editor/plugins/index.ts b/packages/internal/components/src/ui/lexical-rich-editor/plugins/index.ts index 22d054444..ba383f8bd 100644 --- a/packages/internal/components/src/ui/lexical-rich-editor/plugins/index.ts +++ b/packages/internal/components/src/ui/lexical-rich-editor/plugins/index.ts @@ -1 +1 @@ -export { KeyboardPlugin } from "./KeyboardPlugin" +export { KeyboardPlugin } from "./keyboard" diff --git a/packages/internal/components/src/ui/lexical-rich-editor/plugins/keyboard/index.tsx b/packages/internal/components/src/ui/lexical-rich-editor/plugins/keyboard/index.tsx new file mode 100644 index 000000000..d2ea1b64d --- /dev/null +++ b/packages/internal/components/src/ui/lexical-rich-editor/plugins/keyboard/index.tsx @@ -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 +} diff --git a/packages/internal/components/src/ui/lexical-rich-editor/types.ts b/packages/internal/components/src/ui/lexical-rich-editor/types.ts index 83a0eb444..3021c9bb9 100644 --- a/packages/internal/components/src/ui/lexical-rich-editor/types.ts +++ b/packages/internal/components/src/ui/lexical-rich-editor/types.ts @@ -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 = React.FC & { + id: string + nodes?: ReadonlyArray> } diff --git a/packages/internal/tracker/src/enums.ts b/packages/internal/tracker/src/enums.ts index badbd8e6c..69a52208d 100644 --- a/packages/internal/tracker/src/enums.ts +++ b/packages/internal/tracker/src/enums.ts @@ -25,7 +25,7 @@ export enum TrackerMapper { Register = 3000, OnBoarding = 3001, Subscribe = 3002, - EntryRead = 3003, - EntryAction = 3004, - ViewAction = 3005, + + // AI + AIChatMessageSent = 4000, } diff --git a/packages/internal/tracker/src/tracker-points.ts b/packages/internal/tracker/src/tracker-points.ts index b5c7c65c3..1c76c36ae 100644 --- a/packages/internal/tracker/src/tracker-points.ts +++ b/packages/internal/tracker/src/tracker-points.ts @@ -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) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4783f804..caf629044 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 76ee0a4d7..9a078b067 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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"