import React, { useState, useEffect, useRef, useCallback, useMemo, useImperativeHandle, } from 'react'; import { Task } from '../../entities/Task'; import { Tag } from '../../entities/Tag'; import { Project } from '../../entities/Project'; import { Note } from '../../entities/Note'; import { useToast } from '../Shared/ToastContext'; import { useTranslation } from 'react-i18next'; import { createInboxItemWithStore } from '../../utils/inboxService'; import { isAuthError } from '../../utils/authUtils'; import { createTag } from '../../utils/tagsService'; import { createProject } from '../../utils/projectsService'; import { ClipboardDocumentListIcon, DocumentTextIcon, FolderIcon, PlusIcon, LightBulbIcon, LinkIcon, XMarkIcon, } from '@heroicons/react/24/outline'; import { useStore } from '../../store/useStore'; import { isUrl, extractUrlTitle } from '../../utils/urlService'; import { getApiPath } from '../../config/paths'; import { getCsrfToken } from '../../utils/csrfService'; import InboxSelectedChips from './InboxSelectedChips'; import SuggestionsDropdown from './SuggestionsDropdown'; import InboxCard from './InboxCard'; export interface QuickCaptureInputHandle { submit: (forceInbox?: boolean) => Promise; } export interface InboxComposerFooterContext { text: string; cleanedText: string; hashtags: string[]; projectRefs: string[]; clearText: () => void; } interface QuickCaptureInputProps { onTaskCreate?: (task: Task) => Promise; onNoteCreate?: (note: Note) => Promise; projects?: Project[]; autoFocus?: boolean; mode?: 'create' | 'edit'; initialValue?: string; hidePrimaryButton?: boolean; onSubmitOverride?: (text: string) => Promise; onAfterSubmit?: () => void; renderFooterActions?: ( context: InboxComposerFooterContext ) => React.ReactNode; openTaskModal?: (task: Task, inboxItemUid?: string) => void; openProjectModal?: (project: Project | null, inboxItemUid?: string) => void; openNoteModal?: (note: Note | null, inboxItemUid?: string) => void; cardClassName?: string; multiline?: boolean; } interface UrlPreviewState { detectedText: string; url: string; title: string | null; description: string | null; image: string | null; isLoading: boolean; error?: string | null; } const urlWithProtocolRegex = /(https?:\/\/[^\s]+)/i; // Simplified regex to avoid catastrophic backtracking with nested quantifiers const urlWithoutProtocolRegex = /(?:^|\s)((?:www\.)?[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::[0-9]+)?(?:\/[^\s]*)?)/i; const normalizeUrl = (value: string) => { if (!value) { return ''; } if (/^https?:\/\//i.test(value)) { return value; } return `https://${value}`; }; const extractFirstUrlFromText = (text: string): string | null => { if (!text) { return null; } const withProtocolMatch = text.match(urlWithProtocolRegex); if (withProtocolMatch && withProtocolMatch[0]) { return withProtocolMatch[0]; } const withoutProtocolMatch = text.match(urlWithoutProtocolRegex); if (withoutProtocolMatch && withoutProtocolMatch[1]) { return withoutProtocolMatch[1]; } return null; }; const QuickCaptureInput = React.forwardRef< QuickCaptureInputHandle, QuickCaptureInputProps >( ( { onTaskCreate, onNoteCreate, projects: propProjects = [], autoFocus = false, mode = 'create', initialValue = '', hidePrimaryButton = false, onSubmitOverride, onAfterSubmit, renderFooterActions, openTaskModal, openProjectModal, openNoteModal, cardClassName, multiline = false, }, ref ) => { const { t } = useTranslation(); const [inputText, setInputText] = useState(initialValue); const [isSaving, setIsSaving] = useState(false); const { showSuccessToast, showErrorToast } = useToast(); const inputRef = useRef(null); const { tagsStore } = useStore(); const { setTags, refreshTags } = tagsStore; const tags = tagsStore.getTags(); const [showTagSuggestions, setShowTagSuggestions] = useState(false); const [filteredTags, setFilteredTags] = useState([]); const [showProjectSuggestions, setShowProjectSuggestions] = useState(false); const [filteredProjects, setFilteredProjects] = useState([]); const projects = propProjects; const [cursorPosition, setCursorPosition] = useState(0); const [, setCurrentHashtagQuery] = useState(''); const [, setCurrentProjectQuery] = useState(''); const [dropdownPosition, setDropdownPosition] = useState({ left: 0, top: 0, }); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); const [analysisResult, setAnalysisResult] = useState<{ parsed_tags: string[]; parsed_projects: string[]; cleaned_content: string; suggested_type: 'task' | 'note' | null; suggested_reason: string | null; } | null>(null); const [isAnalyzing, setIsAnalyzing] = useState(false); const analysisTimeoutRef = useRef(); const analysisRequestIdRef = useRef(0); const [urlPreview, setUrlPreview] = useState( null ); const [urlPreviewImageError, setUrlPreviewImageError] = useState(false); const urlPreviewRequestIdRef = useRef(0); const dismissedPreviewUrlRef = useRef(null); const isEditMode = mode === 'edit'; useEffect(() => { if (isEditMode) { setInputText(initialValue || ''); } }, [initialValue, isEditMode]); useEffect(() => { if (autoFocus && inputRef.current) { inputRef.current.focus(); } }, [autoFocus]); const clearComposerText = useCallback(() => { setInputText(''); setAnalysisResult(null); setUrlPreview(null); dismissedPreviewUrlRef.current = null; if (inputRef.current) { inputRef.current.focus(); } }, []); const parseHashtags = (text: string): string[] => { const trimmedText = text.trim(); const matches: string[] = []; const words = trimmedText.split(/\s+/); if (words.length === 0) return matches; let i = 0; while (i < words.length) { if (words[i].startsWith('#') || words[i].startsWith('+')) { let groupEnd = i; while ( groupEnd < words.length && (words[groupEnd].startsWith('#') || words[groupEnd].startsWith('+')) ) { groupEnd++; } for (let j = i; j < groupEnd; j++) { if (words[j].startsWith('#')) { const tagName = words[j].substring(1); if ( tagName && /^[a-zA-Z0-9_-]+$/.test(tagName) && !matches.includes(tagName) ) { matches.push(tagName); } } } i = groupEnd; } else { i++; } } return matches; }; const parseProjectRefs = (text: string): string[] => { const trimmedText = text.trim(); const matches: string[] = []; const tokens = tokenizeText(trimmedText); let i = 0; while (i < tokens.length) { if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) { let groupEnd = i; while ( groupEnd < tokens.length && (tokens[groupEnd].startsWith('#') || tokens[groupEnd].startsWith('+')) ) { groupEnd++; } for (let j = i; j < groupEnd; j++) { if (tokens[j].startsWith('+')) { let projectName = tokens[j].substring(1); if ( projectName.startsWith('"') && projectName.endsWith('"') ) { projectName = projectName.slice(1, -1); } if (projectName && !matches.includes(projectName)) { matches.push(projectName); } } } i = groupEnd; } else { i++; } } return matches; }; const fetchUrlPreview = useCallback( async (rawUrl: string, detectedText: string) => { const normalizedUrl = normalizeUrl(rawUrl); urlPreviewRequestIdRef.current += 1; const currentRequestId = urlPreviewRequestIdRef.current; dismissedPreviewUrlRef.current = null; setUrlPreviewImageError(false); setUrlPreview({ detectedText, url: normalizedUrl, title: null, description: null, image: null, isLoading: true, error: null, }); const result = await extractUrlTitle(normalizedUrl); if (currentRequestId !== urlPreviewRequestIdRef.current) { return; } setUrlPreview((prev) => { if (!prev || prev.url !== normalizedUrl) { return prev; } return { ...prev, title: result.title ?? null, description: result.description ?? null, image: result.image ?? null, isLoading: false, error: result.error ?? null, }; }); }, [] ); useEffect(() => { const detectedUrl = extractFirstUrlFromText(inputText); if (!detectedUrl) { if (urlPreview) { setUrlPreview(null); } dismissedPreviewUrlRef.current = null; urlPreviewRequestIdRef.current += 1; return; } const normalized = normalizeUrl(detectedUrl); if (dismissedPreviewUrlRef.current === normalized) { return; } if (urlPreview && urlPreview.url === normalized) { return; } fetchUrlPreview(detectedUrl, detectedUrl); }, [inputText, fetchUrlPreview, urlPreview]); useEffect(() => { if (!urlPreview) { setUrlPreviewImageError(false); return; } setUrlPreviewImageError(false); }, [urlPreview?.url]); const tokenizeText = (text: string): string[] => { const tokens: string[] = []; let currentToken = ''; let inQuotes = false; let i = 0; while (i < text.length) { const char = text[i]; if (char === '"' && (i === 0 || text[i - 1] === '+')) { inQuotes = true; currentToken += char; } else if (char === '"' && inQuotes) { inQuotes = false; currentToken += char; } else if (char === ' ' && !inQuotes) { if (currentToken) { tokens.push(currentToken); currentToken = ''; } } else { currentToken += char; } i++; } if (currentToken) { tokens.push(currentToken); } return tokens; }; const getCurrentHashtagQuery = ( text: string, position: number ): string => { const beforeCursor = text.substring(0, position); const afterCursor = text.substring(position); const hashtagMatch = beforeCursor.match(/#([a-zA-Z0-9_]*)$/); if (!hashtagMatch) return ''; const hashtagStart = beforeCursor.lastIndexOf('#'); const textBeforeHashtag = text.substring(0, hashtagStart).trim(); const textAfterCursor = afterCursor.trim(); if (textAfterCursor === '') { return hashtagMatch[1]; } if (textBeforeHashtag === '') { return hashtagMatch[1]; } const wordsBeforeHashtag = textBeforeHashtag .split(/\s+/) .filter((word) => word.length > 0); const allWordsAreTagsOrProjects = wordsBeforeHashtag.every( (word) => word.startsWith('#') || word.startsWith('+') ); if (allWordsAreTagsOrProjects) { return hashtagMatch[1]; } return ''; }; const getCurrentProjectQuery = ( text: string, position: number ): string => { const beforeCursor = text.substring(0, position); const afterCursor = text.substring(position); const projectMatch = beforeCursor.match( /\+(?:"([^"]*)"|([a-zA-Z0-9_\s]*))$/ ); if (!projectMatch) return ''; const projectQuery = projectMatch[1] || projectMatch[2] || ''; const projectStart = beforeCursor.lastIndexOf('+'); const textBeforeProject = text.substring(0, projectStart).trim(); const textAfterCursor = afterCursor.trim(); if (textAfterCursor === '') { return projectQuery; } if (textBeforeProject === '') { return projectQuery; } const wordsBeforeProject = textBeforeProject .split(/\s+/) .filter((word) => word.length > 0); const allWordsAreTagsOrProjects = wordsBeforeProject.every( (word) => word.startsWith('#') || word.startsWith('+') ); if (allWordsAreTagsOrProjects) { return projectQuery; } return ''; }; const escapeRegExp = (value: string) => value.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); const cleanInputSpacing = (text: string) => text .replace(/\s{2,}/g, ' ') .replace(/\s+\n/g, '\n') .replace(/\n\s+/g, '\n') .trim(); const removeTagFromText = (tagToRemove: string) => { const escaped = escapeRegExp(tagToRemove); const pattern = new RegExp(`(^|\\s)#${escaped}(?=$|\\s)`, 'gi'); const updated = cleanInputSpacing( inputText.replace(pattern, (_, prefix) => prefix ?? '') ); setInputText(updated); setAnalysisResult(null); if (inputRef.current) { inputRef.current.focus(); } }; const removeProjectFromText = (projectToRemove: string) => { const escaped = escapeRegExp(projectToRemove); const quotedPattern = new RegExp( `(^|\\s)\\+"${escaped}"(?=$|\\s)`, 'gi' ); const simplePattern = new RegExp( `(^|\\s)\\+${escaped}(?=$|\\s)`, 'gi' ); const updated = cleanInputSpacing( inputText .replace(quotedPattern, (_, prefix) => prefix ?? '') .replace(simplePattern, (_, prefix) => prefix ?? '') ); setInputText(updated); setAnalysisResult(null); if (inputRef.current) { inputRef.current.focus(); } }; const calculateDropdownPosition = ( input: HTMLInputElement | HTMLTextAreaElement, cursorPos: number ) => { const temp = document.createElement('span'); temp.style.visibility = 'hidden'; temp.style.position = 'absolute'; temp.style.fontSize = getComputedStyle(input).fontSize; temp.style.fontFamily = getComputedStyle(input).fontFamily; temp.style.fontWeight = getComputedStyle(input).fontWeight; temp.textContent = inputText.substring(0, cursorPos); document.body.appendChild(temp); const textWidth = temp.getBoundingClientRect().width; document.body.removeChild(temp); const beforeCursor = inputText.substring(0, cursorPos); const afterCursor = inputText.substring(cursorPos); const hashtagMatch = beforeCursor.match(/#[a-zA-Z0-9_]*$/); const projectMatch = beforeCursor.match(/\+[a-zA-Z0-9_\s]*$/); if (hashtagMatch) { const hashtagStart = beforeCursor.lastIndexOf('#'); const textBeforeHashtag = inputText .substring(0, hashtagStart) .trim(); const textAfterCursor = afterCursor.trim(); let showDropdown = false; if (textAfterCursor === '' || textBeforeHashtag === '') { showDropdown = true; } else { const wordsBeforeHashtag = textBeforeHashtag .split(/\s+/) .filter((word) => word.length > 0); const allWordsAreTagsOrProjects = wordsBeforeHashtag.every( (word) => word.startsWith('#') || word.startsWith('+') ); if (allWordsAreTagsOrProjects) { showDropdown = true; } } if (showDropdown) { const tempToHashtag = document.createElement('span'); tempToHashtag.style.visibility = 'hidden'; tempToHashtag.style.position = 'absolute'; tempToHashtag.style.fontSize = getComputedStyle(input).fontSize; tempToHashtag.style.fontFamily = getComputedStyle(input).fontFamily; tempToHashtag.style.fontWeight = getComputedStyle(input).fontWeight; tempToHashtag.textContent = inputText.substring( 0, hashtagStart ); document.body.appendChild(tempToHashtag); const hashtagOffset = tempToHashtag.getBoundingClientRect().width; document.body.removeChild(tempToHashtag); return { left: hashtagOffset, top: input.offsetHeight, }; } } if (projectMatch) { const projectStart = beforeCursor.lastIndexOf('+'); const textBeforeProject = inputText .substring(0, projectStart) .trim(); const textAfterCursor = afterCursor.trim(); let showDropdown = false; if (textAfterCursor === '' || textBeforeProject === '') { showDropdown = true; } else { const wordsBeforeProject = textBeforeProject .split(/\s+/) .filter((word) => word.length > 0); const allWordsAreTagsOrProjects = wordsBeforeProject.every( (word) => word.startsWith('#') || word.startsWith('+') ); if (allWordsAreTagsOrProjects) { showDropdown = true; } } if (showDropdown) { const tempToProject = document.createElement('span'); tempToProject.style.visibility = 'hidden'; tempToProject.style.position = 'absolute'; tempToProject.style.fontSize = getComputedStyle(input).fontSize; tempToProject.style.fontFamily = getComputedStyle(input).fontFamily; tempToProject.style.fontWeight = getComputedStyle(input).fontWeight; tempToProject.textContent = inputText.substring( 0, projectStart ); document.body.appendChild(tempToProject); const projectOffset = tempToProject.getBoundingClientRect().width; document.body.removeChild(tempToProject); return { left: projectOffset, top: input.offsetHeight, }; } } return { left: textWidth, top: input.offsetHeight }; }; const handleChange = ( e: React.ChangeEvent ) => { const newText = e.target.value; const newCursorPosition = e.target.selectionStart || 0; setInputText(newText); setCursorPosition(newCursorPosition); const hashtagQuery = getCurrentHashtagQuery( newText, newCursorPosition ); setCurrentHashtagQuery(hashtagQuery); const projectQuery = getCurrentProjectQuery( newText, newCursorPosition ); setCurrentProjectQuery(projectQuery); if ( (newText.charAt(newCursorPosition - 1) === '#' || hashtagQuery) && hashtagQuery !== '' ) { setShowProjectSuggestions(false); setFilteredProjects([]); setSelectedSuggestionIndex(-1); const filtered = tags .filter((tag) => tag.name .toLowerCase() .startsWith(hashtagQuery.toLowerCase()) ) .slice(0, 5); const position = calculateDropdownPosition( e.target, newCursorPosition ); setDropdownPosition(position); setFilteredTags(filtered); setShowTagSuggestions(true); setSelectedSuggestionIndex(-1); } else if ( (newText.charAt(newCursorPosition - 1) === '+' || projectQuery) && projectQuery !== '' ) { setShowTagSuggestions(false); setFilteredTags([]); setSelectedSuggestionIndex(-1); const filtered = projects .filter((project) => project.name .toLowerCase() .includes(projectQuery.toLowerCase()) ) .slice(0, 5); const position = calculateDropdownPosition( e.target, newCursorPosition ); setDropdownPosition(position); setFilteredProjects(filtered); setShowProjectSuggestions(true); setSelectedSuggestionIndex(-1); } else { setShowTagSuggestions(false); setFilteredTags([]); setShowProjectSuggestions(false); setFilteredProjects([]); setSelectedSuggestionIndex(-1); } }; const getAllTags = (text: string): string[] => { if (analysisResult) { const explicitTags = analysisResult.parsed_tags; const isUrlContent = isUrl(text.trim()) || analysisResult.suggested_reason === 'url_detected'; if (isUrlContent) { const hasBookmarkTag = explicitTags.some( (tag) => tag.toLowerCase() === 'bookmark' ); if (!hasBookmarkTag) { return [...explicitTags, 'bookmark']; } } return explicitTags; } const explicitTags = parseHashtags(text); if (isUrl(text.trim())) { const hasBookmarkTag = explicitTags.some( (tag) => tag.toLowerCase() === 'bookmark' ); if (!hasBookmarkTag) { return [...explicitTags, 'bookmark']; } } return explicitTags; }; const getAllProjects = (text: string): string[] => { if (analysisResult) { return analysisResult.parsed_projects; } return parseProjectRefs(text); }; const getCleanedContent = (text: string): string => { if (analysisResult) { return analysisResult.cleaned_content; } return text .replace(/#[a-zA-Z0-9_-]+/g, '') .replace(/\+\S+/g, '') .trim(); }; const buildTagObjects = (hashtagNames: string[]) => { return hashtagNames.map((hashtagName) => { const existingTag = tags.find( (tag) => tag.name.toLowerCase() === hashtagName.toLowerCase() ); return existingTag || { name: hashtagName }; }); }; const resolveProjectUid = (projectRefsList: string[]) => { if (projectRefsList.length === 0) { return undefined; } const projectName = projectRefsList[0]; const matchingProject = projects.find( (project) => project.name.toLowerCase() === projectName.toLowerCase() ); return matchingProject ? matchingProject.uid : undefined; }; const getSuggestion = (): { type: 'note' | 'task' | null; message: string | null; projectName: string | null; } => { if (!analysisResult || !analysisResult.suggested_type) { return { type: null, message: null, projectName: null }; } const projectName = analysisResult.parsed_projects[0] || null; const type = analysisResult.suggested_type; if (type === 'note') { const isUrlNote = analysisResult.suggested_reason === 'url_detected'; const message = isUrlNote ? `Will be saved as a bookmark note${projectName ? ` for ${projectName}` : ''}.` : `Will be saved as a note${projectName ? ` for ${projectName}` : ''}.`; return { type: 'note', message, projectName, }; } else if (type === 'task') { return { type: 'task', message: `Will be created as a task${projectName ? ` under ${projectName}` : ''}.`, projectName, }; } return { type: null, message: null, projectName: null }; }; const analyzeText = useCallback( async (text: string, requestId: number) => { if (!text.trim()) { if (analysisRequestIdRef.current === requestId) { setAnalysisResult(null); setIsAnalyzing(false); } return; } try { if (analysisRequestIdRef.current === requestId) { setIsAnalyzing(true); } const token = await getCsrfToken(); const response = await fetch( getApiPath('inbox/analyze-text'), { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-csrf-token': token, }, credentials: 'include', body: JSON.stringify({ content: text }), } ); if (analysisRequestIdRef.current !== requestId) { return; } if (response.ok) { const result = await response.json(); setAnalysisResult(result); } else { console.error( 'Failed to analyze text:', response.statusText ); setAnalysisResult(null); } } catch (error) { if (analysisRequestIdRef.current !== requestId) { return; } console.error('Error analyzing text:', error); setAnalysisResult(null); } finally { if (analysisRequestIdRef.current === requestId) { setIsAnalyzing(false); } } }, [] ); useEffect(() => { if (analysisTimeoutRef.current) { clearTimeout(analysisTimeoutRef.current); } const requestId = analysisRequestIdRef.current + 1; analysisRequestIdRef.current = requestId; const textForAnalysis = inputText; analysisTimeoutRef.current = setTimeout(() => { analyzeText(textForAnalysis, requestId); }, 300); return () => { if (analysisTimeoutRef.current) { clearTimeout(analysisTimeoutRef.current); } }; }, [inputText, analyzeText]); const handleTagSelect = (tagName: string) => { const beforeCursor = inputText.substring(0, cursorPosition); const afterCursor = inputText.substring(cursorPosition); const hashtagMatch = beforeCursor.match(/#([a-zA-Z0-9_]*)$/); if (hashtagMatch) { const newText = beforeCursor.replace( /#([a-zA-Z0-9_]*)$/, `#${tagName} ` ) + afterCursor; setInputText(newText); setShowTagSuggestions(false); setFilteredTags([]); setSelectedSuggestionIndex(-1); setTimeout(() => { if (inputRef.current) { inputRef.current.focus(); const newCursorPos = beforeCursor.replace( /#([a-zA-Z0-9_]*)$/, `#${tagName} ` ).length; inputRef.current.setSelectionRange( newCursorPos, newCursorPos ); } }, 0); } }; const handleProjectSelect = (projectName: string) => { const beforeCursor = inputText.substring(0, cursorPosition); const afterCursor = inputText.substring(cursorPosition); const projectMatch = beforeCursor.match( /\+(?:"([^"]*)"|([a-zA-Z0-9_\s]*))$/ ); if (projectMatch) { const formattedProjectName = projectName.includes(' ') ? `"${projectName}"` : projectName; const newText = beforeCursor.replace( /\+(?:"([^"]*)"|([a-zA-Z0-9_\s]*))$/, `+${formattedProjectName} ` ) + afterCursor; setInputText(newText); setShowProjectSuggestions(false); setFilteredProjects([]); setSelectedSuggestionIndex(-1); setTimeout(() => { if (inputRef.current) { inputRef.current.focus(); const newCursorPos = beforeCursor.replace( /\+(?:"([^"]*)"|([a-zA-Z0-9_\s]*))$/, `+${formattedProjectName} ` ).length; inputRef.current.setSelectionRange( newCursorPos, newCursorPos ); } }, 0); } }; const createMissingTags = async (text: string): Promise => { const hashtagsInText = getAllTags(text); const currentTags = tagsStore.getTags(); const existingTagNames = currentTags.map((tag) => tag.name.toLowerCase() ); const missingTags = hashtagsInText.filter( (tagName) => !existingTagNames.includes(tagName.toLowerCase()) ); let createdNewTag = false; for (const tagName of missingTags) { try { const newTag = await createTag({ name: tagName }); setTags([...tagsStore.getTags(), newTag]); createdNewTag = true; } catch (error) { console.error(`Failed to create tag "${tagName}":`, error); } } if (createdNewTag && typeof refreshTags === 'function') { try { await refreshTags(); } catch (error) { console.error( 'Failed to refresh tags after creation:', error ); } } }; const createMissingProjects = async (text: string): Promise => { const projectsInText = getAllProjects(text); const existingProjectNames = projects.map((project) => project.name.toLowerCase() ); const missingProjects = projectsInText.filter( (projectName) => !existingProjectNames.includes(projectName.toLowerCase()) ); for (const projectName of missingProjects) { try { await createProject({ name: projectName, status: 'planned', }); } catch (error) { console.error( `Failed to create project "${projectName}":`, error ); } } }; const handleSubmit = useCallback( async (forceInbox = false) => { const trimmedText = inputText.trim(); if ((!trimmedText && !isEditMode) || isSaving) return; setIsSaving(true); try { if (onSubmitOverride) { await createMissingTags(trimmedText); await createMissingProjects(trimmedText); await onSubmitOverride(trimmedText); onAfterSubmit?.(); setIsSaving(false); return; } if ( analysisResult?.suggested_type === 'task' && !forceInbox && onTaskCreate ) { await createMissingTags(trimmedText); await createMissingProjects(trimmedText); const cleanedText = getCleanedContent(trimmedText); const taskTags = analysisResult.parsed_tags.map( (tagName) => { const existingTag = tags.find( (tag) => tag.name.toLowerCase() === tagName.toLowerCase() ); return existingTag || { name: tagName }; } ); let projectId = undefined; if (analysisResult.parsed_projects.length > 0) { const projectName = analysisResult.parsed_projects[0]; const matchingProject = projects.find( (project) => project.name.toLowerCase() === projectName.toLowerCase() ); if (matchingProject) { projectId = matchingProject.id; } } const newTask: Task = { name: cleanedText, status: 'not_started', priority: 'low', tags: taskTags, project_id: projectId, completed_at: null, }; try { await onTaskCreate(newTask); showSuccessToast(t('task.createSuccess')); setInputText(''); setAnalysisResult(null); if (inputRef.current) { inputRef.current.focus(); } return; } catch (error: any) { if (isAuthError(error)) { return; } throw error; } } if ( analysisResult?.suggested_type === 'note' && !forceInbox && onNoteCreate ) { await createMissingTags(trimmedText); await createMissingProjects(trimmedText); const cleanedText = getCleanedContent(trimmedText); const hashtagTags = analysisResult.parsed_tags.map( (tagName) => { const existingTag = tags.find( (tag) => tag.name.toLowerCase() === tagName.toLowerCase() ); return existingTag || { name: tagName }; } ); const isUrlContent = isUrl(trimmedText) || analysisResult.suggested_reason === 'url_detected'; const bookmarkTag = isUrlContent ? [{ name: 'bookmark' }] : []; const hasBookmarkInParsed = hashtagTags.some( (tag) => tag.name.toLowerCase() === 'bookmark' ); const finalBookmarkTag = hasBookmarkInParsed ? [] : bookmarkTag; const taskTags = [...hashtagTags, ...finalBookmarkTag]; let projectId = undefined; if (analysisResult.parsed_projects.length > 0) { const projectName = analysisResult.parsed_projects[0]; const matchingProject = projects.find( (project) => project.name.toLowerCase() === projectName.toLowerCase() ); if (matchingProject) { projectId = matchingProject.id; } } const newNote: Note = { title: cleanedText || trimmedText, content: trimmedText, tags: taskTags, project_id: projectId, }; try { await onNoteCreate(newNote); showSuccessToast( t( 'note.createSuccess', 'Note created successfully' ) ); setInputText(''); setAnalysisResult(null); if (inputRef.current) { inputRef.current.focus(); } return; } catch (error: any) { console.error( 'Error in note creation flow:', error ); if (isAuthError(error)) { return; } throw error; } } try { await createMissingTags(trimmedText); await createMissingProjects(trimmedText); await createInboxItemWithStore(trimmedText); showSuccessToast(t('inbox.itemAdded')); setInputText(''); setAnalysisResult(null); if (inputRef.current) { inputRef.current.focus(); } } catch (error) { console.error('Failed to create inbox item:', error); showErrorToast(t('inbox.addError')); } } catch (error) { console.error('Failed to save:', error); showErrorToast(t('inbox.addError')); } finally { setIsSaving(false); } }, [ inputText, isSaving, onTaskCreate, onNoteCreate, showSuccessToast, showErrorToast, t, tags, setTags, analysisResult, createMissingTags, createMissingProjects, getCleanedContent, projects, onSubmitOverride, onAfterSubmit, ] ); useImperativeHandle( ref, () => ({ submit: (forceInbox = false) => handleSubmit(forceInbox), }), [handleSubmit] ); const composerFooterContext = useMemo( () => ({ text: inputText, cleanedText: getCleanedContent(inputText.trim()), hashtags: getAllTags(inputText), projectRefs: getAllProjects(inputText), clearText: clearComposerText, updateText: (value: string) => setInputText(value), }), [inputText, clearComposerText] ); const defaultFooterActions = !renderFooterActions && !isEditMode && (openTaskModal || openProjectModal || openNoteModal) ? (
{openTaskModal && ( )} {openNoteModal && ( )} {openProjectModal && ( )}
) : null; const footerActions = renderFooterActions?.(composerFooterContext) || defaultFooterActions; const shouldShowPrimaryButton = !hidePrimaryButton && !isEditMode; const cardClasses = cardClassName ?? 'mb-6'; return (
{multiline ? ( ) : ( { inputRef.current = el; }} type="text" data-testid="quick-capture-input" value={inputText} onChange={handleChange} onSelect={(e) => { const pos = e.currentTarget .selectionStart || 0; setCursorPosition(pos); if ( showTagSuggestions || showProjectSuggestions ) { const position = calculateDropdownPosition( e.currentTarget, pos ); setDropdownPosition(position); } }} onKeyUp={(e) => { const pos = e.currentTarget .selectionStart || 0; setCursorPosition(pos); if ( showTagSuggestions || showProjectSuggestions ) { const position = calculateDropdownPosition( e.currentTarget, pos ); setDropdownPosition(position); } }} onClick={(e) => { const pos = e.currentTarget .selectionStart || 0; setCursorPosition(pos); if ( showTagSuggestions || showProjectSuggestions ) { const position = calculateDropdownPosition( e.currentTarget, pos ); setDropdownPosition(position); } }} className="w-full text-base font-normal bg-transparent text-gray-900 dark:text-gray-100 border-0 focus:outline-none focus:ring-0 px-0 py-2 placeholder-gray-400 dark:placeholder-gray-500" placeholder={t( 'inbox.captureThought', 'Capture a thought...' )} onKeyDown={(e) => { const hasTagSuggestions = showTagSuggestions && filteredTags.length > 0; const hasProjectSuggestions = showProjectSuggestions && filteredProjects.length > 0; if (hasTagSuggestions) { if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedSuggestionIndex( (prev) => prev < filteredTags.length - 1 ? prev + 1 : 0 ); return; } else if ( e.key === 'ArrowUp' ) { e.preventDefault(); setSelectedSuggestionIndex( (prev) => prev > 0 ? prev - 1 : filteredTags.length - 1 ); return; } else if (e.key === 'Tab') { e.preventDefault(); const selectedTag = selectedSuggestionIndex >= 0 ? filteredTags[ selectedSuggestionIndex ] : filteredTags[0]; handleTagSelect( selectedTag.name ); return; } else if ( e.key === 'Enter' && selectedSuggestionIndex >= 0 ) { e.preventDefault(); handleTagSelect( filteredTags[ selectedSuggestionIndex ].name ); return; } else if (e.key === 'Escape') { e.preventDefault(); setShowTagSuggestions( false ); setFilteredTags([]); setSelectedSuggestionIndex( -1 ); return; } } if (hasProjectSuggestions) { if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedSuggestionIndex( (prev) => prev < filteredProjects.length - 1 ? prev + 1 : 0 ); return; } else if ( e.key === 'ArrowUp' ) { e.preventDefault(); setSelectedSuggestionIndex( (prev) => prev > 0 ? prev - 1 : filteredProjects.length - 1 ); return; } else if (e.key === 'Tab') { e.preventDefault(); const selectedProject = selectedSuggestionIndex >= 0 ? filteredProjects[ selectedSuggestionIndex ] : filteredProjects[0]; handleProjectSelect( selectedProject.name ); return; } else if ( e.key === 'Enter' && selectedSuggestionIndex >= 0 ) { e.preventDefault(); handleProjectSelect( filteredProjects[ selectedSuggestionIndex ].name ); return; } else if (e.key === 'Escape') { e.preventDefault(); setShowProjectSuggestions( false ); setFilteredProjects([]); setSelectedSuggestionIndex( -1 ); return; } } if ( e.key === 'Escape' && !hasTagSuggestions && !hasProjectSuggestions ) { e.preventDefault(); if (isEditMode && !isSaving) { handleSubmit(); } return; } if ( e.key === 'Enter' && !e.shiftKey && !isSaving ) { if ( hasTagSuggestions || hasProjectSuggestions ) { return; } e.preventDefault(); handleSubmit(); } }} /> )}
0 } items={filteredTags} position={dropdownPosition} selectedIndex={selectedSuggestionIndex} onSelect={(tag) => handleTagSelect(tag.name)} renderLabel={(tag) => <>#{tag.name}} /> 0 } items={filteredProjects} position={dropdownPosition} selectedIndex={selectedSuggestionIndex} onSelect={(project) => handleProjectSelect(project.name) } renderLabel={(project) => <>+{project.name}} /> {urlPreview && (
{urlPreview.isLoading ? ( ) : urlPreview.image && !urlPreviewImageError ? ( { setUrlPreviewImageError( true ) } /> ) : ( )}

{urlPreview.title || t( 'inbox.linkPreview', 'Link preview' )}

{urlPreview.description && (

{ urlPreview.description }

)}
{urlPreview.url} {!urlPreview.isLoading && !urlPreview.error && ( {t( 'common.open', 'Open' )} )} {urlPreview.error && !urlPreview.isLoading && ( <> {t( 'inbox.linkPreviewError', 'Could not fetch link details' )} )}
)} {(() => { const suggestion = getSuggestion(); return suggestion.type && suggestion.message ? (

{suggestion.message}

or
{isAnalyzing && (
)}
) : null; })()}
{shouldShowPrimaryButton && ( )}
{footerActions}
); } ); QuickCaptureInput.displayName = 'QuickCaptureInput'; export default QuickCaptureInput;