diff --git a/apps/mobile/v1/package-lock.json b/apps/mobile/v1/package-lock.json index 6ca22b6..463395b 100644 --- a/apps/mobile/v1/package-lock.json +++ b/apps/mobile/v1/package-lock.json @@ -1,12 +1,12 @@ { "name": "v1", - "version": "1.22.4", + "version": "1.22.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "v1", - "version": "1.22.4", + "version": "1.22.9", "dependencies": { "@10play/tentap-editor": "^0.5.0", "@clerk/clerk-expo": "^2.15.2", @@ -22,12 +22,15 @@ "expo-constants": "~18.0.9", "expo-crypto": "^15.0.7", "expo-dev-client": "~6.0.12", + "expo-document-picker": "~14.0.7", + "expo-file-system": "~19.0.17", "expo-font": "~14.0.8", "expo-haptics": "~15.0.7", "expo-image": "~3.0.8", "expo-linking": "~8.0.8", "expo-router": "~6.0.8", "expo-secure-store": "^15.0.7", + "expo-sharing": "~14.0.7", "expo-splash-screen": "~31.0.10", "expo-status-bar": "~3.0.8", "expo-symbols": "~1.0.7", @@ -8349,10 +8352,19 @@ "expo": "*" } }, + "node_modules/expo-document-picker": { + "version": "14.0.7", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.7.tgz", + "integrity": "sha512-81Jh8RDD0GYBUoSTmIBq30hXXjmkDV1ZY2BNIp1+3HR5PDSh2WmdhD/Ezz5YFsv46hIXHsQc+Kh1q8vn6OLT9Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-file-system": { - "version": "19.0.16", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.16.tgz", - "integrity": "sha512-9Ee6HpcUEfO7dOet/on9yAg7ysegBua35Q0oGrJzoRc+xW6IlTxoSFbmK8QhjA3MZpkukP3DhaiYENYOzkw9SQ==", + "version": "19.0.17", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.17.tgz", + "integrity": "sha512-WwaS01SUFrxBnExn87pg0sCTJjZpf2KAOzfImG0o8yhkU7fbYpihpl/oocXBEsNbj58a8hVt1Y4CVV5c1tzu/g==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -8743,6 +8755,15 @@ "node": ">=20.16.0" } }, + "node_modules/expo-sharing": { + "version": "14.0.7", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.7.tgz", + "integrity": "sha512-t/5tR8ZJNH6tMkHXlF7453UafNIfrpfTG+THN9EMLC4Wsi4bJuESPm3NdmWDg2D4LDALJI/LQo0iEnLAd5Sp4g==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-splash-screen": { "version": "31.0.10", "resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.10.tgz", diff --git a/apps/mobile/v1/package.json b/apps/mobile/v1/package.json index 243ab31..ebf04b2 100644 --- a/apps/mobile/v1/package.json +++ b/apps/mobile/v1/package.json @@ -25,12 +25,15 @@ "expo-constants": "~18.0.9", "expo-crypto": "^15.0.7", "expo-dev-client": "~6.0.12", + "expo-document-picker": "~14.0.7", + "expo-file-system": "~19.0.17", "expo-font": "~14.0.8", "expo-haptics": "~15.0.7", "expo-image": "~3.0.8", "expo-linking": "~8.0.8", "expo-router": "~6.0.8", "expo-secure-store": "^15.0.7", + "expo-sharing": "~14.0.7", "expo-splash-screen": "~31.0.10", "expo-status-bar": "~3.0.8", "expo-symbols": "~1.0.7", diff --git a/apps/mobile/v1/src/components/FileUpload.tsx b/apps/mobile/v1/src/components/FileUpload.tsx new file mode 100644 index 0000000..6db1f35 --- /dev/null +++ b/apps/mobile/v1/src/components/FileUpload.tsx @@ -0,0 +1,270 @@ +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + ActivityIndicator, + Alert, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import * as Haptics from 'expo-haptics'; +import { useTheme } from '../theme'; +import { useApiService, type FileAttachment } from '../services/api'; + +interface FileUploadProps { + noteId: string; + attachments?: FileAttachment[]; + onUploadComplete?: (attachments: FileAttachment[]) => void; + onDeleteComplete?: () => void; +} + +export function FileUpload({ + noteId, + attachments = [], + onUploadComplete, + onDeleteComplete, +}: FileUploadProps) { + const theme = useTheme(); + const api = useApiService(); + const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [deletingIds, setDeletingIds] = useState([]); + + const handlePickFiles = async () => { + try { + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + const files = await api.pickFiles(); + + if (files.length === 0) { + return; + } + + setIsUploading(true); + setUploadProgress(0); + + const uploadedFiles = await api.uploadFiles(noteId, files, (progress) => { + setUploadProgress(progress.percentage); + }); + + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + onUploadComplete?.(uploadedFiles); + } catch (error) { + console.error('Upload error:', error); + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + Alert.alert('Upload Failed', error instanceof Error ? error.message : 'Failed to upload files'); + } finally { + setIsUploading(false); + setUploadProgress(0); + } + }; + + const handleDownload = async (attachment: FileAttachment) => { + try { + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + + const fileUri = await api.downloadFile(attachment); + await api.shareFile(fileUri); + + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } catch (error) { + console.error('Download error:', error); + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + Alert.alert('Download Failed', error instanceof Error ? error.message : 'Failed to download file'); + } + }; + + const handleDelete = async (attachment: FileAttachment) => { + await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + + Alert.alert( + 'Delete Attachment', + `Delete ${attachment.originalName}?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + try { + setDeletingIds((prev) => [...prev, attachment.id]); + + await api.deleteAttachment(attachment.id); + + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + onDeleteComplete?.(); + } catch (error) { + console.error('Delete error:', error); + await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + Alert.alert('Delete Failed', error instanceof Error ? error.message : 'Failed to delete attachment'); + } finally { + setDeletingIds((prev) => prev.filter((id) => id !== attachment.id)); + } + }, + }, + ] + ); + }; + + const styles = createStyles(theme); + + return ( + + {/* Upload Button */} + + + {isUploading ? ( + <> + + + Uploading... {uploadProgress.toFixed(0)}% + + + ) : ( + <> + + + Tap to attach files + + + )} + + + + {/* Attachments List */} + {attachments.length > 0 && ( + + + Attachments ({attachments.length}) + + + {attachments.map((attachment) => { + const isDeleting = deletingIds.includes(attachment.id); + const icon = api.getFileIcon(attachment.mimeType); + + return ( + + {icon} + + + + {attachment.originalName} + + + {api.formatFileSize(attachment.size)} + + + + + handleDownload(attachment)} + disabled={isDeleting} + > + + + + handleDelete(attachment)} + disabled={isDeleting} + > + {isDeleting ? ( + + ) : ( + + )} + + + + ); + })} + + )} + + ); +} + +const createStyles = (theme: any) => + StyleSheet.create({ + container: { + gap: 12, + }, + uploadButton: { + borderWidth: 1, + borderStyle: 'dashed', + borderRadius: 8, + padding: 16, + }, + uploadContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + uploadText: { + fontSize: 14, + }, + attachmentsList: { + gap: 8, + }, + attachmentsTitle: { + fontSize: 14, + fontWeight: '600', + marginBottom: 4, + }, + attachmentItem: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + borderRadius: 8, + borderWidth: 1, + gap: 12, + }, + fileIcon: { + fontSize: 20, + }, + fileInfo: { + flex: 1, + minWidth: 0, + }, + fileName: { + fontSize: 14, + fontWeight: '500', + marginBottom: 2, + }, + fileSize: { + fontSize: 12, + }, + actions: { + flexDirection: 'row', + gap: 8, + }, + actionButton: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + }, + }); diff --git a/apps/mobile/v1/src/hooks/useNoteEditor.ts b/apps/mobile/v1/src/hooks/useNoteEditor.ts index 014d9eb..0908b1b 100644 --- a/apps/mobile/v1/src/hooks/useNoteEditor.ts +++ b/apps/mobile/v1/src/hooks/useNoteEditor.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import { Alert } from 'react-native'; import { useEditorBridge, TenTapStartKit, type EditorBridge } from '@10play/tentap-editor'; import { useRouter } from 'expo-router'; @@ -6,7 +6,6 @@ import { useTheme } from '../theme'; import { useApiService, type Note } from '../services/api'; import { generateEditorStyles } from '../screens/EditNote/styles'; -const EDITOR_LOAD_DELAY = 300; const CSS_INJECTION_DELAY = 100; interface UseNoteEditorReturn { @@ -24,8 +23,9 @@ export function useNoteEditor(noteId?: string): UseNoteEditorReturn { const theme = useTheme(); const api = useApiService(); const router = useRouter(); + const editorReadyRef = useRef(false); + const pendingContentRef = useRef(null); - // Initialize editor with TenTapStartKit const editor = useEditorBridge({ autofocus: false, avoidIosKeyboard: true, @@ -33,23 +33,29 @@ export function useNoteEditor(noteId?: string): UseNoteEditorReturn { bridgeExtensions: TenTapStartKit, }); - // Generate custom CSS memoized on theme colors const customCSS = useMemo( () => generateEditorStyles(theme.colors), [theme.colors] ); - // Handler for when WebView loads - inject CSS at the right time const handleEditorLoad = useCallback(() => { editor.injectCSS(customCSS, 'theme-css'); - // Also inject after a slight delay to ensure it overrides TenTap's default styles + // Inject after delay to override TenTap's default styles setTimeout(() => { editor.injectCSS(customCSS, 'theme-css'); + + editorReadyRef.current = true; + if (pendingContentRef.current !== null) { + if (__DEV__) { + console.log('Setting pending content after editor ready...'); + } + editor.setContent(pendingContentRef.current); + pendingContentRef.current = null; + } }, CSS_INJECTION_DELAY); }, [editor, customCSS]); - // Load note content if editing const loadNote = useCallback(async () => { if (!noteId) return; @@ -59,13 +65,21 @@ export function useNoteEditor(noteId?: string): UseNoteEditorReturn { console.log('Loaded note:', { id: note.id, title: note.title, contentLength: note.content?.length }); } - // Wait for editor to mount, then set content - setTimeout(() => { + const content = note.content || ''; + + if (editorReadyRef.current) { + setTimeout(() => { + if (__DEV__) { + console.log('Setting editor content (editor ready)...'); + } + editor.setContent(content); + }, 100); + } else { if (__DEV__) { - console.log('Setting editor content...'); + console.log('Editor not ready yet, storing content...'); } - editor.setContent(note.content || ''); - }, EDITOR_LOAD_DELAY); + pendingContentRef.current = content; + } return note; } catch (error) { diff --git a/apps/mobile/v1/src/screens/EditNote/EditorHeader.tsx b/apps/mobile/v1/src/screens/EditNote/EditorHeader.tsx index 1636c34..57327d4 100644 --- a/apps/mobile/v1/src/screens/EditNote/EditorHeader.tsx +++ b/apps/mobile/v1/src/screens/EditNote/EditorHeader.tsx @@ -1,14 +1,17 @@ import React from 'react'; -import { View, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'; +import { View, StyleSheet, TouchableOpacity, ActivityIndicator, Text } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; interface EditorHeaderProps { isEditing: boolean; noteData: unknown; isSaving: boolean; + attachmentsCount?: number; + showAttachments?: boolean; onBack: () => void; onDelete: () => void; onSave: () => void; + onToggleAttachments?: () => void; theme: { colors: { primary: string; @@ -25,9 +28,12 @@ export function EditorHeader({ isEditing, noteData, isSaving, + attachmentsCount = 0, + showAttachments = false, onBack, onDelete, onSave, + onToggleAttachments, theme, }: EditorHeaderProps) { return ( @@ -42,6 +48,30 @@ export function EditorHeader({ + {isEditing && onToggleAttachments && ( + + + + + + {attachmentsCount > 0 && ( + + + {attachmentsCount > 9 ? '9+' : attachmentsCount} + + + )} + + + )} + {isEditing && ( (null); + const [attachments, setAttachments] = useState([]); + const [showAttachments, setShowAttachments] = useState(false); const keyboardHeight = useKeyboardHeight(); const { editor, handleEditorLoad, loadNote } = useNoteEditor(noteId as string); @@ -36,6 +39,11 @@ export default function EditNoteScreen() { const note = await loadNote(); setNoteData(note || null); setTitle(note?.title || ''); + + if (note) { + const noteAttachments = await api.getAttachments(noteId as string); + setAttachments(noteAttachments); + } } finally { setLoading(false); } @@ -45,6 +53,17 @@ export default function EditNoteScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [noteId, isEditing]); + const refreshAttachments = async () => { + if (isEditing && noteId) { + try { + const noteAttachments = await api.getAttachments(noteId as string); + setAttachments(noteAttachments); + } catch (error) { + console.error('Failed to refresh attachments:', error); + } + } + }; + const handleSave = async () => { if (!title.trim()) { Alert.alert('Error', 'Please enter a title for your note'); @@ -72,6 +91,7 @@ export default function EditNoteScreen() { starred: false, archived: false, deleted: false, + hidden: false, }); } @@ -147,9 +167,12 @@ export default function EditNoteScreen() { isEditing={isEditing} noteData={noteData} isSaving={isSaving} + attachmentsCount={attachments.length} + showAttachments={showAttachments} onBack={() => router.back()} onDelete={handleDelete} onSave={handleSave} + onToggleAttachments={() => setShowAttachments(!showAttachments)} theme={theme} /> @@ -172,6 +195,17 @@ export default function EditNoteScreen() { )} + {isEditing && noteId && showAttachments && ( + + + + )} + @@ -248,6 +282,9 @@ const styles = StyleSheet.create({ date: { fontSize: 12, }, + attachmentsSection: { + marginTop: 12, + }, divider: { height: 0.5, marginTop: 12, diff --git a/apps/mobile/v1/src/screens/NotesListScreen.tsx b/apps/mobile/v1/src/screens/NotesListScreen.tsx index 12edee4..223eb84 100644 --- a/apps/mobile/v1/src/screens/NotesListScreen.tsx +++ b/apps/mobile/v1/src/screens/NotesListScreen.tsx @@ -225,7 +225,6 @@ export default function NotesListScreen({ navigation, route, renderHeader, scrol // Add note counts to subfolders (including nested subfolder notes) const subfoldersWithCounts = currentFolderSubfolders.map(folder => { const allNestedFolderIds = getAllNestedFolderIds(folder.id); - // Use allNotesData (not notesData) to get accurate counts const folderNotes = allNotesData.filter(note => allNestedFolderIds.includes(note.folderId || '') && !note.deleted && @@ -552,6 +551,16 @@ export default function NotesListScreen({ navigation, route, renderHeader, scrol {String(note.title || 'Untitled')} + {((note.attachments?.length ?? 0) > 0 || (note.attachmentCount ?? 0) > 0) && ( + + + + + + {note.attachments?.length || note.attachmentCount || 0} + + + )} {note.starred && ( )} @@ -907,6 +916,16 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', }, + attachmentBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 3, + marginRight: 8, + }, + attachmentCount: { + fontSize: 12, + fontWeight: '500', + }, noteListTime: { fontSize: 14, fontWeight: '400', diff --git a/apps/mobile/v1/src/screens/ViewNote/NoteContent.tsx b/apps/mobile/v1/src/screens/ViewNote/NoteContent.tsx index 9958a19..789711c 100644 --- a/apps/mobile/v1/src/screens/ViewNote/NoteContent.tsx +++ b/apps/mobile/v1/src/screens/ViewNote/NoteContent.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import { View, Text, StyleSheet, Animated } from 'react-native'; import { WebView } from 'react-native-webview'; import type { Note } from '../../services/api'; @@ -8,6 +8,7 @@ interface NoteContentProps { htmlContent: string; scrollY: Animated.Value; scrollViewRef: React.RefObject; + showTitle?: boolean; theme: { colors: { foreground: string; @@ -25,10 +26,11 @@ interface NoteContentProps { * Supports rich text formatting, code blocks with syntax highlighting, * and communicates scroll position to React Native */ -export function NoteContent({ note, htmlContent, scrollY, scrollViewRef, theme }: NoteContentProps) { +export function NoteContent({ note, htmlContent, scrollY, scrollViewRef, showTitle = true, theme }: NoteContentProps) { const webViewRef = useRef(null); + const [webViewHeight, setWebViewHeight] = useState(300); - // Enhanced HTML with title and metadata + // Enhanced HTML with optional title and metadata const fullHtml = ` @@ -62,6 +64,7 @@ export function NoteContent({ note, htmlContent, scrollY, scrollViewRef, theme } + ${showTitle ? ` + ` : ''} ${htmlContent.match(/([\s\S]*?)<\/body>/)?.[1] || note.content} @@ -95,30 +117,21 @@ export function NoteContent({ note, htmlContent, scrollY, scrollViewRef, theme } { try { const data = JSON.parse(event.nativeEvent.data); - if (data.type === 'scroll' && scrollY) { - scrollY.setValue(data.scrollY); + if (data.type === 'height') { + setWebViewHeight(data.height); } } catch { - // Ignore parse errors from invalid message data + // Ignore parse errors } }} - injectedJavaScript={` - window.addEventListener('scroll', () => { - window.ReactNativeWebView.postMessage(JSON.stringify({ - type: 'scroll', - scrollY: window.scrollY - })); - }, { passive: true }); - true; - `} /> )} @@ -127,14 +140,12 @@ export function NoteContent({ note, htmlContent, scrollY, scrollViewRef, theme } const styles = StyleSheet.create({ container: { - flex: 1, }, webview: { - flex: 1, backgroundColor: 'transparent', }, hiddenContainer: { - flex: 1, + minHeight: 200, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16, diff --git a/apps/mobile/v1/src/screens/ViewNote/ViewHeader.tsx b/apps/mobile/v1/src/screens/ViewNote/ViewHeader.tsx index 7136dda..40ed2a1 100644 --- a/apps/mobile/v1/src/screens/ViewNote/ViewHeader.tsx +++ b/apps/mobile/v1/src/screens/ViewNote/ViewHeader.tsx @@ -7,9 +7,12 @@ interface ViewHeaderProps { isHidden: boolean; title: string; scrollY: Animated.Value; + attachmentsCount: number; + showAttachments: boolean; onBack: () => void; onToggleStar: () => void; onToggleHidden: () => void; + onToggleAttachments: () => void; onEdit: () => void; theme: { colors: { @@ -30,9 +33,12 @@ export function ViewHeader({ isHidden, title, scrollY, + attachmentsCount, + showAttachments, onBack, onToggleStar, onToggleHidden, + onToggleAttachments, onEdit, theme, }: ViewHeaderProps) { @@ -69,6 +75,30 @@ export function ViewHeader({ + {attachmentsCount > 0 && ( + + + + + + {attachmentsCount > 0 && ( + + + {attachmentsCount > 9 ? '9+' : attachmentsCount} + + + )} + + + )} + (null); + const [showAttachments, setShowAttachments] = useState(false); + const [attachments, setAttachments] = useState([]); + const [loadingAttachments, setLoadingAttachments] = useState(false); + const [downloadingId, setDownloadingId] = useState(null); const { note, loading, htmlContent, handleEdit, handleToggleStar, handleToggleHidden } = useViewNote(noteId as string); useEffect(() => { - // Reset scroll position when note changes scrollY.setValue(0); if (scrollViewRef.current) { scrollViewRef.current.scrollTo({ y: 0, animated: false }); } }, [noteId, scrollY]); + const loadingRef = useRef(false); + const lastLoadedNoteId = useRef(null); + + const loadAttachments = useCallback(async () => { + if (!noteId || loadingRef.current) return; + + try { + loadingRef.current = true; + setLoadingAttachments(true); + const noteAttachments = await api.getAttachments(noteId as string); + setAttachments(noteAttachments); + lastLoadedNoteId.current = noteId as string; + } catch (error) { + console.error('Failed to load attachments:', error); + setAttachments([]); + } finally { + setLoadingAttachments(false); + loadingRef.current = false; + } + }, [noteId, api]); + + useEffect(() => { + if (noteId && lastLoadedNoteId.current !== noteId) { + loadAttachments(); + } + }, [noteId, loadAttachments]); + + const handleDownloadAttachment = async (attachment: FileAttachment) => { + if (downloadingId) { + alert('Please wait for the current download to complete'); + return; + } + + try { + setDownloadingId(attachment.id); + const fileUri = await api.downloadFile(attachment); + await api.shareFile(fileUri); + } catch (error) { + console.error('Failed to download attachment:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + + if (errorMessage.includes('Too Many Requests') || errorMessage.includes('429')) { + alert('Too many download requests. Please wait 1-2 minutes and try again.'); + } else { + alert(`Failed to download file: ${errorMessage.substring(0, 100)}`); + } + } finally { + setDownloadingId(null); + } + }; + useFocusEffect( - React.useCallback(() => { - // Reset scroll position when screen comes into focus + useCallback(() => { scrollY.setValue(0); if (scrollViewRef.current) { scrollViewRef.current.scrollTo({ y: 0, animated: false }); } - }, [scrollY]) + + if (noteId && lastLoadedNoteId.current === noteId && !loadingRef.current) { + lastLoadedNoteId.current = null; + loadAttachments(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scrollY, noteId]) ); if (loading) { @@ -68,23 +130,94 @@ export default function ViewNoteScreen() { isHidden={note.hidden} title={note.title} scrollY={scrollY} + attachmentsCount={attachments.length} + showAttachments={showAttachments} onBack={() => router.back()} onToggleStar={handleToggleStar} onToggleHidden={handleToggleHidden} + onToggleAttachments={() => setShowAttachments(!showAttachments)} onEdit={handleEdit} theme={theme} /> - + + + + {note.title} + + + Created {new Date(note.createdAt).toLocaleDateString()} + {note.updatedAt !== note.createdAt && ` • Updated ${new Date(note.updatedAt).toLocaleDateString()}`} + + + + {showAttachments && attachments.length > 0 && ( + + + {loadingAttachments ? ( + + ) : ( + attachments.map((attachment) => { + const isDownloading = downloadingId === attachment.id; + return ( + handleDownloadAttachment(attachment)} + disabled={isDownloading || !!downloadingId} + > + + {api.getFileIcon(attachment.mimeType)} + + + + {attachment.originalName} + + + {api.formatFileSize(attachment.size)} + + + {isDownloading ? ( + + ) : ( + + )} + + ); + }) + )} + + + )} + + + - + ); } @@ -103,4 +236,62 @@ const styles = StyleSheet.create({ loadingText: { fontSize: 16, }, + titleContainer: { + paddingHorizontal: 16, + paddingTop: 8, + paddingBottom: 12, + }, + noteTitle: { + fontSize: 24, + fontWeight: '600', + marginBottom: 8, + lineHeight: 32, + }, + noteMetadata: { + fontSize: 12, + }, + divider: { + height: StyleSheet.hairlineWidth, + marginHorizontal: 16, + }, + attachmentsContainer: { + borderBottomWidth: StyleSheet.hairlineWidth, + paddingVertical: 12, + }, + attachmentsScroll: { + paddingHorizontal: 16, + gap: 12, + }, + attachmentItem: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 8, + borderWidth: 1, + gap: 10, + minWidth: 200, + maxWidth: 250, + }, + attachmentIcon: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + }, + attachmentEmoji: { + fontSize: 20, + }, + attachmentInfo: { + flex: 1, + minWidth: 0, + }, + attachmentName: { + fontSize: 14, + fontWeight: '500', + marginBottom: 2, + }, + attachmentSize: { + fontSize: 12, + }, }); diff --git a/apps/mobile/v1/src/services/api/index.ts b/apps/mobile/v1/src/services/api/index.ts index 057b0df..b8e37e7 100644 --- a/apps/mobile/v1/src/services/api/index.ts +++ b/apps/mobile/v1/src/services/api/index.ts @@ -9,7 +9,16 @@ import { createFoldersApi } from './folders'; import { createUserApi } from './user'; // Re-export types for convenience -export type { Folder, Note, NoteQueryParams, EmptyTrashResponse, ApiUser, ApiUserUsage } from './types'; +export type { + Folder, + Note, + NoteQueryParams, + EmptyTrashResponse, + ApiUser, + ApiUserUsage, + FileAttachment +} from './types'; +export type { PickedFile } from '../fileService'; /** * Main API service hook @@ -38,6 +47,16 @@ export const useApiService = () => { unhideNote: notesApi.unhideNote, emptyTrash: notesApi.emptyTrash, + // File attachment methods + pickFiles: notesApi.pickFiles, + uploadFiles: notesApi.uploadFiles, + getAttachments: notesApi.getAttachments, + downloadFile: notesApi.downloadFile, + shareFile: notesApi.shareFile, + deleteAttachment: notesApi.deleteAttachment, + formatFileSize: notesApi.formatFileSize, + getFileIcon: notesApi.getFileIcon, + // Folders methods getFolders: foldersApi.getFolders, createFolder: foldersApi.createFolder, diff --git a/apps/mobile/v1/src/services/api/notes.ts b/apps/mobile/v1/src/services/api/notes.ts index 62f691e..b4ee697 100644 --- a/apps/mobile/v1/src/services/api/notes.ts +++ b/apps/mobile/v1/src/services/api/notes.ts @@ -4,15 +4,19 @@ */ import { createHttpClient, AuthTokenGetter } from './client'; -import { Note, NoteQueryParams, NotesResponse, EmptyTrashResponse } from './types'; +import { Note, NoteQueryParams, NotesResponse, EmptyTrashResponse, FileAttachment } from './types'; import { decryptNote, decryptNotes, encryptNoteForApi, clearEncryptionCache } from './encryption'; import { fetchAllPages, createPaginationParams } from './utils/pagination'; import { handleApiError } from './utils/errors'; import { logger } from '../../lib/logger'; +import { fileService, type PickedFile } from '../fileService'; export function createNotesApi(getToken: AuthTokenGetter, getUserId: () => string | undefined) { const { makeRequest } = createHttpClient(getToken); + // Initialize fileService with token provider + fileService.setTokenProvider(getToken); + return { /** * Get all notes with optional filters @@ -232,5 +236,144 @@ export function createNotesApi(getToken: AuthTokenGetter, getUserId: () => strin return handleApiError(error, 'emptyTrash'); } }, + + /** + * Pick files from device + */ + async pickFiles(): Promise { + try { + return await fileService.pickFiles(); + } catch (error) { + logger.error('Failed to pick files', error as Error, { + attributes: { operation: 'pickFiles' }, + }); + throw error; + } + }, + + /** + * Upload files to a note + */ + async uploadFiles( + noteId: string, + files: PickedFile[], + onProgress?: (progress: { loaded: number; total: number; percentage: number }) => void + ): Promise { + try { + const userId = getUserId(); + if (!userId) { + throw new Error('User ID required for file upload'); + } + + const uploadedFiles = await fileService.uploadFiles(noteId, files, userId, onProgress); + + logger.recordEvent('files_uploaded', { + noteId, + fileCount: files.length, + totalSize: files.reduce((sum, f) => sum + (f.size || 0), 0), + }); + + return uploadedFiles; + } catch (error) { + logger.error('Failed to upload files', error as Error, { + attributes: { + operation: 'uploadFiles', + noteId, + fileCount: files.length, + }, + }); + throw error; + } + }, + + /** + * Get attachments for a note + */ + async getAttachments(noteId: string): Promise { + try { + return await fileService.getAttachments(noteId); + } catch (error) { + logger.error('Failed to get attachments', error as Error, { + attributes: { operation: 'getAttachments', noteId }, + }); + throw error; + } + }, + + /** + * Download and decrypt a file attachment + */ + async downloadFile(attachment: FileAttachment): Promise { + try { + const userId = getUserId(); + if (!userId) { + throw new Error('User ID required for file download'); + } + + const fileUri = await fileService.downloadFile(attachment, userId); + + logger.recordEvent('file_downloaded', { + fileId: attachment.id, + fileName: attachment.originalName, + fileSize: attachment.size, + }); + + return fileUri; + } catch (error) { + logger.error('Failed to download file', error as Error, { + attributes: { + operation: 'downloadFile', + fileId: attachment.id, + }, + }); + throw error; + } + }, + + /** + * Share a downloaded file + */ + async shareFile(fileUri: string): Promise { + try { + await fileService.shareFile(fileUri); + logger.recordEvent('file_shared', { fileUri }); + } catch (error) { + logger.error('Failed to share file', error as Error, { + attributes: { operation: 'shareFile' }, + }); + throw error; + } + }, + + /** + * Delete a file attachment + */ + async deleteAttachment(attachmentId: string): Promise { + try { + await fileService.deleteFile(attachmentId); + + logger.recordEvent('file_deleted', { + fileId: attachmentId, + }); + } catch (error) { + logger.error('Failed to delete attachment', error as Error, { + attributes: { + operation: 'deleteAttachment', + fileId: attachmentId, + }, + }); + throw error; + } + }, + + /** + * Format file size for display + */ + formatFileSize: (bytes: number) => fileService.formatFileSize(bytes), + + /** + * Get file icon emoji based on MIME type + */ + getFileIcon: (mimeType: string) => fileService.getFileIcon(mimeType), }; } diff --git a/apps/mobile/v1/src/services/api/types.ts b/apps/mobile/v1/src/services/api/types.ts index 0797334..57d96f8 100644 --- a/apps/mobile/v1/src/services/api/types.ts +++ b/apps/mobile/v1/src/services/api/types.ts @@ -3,6 +3,16 @@ * Shared types for the API service */ +export interface FileAttachment { + id: string; + noteId: string; + filename: string; + originalName: string; + mimeType: string; + size: number; + uploadedAt: string; +} + export interface Folder { id: string; name: string; @@ -30,6 +40,8 @@ export interface Note { hiddenAt?: string | null; createdAt: string; updatedAt: string; + attachments?: FileAttachment[]; + attachmentCount?: number; // Backend can populate this for performance // Encrypted fields (if note is encrypted) encryptedTitle?: string; encryptedContent?: string; diff --git a/apps/mobile/v1/src/services/fileService.ts b/apps/mobile/v1/src/services/fileService.ts new file mode 100644 index 0000000..397ffff --- /dev/null +++ b/apps/mobile/v1/src/services/fileService.ts @@ -0,0 +1,398 @@ +/** + * File Service for Mobile + * Handles file uploads, downloads, and encryption + */ + +import * as FileSystem from 'expo-file-system/legacy'; +import * as DocumentPicker from 'expo-document-picker'; +import * as Sharing from 'expo-sharing'; +import * as Crypto from 'expo-crypto'; +import forge from 'node-forge'; +import { encryptWithAESGCM, decryptWithAESGCM } from '../lib/encryption/core/aes'; +import { deriveEncryptionKey } from '../lib/encryption/core/keyDerivation'; +import { ENCRYPTION_CONFIG } from '../lib/encryption/config'; +import { getUserSecret, getMasterKey } from '../lib/encryption/storage/secureStorage'; +import type { FileAttachment } from './api/types'; + +const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'http://localhost:3001/api'; + +export interface UploadProgress { + loaded: number; + total: number; + percentage: number; +} + +export interface PickedFile { + uri: string; + name: string; + size?: number; + mimeType?: string; +} + +class FileService { + private getToken: (() => Promise) | null = null; + + setTokenProvider(getToken: () => Promise) { + this.getToken = getToken; + } + + private async getAuthHeaders(): Promise> { + if (!this.getToken) { + throw new Error('Token provider not set. Make sure to call setTokenProvider first.'); + } + + const token = await this.getToken(); + if (!token) { + throw new Error('No authentication token available'); + } + + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }; + } + + /** + * Convert base64 to Uint8Array + */ + private base64ToUint8Array(base64: string): Uint8Array { + const binary = forge.util.decode64(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } + + /** + * Convert base64 to ArrayBuffer + */ + private base64ToArrayBuffer(base64: string): ArrayBuffer { + const bytes = this.base64ToUint8Array(base64); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + } + + /** + * Encrypt file content (for uploads - mobile-compatible) + */ + private async encryptFile( + base64Content: string, + fileName: string, + userId: string + ): Promise<{ + encryptedData: string; + encryptedTitle: string; + iv: string; + salt: string; + }> { + try { + // Generate random salt and IV + const saltBytes = await Crypto.getRandomBytesAsync(ENCRYPTION_CONFIG.SALT_LENGTH); + const ivBytes = await Crypto.getRandomBytesAsync(ENCRYPTION_CONFIG.IV_LENGTH); + + // Convert Uint8Array to base64 using node-forge + const saltString = String.fromCharCode.apply(null, Array.from(saltBytes)); + const ivString = String.fromCharCode.apply(null, Array.from(ivBytes)); + const saltBase64 = forge.util.encode64(saltString); + const ivBase64 = forge.util.encode64(ivString); + + // Derive encryption key (mobile-compatible) + const keyBase64 = await this.deriveKeyForFiles(userId, saltBase64); + + // Encrypt filename and content using mobile encryption helpers + const encryptedTitle = await encryptWithAESGCM(fileName, keyBase64, ivBase64); + const encryptedData = await encryptWithAESGCM(base64Content, keyBase64, ivBase64); + + return { + encryptedTitle, + encryptedData, + iv: ivBase64, + salt: saltBase64, + }; + } catch (error) { + throw new Error(`File encryption failed: ${error}`); + } + } + + /** + * Derive base64 key for file encryption/decryption (mobile-compatible) + */ + private async deriveKeyForFiles(userId: string, saltBase64: string): Promise { + // Check if user has master password (same logic as mobile encryption) + const masterKey = await getMasterKey(userId); + + if (masterKey) { + // Master password mode: return the stored key directly + return masterKey; + } + + // Non-master-password mode: derive key from user secret using mobile's key derivation + const userSecret = await getUserSecret(userId); + return await deriveEncryptionKey(userId, userSecret, saltBase64); + } + + /** + * Decrypt file content (mobile-compatible using node-forge) + */ + private async decryptFile( + encryptedData: string, + ivBase64: string, + saltBase64: string, + userId: string + ): Promise { + try { + console.log('[Decrypt] Deriving key...'); + + // Derive the base64 key (mobile-compatible) + const keyBase64 = await this.deriveKeyForFiles(userId, saltBase64); + + console.log('[Decrypt] Decrypting with AES-GCM...'); + + // Decrypt the file content using mobile encryption helpers + const decryptedBase64 = await decryptWithAESGCM(encryptedData, keyBase64, ivBase64); + + console.log('[Decrypt] Decryption successful. Result length:', decryptedBase64.length); + + return decryptedBase64; + } catch (error) { + console.error('[Decrypt] Error:', error); + throw new Error(`File decryption failed: ${error}`); + } + } + + + /** + * Pick files from device + */ + async pickFiles(): Promise { + try { + const result = await DocumentPicker.getDocumentAsync({ + multiple: true, + copyToCacheDirectory: true, + }); + + if (result.canceled) { + return []; + } + + return result.assets.map((asset) => ({ + uri: asset.uri, + name: asset.name, + size: asset.size || 0, + mimeType: asset.mimeType || 'application/octet-stream', + })); + } catch (error) { + console.error('Error picking files:', error); + throw new Error('Failed to pick files'); + } + } + + /** + * Upload a single file + */ + async uploadFile( + noteId: string, + file: PickedFile, + userId: string, + onProgress?: (progress: UploadProgress) => void + ): Promise { + const fileSize = file.size || 0; + + // Check file size (10MB limit) + if (fileSize > 10 * 1024 * 1024) { + throw new Error('File size exceeds 10MB limit'); + } + + // Step 1: Reading file (0-20%) + onProgress?.({ loaded: 0, total: fileSize, percentage: 0 }); + const base64Content = await FileSystem.readAsStringAsync(file.uri, { + encoding: 'base64' as any, + }); + onProgress?.({ loaded: fileSize * 0.2, total: fileSize, percentage: 20 }); + + // Step 2: Encrypting file (20-40%) + const encrypted = await this.encryptFile(base64Content, file.name, userId); + onProgress?.({ loaded: fileSize * 0.4, total: fileSize, percentage: 40 }); + + const fileData = { + originalName: file.name, + mimeType: file.mimeType || 'application/octet-stream', + size: fileSize, + encryptedData: encrypted.encryptedData, + encryptedTitle: encrypted.encryptedTitle, + iv: encrypted.iv, + salt: encrypted.salt, + }; + + // Step 3: Uploading (40-90%) + onProgress?.({ loaded: fileSize * 0.5, total: fileSize, percentage: 50 }); + const response = await fetch(`${API_BASE_URL}/notes/${noteId}/files`, { + method: 'POST', + headers: await this.getAuthHeaders(), + body: JSON.stringify(fileData), + }); + onProgress?.({ loaded: fileSize * 0.9, total: fileSize, percentage: 90 }); + + if (!response.ok) { + if (response.status === 413) { + throw new Error('File too large. Maximum 10MB per file, 50MB total per note.'); + } + throw new Error(`Upload failed: ${response.statusText}`); + } + + // Step 4: Processing response (90-100%) + const result = await response.json(); + onProgress?.({ loaded: fileSize, total: fileSize, percentage: 100 }); + + return result; + } + + /** + * Upload multiple files + */ + async uploadFiles( + noteId: string, + files: PickedFile[], + userId: string, + onProgress?: (progress: UploadProgress) => void + ): Promise { + const uploadPromises = files.map((file) => + this.uploadFile(noteId, file, userId, onProgress) + ); + + return await Promise.all(uploadPromises); + } + + /** + * Download and decrypt a file + */ + async downloadFile(attachment: FileAttachment, userId: string): Promise { + try { + // Check if file already exists in cache + // @ts-ignore - cacheDirectory exists on FileSystem but types are incomplete + const cacheDir = FileSystem.cacheDirectory || ''; + const fileUri = `${cacheDir}${attachment.id}_${attachment.originalName}`; + + const fileInfo = await FileSystem.getInfoAsync(fileUri); + if (fileInfo.exists) { + console.log('[Cache] File already downloaded:', fileUri); + return fileUri; + } + + console.log('[1/6] Starting download:', attachment.id, attachment.originalName); + + const headers = await this.getAuthHeaders(); + console.log('[2/6] Got auth headers'); + + const response = await fetch(`${API_BASE_URL}/files/${attachment.id}`, { + headers, + }); + + console.log('[3/6] Response received, status:', response.status); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[ERROR] Download failed:', response.status, errorText); + throw new Error(`${response.status} ${errorText}`); + } + + console.log('[4/6] Parsing JSON and decrypting...'); + const encryptedFile = await response.json(); + + const decryptedBase64 = await this.decryptFile( + encryptedFile.encryptedData, + encryptedFile.iv, + encryptedFile.salt, + userId + ); + + console.log('[5/6] Writing to cache...'); + + await FileSystem.writeAsStringAsync(fileUri, decryptedBase64, { + encoding: 'base64' as any, + }); + + console.log('[6/6] File saved successfully:', fileUri); + + return fileUri; + } catch (error) { + console.error('[ERROR] Download failed:', error); + if (error instanceof Error) { + console.error('Error details:', { + name: error.name, + message: error.message, + stack: error.stack?.substring(0, 200), + }); + } + throw error; + } + } + + /** + * Share/open a downloaded file + */ + async shareFile(fileUri: string): Promise { + const isAvailable = await Sharing.isAvailableAsync(); + if (isAvailable) { + await Sharing.shareAsync(fileUri); + } else { + throw new Error('Sharing is not available on this device'); + } + } + + /** + * Delete a file attachment + */ + async deleteFile(attachmentId: string): Promise { + const response = await fetch(`${API_BASE_URL}/files/${attachmentId}`, { + method: 'DELETE', + headers: await this.getAuthHeaders(), + }); + + if (!response.ok) { + throw new Error(`Delete failed: ${response.statusText}`); + } + } + + /** + * Get attachments for a note + */ + async getAttachments(noteId: string): Promise { + const response = await fetch(`${API_BASE_URL}/notes/${noteId}/files`, { + headers: await this.getAuthHeaders(), + }); + + if (!response.ok) { + throw new Error(`Failed to fetch attachments: ${response.statusText}`); + } + + return await response.json(); + } + + /** + * Format file size for display + */ + formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + /** + * Get file icon emoji based on MIME type + */ + getFileIcon(mimeType: string): string { + if (mimeType.startsWith('image/')) return '🖼️'; + if (mimeType.startsWith('video/')) return '🎥'; + if (mimeType.startsWith('audio/')) return '🎵'; + if (mimeType.includes('pdf')) return '📄'; + if (mimeType.includes('document') || mimeType.includes('word')) return '📝'; + if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return '📊'; + if (mimeType.includes('presentation') || mimeType.includes('powerpoint')) return '📊'; + return '📎'; + } +} + +export const fileService = new FileService();