mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 23:21:24 +02:00
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>
1066 lines
36 KiB
TypeScript
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;
|