managarten/memoro/apps/mobile/components/molecules/MemoList.tsx
Till-JS e7f5f942f3 chore: initial commit - consolidate 4 projects into monorepo
Projects included:
- maerchenzauber (NestJS backend + Expo mobile + SvelteKit web + Astro landing)
- manacore (Expo mobile + SvelteKit web + Astro landing)
- manadeck (NestJS backend + Expo mobile + SvelteKit web)
- memoro (Expo mobile + SvelteKit web + Astro landing)

This commit preserves the current state before monorepo restructuring.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 23:38:24 +01:00

1066 lines
36 KiB
TypeScript

import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { View, StyleSheet, Pressable, ActivityIndicator, Platform } from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { useActionSheet } from '@expo/react-native-action-sheet';
import { router } from 'expo-router';
import * as Clipboard from 'expo-clipboard';
import { getAuthenticatedClient } from '~/features/auth/lib/supabaseClient';
import Text from '~/components/atoms/Text';
import Icon from '~/components/atoms/Icon';
import MemoPreview from '~/components/molecules/MemoPreview';
import MemoPreviewSkeleton from '~/components/molecules/MemoPreviewSkeleton';
import { useToast } from '~/features/toast/contexts/ToastContext';
import { useMemoStore } from '~/features/memos/store/memoStore';
import { useAllMemoUpdates } from '~/features/memos/contexts/MemoRealtimeContext';
import { useMemoPhotos } from '~/features/storage/hooks/useMemoPhotos';
import { memoRealtimeService } from '~/features/memos/services/memoRealtimeService';
// Pagination constants
const PAGE_SIZE = 20;
export interface MemoModel {
id: string;
title: string;
created_at: string;
source?: {
type?: string;
content?: string;
duration_seconds?: number;
audio_path?: string;
transcript?: string;
};
is_archived: boolean;
is_pinned?: boolean;
metadata?: {
processing?: {
transcription?: {
status?: string;
timestamp?: string;
};
headline?: {
status?: string;
timestamp?: string;
};
headline_and_intro?: {
status?: string;
updated_at?: string;
};
};
transcriptionStatus?: string;
blueprintId?: string | null;
};
}
interface MemoListProps {
showArchived?: boolean | null;
spaceId?: string;
memos?: MemoModel[];
tagIds?: string[];
selectionMode?: boolean;
selectedMemoIds?: string[];
onMemoSelection?: (memoId: string, selected: boolean) => void;
refreshTrigger?: number; // Trigger to force refresh
onShare?: (memo: MemoModel) => void; // Callback for sharing a memo
onMemosLoaded?: (memos: MemoModel[]) => void; // Callback when memos are loaded
maintainScrollPosition?: boolean; // Whether to maintain scroll position on refresh
}
const MemoList = ({
showArchived = null,
spaceId,
memos: propMemos,
tagIds,
selectionMode = false,
selectedMemoIds = [],
onMemoSelection,
refreshTrigger = 0,
onShare,
onMemosLoaded,
maintainScrollPosition = false,
}: MemoListProps) => {
const [memos, setMemos] = useState<MemoModel[]>(propMemos || []);
const [loading, setLoading] = useState(!propMemos);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(0);
const [retryCount, setRetryCount] = useState(0);
const [networkError, setNetworkError] = useState(false);
const [hasNewContent, setHasNewContent] = useState(false);
const loadedMemoIds = useRef(new Set<string>());
const updateTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
const hasLoadedFromDB = useRef(false); // Track if we've loaded from database
const pendingUpdates = useRef<{
inserts: MemoModel[];
updates: Map<string, MemoModel>;
deletes: Set<string>;
}>({ inserts: [], updates: new Map(), deletes: new Set() });
const { showActionSheetWithOptions } = useActionSheet();
const { showSuccess, showError } = useToast();
const flashListRef = useRef<FlashList<MemoModel>>(null);
const scrollPositionRef = useRef({ index: 0, offset: 0 });
const shouldMaintainScroll = useRef(false);
// Get the latest memo ID from the store at the component level
const latestMemoId = useMemoStore((state) => state.latestMemo?.id);
// Photo status hook
const { hasPhotos: getMemoHasPhotos, checkPhotoStatus } = useMemoPhotos();
// Static theme colors to avoid useTheme hook issues
const themeColors = useMemo(() => {
return {
primary: '#f8d62b',
primaryWithOpacity: 'rgba(248, 214, 43, 0.5)',
text: '#2c2c2c',
};
}, []);
// Fetch memos function with pagination support
const fetchMemos = useCallback(
async (pageNumber: number = 0, append: boolean = false) => {
try {
// Debug log removed
if (pageNumber === 0) {
setLoading(true);
loadedMemoIds.current.clear();
} else {
setLoadingMore(true);
}
// Verwende den authentifizierten Client
const supabase = await getAuthenticatedClient();
// Removed debug log('MemoList: Got authenticated client');
// Base query for memos
let query = supabase
.from('memos')
.select('id, title, created_at, source, is_archived, metadata, is_pinned')
.order('is_pinned', { ascending: false })
.order('created_at', { ascending: false });
// Filter by archive status if specifically requested
if (showArchived !== null) {
query = query.eq('is_archived', showArchived);
// Removed debug log('MemoList: Filtering by is_archived =', showArchived);
}
// If tag filtering is active, use optimized RPC function
if (tagIds && tagIds.length > 0) {
// Use the optimized RPC function to get memos with ALL tags in a single query
const { data: memoIdsWithAllTags, error: tagError } = await supabase
.rpc('get_memos_with_all_tags', { tag_ids: tagIds });
if (tagError) {
console.error('Error fetching memos for tags:', tagError);
setMemos([]);
setLoading(false);
return;
}
if (memoIdsWithAllTags && memoIdsWithAllTags.length > 0) {
// Add filter for these memo IDs
query = query.in('id', memoIdsWithAllTags.map(m => m.memo_id));
} else {
// No memos with these tags, return empty array
setMemos([]);
setLoading(false);
return;
}
}
// If spaceId is provided, skip loading - spaces feature not yet implemented
if (spaceId) {
// Spaces feature is not yet implemented
console.log('MemoList: Spaces feature not yet implemented, skipping space-specific memos');
setMemos([]);
setLoading(false);
return;
}
// Add pagination
const from = pageNumber * PAGE_SIZE;
const to = from + PAGE_SIZE - 1;
query = query.range(from, to);
// Removed debug log('MemoList: Executing query...');
const { data, error } = await query;
if (error) {
// Debug log removed
return;
}
if (data) {
if (append) {
// For append operations, filter out duplicates
const newMemos = data.filter((memo: MemoModel) => !loadedMemoIds.current.has(memo.id));
newMemos.forEach((memo: MemoModel) => loadedMemoIds.current.add(memo.id));
setMemos((prev) => [...prev, ...newMemos]);
// Check photo status for newly loaded memos
if (newMemos.length > 0) {
const memoIds = newMemos.map((memo: MemoModel) => memo.id);
checkPhotoStatus(memoIds);
}
} else {
// For replace operations (pageNumber === 0), use all data and reset tracking
loadedMemoIds.current.clear();
data.forEach((memo: MemoModel) => loadedMemoIds.current.add(memo.id));
setMemos(data);
// Call onMemosLoaded only for initial database load
if (!hasLoadedFromDB.current && onMemosLoaded) {
onMemosLoaded(data);
hasLoadedFromDB.current = true;
}
// Check photo status for all loaded memos
if (data.length > 0) {
const memoIds = data.map((memo: MemoModel) => memo.id);
checkPhotoStatus(memoIds);
}
// Only scroll to top when explicitly refreshing (not when maintaining position)
if (flashListRef.current && data.length > 0 && !maintainScrollPosition && !shouldMaintainScroll.current) {
// Use setTimeout to ensure the list has been rendered with new data
setTimeout(() => {
flashListRef.current?.scrollToIndex({ index: 0, animated: false });
}, 100);
} else if (shouldMaintainScroll.current && scrollPositionRef.current.index > 0) {
// Restore scroll position after data reload
setTimeout(() => {
flashListRef.current?.scrollToIndex({
index: Math.min(scrollPositionRef.current.index, data.length - 1),
animated: false
});
shouldMaintainScroll.current = false;
}, 100);
}
}
// Update hasMore based on whether we got a full page
setHasMore(data.length === PAGE_SIZE);
// Reset retry count and network error on success
setRetryCount(0);
setNetworkError(false);
}
} catch (error: any) {
console.error('Error loading memos:', error);
// Check if it's a network error
const isNetworkError =
error?.message?.toLowerCase().includes('network') ||
error?.message?.toLowerCase().includes('fetch') ||
error?.code === 'NETWORK_ERROR' ||
!navigator.onLine;
if (isNetworkError) {
setNetworkError(true);
if (pageNumber > 0) {
// Only show error for pagination, not initial load
showError('Unable to load more memos. Check your connection.');
}
}
// Keep hasMore true to allow retry
if (pageNumber > 0) {
setHasMore(true);
}
// Implement retry with exponential backoff for pagination
if (pageNumber > 0 && retryCount < 3 && isNetworkError) {
const retryDelay = Math.min(1000 * Math.pow(2, retryCount), 5000);
setTimeout(() => {
setRetryCount((prev) => prev + 1);
fetchMemos(pageNumber, append);
}, retryDelay);
}
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[showArchived, spaceId, tagIds, checkPhotoStatus]
);
// Handle loading more memos on scroll
const handleLoadMore = useCallback(() => {
if (!loadingMore && hasMore && !loading) {
// Don't update page until fetch succeeds
const nextPage = page + 1;
fetchMemos(nextPage, true)
.then(() => {
// Only update page on successful fetch
setPage(nextPage);
})
.catch(() => {
// Page remains unchanged on error, allowing retry
console.log('Failed to load page', nextPage, '- page counter remains at', page);
});
}
}, [loadingMore, hasMore, loading, page, fetchMemos]);
// Process batched updates
const processBatchedUpdates = useCallback(() => {
const updates = pendingUpdates.current;
if (updates.deletes.size === 0 && updates.updates.size === 0 && updates.inserts.length === 0) {
return;
}
setMemos((prevMemos) => {
let updatedMemos = [...prevMemos];
// Apply deletes
if (updates.deletes.size > 0) {
updatedMemos = updatedMemos.filter((memo) => !updates.deletes.has(memo.id));
updates.deletes.forEach((id) => loadedMemoIds.current.delete(id));
}
// Apply updates in-place
if (updates.updates.size > 0) {
updatedMemos = updatedMemos.map((memo) => {
const update = updates.updates.get(memo.id);
if (update) {
// Log title changes for debugging
if (memo.title !== update.title) {
console.log(
'MemoList: Title changed for memo',
memo.id,
'from',
memo.title,
'to',
update.title
);
}
// Force new object creation with all properties to ensure React detects the change
return {
...memo,
...update,
// Ensure timestamp is preserved or updated
timestamp: update.timestamp || memo.timestamp,
// Force a unique key to trigger re-render
_updateKey: Date.now()
};
}
return memo;
});
}
// Apply inserts
if (updates.inserts.length > 0) {
if (page === 0) {
// We're at the top, prepend new memos
const newMemos = updates.inserts.filter((memo) => !loadedMemoIds.current.has(memo.id));
newMemos.forEach((memo) => loadedMemoIds.current.add(memo.id));
// Combine and sort to maintain pinned/chronological order
updatedMemos = [...newMemos, ...updatedMemos].sort((a, b) => {
// Pinned items first
if (a.is_pinned && !b.is_pinned) return -1;
if (!a.is_pinned && b.is_pinned) return 1;
// Then by created_at
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
// Check photo status for newly inserted memos
if (newMemos.length > 0) {
const memoIds = newMemos.map((memo) => memo.id);
checkPhotoStatus(memoIds);
// Auto-scroll to top with smooth animation when new memos arrive
setTimeout(() => {
if (flashListRef.current) {
flashListRef.current.scrollToIndex({ index: 0, animated: true });
}
}, 100);
}
} else {
// User has scrolled, show notification
setHasNewContent(true);
}
}
return updatedMemos;
});
// Clear pending updates
pendingUpdates.current = { inserts: [], updates: new Map(), deletes: new Set() };
}, [page, checkPhotoStatus]);
// Subscribe to broadcast channels for visible memos to catch service_role updates
useEffect(() => {
const unsubscribeFunctions: (() => void)[] = [];
// Subscribe to broadcasts for all visible memos
memos.forEach((memo) => {
const unsubscribe = memoRealtimeService.subscribeToBroadcastChannel(
`memo-updates-${memo.id}`,
async (payload) => {
console.log('MemoList: 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('MemoList: Error fetching updated memo after broadcast:', error);
return;
}
if (updatedMemo) {
// Update the memo in the list immediately
setMemos(prevMemos =>
prevMemos.map(m => m.id === memo.id ? updatedMemo : m)
);
console.log('MemoList: Updated memo from broadcast', {
id: updatedMemo.id,
title: updatedMemo.title,
headlineStatus: updatedMemo.metadata?.processing?.headline_and_intro?.status
});
}
} catch (error) {
console.error('MemoList: Error processing broadcast update:', error);
}
}
);
unsubscribeFunctions.push(unsubscribe);
});
// Cleanup on unmount or memo list change
return () => {
unsubscribeFunctions.forEach(unsub => unsub());
};
}, [memos.map(m => m.id).join(',')]); // Re-subscribe when memo IDs change
// Handle real-time memo updates with batching (postgres_changes)
useAllMemoUpdates(
(payload) => {
console.log(
'MemoList: Received postgres_changes update:',
payload.event,
payload.new?.id || payload.old?.id
);
// Log more details for debugging
if (payload.event === 'UPDATE' && payload.new) {
console.log('MemoList: UPDATE details:', {
id: payload.new.id,
title: payload.new.title,
transcriptionStatus: payload.new.metadata?.processing?.transcription?.status,
headlineStatus: payload.new.metadata?.processing?.headline_and_intro?.status,
});
// Check if this is a title update for a memo we're tracking
const currentMemo = memos.find((m) => m.id === payload.new.id);
if (currentMemo) {
console.log(
'MemoList: Found memo in current list, current title:',
currentMemo.title,
'new title:',
payload.new.title
);
}
}
// Queue the update
if (payload.event === 'DELETE' && payload.old?.id) {
pendingUpdates.current.deletes.add(payload.old.id);
} else if (payload.event === 'UPDATE' && payload.new) {
pendingUpdates.current.updates.set(payload.new.id, payload.new);
// Check if this update is for a memo that's also in our insert queue
// This handles the case where a memo is inserted without a title, then quickly updated with one
const insertIndex = pendingUpdates.current.inserts.findIndex(
(m) => m.id === payload.new.id
);
if (insertIndex !== -1) {
// Update the queued insert with the new data
pendingUpdates.current.inserts[insertIndex] = payload.new;
}
// If this is an existing memo in our list, ensure we process the update quickly
const existingMemoIndex = memos.findIndex((m) => m.id === payload.new.id);
if (existingMemoIndex !== -1) {
const existingMemo = memos[existingMemoIndex];
// Check if this is a significant update (title change or processing status change)
const isTitleUpdate = existingMemo.title !== payload.new.title;
const isSignificantUpdate =
isTitleUpdate ||
(!existingMemo.title || existingMemo.title.trim() === '') &&
payload.new.title &&
payload.new.title.trim() !== '';
// Process title updates immediately, other updates with short delay
if (updateTimerRef?.current) {
clearTimeout(updateTimerRef.current);
}
updateTimerRef.current = setTimeout(
() => {
processBatchedUpdates();
},
isTitleUpdate ? 0 : (isSignificantUpdate ? 10 : 50)
); // Immediate for title updates
return;
}
} else if (payload.event === 'INSERT' && payload.new) {
// Check if memo passes current filters
const memo = payload.new;
const passesFilters =
(showArchived === null || memo.is_archived === showArchived) && (!spaceId || true); // Space filtering would need additional check
if (passesFilters) {
// Check if we already have this memo in our list or updates (race condition protection)
const alreadyExists =
memos.some((m) => m.id === memo.id) ||
pendingUpdates.current.updates.has(memo.id) ||
pendingUpdates.current.inserts.some((m) => m.id === memo.id);
if (!alreadyExists) {
console.log('MemoList: INSERT details:', {
id: memo.id,
title: memo.title,
created_at: memo.created_at,
hasSource: !!memo.source,
hasMetadata: !!memo.metadata,
transcriptionStatus: memo.metadata?.processing?.transcription?.status,
headlineStatus: memo.metadata?.processing?.headline_and_intro?.status,
});
pendingUpdates.current.inserts.push(memo);
// Process inserts immediately for new memos
if (updateTimerRef.current) {
clearTimeout(updateTimerRef.current);
}
updateTimerRef.current = setTimeout(() => {
processBatchedUpdates();
}, 100); // Quick processing for new memos
return; // Important: return here to avoid overriding the timer
}
}
}
// Clear existing timer (only if we didn't already set one above)
if (updateTimerRef.current) {
clearTimeout(updateTimerRef.current);
}
// Set new timer for batch processing
updateTimerRef.current = setTimeout(() => {
processBatchedUpdates();
}, 500); // 500ms batch window
},
[showArchived, spaceId, tagIds, processBatchedUpdates, memos, page]
);
// Load memos on mount and dependency changes
useEffect(() => {
// Wenn Memos als Props übergeben werden, verwenden wir diese direkt
if (propMemos) {
setMemos(propMemos);
setLoading(false);
// Check photo status for prop memos as well
if (propMemos.length > 0) {
const memoIds = propMemos.map((memo) => memo.id);
checkPhotoStatus(memoIds);
}
// Don't call onMemosLoaded for prop memos to avoid infinite loop
return;
}
// Reset flags when dependencies change (but not for propMemos)
hasLoadedFromDB.current = false;
// When maintainScrollPosition is true, preserve the scroll position
if (maintainScrollPosition) {
shouldMaintainScroll.current = true;
} else {
// Reset pagination when dependencies change
setPage(0);
setHasMore(true);
loadedMemoIds.current.clear();
}
// Memos laden, wenn die Komponente gemountet wird oder wenn sich Abhängigkeiten ändern
fetchMemos(0, false);
}, [showArchived, spaceId, propMemos, refreshTrigger, tagIds, fetchMemos, checkPhotoStatus, maintainScrollPosition]);
const handleMemoPress = (memoId: string) => {
if (selectionMode) {
// In selection mode, toggle selection instead of navigating
const isSelected = selectedMemoIds.includes(memoId);
onMemoSelection?.(memoId, !isSelected);
} else {
// Normal mode - navigate to memo detail
// Defensive check to ensure we have a valid ID
if (!memoId || memoId === 'undefined' || memoId === 'null') {
console.error('MemoList: Attempted to navigate with invalid memo ID:', memoId);
return;
}
router.push(`/(protected)/(memo)/${memoId}`);
}
};
// Handle copying memo content
const handleCopyMemo = async (memo: MemoModel) => {
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 || '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 || 'Memory'}\n${memory.content}\n\n`;
});
}
// Add transcript if available
const transcript =
fullMemo?.source?.content ||
fullMemo?.source?.transcription ||
fullMemo?.source?.transcript ||
memo.source?.content;
if (transcript) {
content += `Transkript:\n${transcript}\n\n`;
}
await Clipboard.setStringAsync(content);
showSuccess('Kompletter Memo-Inhalt wurde in die Zwischenablage kopiert');
} catch (error) {
console.debug('Fehler beim Kopieren des Memo-Inhalts:', error);
showSuccess('Der Memo-Inhalt konnte nicht kopiert werden');
}
};
// Handle pinning/unpinning memo
const handlePinMemo = async (memo: MemoModel) => {
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('Das Memo konnte nicht angeheftet/entfernt werden');
return;
}
// Update the local state
setMemos((prevMemos) =>
prevMemos.map((m) => (m.id === memo.id ? { ...m, is_pinned: newPinnedState } : m))
);
// Refresh the list to ensure proper sorting (pinned items go to top)
fetchMemos(0, false);
showSuccess(newPinnedState ? 'Memo wurde angeheftet' : 'Anheftung wurde entfernt');
} catch (error) {
console.debug('Error in handlePinMemo:', error);
showSuccess('Ein Fehler ist aufgetreten');
}
};
const handleDeleteMemo = async (memoId: string, memoTitle: string) => {
// Show confirmation dialog
showActionSheetWithOptions(
{
options: ['Löschen', 'Abbrechen'],
cancelButtonIndex: 1,
destructiveButtonIndex: 0,
title: 'Memo löschen',
message: `Möchtest du "${memoTitle}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`,
},
async (selectedIndex) => {
if (selectedIndex === 0) {
// Delete confirmed
try {
// Removed debug log('Deleting memo:', memoId);
// 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', memoId)
.single();
// First, delete related memories
const { error: memoriesError } = await supabase
.from('memories')
.delete()
.eq('memo_id', memoId);
if (memoriesError) {
// Removed debug log('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) {
// Removed debug log('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', memoId);
if (memoError) {
// Removed debug log('Error deleting memo:', memoError.message);
return;
}
// Update the local state to remove the deleted memo
setMemos((prevMemos) => prevMemos.filter((memo) => memo.id !== memoId));
// Fetch fresh data to ensure the list is up to date
fetchMemos(0, false);
// Show success toast
showSuccess('Memo wurde erfolgreich gelöscht');
} catch (error) {
// Removed debug log('Error in handleDeleteMemo:', error);
}
}
}
);
};
const renderMemoItem = ({ item }: { item: MemoModel }) => {
// Only the latest memo should react to global recording status
const shouldReactToRecordingStatus = item.id === latestMemoId;
// Format the memo for MemoPreview component with all necessary data
const formattedMemo = {
id: item.id,
title: item.title || '', // Let useMemoProcessing handle empty title
timestamp: new Date(item.created_at),
// Wichtig: Vollständige Source- und Metadaten übergeben
source: item.source,
metadata: item.metadata,
is_pinned: item.is_pinned || false,
// Leeres Tags-Array, das von der MemoPreview-Komponente gefüllt wird
tags: [],
};
// Check if this memo is selected
const isSelected = selectedMemoIds.includes(item.id);
// Debug: Check photo status
const memoHasPhotos = getMemoHasPhotos(item.id);
return (
<View style={styles.memoItem}>
{selectionMode && (
<View
style={[
styles.selectionArea,
isSelected && {
backgroundColor: themeColors.primaryWithOpacity,
},
]}>
<Pressable
style={styles.selectionIndicator}
onPress={() => onMemoSelection?.(item.id, !isSelected)}>
<Icon
name={isSelected ? 'checkmark-circle' : 'ellipse-outline'}
size={24}
color={themeColors.text}
/>
</Pressable>
</View>
)}
<View
style={[
styles.memoPreviewContainer,
selectionMode ? styles.memoPreviewContainerInSelectionMode : null,
]}>
<MemoPreview
memo={formattedMemo}
selectionMode={selectionMode}
selected={selectedMemoIds.includes(item.id)}
onSelect={(selected) => onMemoSelection && onMemoSelection(item.id, selected)}
onPress={() => handleMemoPress(item.id)}
reactToGlobalRecordingStatus={shouldReactToRecordingStatus}
hasPhotos={memoHasPhotos}
showMargins={true}
onShare={() => {
// Trigger the onShare callback if provided
if (onShare) {
onShare(item);
}
}}
onCopy={() => handleCopyMemo(item)}
onPinToTop={() => handlePinMemo(item)}
onDelete={() => handleDeleteMemo(item.id, item.title || 'Untitled Memo')}
/>
</View>
</View>
);
};
const renderSkeletonList = () => (
<View style={styles.listWrapper}>
<View>
{Array.from({ length: 10 }).map((_, index) => {
// Calculate opacity fade-out towards the bottom
const opacity = Math.max(0.2, 1 - index * 0.08);
return (
<View key={`skeleton-${index}`} style={{ opacity }}>
<MemoPreviewSkeleton showMargins={true} />
</View>
);
})}
</View>
</View>
);
const renderEmptyList = () => (
<View style={styles.emptyContainer}>
<Text variant="h2">Keine Einträge</Text>
<Text variant="body" style={styles.emptyText}>
{spaceId
? 'Dieser Space enthält noch keine Memos.'
: showArchived === true
? 'Du hast noch keine archivierten Aufnahmen.'
: showArchived === false
? 'Du hast noch keine aktiven Aufnahmen.'
: 'Du hast noch keine Aufnahmen erstellt.'}
</Text>
</View>
);
const renderFooter = () => {
if (!loadingMore && !networkError) return null;
return (
<View style={styles.footer}>
{loadingMore ? (
<>
<ActivityIndicator size="small" color="#666" />
<Text variant="caption" style={styles.loadingText}>
Lade weitere Memos...
</Text>
</>
) : networkError && hasMore ? (
<Pressable
style={styles.retryButton}
onPress={() => {
setNetworkError(false);
handleLoadMore();
}}>
<Icon name="refresh" size={20} color="#666" />
<Text variant="caption" style={styles.retryText}>
Tap to retry loading more memos
</Text>
</Pressable>
) : null}
</View>
);
};
// Show skeleton loader during initial loading
if (loading && memos.length === 0) {
return renderSkeletonList();
}
return (
<View style={styles.listWrapper}>
{hasNewContent && page > 0 && (
<Pressable
style={styles.newContentBanner}
onPress={() => {
setPage(0);
setHasNewContent(false);
loadedMemoIds.current.clear();
fetchMemos(0, false);
}}>
<Icon name="arrow-up" size={16} color="#fff" />
<Text variant="caption" style={styles.newContentText}>
New memos available - tap to refresh
</Text>
</Pressable>
)}
<FlashList
ref={flashListRef}
data={memos}
renderItem={renderMemoItem}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={Platform.OS !== 'web'}
ListEmptyComponent={renderEmptyList}
ListFooterComponent={renderFooter}
contentContainerStyle={styles.listContent}
estimatedItemSize={180}
extraData={[selectedMemoIds, selectionMode, loadingMore, memos]}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.3}
onScroll={(event) => {
// Save current scroll position
const offset = event.nativeEvent.contentOffset.y;
const index = Math.floor(offset / 180); // Using estimatedItemSize
scrollPositionRef.current = { index, offset };
}}
scrollEventThrottle={100}
// Web-specific props for better scrolling
{...(Platform.OS === 'web' ? {
style: { flex: 1, overflow: 'auto' },
showsVerticalScrollIndicator: true,
} : {})}
/>
</View>
);
};
const styles = StyleSheet.create({
listWrapper: {
flex: 1,
width: '100%',
},
listContent: {
paddingBottom: 20,
// Removed paddingHorizontal - MemoPreview now manages its own spacing with showMargins prop
},
memoItem: {
marginBottom: 12,
flexDirection: 'row',
alignItems: 'center',
},
// selectedMemoItem style is now dynamic and inline
selectionArea: {
borderTopLeftRadius: 12,
borderBottomLeftRadius: 12,
paddingVertical: 8,
marginRight: 4,
},
selectionIndicator: {
marginLeft: 4,
width: 36,
height: 36,
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
memoPreviewContainer: {
flex: 1,
width: '100%',
marginTop: 0,
},
memoPreviewContainerInSelectionMode: {
width: '92%', // Macht das Element schmaler im Auswahlmodus
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
marginTop: 40,
},
emptyText: {
textAlign: 'center',
marginTop: 16,
opacity: 0.7,
},
footer: {
paddingVertical: 20,
alignItems: 'center',
justifyContent: 'center',
},
loadingText: {
marginTop: 8,
opacity: 0.7,
},
retryButton: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
gap: 8,
},
retryText: {
opacity: 0.7,
},
newContentBanner: {
position: 'absolute',
top: 0,
left: 20,
right: 20,
backgroundColor: '#4CAF50',
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 20,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
zIndex: 100,
elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
},
newContentText: {
color: '#fff',
fontWeight: '600',
},
});
export default MemoList;