import React, { useState, useEffect, useRef, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { InboxItem } from '../../entities/InboxItem'; import { useTranslation } from 'react-i18next'; import { TrashIcon, DocumentTextIcon, FolderIcon, ClipboardDocumentListIcon, TagIcon, GlobeAltIcon, } from '@heroicons/react/24/outline'; import { Task } from '../../entities/Task'; import { Project } from '../../entities/Project'; import { Note } from '../../entities/Note'; import ConfirmDialog from '../Shared/ConfirmDialog'; import { useStore } from '../../store/useStore'; import QuickCaptureInput, { InboxComposerFooterContext, QuickCaptureInputHandle, } from './QuickCaptureInput'; import InboxCard from './InboxCard'; import { isUrl } from '../../utils/urlService'; interface InboxItemDetailProps { item: InboxItem; onDelete: (uid: string) => void; onUpdate?: (uid: string, newContent: string) => Promise; openTaskModal: (task: Task, inboxItemUid?: string) => void; openProjectModal: (project: Project | null, inboxItemUid?: string) => void; openNoteModal: (note: Note | null, inboxItemUid?: string) => void; projects: Project[]; } const InboxItemDetail: React.FC = ({ item, onDelete, onUpdate, openTaskModal, openProjectModal, openNoteModal, projects, }) => { const { t } = useTranslation(); const { tagsStore: { tags }, } = useStore(); const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [loading, setLoading] = useState(false); const [isEditing, setIsEditing] = useState(false); const containerRef = useRef(null); const composerRef = useRef(null); useEffect(() => { if (!isEditing) { return; } const handleClickOutside = (event: MouseEvent) => { if ( containerRef.current && !containerRef.current.contains(event.target as Node) ) { if (composerRef.current) { void composerRef.current.submit(); } else { setIsEditing(false); } } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); setIsEditing(false); } }; document.addEventListener('mousedown', handleClickOutside); document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleKeyDown); }; }, [isEditing]); 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 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 cleanTextFromTagsAndProjects = (text: string): string => { const trimmedText = text.trim(); const tokens = tokenizeText(trimmedText); const cleanedTokens: string[] = []; let i = 0; while (i < tokens.length) { if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) { while ( i < tokens.length && (tokens[i].startsWith('#') || tokens[i].startsWith('+')) ) { i++; } } else { cleanedTokens.push(tokens[i]); i++; } } return cleanedTokens.join(' ').trim(); }; const fullContent = item.content || ''; const displayText = item.title && item.title.trim().length > 0 ? item.title : fullContent; const baseContent = fullContent || displayText; const cleanedPreviewText = cleanTextFromTagsAndProjects(displayText); const previewText = cleanedPreviewText.length > 0 ? cleanedPreviewText : displayText; const hashtags = useMemo(() => { const parsed = parseHashtags(fullContent); const hasBookmark = parsed.some( (tag) => tag.toLowerCase() === 'bookmark' ); if (!hasBookmark && isUrl(fullContent.trim())) { return [...parsed, 'bookmark']; } return parsed; }, [fullContent]); const isBookmarkItem = useMemo( () => hashtags.some((tag) => tag.toLowerCase() === 'bookmark'), [hashtags] ); const projectRefs = parseProjectRefs(fullContent); const hasLongContent = Boolean(item.title && item.title.trim()) && item.title !== null && item.title !== fullContent; const iconTooltip = isBookmarkItem ? t('inbox.iconTooltip.bookmark', 'Bookmark link') : t('inbox.iconTooltip.text', 'Captured text'); const slugify = (text: string) => text .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, ''); const getTagLink = (tagName: string) => { const tag = tags.find( (t) => t.name.toLowerCase() === tagName.toLowerCase() ); if (tag?.uid) { return `/tag/${tag.uid}-${slugify(tag.name)}`; } return `/tag/${encodeURIComponent(tagName)}`; }; const linkifyContent = (text: string): React.ReactNode => { const urlRegex = /(https?:\/\/[^\s]+)/gi; const matches = [...text.matchAll(urlRegex)]; if (matches.length === 0) { return text; } const nodes: React.ReactNode[] = []; let lastIndex = 0; matches.forEach((match, idx) => { const start = match.index ?? 0; const url = match[0]; if (start > lastIndex) { nodes.push( {text.slice(lastIndex, start)} ); } nodes.push( {url} ); lastIndex = start + url.length; }); if (lastIndex < text.length) { nodes.push( {text.slice(lastIndex)} ); } return nodes; }; const buildConversionPayload = ( textOverride?: string, hashtagOverride?: string[], projectRefsOverride?: string[], cleanedOverride?: string ) => { const sourceText = textOverride ?? baseContent; const sourceHashtags = hashtagOverride ?? parseHashtags(sourceText); const sourceProjectRefs = projectRefsOverride ?? parseProjectRefs(sourceText); const cleaned = cleanedOverride ?? cleanTextFromTagsAndProjects(sourceText) ?? sourceText; const tagObjects = sourceHashtags.map((hashtagName) => { const existingTag = tags.find( (tag) => tag.name.toLowerCase() === hashtagName.toLowerCase() ); return existingTag || { name: hashtagName }; }); let projectId = undefined; if (sourceProjectRefs.length > 0) { const projectName = sourceProjectRefs[0]; const matchingProject = projects.find( (project) => project.name.toLowerCase() === projectName.toLowerCase() ); if (matchingProject) { projectId = matchingProject.id; } } return { sourceText, cleanedContent: cleaned, tagObjects, projectId, projectRefsList: sourceProjectRefs, hashtagsList: sourceHashtags, }; }; const handleConvertToTask = (context?: InboxComposerFooterContext) => { try { const payload = buildConversionPayload( context?.text, context?.hashtags, context?.projectRefs, context?.cleanedText ); const newTask: Task = { name: payload.cleanedContent || displayText, status: 'not_started', priority: null, tags: payload.tagObjects, project_id: payload.projectId, completed_at: null, }; if (item.uid !== undefined) { void openTaskModal(newTask, item.uid); } else { void openTaskModal(newTask); } } catch (error) { console.error('Error converting to task:', error); } }; const handleSubmitEdit = async (text: string) => { if (!onUpdate || item.uid === undefined) { return; } const trimmedCurrent = baseContent.trim(); const trimmedNew = text.trim(); if (trimmedCurrent === trimmedNew) { setIsEditing(false); return; } await onUpdate(item.uid, text); }; const handleConvertToProject = (context?: InboxComposerFooterContext) => { try { const payload = buildConversionPayload( context?.text, context?.hashtags, context?.projectRefs, context?.cleanedText ); const newProject: Project = { name: payload.cleanedContent || displayText, description: '', status: 'planned', tags: payload.tagObjects, }; if (item.uid !== undefined) { openProjectModal(newProject, item.uid); } else { openProjectModal(newProject); } } catch (error) { console.error('Error converting to project:', error); } }; const handleConvertToNote = async ( context?: InboxComposerFooterContext ) => { const sourceText = context?.text ?? baseContent; let title = sourceText.split('\n')[0] || sourceText.substring(0, 50); let content = sourceText; let isBookmark = false; try { const { isUrl: detectUrl, extractUrlTitle } = await import( '../../utils/urlService' ); if (detectUrl(sourceText.trim())) { setLoading(true); try { const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 3000) ); const result = (await Promise.race([ extractUrlTitle(sourceText.trim()), timeoutPromise, ])) as any; if (result && result.title) { title = result.title; content = sourceText; isBookmark = true; } } catch (titleError) { console.error('Error extracting URL title:', titleError); isBookmark = true; } finally { setLoading(false); } } } catch (error) { console.error('Error checking URL or extracting title:', error); setLoading(false); } const payload = buildConversionPayload( context?.text, context?.hashtags, context?.projectRefs, context?.cleanedText ); const bookmarkTag = isBookmark ? [{ name: 'bookmark' }] : []; const tagObjects = [...payload.tagObjects, ...bookmarkTag]; const finalTitle = title === content ? payload.cleanedContent || sourceText : title; const finalContent = payload.cleanedContent || sourceText; const newNote: Note = { title: finalTitle, content: finalContent, tags: tagObjects, project_uid: payload.projectId, }; if (item.uid !== undefined) { openNoteModal(newNote, item.uid); } else { openNoteModal(newNote); } }; const renderComposerFooter = (context: InboxComposerFooterContext) => (
{loading &&
}
); const handleDelete = () => { setShowConfirmDialog(true); }; const confirmDelete = () => { if (item.uid !== undefined) { onDelete(item.uid); } setShowConfirmDialog(false); }; const handleStartEdit = () => { if (!isEditing) { setIsEditing(true); } }; const renderMetadata = () => (hashtags.length > 0 || projectRefs.length > 0) && (
{projectRefs.length > 0 && (
{projectRefs.map((projectRef, index) => { const matchingProject = projects.find( (project) => project.name.toLowerCase() === projectRef.toLowerCase() ); if (matchingProject) { return ( {projectRef} {index < projectRefs.length - 1 && ', '} ); } return ( {projectRef} {index < projectRefs.length - 1 && ', '} ); })}
)} {projectRefs.length > 0 && hashtags.length > 0 && ( )} {hashtags.length > 0 && (
{hashtags.map((hashtag, index) => ( {hashtag} {index < hashtags.length - 1 && ', '} ))}
)}
); return (
{isEditing ? ( setIsEditing(false)} renderFooterActions={renderComposerFooter} openTaskModal={openTaskModal} openProjectModal={openProjectModal} openNoteModal={openNoteModal} cardClassName="mb-0" multiline={hasLongContent} /> ) : (
{isBookmarkItem ? ( ) : ( )}
{renderMetadata()}
)} {showConfirmDialog && ( setShowConfirmDialog(false)} /> )}
); }; export default InboxItemDetail;