import React, { useState, useEffect, memo } from 'react'; import { View, Pressable, TouchableOpacity, Alert, ScrollView } from 'react-native'; import * as Clipboard from 'expo-clipboard'; import * as Haptics from 'expo-haptics'; import { useTheme } from '~/features/theme/ThemeProvider'; import { useAuth } from '~/features/auth/contexts/AuthContext'; import { getAuthenticatedClient } from '~/features/auth/lib/supabaseClient'; import Text from '~/components/atoms/Text'; import Icon from '~/components/atoms/Icon'; import TagList from '~/components/molecules/TagList'; import Pill from '~/components/atoms/Pill'; import TagSelectorModal from '~/features/tags/TagSelectorModal'; import colors from '~/tailwind.config.js'; import { useSpaceContext } from '~/features/spaces'; import { useToast } from '~/features/toast/contexts/ToastContext'; import { useTranslation } from 'react-i18next'; // Import our custom hooks and utilities import { useMemoProcessing } from '~/features/memos/hooks/useMemoProcessing'; import { useMemoTagsAndSpace } from '~/features/memos/hooks/useMemoTagsAndSpace'; import { formatDuration, useFormatTime, useFormatDate, } from '~/features/memos/utils/dateFormatters'; import { useRecordingStore } from '~/features/audioRecordingV2/store/recordingStore'; import { useMemoStore } from '~/features/memos/store/memoStore'; import { creditService } from '~/features/credits/creditService'; import tagEvents from '~/features/tags/tagEvents'; import { getTranscriptText, isCombinedMemo, getCombinedMemoDuration, } from '~/features/memos/utils/transcriptUtils'; import { memoRealtimeService } from '~/features/memos/services/memoRealtimeService'; import { useUploadProgress } from '~/features/storage/hooks/useUploadProgress'; import { UploadStatus } from '~/features/storage/uploadStatus.types'; import { useActionSheet } from '@expo/react-native-action-sheet'; // Interface for the memo model interface MemoModel { id: string; title?: string; timestamp?: Date; is_pinned?: boolean; tags?: Array<{ id: string; text: string; color: string }>; space?: { id: string; name: string; color?: string; }; source?: { type?: string; content?: string; audio_path?: string; transcript?: string; }; metadata?: { processing?: { transcription?: { status?: string; timestamp?: string; retryAttempts?: number; }; headline?: { status?: string; timestamp?: string; }; headline_and_intro?: { status?: string; updated_at?: string; retryAttempts?: number; }; }; transcriptionStatus?: string; blueprintId?: string | null; audioFileId?: string; // ID of the audio file for upload status tracking stats?: { viewCount?: number; wordCount?: number; lastViewed?: string; audioDuration?: number; }; }; } // Props for the MemoPreview component interface MemoPreviewProps { memo: MemoModel; onPress?: () => void; onShare?: () => void; onCopy?: () => void; onPinToTop?: () => void; onDelete?: () => void; isLoading?: boolean; /** * If true, shows selection checkbox. */ selectionMode?: boolean; /** * Whether this memo is currently selected. */ selected?: boolean; /** * Called when the selection checkbox is pressed. */ onSelect?: (selected: boolean) => void; /** * If true, this memo can react to global recording status (e.g., for the latest memo on home page) */ reactToGlobalRecordingStatus?: boolean; /** * If true, this memo has photos/images attached */ hasPhotos?: boolean; /** * If true, adds horizontal margins to the preview (default: true) */ showMargins?: boolean; } /** * MemoPreviewSkeleton-Komponente * * Shows a simple skeleton loader for the MemoPreview component. * Only shows the background without placeholders for content. */ const MemoPreviewSkeleton: React.FC<{ isDark: boolean; themeVariant: string; showMargins?: boolean; }> = ({ isDark, themeVariant, showMargins = true }) => { // Container style with background color from the Tailwind configuration const getContainerStyle = () => { // Direct access to colors from the Tailwind configuration const backgroundColor = isDark ? (colors as any).theme?.extend?.colors?.dark?.[themeVariant]?.contentBackground || '#1E1E1E' : (colors as any).theme?.extend?.colors?.[themeVariant]?.contentBackground || '#FFFFFF'; return { backgroundColor, borderRadius: 16, minHeight: 140, padding: 16, ...(showMargins && { marginLeft: 8, marginRight: 8, }), flexShrink: 0, }; }; return ; }; // Import the direct title component /** * MemoTitle component * * A memoized component that only re-renders when the title changes */ const MemoTitle: React.FC<{ title: string; titleClasses: string; textColor: string }> = memo( ({ title, titleClasses, textColor }) => { return ( {title} ); } ); /** * MemoPreview component * * Displays a preview of a memo with title, tags, and space information. */ const MemoPreviewComponent: React.FC = ({ memo, onPress, onShare, onCopy, onPinToTop, onDelete, isLoading = false, selectionMode = false, selected = false, onSelect, reactToGlobalRecordingStatus = false, hasPhotos = false, showMargins = true, }) => { const { isDark, tw, themeVariant, colors: themeColors } = useTheme(); const { user } = useAuth(); const { spaces } = useSpaceContext(); const { setLatestMemo, loadLatestMemo } = useMemoStore(); const { showSuccess } = useToast(); const { t } = useTranslation(); const { showActionSheetWithOptions } = useActionSheet(); // Guard against invalid memo if (!memo || !memo.id) { return null; } const shouldShowPhotoIcon = hasPhotos; // Use local state to track the current memo data (so we can update it from broadcasts) const [currentMemo, setCurrentMemo] = useState(memo); // Update local memo when prop changes useEffect(() => { setCurrentMemo(memo); }, [memo]); // Get the current recording status from the recording store only if we should react to it const recordingStatus = useRecordingStore((state) => reactToGlobalRecordingStatus ? state.status : undefined ); // Use our custom hooks with the current memo state const { processingStatus, displayTitle } = useMemoProcessing({ memo: currentMemo, recordingStatus: recordingStatus, }); // Track upload status for this memo's audio file const audioFileId = currentMemo.metadata?.audioFileId; const uploadProgress = useUploadProgress(audioFileId); // State for UI const [isTagSelectorVisible, setIsTagSelectorVisible] = useState(false); // Use the memo tags and space hook (with currentMemo for real-time updates) const { selectedTagIds, tagItems, memoSpace, isLoading: tagsLoading, onSelectTag, onCreateTag, } = useMemoTagsAndSpace({ memo: currentMemo, spaces, userId: user?.id || '', }); // Get locale-aware formatters const formatTimeLocale = useFormatTime(); const formatDateLocale = useFormatDate(); // Subscribe to broadcast channel for this memo to catch service_role updates useEffect(() => { if (!memo?.id) return; const unsubscribe = memoRealtimeService.subscribeToBroadcastChannel( `memo-updates-${memo.id}`, async (payload) => { console.log('MemoPreview: Received broadcast for memo', memo.id, payload); try { // Fetch fresh memo data from Supabase const supabase = await getAuthenticatedClient(); const { data: updatedMemo, error } = await supabase .from('memos') .select('*') .eq('id', memo.id) .single(); if (error) { console.error('MemoPreview: Error fetching updated memo after broadcast:', error); return; } if (updatedMemo) { // Preserve audioDuration from currentMemo if it exists and updatedMemo doesn't have it // This is important for placeholders that have audioDuration set before the backend calculates it const preservedStats = { ...updatedMemo.metadata?.stats, // Preserve audioDuration from current memo if the updated memo doesn't have it ...(!updatedMemo.metadata?.stats?.audioDuration && currentMemo.metadata?.stats?.audioDuration && { audioDuration: currentMemo.metadata.stats.audioDuration, }), }; const memoWithPreservedData = { ...updatedMemo, // Map database created_at to timestamp for UI compatibility timestamp: new Date(updatedMemo.created_at), metadata: { ...updatedMemo.metadata, stats: preservedStats, }, }; // Update local state with fresh memo data - this triggers useMemoProcessing to recalculate setCurrentMemo(memoWithPreservedData); // If this is the latest memo on recording page, also update it in the store if (reactToGlobalRecordingStatus) { setLatestMemo(memoWithPreservedData); } console.log('MemoPreview: Updated memo from broadcast', { id: updatedMemo.id, title: updatedMemo.title, headlineStatus: updatedMemo.metadata?.processing?.headline_and_intro?.status, oldTitle: currentMemo.title, preservedAudioDuration: preservedStats.audioDuration, timestamp: memoWithPreservedData.timestamp, }); } } catch (error) { console.error('MemoPreview: Error processing broadcast update:', error); } } ); return () => unsubscribe(); }, [memo?.id, reactToGlobalRecordingStatus, setLatestMemo]); // Get text colors from theme configuration const textColor = isDark ? (colors as any).theme?.extend?.colors?.dark?.[themeVariant]?.text || '#FFFFFF' : (colors as any).theme?.extend?.colors?.[themeVariant]?.text || '#000000'; // Icon-Farbe basierend auf Theme (weiß im Dark Mode, dunkel im Light Mode) const iconColor = '#AEAEB2'; // Light gray icon color for both light and dark mode // CSS classes for the component const contentClasses = tw('flex-col h-full'); const titleClasses = tw('text-[16px] font-bold mb-1 mt-2'); const infoRowClasses = tw('flex-row items-center mb-1'); const infoTextClasses = tw('text-[10px]'); const separatorClasses = tw('mx-1'); const tagContainerClasses = tw('h-8 w-full'); // Tag selector handlers const handleOpenTagSelector = () => { setIsTagSelectorVisible(true); }; const handleCloseTagSelector = () => { setIsTagSelectorVisible(false); }; // Function to copy transcript to clipboard const handleCopyTranscript = async () => { try { const transcript = getTranscriptText(memo); if (transcript) { await Clipboard.setStringAsync(transcript); showSuccess(t('memo.transcript_copied_success', 'Transcript successfully copied!')); } else { showSuccess(t('memo.no_transcript_available', 'No transcript available')); } } catch (error) { console.debug('Fehler beim Kopieren des Transkripts:', error); showSuccess(t('memo.transcript_copy_error', 'Transcript could not be copied')); } }; // Function to copy all memo content to clipboard const handleCopyAll = async () => { try { // Get authenticated client to fetch complete memo data const supabase = await getAuthenticatedClient(); // Fetch complete memo data with intro const { data: fullMemo, error: memoError } = await supabase .from('memos') .select('*') .eq('id', memo.id) .single(); if (memoError) { console.debug('Error fetching full memo for copy:', memoError); // Fallback to basic content if full memo fetch fails } // Fetch memories for this memo const { data: memories, error: memoriesError } = await supabase .from('memories') .select('*') .eq('memo_id', memo.id) .order('sort_order', { ascending: true }) .order('created_at', { ascending: false }); if (memoriesError) { console.debug('Error fetching memories for copy:', memoriesError); // Continue without memories } // Build the content string let content = `${fullMemo?.title || memo.title || t('memo.untitled', 'Untitled')}\n\n`; // Add intro if available if (fullMemo?.intro) { content += `${fullMemo.intro}\n\n`; } // Add memories if available if (memories && memories.length > 0) { memories.forEach((memory: any) => { content += `${memory.title || t('memo.memory', 'Memory')}\n${memory.content}\n\n`; }); } // Add transcript if available const transcript = getTranscriptText(fullMemo || memo); if (transcript) { content += `${t('memo.transcript_title', 'Transcript')}:\n${transcript}\n\n`; } await Clipboard.setStringAsync(content); showSuccess(t('memo.content_copied_success', 'Complete memo content copied to clipboard')); } catch (error) { console.debug('Fehler beim Kopieren des Memo-Inhalts:', error); showSuccess(t('memo.content_copy_error', 'Memo content could not be copied')); } }; // Function to pin/unpin memo const handlePinToggle = async () => { try { const supabase = await getAuthenticatedClient(); const newPinnedState = !memo.is_pinned; const { error } = await supabase .from('memos') .update({ is_pinned: newPinnedState }) .eq('id', memo.id); if (error) { console.debug('Error toggling pin state:', error); showSuccess(t('memo.pin_error', 'Memo could not be pinned/unpinned')); return; } // Update local state setCurrentMemo({ ...currentMemo, is_pinned: newPinnedState, }); // Update the memo store if this is the latest memo if (reactToGlobalRecordingStatus) { setLatestMemo({ ...currentMemo, is_pinned: newPinnedState, }); } showSuccess( newPinnedState ? t('memo.pinned_success', 'Memo successfully pinned!') : t('memo.unpinned_success', 'Memo successfully unpinned!') ); // Event emittieren für andere Komponenten tagEvents.emitMemoPinned(memo.id, newPinnedState); // Call the onPinToTop callback if provided onPinToTop && onPinToTop(); } catch (error) { console.debug('Error in handlePinToggle:', error); showSuccess(t('common.unexpected_error', 'An unexpected error occurred.')); } }; // Function to retry transcription const handleRetryTranscription = async () => { try { await creditService.retryTranscription(memo.id); Alert.alert( t('common.success', 'Success'), t( 'memo.transcription_retry_initiated', 'Transcription retry initiated. This may take a few moments.' ) ); // Refresh memo data to get updated status and title await loadLatestMemo(); } catch (error) { console.error('Error retrying transcription:', error); Alert.alert( t('common.error', 'Error'), error instanceof Error ? error.message : t('memo.transcription_retry_failed', 'Failed to retry transcription') ); } }; // Function to retry headline generation const handleRetryHeadline = async () => { try { await creditService.retryHeadline(memo.id); Alert.alert( t('common.success', 'Success'), t('memo.headline_retry_initiated', 'Headline generation retry initiated.') ); // Refresh memo data to get updated status and title await loadLatestMemo(); } catch (error) { console.error('Error retrying headline:', error); Alert.alert( t('common.error', 'Error'), error instanceof Error ? error.message : t('memo.headline_retry_failed', 'Failed to retry headline generation') ); } }; // Function to delete memo const handleDelete = async () => { // If parent provides onDelete callback, delegate to it (parent will handle confirmation) if (onDelete) { onDelete(); return; } // Otherwise, handle deletion internally with confirmation Alert.alert( t('memo.delete_memo', 'Delete Memo'), t( 'memo.delete_confirmation', 'Do you really want to delete "{{title}}"? This action cannot be undone.', { title: memo.title || t('memo.this_memo', 'this memo') } ), [ { text: t('common.cancel', 'Cancel'), style: 'cancel', }, { text: t('common.delete', 'Delete'), style: 'destructive', onPress: async () => { // Delete confirmed try { // Get authenticated supabase client const supabase = await getAuthenticatedClient(); // Get the memo to check if it has audio const { data: memoData } = await supabase .from('memos') .select('source') .eq('id', memo.id) .single(); // First, delete related memories const { error: memoriesError } = await supabase .from('memories') .delete() .eq('memo_id', memo.id); if (memoriesError) { console.debug('Error deleting related memories:', memoriesError.message); return; } // If there's audio, delete it from storage if (memoData?.source?.audio_path) { const audioPath = memoData.source.audio_path; if (audioPath) { const { error: storageError } = await supabase.storage .from('user-uploads') .remove([audioPath]); if (storageError) { console.debug('Error deleting audio file:', storageError.message); // Continue with memo deletion even if audio deletion fails } } } // Finally, delete the memo itself const { error: memoError } = await supabase.from('memos').delete().eq('id', memo.id); if (memoError) { console.debug('Error deleting memo:', memoError.message); showSuccess(t('memo.delete_error', 'Memo could not be deleted')); return; } showSuccess(t('memo.deleted_success', 'Memo successfully deleted!')); // Wenn diese Komponente auf der Recording-Seite verwendet wird, lade das nächste Memo if (reactToGlobalRecordingStatus) { console.debug('🗑️ MemoPreview: Deleted memo on recording page, loading next memo'); // Clear the current memo and load the next one setLatestMemo(null); setTimeout(() => { loadLatestMemo(); }, 100); } } catch (error) { console.debug('Error in handleDelete:', error); showSuccess(t('memo.delete_unexpected_error', 'An error occurred while deleting')); } }, }, ] ); }; // Haptic feedback for context menu const triggerContextMenuHaptic = async () => { try { await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); } catch (error) { console.debug('Haptic feedback error:', error); } }; // Show action sheet on long press const handleLongPress = () => { triggerContextMenuHaptic(); const options = [ t('memo_menu.share', 'Share'), t('memo_menu.copy', 'Copy'), t('memo_menu.copy_transcript', 'Copy Transcript'), memo.is_pinned ? t('memo_menu.unpin', 'Unpin') : t('memo_menu.pin', 'Pin'), t('memo_menu.delete', 'Delete'), t('common.cancel', 'Cancel'), ]; const destructiveButtonIndex = 4; // Delete const cancelButtonIndex = 5; showActionSheetWithOptions( { options, cancelButtonIndex, destructiveButtonIndex, userInterfaceStyle: isDark ? 'dark' : 'light', }, (buttonIndex) => { if (buttonIndex === undefined || buttonIndex === cancelButtonIndex) { return; } switch (buttonIndex) { case 0: // Share if (onShare) { onShare(); } break; case 1: // Copy All if (onCopy) { onCopy(); } else { handleCopyAll(); } break; case 2: // Copy Transcript handleCopyTranscript(); break; case 3: // Pin/Unpin if (onPinToTop) { onPinToTop(); } else { handlePinToggle(); } break; case 4: // Delete handleDelete(); break; } } ); }; // Container style const getContainerStyle = () => { // Direct access to colors from the Tailwind configuration const backgroundColor = isDark ? (colors as any).theme?.extend?.colors?.dark?.[themeVariant]?.contentBackground : (colors as any).theme?.extend?.colors?.[themeVariant]?.contentBackground; // Get border color from theme const borderColor = isDark ? (colors as any).theme?.extend?.colors?.dark?.[themeVariant]?.border || '#424242' : (colors as any).theme?.extend?.colors?.[themeVariant]?.border || '#e6e6e6'; // If viewCount is 0, use primary color for border const hasZeroViews = currentMemo.metadata?.stats?.viewCount === 0; const primaryColor = isDark ? (colors as any).theme?.extend?.colors?.dark?.[themeVariant]?.primary : (colors as any).theme?.extend?.colors?.[themeVariant]?.primary; return { backgroundColor, borderRadius: 16, borderWidth: hasZeroViews ? 2 : 1, borderColor: hasZeroViews ? primaryColor || '#4FC3F7' : borderColor, minHeight: 140, width: showMargins ? undefined : '100%', // Full width when no margins flexShrink: 0, ...(showMargins && { marginLeft: 8, marginRight: 8, }), }; }; // Render tag selector const renderTagSelector = () => { return ( ); }; // Check if transcription or headline failed and can be retried const canRetryTranscription = (currentMemo.metadata?.processing?.transcription?.status === 'error' || currentMemo.metadata?.transcription_status === 'failed') && (currentMemo.metadata?.processing?.transcription?.retryAttempts || 0) < 3; const canRetryHeadline = currentMemo.metadata?.processing?.headline_and_intro?.status === 'error' && (currentMemo.metadata?.processing?.headline_and_intro?.retryAttempts || 0) < 3; // Content to render inside the container const renderContent = () => { // Filter selected tags const selectedTags = tagItems.filter((tagItem) => selectedTagIds.includes(tagItem.id)); return ( {/* Content area with padding */} {currentMemo?.timestamp && ( {formatDateLocale(currentMemo.timestamp, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', })} {formatTimeLocale(currentMemo.timestamp)} {(() => { // Get duration - either from stats or calculate for combined memos const audioDuration = isCombinedMemo(currentMemo) ? getCombinedMemoDuration(currentMemo) : currentMemo.metadata?.stats?.audioDuration; if (audioDuration !== undefined && audioDuration > 0) { return ( <> {formatDuration(audioDuration)} ); } return null; })()} {currentMemo.is_pinned && ( <> )} {shouldShowPhotoIcon && ( <> )} {currentMemo.metadata?.location && ( <> )} {currentMemo.metadata?.combined_memo_count && currentMemo.metadata.combined_memo_count > 1 && ( <> {currentMemo.metadata.combined_memo_count} )} )} {/* Space Pill */} {memoSpace && ( )} {/* Upload Status Badge */} {uploadProgress.status !== UploadStatus.NOT_UPLOADED && uploadProgress.status !== UploadStatus.SUCCESS && ( {uploadProgress.isUploading ? ( ) : uploadProgress.isPending ? ( ) : ( )} {uploadProgress.isUploading ? 'Uploading...' : uploadProgress.isPending ? uploadProgress.attemptCount > 0 ? `Retry ${uploadProgress.attemptCount}...` : 'Queued...' : 'Upload Failed'} )} {/* Retry Buttons for Failed Operations */} {!selectionMode && (canRetryTranscription || canRetryHeadline) && ( {canRetryTranscription && ( {t('memo.retry_transcription', 'Retry Transcription')} )} {canRetryHeadline && ( {t('memo.retry_headline', 'Retry Headline')} )} )} {/* Tags Container - positioned with consistent spacing from title */} {!selectionMode && renderTagSelector()} ); }; // If isLoading is true, show the skeleton loader if (isLoading) { return ( ); } // Platform-specific implementations // In selection mode, use simple Pressable without context menu if (selectionMode) { return ( onSelect && onSelect(!selected)}> {renderContent()} ); } // Simple Pressable with ActionSheet - no ContextMenu component needed return ( {renderContent()} ); }; // Memorize the component to prevent unnecessary re-renders // Only re-render when essential properties change, not on processing status changes const MemoPreview = memo(MemoPreviewComponent, (prevProps, nextProps) => { // Always re-render if isLoading changes if (prevProps.isLoading !== nextProps.isLoading) return false; // Always re-render if reactToGlobalRecordingStatus changes if (prevProps.reactToGlobalRecordingStatus !== nextProps.reactToGlobalRecordingStatus) return false; // Always re-render if the memo ID changes if (prevProps.memo?.id !== nextProps.memo?.id) return false; // Always re-render if the memo title changes if (prevProps.memo?.title !== nextProps.memo?.title) return false; // Always re-render if tags or space changes if (JSON.stringify(prevProps.memo?.tags) !== JSON.stringify(nextProps.memo?.tags)) return false; if (JSON.stringify(prevProps.memo?.space) !== JSON.stringify(nextProps.memo?.space)) return false; // Always re-render if pin state changes if (prevProps.memo?.is_pinned !== nextProps.memo?.is_pinned) return false; // Always re-render if showMargins changes if (prevProps.showMargins !== nextProps.showMargins) return false; // Always re-render if selection state changes if (prevProps.selected !== nextProps.selected) return false; if (prevProps.selectionMode !== nextProps.selectionMode) return false; // Always re-render if photo state changes if (prevProps.hasPhotos !== nextProps.hasPhotos) return false; // Always re-render if timestamp changes if (prevProps.memo?.timestamp?.getTime() !== nextProps.memo?.timestamp?.getTime()) return false; // Always re-render if stats change (especially viewCount for yellow border) if (prevProps.memo?.metadata?.stats?.viewCount !== nextProps.memo?.metadata?.stats?.viewCount) return false; if (prevProps.memo?.metadata?.stats?.shareCount !== nextProps.memo?.metadata?.stats?.shareCount) return false; if (prevProps.memo?.metadata?.stats?.editCount !== nextProps.memo?.metadata?.stats?.editCount) return false; if ( prevProps.memo?.metadata?.stats?.audioDuration !== nextProps.memo?.metadata?.stats?.audioDuration ) return false; // Don't re-render just because processing status changes - MemoTitle will handle this // The displayTitle is calculated in useMemoProcessing and passed to MemoTitle // MemoTitle is memoized separately and will only re-render when the title changes // Default to using React's memoization logic for other props return true; }); export default MemoPreview;