mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 09:39:41 +02:00
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>
1123 lines
33 KiB
TypeScript
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;
|