managarten/apps-archived/memoro/apps/mobile/components/molecules/MemoPreview.tsx
Till-JS 61d181fbc2 chore: archive inactive projects to apps-archived/
Move inactive projects out of active workspace:
- bauntown (community website)
- maerchenzauber (AI story generation)
- memoro (voice memo app)
- news (news aggregation)
- nutriphi (nutrition tracking)
- reader (reading app)
- uload (URL shortener)
- wisekeep (AI wisdom extraction)

Update CLAUDE.md documentation:
- Add presi to active projects
- Document archived projects section
- Update workspace configuration

Archived apps can be re-activated by moving back to apps/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 07:03:59 +01:00

1123 lines
33 KiB
TypeScript

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 <View style={getContainerStyle()} />;
};
// 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 (
<Text
className={titleClasses}
numberOfLines={2}
ellipsizeMode="tail"
style={{ color: textColor, lineHeight: 24 }}
>
{title}
</Text>
);
}
);
/**
* MemoPreview component
*
* Displays a preview of a memo with title, tags, and space information.
*/
const MemoPreviewComponent: React.FC<MemoPreviewProps> = ({
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<MemoModel>(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 (
<TagSelectorModal
isVisible={isTagSelectorVisible}
onClose={handleCloseTagSelector}
onTagSelect={onSelectTag}
selectedTagIds={selectedTagIds}
tagItems={tagItems}
isLoading={tagsLoading}
title={t('tags.add_tags', 'Add Tags')}
onCreateTag={onCreateTag}
/>
);
};
// 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 (
<View className={contentClasses}>
{/* Content area with padding */}
<View style={{ flex: 1, paddingTop: 16, paddingHorizontal: 16 }}>
<View className="flex-row items-center justify-between" style={{ opacity: 0.6 }}>
{currentMemo?.timestamp && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ flex: 1 }}
contentContainerStyle={{ alignItems: 'center' }}
>
<View className="flex-row items-center">
<Text
variant="tiny"
className={infoTextClasses}
style={{ lineHeight: 14, color: `${textColor}B3` }}
>
{formatDateLocale(currentMemo.timestamp, {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</Text>
<Text
variant="tiny"
className={separatorClasses}
style={{ lineHeight: 14, color: `${textColor}80` }}
>
</Text>
<Text
variant="tiny"
className={infoTextClasses}
style={{ lineHeight: 14, color: `${textColor}B3` }}
>
{formatTimeLocale(currentMemo.timestamp)}
</Text>
{(() => {
// 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 (
<>
<Text
variant="tiny"
className={separatorClasses}
style={{ lineHeight: 14, color: `${textColor}80` }}
>
</Text>
<Text
variant="tiny"
className={infoTextClasses}
style={{ lineHeight: 14, color: `${textColor}B3` }}
>
{formatDuration(audioDuration)}
</Text>
</>
);
}
return null;
})()}
{currentMemo.is_pinned && (
<>
<Text
variant="tiny"
className={separatorClasses}
style={{ lineHeight: 14, color: `${textColor}80` }}
>
</Text>
<Icon name="pin" size={12} color="#FF9500" style={{ marginLeft: 2 }} />
</>
)}
{shouldShowPhotoIcon && (
<>
<Text
variant="tiny"
className={separatorClasses}
style={{ lineHeight: 14, color: `${textColor}80` }}
>
</Text>
<Icon
name="image-outline"
size={12}
color={isDark ? '#FFFFFF' : '#000000'}
style={{ marginLeft: 2, opacity: 0.8 }}
/>
</>
)}
{currentMemo.metadata?.location && (
<>
<Text
variant="tiny"
className={separatorClasses}
style={{ lineHeight: 14, color: `${textColor}80` }}
>
</Text>
<Icon
name="location-outline"
size={12}
color={isDark ? '#FFFFFF' : '#000000'}
style={{ marginLeft: 2, opacity: 0.8 }}
/>
</>
)}
{currentMemo.metadata?.combined_memo_count &&
currentMemo.metadata.combined_memo_count > 1 && (
<>
<Text
variant="tiny"
className={separatorClasses}
style={{ lineHeight: 14, color: `${textColor}80` }}
>
</Text>
<Icon
name="git-merge"
size={12}
color={isDark ? '#FFFFFF' : '#000000'}
style={{ marginLeft: 2, opacity: 0.8 }}
/>
<Text
variant="tiny"
style={{ marginLeft: 2, color: `${textColor}B3`, fontSize: 10 }}
>
{currentMemo.metadata.combined_memo_count}
</Text>
</>
)}
</View>
</ScrollView>
)}
{/* Space Pill */}
{memoSpace && (
<View style={{ maxWidth: '45%', alignSelf: 'center', marginTop: -4, flexShrink: 0 }}>
<Pill
label={memoSpace.name}
color={memoSpace.color || '#4FC3F7'}
size="small"
maxLength={20}
variant="underlined"
/>
</View>
)}
</View>
{/* Upload Status Badge */}
{uploadProgress.status !== UploadStatus.NOT_UPLOADED &&
uploadProgress.status !== UploadStatus.SUCCESS && (
<View style={{ marginTop: 8, marginBottom: 4 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
backgroundColor: uploadProgress.isUploading
? isDark
? '#1E3A8A'
: '#DBEAFE'
: uploadProgress.isPending
? isDark
? '#78350F'
: '#FEF3C7'
: isDark
? '#7F1D1D'
: '#FEE2E2',
gap: 4,
alignSelf: 'flex-start',
}}
>
{uploadProgress.isUploading ? (
<View style={{ width: 14, height: 14 }}>
<Icon
name="cloud-upload-outline"
size={14}
color={isDark ? '#93C5FD' : '#1E40AF'}
/>
</View>
) : uploadProgress.isPending ? (
<Icon
name="cloud-upload-outline"
size={14}
color={isDark ? '#FCD34D' : '#92400E'}
/>
) : (
<Icon
name="alert-circle-outline"
size={14}
color={isDark ? '#FCA5A5' : '#991B1B'}
/>
)}
<Text
style={{
fontSize: 11,
fontWeight: '500',
color: uploadProgress.isUploading
? isDark
? '#93C5FD'
: '#1E40AF'
: uploadProgress.isPending
? isDark
? '#FCD34D'
: '#92400E'
: isDark
? '#FCA5A5'
: '#991B1B',
}}
>
{uploadProgress.isUploading
? 'Uploading...'
: uploadProgress.isPending
? uploadProgress.attemptCount > 0
? `Retry ${uploadProgress.attemptCount}...`
: 'Queued...'
: 'Upload Failed'}
</Text>
</View>
</View>
)}
<MemoTitle title={displayTitle} titleClasses={titleClasses} textColor={textColor} />
{/* Retry Buttons for Failed Operations */}
{!selectionMode && (canRetryTranscription || canRetryHeadline) && (
<View className="mt-2 flex-row items-center" style={{ gap: 8 }}>
{canRetryTranscription && (
<TouchableOpacity
onPress={handleRetryTranscription}
className="flex-row items-center rounded-md px-2 py-1"
style={{
backgroundColor: isDark ? '#333' : '#f0f0f0',
borderWidth: 1,
borderColor: '#FF6B6B',
}}
>
<Icon name="refresh" size={12} color="#FF6B6B" />
<Text className="ml-1 text-xs" style={{ color: '#FF6B6B' }}>
{t('memo.retry_transcription', 'Retry Transcription')}
</Text>
</TouchableOpacity>
)}
{canRetryHeadline && (
<TouchableOpacity
onPress={handleRetryHeadline}
className="flex-row items-center rounded-md px-2 py-1"
style={{
backgroundColor: isDark ? '#333' : '#f0f0f0',
borderWidth: 1,
borderColor: '#FFA500',
}}
>
<Icon name="refresh" size={12} color="#FFA500" />
<Text className="ml-1 text-xs" style={{ color: '#FFA500' }}>
{t('memo.retry_headline', 'Retry Headline')}
</Text>
</TouchableOpacity>
)}
</View>
)}
</View>
{/* Tags Container - positioned with consistent spacing from title */}
<View style={{ marginTop: 4, minHeight: 32, paddingBottom: 10, opacity: 0.6 }}>
<TagList
tags={selectedTags}
horizontal
showAddButton={!selectionMode}
onTagPress={selectionMode ? undefined : onSelectTag}
onAddPress={selectionMode ? undefined : handleOpenTagSelector}
/>
</View>
{!selectionMode && renderTagSelector()}
</View>
);
};
// If isLoading is true, show the skeleton loader
if (isLoading) {
return (
<MemoPreviewSkeleton isDark={isDark} themeVariant={themeVariant} showMargins={showMargins} />
);
}
// Platform-specific implementations
// In selection mode, use simple Pressable without context menu
if (selectionMode) {
return (
<Pressable style={getContainerStyle()} onPress={() => onSelect && onSelect(!selected)}>
<View style={{ flex: 1 }}>{renderContent()}</View>
</Pressable>
);
}
// Simple Pressable with ActionSheet - no ContextMenu component needed
return (
<Pressable
style={getContainerStyle()}
onPress={onPress}
onLongPress={handleLongPress}
delayLongPress={500}
>
<View style={{ flex: 1 }}>{renderContent()}</View>
</Pressable>
);
};
// 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;