From 0b887599ec9bec7e2494411e18f5119b7f262706 Mon Sep 17 00:00:00 2001 From: Rui Costa Date: Wed, 5 Nov 2025 22:02:32 -0500 Subject: [PATCH] feat: add diagram support with Mermaid.js and optimize notes list performance --- apps/mobile/v1/index.js | 4 + apps/mobile/v1/package.json | 2 + apps/mobile/v1/src/constants/ui.ts | 2 +- .../src/lib/encryption/EncryptionService.ts | 26 +++- apps/mobile/v1/src/lib/encryption/core/aes.ts | 116 +++++++++++++--- .../src/lib/encryption/core/keyDerivation.ts | 93 +++++++++---- .../components/NotesList/NoteListItem.tsx | 39 ++++-- .../components/NotesList/NoteSkeletonItem.tsx | 1 + .../components/NotesList/NotesHeader.tsx | 58 ++++---- .../components/NotesList/index.tsx | 77 ++++++++--- .../components/NotesList/useNotesLoader.ts | 55 +++----- apps/mobile/v1/src/screens/Home/index.tsx | 18 ++- apps/mobile/v1/src/screens/Settings/index.tsx | 15 +- .../v1/src/screens/ViewNote/NoteContent.tsx | 128 ++++++++++++++++-- apps/mobile/v1/src/screens/ViewNote/index.tsx | 1 + .../v1/src/services/api/databaseCache.ts | 126 +++++++++-------- apps/mobile/v1/src/services/api/types.ts | 1 + apps/mobile/v1/src/theme/index.tsx | 20 ++- pnpm-lock.yaml | 121 ++++++++++++++++- 19 files changed, 665 insertions(+), 238 deletions(-) diff --git a/apps/mobile/v1/index.js b/apps/mobile/v1/index.js index 5188443..48f8e1f 100644 --- a/apps/mobile/v1/index.js +++ b/apps/mobile/v1/index.js @@ -1,5 +1,9 @@ import 'expo-router/entry'; +// Install crypto polyfills FIRST (before any other imports that might need Buffer) +import { install } from 'react-native-quick-crypto'; +install(); + import * as Sentry from '@sentry/react-native'; import Constants from 'expo-constants'; import { Platform } from 'react-native'; diff --git a/apps/mobile/v1/package.json b/apps/mobile/v1/package.json index 3c0802d..4f5df21 100644 --- a/apps/mobile/v1/package.json +++ b/apps/mobile/v1/package.json @@ -30,6 +30,7 @@ "expo-font": "~14.0.8", "expo-haptics": "~15.0.7", "expo-image": "~3.0.8", + "expo-linear-gradient": "~15.0.7", "expo-linking": "~8.0.8", "expo-router": "~6.0.8", "expo-screen-orientation": "~9.0.7", @@ -46,6 +47,7 @@ "react": "19.1.0", "react-native": "0.81.4", "react-native-gesture-handler": "~2.28.0", + "react-native-quick-crypto": "^0.7.17", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", diff --git a/apps/mobile/v1/src/constants/ui.ts b/apps/mobile/v1/src/constants/ui.ts index b4d22db..fc31e0b 100644 --- a/apps/mobile/v1/src/constants/ui.ts +++ b/apps/mobile/v1/src/constants/ui.ts @@ -2,7 +2,7 @@ * Shared UI constants for consistent design across the app */ -const SHARED_BORDER_RADIUS = 8; +const SHARED_BORDER_RADIUS = 4; export const NOTE_CARD = { BORDER_RADIUS: SHARED_BORDER_RADIUS, diff --git a/apps/mobile/v1/src/lib/encryption/EncryptionService.ts b/apps/mobile/v1/src/lib/encryption/EncryptionService.ts index 2fa676f..9caecb2 100644 --- a/apps/mobile/v1/src/lib/encryption/EncryptionService.ts +++ b/apps/mobile/v1/src/lib/encryption/EncryptionService.ts @@ -17,6 +17,7 @@ import { DecryptedData, EncryptedNote, PotentiallyEncrypted } from './types'; export class MobileEncryptionService { private cache: DecryptionCache; private masterPasswordMode = false; + private keyCache: Map = new Map(); // Cache for derived keys by salt constructor() { this.cache = new DecryptionCache(); @@ -30,17 +31,30 @@ export class MobileEncryptionService { throw new Error('Key derivation attempted without user ID'); } + // Check key cache first (critical for performance!) + const cacheKey = `${userId}:${saltBase64}`; + const cachedKey = this.keyCache.get(cacheKey); + if (cachedKey) { + return cachedKey; + } + try { const masterKey = await getMasterKey(userId); if (this.masterPasswordMode && masterKey) { // In master password mode, return the stored key directly + this.keyCache.set(cacheKey, masterKey); return masterKey; } // For non-master password mode, derive key from user secret and salt const userSecret = await getUserSecret(userId); - return await deriveEncryptionKey(userId, userSecret, saltBase64); + const derivedKey = await deriveEncryptionKey(userId, userSecret, saltBase64); + + // Cache the derived key to avoid expensive re-derivation + this.keyCache.set(cacheKey, derivedKey); + + return derivedKey; } catch (error) { throw new Error(`Key derivation failed: ${error}`); } @@ -150,6 +164,7 @@ export class MobileEncryptionService { */ clearKeys(): void { this.cache.clearAll(); + this.keyCache.clear(); // Also clear key derivation cache } /** @@ -157,6 +172,15 @@ export class MobileEncryptionService { */ clearNoteCache(userId: string, encryptedTitle?: string): void { this.cache.clearUser(userId, encryptedTitle); + + // Clear key cache entries for this user + const keysToDelete: string[] = []; + for (const key of this.keyCache.keys()) { + if (key.startsWith(`${userId}:`)) { + keysToDelete.push(key); + } + } + keysToDelete.forEach((key) => this.keyCache.delete(key)); } /** diff --git a/apps/mobile/v1/src/lib/encryption/core/aes.ts b/apps/mobile/v1/src/lib/encryption/core/aes.ts index 498e029..d91f1d8 100644 --- a/apps/mobile/v1/src/lib/encryption/core/aes.ts +++ b/apps/mobile/v1/src/lib/encryption/core/aes.ts @@ -1,12 +1,49 @@ /** * AES-GCM Encryption/Decryption - * Using node-forge for compatibility with web app + * Using react-native-quick-crypto when available (fast native implementation) + * Falls back to node-forge for compatibility */ import forge from 'node-forge'; import { ENCRYPTION_CONFIG } from '../config'; +// Try to import native crypto, but don't fail if not available (Expo Go) +let createCipheriv: any = null; +let createDecipheriv: any = null; +let QuickCryptoBuffer: any = null; + +// Try to get Buffer from global scope (polyfilled by react-native-quick-crypto) +try { + // @ts-ignore - Buffer should be global after react-native-quick-crypto is loaded + QuickCryptoBuffer = global.Buffer || Buffer; +} catch (e) { + // Buffer not available globally +} + +try { + const quickCrypto = require('react-native-quick-crypto'); + + // Get the cipher functions + createCipheriv = quickCrypto.createCipheriv; + createDecipheriv = quickCrypto.createDecipheriv; + + if (createCipheriv && createDecipheriv && QuickCryptoBuffer) { + console.log('[Encryption] Native AES-GCM available - will use fast implementation'); + } else { + console.log('[Encryption] Native AES-GCM partially available but missing functions'); + if (__DEV__) { + console.log('[Encryption] Available:', { + createCipheriv: !!createCipheriv, + createDecipheriv: !!createDecipheriv, + Buffer: !!QuickCryptoBuffer + }); + } + } +} catch (error) { + console.log('[Encryption] Native AES-GCM not available - using node-forge'); +} + /** * Encrypt plaintext using AES-GCM */ @@ -15,30 +52,39 @@ export async function encryptWithAESGCM( keyBase64: string, ivBase64: string ): Promise { - // Convert base64 to forge-compatible format + // Try native implementation first (if available) + if (createCipheriv && QuickCryptoBuffer) { + try { + const key = QuickCryptoBuffer.from(keyBase64, 'base64'); + const iv = QuickCryptoBuffer.from(ivBase64, 'base64'); + + const cipher = createCipheriv('aes-256-gcm', key, iv); + + let encrypted = cipher.update(plaintext, 'utf8'); + encrypted = QuickCryptoBuffer.concat([encrypted, cipher.final()]); + + const authTag = cipher.getAuthTag(); + const encryptedWithTag = QuickCryptoBuffer.concat([encrypted, authTag]); + + return encryptedWithTag.toString('base64'); + } catch (error) { + console.warn('[Encryption] Native AES-GCM encryption failed, falling back to node-forge:', error); + } + } + + // Fallback to node-forge const key = forge.util.decode64(keyBase64); const iv = forge.util.decode64(ivBase64); - // Create AES-GCM cipher const cipher = forge.cipher.createCipher('AES-GCM', key); - - // Start encryption with IV cipher.start({ iv: forge.util.createBuffer(iv) }); - - // Update with plaintext cipher.update(forge.util.createBuffer(plaintext, 'utf8')); - - // Finish encryption cipher.finish(); - // Get ciphertext and auth tag const ciphertext = cipher.output.getBytes(); const authTag = cipher.mode.tag.getBytes(); - - // Combine ciphertext + auth tag (Web Crypto API format) const encryptedWithTag = ciphertext + authTag; - // Convert to base64 return forge.util.encode64(encryptedWithTag); } @@ -50,13 +96,45 @@ export async function decryptWithAESGCM( keyBase64: string, ivBase64: string ): Promise { - // Convert base64 to forge-compatible format + // Try native implementation first (if available) + if (createDecipheriv && QuickCryptoBuffer) { + try { + const key = QuickCryptoBuffer.from(keyBase64, 'base64'); + const iv = QuickCryptoBuffer.from(ivBase64, 'base64'); + const encryptedDataWithTag = QuickCryptoBuffer.from(encryptedBase64, 'base64'); + + const tagLength = ENCRYPTION_CONFIG.GCM_TAG_LENGTH; + + if (encryptedDataWithTag.length < tagLength) { + throw new Error( + `Encrypted data too short for GCM (${encryptedDataWithTag.length} bytes, need at least ${tagLength})` + ); + } + + // Split the data: ciphertext + auth tag (last 16 bytes) + const ciphertext = encryptedDataWithTag.subarray(0, -tagLength); + const authTag = encryptedDataWithTag.subarray(-tagLength); + + const decipher = createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(ciphertext); + decrypted = QuickCryptoBuffer.concat([decrypted, decipher.final()]); + + return decrypted.toString('utf8'); + } catch (error) { + if (__DEV__) { + console.error('[Encryption] ❌ Native AES-GCM decryption failed:', error); + console.error('[Encryption] Error details:', JSON.stringify(error, Object.getOwnPropertyNames(error))); + } + } + } + + // Fallback to node-forge const key = forge.util.decode64(keyBase64); const iv = forge.util.decode64(ivBase64); const encryptedDataWithTag = forge.util.decode64(encryptedBase64); - // For node-forge GCM, we need to manually handle the auth tag - // Web Crypto API embeds the auth tag at the end of the encrypted data const tagLength = ENCRYPTION_CONFIG.GCM_TAG_LENGTH; if (encryptedDataWithTag.length < tagLength) { @@ -65,23 +143,17 @@ export async function decryptWithAESGCM( ); } - // Split the data: ciphertext + auth tag (last 16 bytes) const ciphertext = encryptedDataWithTag.slice(0, -tagLength); const authTag = encryptedDataWithTag.slice(-tagLength); - // Create AES-GCM decipher const decipher = forge.cipher.createDecipher('AES-GCM', key); - - // Start decryption with IV and auth tag decipher.start({ iv: forge.util.createBuffer(iv), tag: forge.util.createBuffer(authTag), }); - // Update with ciphertext decipher.update(forge.util.createBuffer(ciphertext)); - // Finish and verify auth tag const success = decipher.finish(); if (!success) { diff --git a/apps/mobile/v1/src/lib/encryption/core/keyDerivation.ts b/apps/mobile/v1/src/lib/encryption/core/keyDerivation.ts index 46902c3..a326057 100644 --- a/apps/mobile/v1/src/lib/encryption/core/keyDerivation.ts +++ b/apps/mobile/v1/src/lib/encryption/core/keyDerivation.ts @@ -1,6 +1,7 @@ /** * Key Derivation Functions - * PBKDF2 implementation using node-forge + * PBKDF2 implementation using node-forge (for Expo Go compatibility) + * Can be upgraded to native implementation in development builds */ import forge from 'node-forge'; @@ -8,10 +9,27 @@ import { InteractionManager } from 'react-native'; import { ENCRYPTION_CONFIG } from '../config'; +// Try to import native crypto, but don't fail if not available (Expo Go) +let nativePbkdf2: any = null; +let QuickCryptoBuffer: any = null; +try { + // Dynamic import - won't crash if module not available + const quickCrypto = require('react-native-quick-crypto'); + nativePbkdf2 = quickCrypto.pbkdf2; + QuickCryptoBuffer = quickCrypto.Buffer; + console.log('[Encryption] Native PBKDF2 available - will use fast implementation'); +} catch (error) { + console.log('[Encryption] Native PBKDF2 not available - using node-forge (slower but compatible with Expo Go)'); +} /** - * PBKDF2 implementation using node-forge to match web app - * Wrapped with InteractionManager to ensure UI updates before blocking operation + * PBKDF2 implementation with automatic native/fallback selection + * - Uses react-native-quick-crypto if available (development builds) + * - Falls back to node-forge for Expo Go compatibility + * + * Performance: + * - Native: ~2-5 seconds, non-blocking + * - Fallback: ~120 seconds, UI responsive after initial delay */ export async function pbkdf2( password: string, @@ -19,37 +37,54 @@ export async function pbkdf2( iterations: number = ENCRYPTION_CONFIG.ITERATIONS, keyLength: number = ENCRYPTION_CONFIG.KEY_LENGTH ): Promise { - try { - // Wait for any pending interactions (UI updates) to complete before blocking - await new Promise(resolve => { - InteractionManager.runAfterInteractions(() => { - resolve(true); - }); - }); + // Try native implementation first (if available) + if (nativePbkdf2 && QuickCryptoBuffer) { + try { + const passwordBuffer = QuickCryptoBuffer.from(password, 'utf8'); + const saltBuffer = QuickCryptoBuffer.from(salt, 'utf8'); - // Small delay to ensure loading UI is fully rendered - await new Promise(resolve => setTimeout(resolve, 100)); + const derivedKey = await nativePbkdf2( + passwordBuffer, + saltBuffer, + iterations, + keyLength / 8, + 'sha256' + ); - // Convert inputs to proper format to match web app - const passwordBytes = forge.util.encodeUtf8(password); + return derivedKey.toString('base64'); + } catch (error) { + console.warn('[Encryption] Native PBKDF2 failed, falling back to node-forge:', error); + } + } - // Salt is already a string, use it directly - const saltBytes = forge.util.encodeUtf8(salt); + // Fallback to node-forge + return pbkdf2Fallback(password, salt, iterations, keyLength); +} + +/** + * Fallback PBKDF2 implementation using node-forge + * Used only if native implementation is not available + */ +async function pbkdf2Fallback( + password: string, + salt: string, + iterations: number, + keyLength: number +): Promise { + // Convert inputs to proper format + const passwordBytes = forge.util.encodeUtf8(password); + const saltBytes = forge.util.encodeUtf8(salt); - // Perform PBKDF2 computation (will block for ~2 minutes with 250k iterations) - // This is synchronous and will freeze the UI, but we've ensured the loading screen is shown - const derivedKey = forge.pkcs5.pbkdf2( - passwordBytes, - saltBytes, - iterations, - keyLength / 8, // Convert bits to bytes - 'sha256' - ); + // Perform PBKDF2 computation (synchronous - will block UI) + const derivedKey = forge.pkcs5.pbkdf2( + passwordBytes, + saltBytes, + iterations, + keyLength / 8, + 'sha256' + ); - return forge.util.encode64(derivedKey); - } catch (error) { - throw new Error(`PBKDF2 key derivation failed: ${error}`); - } + return forge.util.encode64(derivedKey); } /** diff --git a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/NoteListItem.tsx b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/NoteListItem.tsx index b37f1f2..08cbee5 100644 --- a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/NoteListItem.tsx +++ b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/NoteListItem.tsx @@ -3,7 +3,6 @@ import React from 'react'; import { Animated, Pressable, StyleSheet, Text, View } from 'react-native'; import type { Folder, Note } from '@/src/services/api'; -import { useTheme } from '@/src/theme'; import { stripHtmlTags } from '@/src/utils/noteUtils'; import { NoteSkeletonItem } from './NoteSkeletonItem'; @@ -17,9 +16,14 @@ interface NoteListItemProps { foldersMap: Map; skeletonOpacity: Animated.Value; enhancedDataCache: React.MutableRefObject>; + // Pass theme colors as props instead of using hook in each item + foregroundColor: string; + mutedForegroundColor: string; + mutedColor: string; + borderColor: string; } -export const NoteListItem: React.FC = ({ +const NoteListItemComponent: React.FC = ({ note, isLastItem, folderId, @@ -28,8 +32,11 @@ export const NoteListItem: React.FC = ({ foldersMap, skeletonOpacity, enhancedDataCache, + foregroundColor, + mutedForegroundColor, + mutedColor, + borderColor, }) => { - const theme = useTheme(); // Check if note is still encrypted (loading skeleton) const noteIsEncrypted = note.title === '[ENCRYPTED]' || note.content === '[ENCRYPTED]'; @@ -40,10 +47,18 @@ export const NoteListItem: React.FC = ({ } // Lazy calculation - strip HTML and format date only when rendered + // Use cache version if available, otherwise create minimal version let enhancedData = enhancedDataCache.current.get(note.id); if (!enhancedData) { + // For encrypted notes, skip expensive HTML stripping - just show placeholder + const preview = note.hidden + ? '[HIDDEN]' + : note.content + ? stripHtmlTags(note.content).substring(0, 200) // Limit preview length + : ''; + enhancedData = { - preview: note.hidden ? '[HIDDEN]' : stripHtmlTags(note.content || ''), + preview, formattedDate: new Date(note.createdAt || Date.now()).toLocaleDateString('en-US', { month: 'short', day: 'numeric', @@ -60,12 +75,12 @@ export const NoteListItem: React.FC = ({ onPress(note.id)} style={styles.noteListItem} - android_ripple={{ color: theme.colors.muted }} + android_ripple={{ color: mutedColor }} > @@ -85,13 +100,13 @@ export const NoteListItem: React.FC = ({ {note.starred && ( )} - + {enhancedData?.formattedDate || ''} {enhancedData?.preview || ''} @@ -99,18 +114,22 @@ export const NoteListItem: React.FC = ({ {!folderId && folderPath && ( - + {folderPath} )} - {!isLastItem && } + {!isLastItem && } ); }; +// Memoized version - just use default shallow comparison +// This is faster than custom comparison during scroll +export const NoteListItem = React.memo(NoteListItemComponent); + const styles = StyleSheet.create({ noteListItemWrapper: { paddingHorizontal: 16, diff --git a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/NoteSkeletonItem.tsx b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/NoteSkeletonItem.tsx index 8dcae6d..740f1d1 100644 --- a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/NoteSkeletonItem.tsx +++ b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/NoteSkeletonItem.tsx @@ -11,6 +11,7 @@ interface NoteSkeletonItemProps { export const NoteSkeletonItem: React.FC = ({ skeletonOpacity, isLastItem }) => { const theme = useTheme(); + // Use shared animation from parent - NO per-item animations for performance return ( diff --git a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/NotesHeader.tsx b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/NotesHeader.tsx index a730a6f..8d74291 100644 --- a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/NotesHeader.tsx +++ b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/NotesHeader.tsx @@ -1,6 +1,6 @@ import { Ionicons } from '@expo/vector-icons'; import React from 'react'; -import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { NOTE_CARD } from '@/src/constants/ui'; import { useTheme } from '@/src/theme'; @@ -52,36 +52,32 @@ export const NotesHeader: React.FC = ({ - {/* Create Note Button / Empty Trash Button */} + {/* Create Note Button / Empty Trash Button - Full width to match note list */} {viewType === 'trash' ? ( filteredNotesCount > 0 && ( - - + - - - - Empty Trash - - - + + + Empty Trash + + ) ) : ( - - + - - - - Create Note - - - + + + Create Note + + )} @@ -114,18 +110,16 @@ const styles = StyleSheet.create({ viewModeButtonActive: { backgroundColor: 'rgba(59, 130, 246, 0.1)', }, + buttonWrapper: { + paddingHorizontal: 16, + marginBottom: NOTE_CARD.SPACING, + }, createNoteButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - padding: 12, - borderRadius: 12, + borderRadius: NOTE_CARD.BORDER_RADIUS, borderWidth: 1, - }, - buttonContent: { - flexDirection: 'row', - alignItems: 'center', - flex: 1, + paddingVertical: 12, + paddingLeft: 12, + paddingRight: 12, }, buttonText: { fontSize: 15, diff --git a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/index.tsx b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/index.tsx index 1c4c311..d6b9a09 100644 --- a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/index.tsx +++ b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/index.tsx @@ -131,6 +131,9 @@ export default function NotesList({ navigation, route, renderHeader, scrollY: pa }; }, [setNotes]); + // Track if this is the first focus (to force load) + const isFirstFocus = useRef(true); + // Load notes when screen focuses or params change useFocusEffect( React.useCallback(() => { @@ -144,6 +147,26 @@ export default function NotesList({ navigation, route, renderHeader, scrollY: pa return; } + // Skip full reload on navigation back IF we already have decrypted notes + // Only reload on first focus or when params change (deps) + // Check if notes are actually decrypted (not just encrypted placeholders) + const hasDecryptedNotes = notes.length > 0 && notes.some(note => + note.title !== '[ENCRYPTED]' && note.content !== '[ENCRYPTED]' + ); + + if (!isFirstFocus.current && hasDecryptedNotes) { + if (__DEV__) { + console.log('[NotesList] Skipping reload - already have', notes.length, 'decrypted notes cached'); + } + loadViewMode(); + return; + } + + if (__DEV__) { + console.log('[NotesList] Full reload - first focus or params changed'); + } + isFirstFocus.current = false; + loadNotes(); loadViewMode(); // Reset scroll position when screen comes into focus @@ -243,13 +266,9 @@ export default function NotesList({ navigation, route, renderHeader, scrollY: pa const folderPathsMap = useFolderPaths(allFolders); // Lazy cache for note previews and dates (only calculate when rendered) + // DON'T clear this cache - let it persist across updates const notesEnhancedDataCache = useRef(new Map()); - // Clear cache when notes change - useEffect(() => { - notesEnhancedDataCache.current.clear(); - }, [notes]); - // Create a folder lookup map for O(1) access const foldersMap = useMemo(() => { return new Map(allFolders.map(folder => [folder.id, folder])); @@ -258,6 +277,12 @@ export default function NotesList({ navigation, route, renderHeader, scrollY: pa // Check if any filters are active const hasActiveFilters = filterConfig.showAttachmentsOnly || filterConfig.showStarredOnly || filterConfig.showHiddenOnly; + // Extract theme colors as individual values to prevent object recreation + const foregroundColor = theme.colors.foreground; + const mutedForegroundColor = theme.colors.mutedForeground; + const mutedColor = theme.colors.muted; + const borderColor = theme.colors.border; + // Skeleton shimmer animation const skeletonOpacity = useRef(new Animated.Value(0.3)).current; @@ -283,6 +308,11 @@ export default function NotesList({ navigation, route, renderHeader, scrollY: pa return () => skeletonOpacity.stopAnimation(); }, [skeletonOpacity]); + // Stable onPress handler (fixes React.memo breaking) + const handleNotePress = useCallback((noteId: string) => { + navigation?.navigate('ViewNote', { noteId }); + }, [navigation]); + // Render individual note item const renderNoteItem = useCallback(({ item: note, index }: { item: Note; index: number }) => { return ( @@ -290,14 +320,28 @@ export default function NotesList({ navigation, route, renderHeader, scrollY: pa note={note} isLastItem={index === filteredNotes.length - 1} folderId={folderId} - onPress={(noteId) => navigation?.navigate('ViewNote', { noteId })} + onPress={handleNotePress} folderPathsMap={folderPathsMap} foldersMap={foldersMap} skeletonOpacity={skeletonOpacity} enhancedDataCache={notesEnhancedDataCache} + foregroundColor={foregroundColor} + mutedForegroundColor={mutedForegroundColor} + mutedColor={mutedColor} + borderColor={borderColor} /> ); - }, [filteredNotes.length, folderId, navigation, folderPathsMap, foldersMap, skeletonOpacity, notesEnhancedDataCache]); + }, [filteredNotes.length, folderId, handleNotePress, folderPathsMap, foldersMap, skeletonOpacity, notesEnhancedDataCache, foregroundColor, mutedForegroundColor, mutedColor, borderColor]); + + // Critical optimization: Tell FlatList exact item heights for instant scrolling + const getItemLayout = useCallback((_data: Note[] | null | undefined, index: number) => { + const ITEM_HEIGHT = 108; // Average height: padding(24) + title(23) + preview(40) + meta(20) + divider(1) + return { + length: ITEM_HEIGHT, + offset: ITEM_HEIGHT * index, + index, + }; + }, []); // Render list header (subfolders and create note button) const renderListHeader = useCallback(() => { @@ -350,25 +394,18 @@ export default function NotesList({ navigation, route, renderHeader, scrollY: pa )} - item.id} + getItemLayout={getItemLayout} ListHeaderComponent={renderListHeader} ListEmptyComponent={renderEmptyComponent} ListFooterComponent={} style={styles.scrollView} contentContainerStyle={{ flexGrow: 1 }} showsVerticalScrollIndicator={false} - scrollEnabled={true} - bounces={true} - alwaysBounceVertical={true} - onScroll={Animated.event( - [{ nativeEvent: { contentOffset: { y: scrollY } } }], - { useNativeDriver: false } - )} - scrollEventThrottle={16} refreshControl={ } - removeClippedSubviews={false} - maxToRenderPerBatch={20} + removeClippedSubviews={true} + maxToRenderPerBatch={15} updateCellsBatchingPeriod={50} - initialNumToRender={100} - windowSize={21} + initialNumToRender={15} + windowSize={7} /> diff --git a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/useNotesLoader.ts b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/useNotesLoader.ts index 6ba9256..b89166c 100644 --- a/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/useNotesLoader.ts +++ b/apps/mobile/v1/src/screens/FolderNotes/components/NotesList/useNotesLoader.ts @@ -157,51 +157,30 @@ export function useNotesLoader({ ); if (encryptedRemaining.length > 0) { - console.log(`[PERF OPTIMIZED] 🔐 Starting background decryption of ${encryptedRemaining.length} notes in batches of 10`); - - // Decrypt in batches of 10 - const decryptInBatches = async () => { - const batchSize = 10; - let processedCount = 0; - - for (let i = 0; i < encryptedRemaining.length; i += batchSize) { - const batch = encryptedRemaining.slice(i, i + batchSize); - const batchStart = performance.now(); - - // Decrypt this batch - const decryptedBatch = await Promise.all( - batch.map(note => decryptNote(note, userId)) + console.log(`[PERF OPTIMIZED] 🔐 Starting background decryption of ${encryptedRemaining.length} notes - will update once at end`); + + // Decrypt ALL in background, update UI ONCE at end to avoid scroll jank + setTimeout(async () => { + try { + // Decrypt all remaining notes at once (parallel for speed) + const allDecrypted = await Promise.all( + encryptedRemaining.map(note => decryptNote(note, userId)) ); - const batchEnd = performance.now(); - processedCount += batch.length; - console.log(`[PERF OPTIMIZED] 🔓 Decrypted batch ${Math.floor(i / batchSize) + 1} (${batch.length} notes) in ${(batchEnd - batchStart).toFixed(2)}ms - ${processedCount}/${encryptedRemaining.length} total`); + console.log(`[PERF OPTIMIZED] ✅ Decrypted all ${encryptedRemaining.length} notes - applying single update`); + + // Single UI update with all decrypted notes + const updateMap = new Map(allDecrypted.map(n => [n.id, n])); - // Update UI with this batch setNotes(currentNotes => { - const updated = [...currentNotes]; - decryptedBatch.forEach(decryptedNote => { - const index = updated.findIndex(n => n.id === decryptedNote.id); - if (index !== -1) { - updated[index] = decryptedNote; - } - }); - return updated; + return currentNotes.map(note => updateMap.get(note.id) || note); }); - // Small delay between batches to keep UI responsive - if (i + batchSize < encryptedRemaining.length) { - await new Promise(resolve => setTimeout(resolve, 100)); - } + console.log(`[PERF OPTIMIZED] 🎉 UI updated with all decrypted notes`); + } catch (error) { + console.error('[PERF OPTIMIZED] Decryption error:', error); } - - console.log(`[PERF OPTIMIZED] ✅ Finished decrypting all ${encryptedRemaining.length} remaining notes`); - }; - - // Start background decryption after initial render - setTimeout(() => { - decryptInBatches(); - }, 300); + }, 1000); // Wait longer before starting decryption } } } else { diff --git a/apps/mobile/v1/src/screens/Home/index.tsx b/apps/mobile/v1/src/screens/Home/index.tsx index 87f4e11..d7075dd 100644 --- a/apps/mobile/v1/src/screens/Home/index.tsx +++ b/apps/mobile/v1/src/screens/Home/index.tsx @@ -618,7 +618,9 @@ const styles = StyleSheet.create({ newNoteAction: { flexDirection: 'row', alignItems: 'center', - padding: ACTION_BUTTON.PADDING, + paddingVertical: 12, + paddingLeft: 12, + paddingRight: 12, borderRadius: ACTION_BUTTON.BORDER_RADIUS, borderWidth: 1, }, @@ -626,7 +628,7 @@ const styles = StyleSheet.create({ marginRight: ACTION_BUTTON.ICON_SPACING, }, actionText: { - fontSize: ACTION_BUTTON.TEXT_SIZE, + fontSize: 15, fontWeight: '500', }, section: { @@ -668,7 +670,9 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - padding: FOLDER_CARD.PADDING, + paddingVertical: 12, + paddingLeft: 12, + paddingRight: 12, borderRadius: FOLDER_CARD.BORDER_RADIUS, borderWidth: 1, }, @@ -681,7 +685,7 @@ const styles = StyleSheet.create({ marginRight: FOLDER_CARD.PADDING, }, specialViewLabel: { - fontSize: FOLDER_CARD.NAME_SIZE, + fontSize: 15, fontWeight: '500', }, countBadge: { @@ -709,7 +713,9 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - padding: FOLDER_CARD.PADDING, + paddingVertical: 12, + paddingLeft: 12, + paddingRight: 12, borderRadius: FOLDER_CARD.BORDER_RADIUS, borderWidth: 1, }, @@ -738,7 +744,7 @@ const styles = StyleSheet.create({ marginRight: FOLDER_CARD.PADDING, }, folderName: { - fontSize: FOLDER_CARD.NAME_SIZE, + fontSize: 15, fontWeight: '500', }, emptyState: { diff --git a/apps/mobile/v1/src/screens/Settings/index.tsx b/apps/mobile/v1/src/screens/Settings/index.tsx index 4a3a5b3..1bef550 100644 --- a/apps/mobile/v1/src/screens/Settings/index.tsx +++ b/apps/mobile/v1/src/screens/Settings/index.tsx @@ -10,6 +10,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { UsageBottomSheet } from '../../components/settings/UsageBottomSheet'; import { Card } from '../../components/ui'; import { APP_VERSION } from '../../constants/version'; +import { NOTE_CARD } from '../../constants/ui'; import { forceGlobalMasterPasswordRefresh } from '../../hooks/useMasterPassword'; import { clearUserEncryptionData } from '../../lib/encryption'; import { clearDecryptedCache,getCacheDecryptedContentPreference, setCacheDecryptedContentPreference } from '../../lib/preferences'; @@ -1003,7 +1004,7 @@ export default function SettingsScreen({ onLogout }: Props) { backgroundColor: '#ef4444', paddingVertical: 14, paddingHorizontal: 20, - borderRadius: 8, + borderRadius: 4, alignItems: 'center', marginTop: 8, }} @@ -1097,7 +1098,7 @@ const styles = StyleSheet.create({ iconContainer: { width: 36, height: 36, - borderRadius: 8, + borderRadius: NOTE_CARD.BORDER_RADIUS, alignItems: 'center', justifyContent: 'center', marginRight: 12, @@ -1174,7 +1175,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', padding: 16, - borderRadius: 12, + borderRadius: 4, borderWidth: 1, }, optionIcon: { @@ -1207,7 +1208,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', padding: 16, - borderRadius: 12, + borderRadius: 4, borderWidth: 1, marginBottom: 12, }, @@ -1221,7 +1222,7 @@ const styles = StyleSheet.create({ themeOptionIcon: { width: 36, height: 36, - borderRadius: 8, + borderRadius: NOTE_CARD.BORDER_RADIUS, alignItems: 'center', justifyContent: 'center', marginRight: 12, @@ -1251,7 +1252,7 @@ const styles = StyleSheet.create({ colorPreview: { width: 36, height: 36, - borderRadius: 8, + borderRadius: NOTE_CARD.BORDER_RADIUS, marginRight: 12, borderWidth: 1, padding: 4, @@ -1261,7 +1262,7 @@ const styles = StyleSheet.create({ colorPreviewInner: { width: 20, height: 20, - borderRadius: 4, + borderRadius: NOTE_CARD.BORDER_RADIUS, }, radioButton: { width: 20, diff --git a/apps/mobile/v1/src/screens/ViewNote/NoteContent.tsx b/apps/mobile/v1/src/screens/ViewNote/NoteContent.tsx index f92675b..a95bceb 100644 --- a/apps/mobile/v1/src/screens/ViewNote/NoteContent.tsx +++ b/apps/mobile/v1/src/screens/ViewNote/NoteContent.tsx @@ -1,5 +1,5 @@ -import React, { useRef, useState } from 'react'; -import { Animated, ScrollView, StyleSheet, Text, View } from 'react-native'; +import React, { useRef, useState, useMemo } from 'react'; +import { Animated, Dimensions, ScrollView, StyleSheet, Text, View } from 'react-native'; import { WebView } from 'react-native-webview'; import type { Note } from '../../services/api'; @@ -34,21 +34,29 @@ export function NoteContent({ theme, }: NoteContentProps) { const webViewRef = useRef(null); - const [webViewHeight, setWebViewHeight] = useState(300); + // For diagrams, use screen height; for regular notes, use dynamic height + const [webViewHeight, setWebViewHeight] = useState( + note.type === 'diagram' ? Dimensions.get('window').height - 150 : 300 + ); // Calculate hairline width for CSS (equivalent to StyleSheet.hairlineWidth) const cssHairlineWidth = `${StyleSheet.hairlineWidth}px`; + // Check if this is a diagram note + const isDiagram = note.type === 'diagram'; + // Enhanced HTML with optional title and metadata - const fullHtml = ` + // Memoized to prevent expensive re-generation on every render + const fullHtml = useMemo(() => ` - + + ${isDiagram ? '' : ''} @@ -298,11 +332,30 @@ export function NoteContent({ ` : '' } + ${isDiagram ? ` +
+
+${note.content} +
+
+ ` : `
${htmlContent.match(/([\s\S]*?)<\/body>/)?.[1] || note.content}
+ `} - `; + `, [ + note.id, // Re-generate when viewing different note + note.content, // Re-generate when content changes + note.type, // Re-generate when note type changes (diagram vs regular) + note.title, // Re-generate when title changes + note.createdAt, // Re-generate when metadata changes + note.updatedAt, + htmlContent, // Re-generate when HTML content changes + showTitle, // Re-generate when title visibility changes + isDiagram, // Re-generate when diagram state changes + theme.colors.foreground, // Re-generate when theme colors change + theme.colors.mutedForeground, + theme.colors.border, + theme.colors.muted, + theme.colors.primary, + theme.isDark, // Re-generate when dark mode toggles + cssHairlineWidth, // Re-generate if hairline width changes (rare) + ]); return ( @@ -341,15 +426,37 @@ export function NoteContent({ ref={webViewRef} source={{ html: fullHtml }} style={[styles.webview, { height: webViewHeight }]} - scrollEnabled={false} + scrollEnabled={true} showsVerticalScrollIndicator={false} showsHorizontalScrollIndicator={false} originWhitelist={['*']} + javaScriptEnabled={true} + domStorageEnabled={true} + // @ts-ignore - Android specific - enable zoom + setSupportMultipleWindows={false} + // @ts-ignore - Android specific - enable zoom controls + builtInZoomControls={isDiagram} + // @ts-ignore - Android specific - hide on-screen zoom buttons + displayZoomControls={false} + // @ts-ignore - Android specific - allow zooming + useWebViewClient={true} + // @ts-ignore - iOS specific prop + allowsInlineMediaPlayback={true} + // @ts-ignore - iOS specific prop + bounces={true} + // @ts-ignore - iOS specific - disable link preview + allowsLinkPreview={false} onMessage={(event) => { try { const data = JSON.parse(event.nativeEvent.data); if (data.type === 'height') { - setWebViewHeight(data.height); + // For diagrams, keep initial screen height - don't update + // For regular notes, use exact content height + if (!isDiagram) { + setWebViewHeight(data.height); + } + } else if (data.type === 'error') { + console.error('WebView error:', data.error); } } catch { // Ignore parse errors @@ -362,9 +469,12 @@ export function NoteContent({ } const styles = StyleSheet.create({ - container: {}, + container: { + // No flex needed - inside ScrollView + }, webview: { backgroundColor: 'transparent', + // Height set inline based on content or screen size }, hiddenContainer: { minHeight: 200, diff --git a/apps/mobile/v1/src/screens/ViewNote/index.tsx b/apps/mobile/v1/src/screens/ViewNote/index.tsx index 5bb5e62..d581bf4 100644 --- a/apps/mobile/v1/src/screens/ViewNote/index.tsx +++ b/apps/mobile/v1/src/screens/ViewNote/index.tsx @@ -195,6 +195,7 @@ export default function ViewNoteScreen() { ref={scrollViewRef} style={{ flex: 1 }} showsVerticalScrollIndicator={false} + scrollEnabled={note.type !== 'diagram'} onScroll={Animated.event( [{ nativeEvent: { contentOffset: { y: scrollY } } }], { useNativeDriver: false } diff --git a/apps/mobile/v1/src/services/api/databaseCache.ts b/apps/mobile/v1/src/services/api/databaseCache.ts index 7aef2bb..bd95a12 100644 --- a/apps/mobile/v1/src/services/api/databaseCache.ts +++ b/apps/mobile/v1/src/services/api/databaseCache.ts @@ -261,45 +261,58 @@ export async function storeCachedNotes( notes: Note[], options?: { storeDecrypted?: boolean } ): Promise { + if (!notes.length) return; + try { const db = getDatabase(); const now = Date.now(); const storeDecrypted = options?.storeDecrypted ?? false; - for (const note of notes) { - // If storing decrypted, use the actual title/content - // If not, clear title/content and only keep encrypted versions - const title = storeDecrypted ? note.title : ''; - const content = storeDecrypted ? note.content : ''; - - await db.runAsync( - `INSERT OR REPLACE INTO notes ( - id, title, content, folder_id, user_id, starred, archived, deleted, hidden, - created_at, updated_at, encrypted_title, encrypted_content, iv, salt, - is_synced, is_dirty, synced_at, attachment_count - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - note.id, - title, - content, - note.folderId || null, - note.userId, - note.starred ? 1 : 0, - note.archived ? 1 : 0, - note.deleted ? 1 : 0, - note.hidden ? 1 : 0, - note.createdAt, // Store as ISO string - note.updatedAt, // Store as ISO string - note.encryptedTitle || null, - note.encryptedContent || null, - note.iv || null, - note.salt || null, - 1, // is_synced - 0, // is_dirty - now, // synced_at - note.attachmentCount || 0, // attachment_count - ] - ); + // Process in smaller chunks to avoid overwhelming the database + const CHUNK_SIZE = 50; + for (let i = 0; i < notes.length; i += CHUNK_SIZE) { + const chunk = notes.slice(i, i + CHUNK_SIZE); + + for (const note of chunk) { + try { + // If storing decrypted, use the actual title/content + // If not, clear title/content and only keep encrypted versions + const title = storeDecrypted ? note.title : ''; + const content = storeDecrypted ? note.content : ''; + + await db.runAsync( + `INSERT OR REPLACE INTO notes ( + id, title, content, folder_id, user_id, starred, archived, deleted, hidden, + created_at, updated_at, encrypted_title, encrypted_content, iv, salt, + is_synced, is_dirty, synced_at, attachment_count + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + note.id, + title, + content, + note.folderId || null, + note.userId, + note.starred ? 1 : 0, + note.archived ? 1 : 0, + note.deleted ? 1 : 0, + note.hidden ? 1 : 0, + note.createdAt, // Store as ISO string + note.updatedAt, // Store as ISO string + note.encryptedTitle || null, + note.encryptedContent || null, + note.iv || null, + note.salt || null, + 1, // is_synced + 0, // is_dirty + now, // synced_at + note.attachmentCount || 0, // attachment_count + ] + ); + } catch (noteError) { + // Skip individual note errors, continue with others + console.warn(`[DatabaseCache] Failed to store note ${note.id}:`, noteError); + } + } } if (__DEV__ && storeDecrypted) { @@ -383,30 +396,37 @@ export async function getCachedFolders(): Promise { * Store folders to database cache */ export async function storeCachedFolders(folders: Folder[]): Promise { + if (!folders.length) return; + try { const db = getDatabase(); const now = Date.now(); for (const folder of folders) { - await db.runAsync( - `INSERT OR REPLACE INTO folders ( - id, name, color, parent_id, user_id, sort_order, - created_at, updated_at, is_synced, is_dirty, synced_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - folder.id, - folder.name, - folder.color, - folder.parentId || null, - folder.userId, - folder.sortOrder || 0, - folder.createdAt, // Store as ISO string - folder.updatedAt, // Store as ISO string - 1, // is_synced - 0, // is_dirty - now, // synced_at - ] - ); + try { + await db.runAsync( + `INSERT OR REPLACE INTO folders ( + id, name, color, parent_id, user_id, sort_order, + created_at, updated_at, is_synced, is_dirty, synced_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + folder.id, + folder.name, + folder.color, + folder.parentId || null, + folder.userId, + folder.sortOrder || 0, + folder.createdAt, // Store as ISO string + folder.updatedAt, // Store as ISO string + 1, // is_synced + 0, // is_dirty + now, // synced_at + ] + ); + } catch (folderError) { + // Skip individual folder errors, continue with others + console.warn(`[DatabaseCache] Failed to store folder ${folder.id}:`, folderError); + } } } catch (error) { if (error instanceof Error && error.message.includes('Database not initialized')) { diff --git a/apps/mobile/v1/src/services/api/types.ts b/apps/mobile/v1/src/services/api/types.ts index 49c564b..d66e44d 100644 --- a/apps/mobile/v1/src/services/api/types.ts +++ b/apps/mobile/v1/src/services/api/types.ts @@ -31,6 +31,7 @@ export interface Note { id: string; title: string; content: string; + type?: 'note' | 'diagram' | 'code'; // Type of note: regular note, diagram, or code folderId?: string; userId: string; starred: boolean; diff --git a/apps/mobile/v1/src/theme/index.tsx b/apps/mobile/v1/src/theme/index.tsx index 5267f14..d48ec48 100644 --- a/apps/mobile/v1/src/theme/index.tsx +++ b/apps/mobile/v1/src/theme/index.tsx @@ -1,5 +1,5 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; -import React, { createContext, useContext, useEffect,useState } from 'react'; +import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { useColorScheme } from 'react-native'; import { DARK_THEME_PRESETS, type DarkThemePreset,LIGHT_THEME_PRESETS, type LightThemePreset } from './presets'; @@ -224,7 +224,8 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre ? DARK_THEME_PRESETS[darkTheme].colors : LIGHT_THEME_PRESETS[lightTheme].colors; - const value = { + // Memoize context value to prevent unnecessary re-renders across entire app + const value = useMemo(() => ({ colors: currentColors, spacing: theme.spacing, typography: theme.typography, @@ -237,7 +238,20 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre setLightTheme, setDarkTheme, toggleTheme, - }; + }), [ + currentColors, + theme.spacing, + theme.typography, + theme.borderRadius, + isDark, + themeMode, + lightTheme, + darkTheme, + setThemeMode, + setLightTheme, + setDarkTheme, + toggleTheme, + ]); // Don't render until preferences are loaded if (!isLoaded) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9eaed3c..0e5d42d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -291,6 +291,9 @@ importers: expo-image: specifier: ~3.0.8 version: 3.0.10(expo@54.0.20)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.1.1(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0) + expo-linear-gradient: + specifier: ~15.0.7 + version: 15.0.7(expo@54.0.20)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0) expo-linking: specifier: ~8.0.8 version: 8.0.8(expo@54.0.20)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0) @@ -339,6 +342,9 @@ importers: react-native-gesture-handler: specifier: ~2.28.0 version: 2.28.0(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0) + react-native-quick-crypto: + specifier: ^0.7.17 + version: 0.7.17(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0) react-native-reanimated: specifier: ~4.1.1 version: 4.1.3(@babel/core@7.28.4)(react-native-worklets@0.5.1(@babel/core@7.28.4)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0) @@ -1100,6 +1106,9 @@ packages: resolution: {integrity: sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw==} engines: {node: '>=v18'} + '@craftzdog/react-native-buffer@6.1.1': + resolution: {integrity: sha512-YXJ0Jr4V+Hk2CZXpQw0A0NJeuiW2Rv6rAAutJCZ2k/JG13vLsppUibkJ8exSMxODtH9yJUrLiR96rilG3pFZ4Q==} + '@egjs/hammerjs@2.0.17': resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} engines: {node: '>=0.8.0'} @@ -3655,6 +3664,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -4652,6 +4664,10 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + exec-async@2.2.0: resolution: {integrity: sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==} @@ -4773,6 +4789,13 @@ packages: expo: '*' react: '*' + expo-linear-gradient@15.0.7: + resolution: {integrity: sha512-yF+y+9Shpr/OQFfy/wglB/0bykFMbwHBTuMRa5Of/r2P1wbkcacx8rg0JsUWkXH/rn2i2iWdubyqlxSJa3ggZA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-linking@8.0.8: resolution: {integrity: sha512-MyeMcbFDKhXh4sDD1EHwd0uxFQNAc6VCrwBkNvvvufUsTYFq3glTA9Y8a+x78CPpjNqwNAamu74yIaIz7IEJyg==} peerDependencies: @@ -5446,6 +5469,10 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -7001,6 +7028,10 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -7158,6 +7189,15 @@ packages: react: '*' react-native: '*' + react-native-quick-base64@2.2.2: + resolution: {integrity: sha512-WLHSifHLoamr2kF00Gov0W9ud6CfPshe1rmqWTquVIi9c62qxOaJCFVDrXFZhEBU8B8PvGLVuOlVKH78yhY0Fg==} + peerDependencies: + react: '*' + react-native: '*' + + react-native-quick-crypto@0.7.17: + resolution: {integrity: sha512-cJzp6oA/dM1lujt+Rwtn46Mgcs3w9F/0oQvNz1jcADc/AXktveAOUTzzKrDMxyg6YPziCYnoqMDzHBo6OLSU1g==} + react-native-reanimated@4.1.3: resolution: {integrity: sha512-GP8wsi1u3nqvC1fMab/m8gfFwFyldawElCcUSBJQgfrXeLmsPPUOpDw44lbLeCpcwUuLa05WTVePdTEwCLTUZg==} peerDependencies: @@ -7280,6 +7320,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -8148,6 +8192,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} @@ -9088,8 +9135,8 @@ snapshots: '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 '@babel/traverse@7.28.4': dependencies: @@ -9405,6 +9452,14 @@ snapshots: '@types/conventional-commits-parser': 5.0.1 chalk: 5.5.0 + '@craftzdog/react-native-buffer@6.1.1(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0)': + dependencies: + ieee754: 1.2.1 + react-native-quick-base64: 2.2.2(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0) + transitivePeerDependencies: + - react + - react-native + '@egjs/hammerjs@2.0.17': dependencies: '@types/hammerjs': 2.0.46 @@ -10095,7 +10150,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.30 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/remapping@2.3.5': dependencies: @@ -11969,7 +12024,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.9.2) '@typescript-eslint/types': 8.44.0 - debug: 4.4.1 + debug: 4.4.3 typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -12159,7 +12214,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -12547,6 +12602,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bytes@3.1.2: {} cachedir@2.3.0: {} @@ -13728,6 +13788,8 @@ snapshots: eventemitter3@5.0.1: {} + events@3.3.0: {} + exec-async@2.2.0: {} execa@5.1.1: @@ -13889,6 +13951,12 @@ snapshots: expo: 54.0.20(@babel/core@7.28.4)(@expo/metro-runtime@6.1.2)(expo-router@6.0.13)(react-native-webview@13.16.0(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0) react: 19.1.0 + expo-linear-gradient@15.0.7(expo@54.0.20)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.20(@babel/core@7.28.4)(@expo/metro-runtime@6.1.2)(expo-router@6.0.13)(react-native-webview@13.16.0(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0) + expo-linking@8.0.8(expo@54.0.20)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0): dependencies: expo-constants: 18.0.10(expo@54.0.20)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0)) @@ -14502,7 +14570,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -14516,7 +14584,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -14631,6 +14699,11 @@ snapshots: dependencies: loose-envify: 1.4.0 + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -16230,6 +16303,8 @@ snapshots: process-nextick-args@2.0.1: {} + process@0.11.10: {} + progress@2.0.3: {} promise@7.3.1: @@ -16436,6 +16511,22 @@ snapshots: react: 19.1.0 react-native: 0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0) + react-native-quick-base64@2.2.2(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: 0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0) + + react-native-quick-crypto@0.7.17(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0): + dependencies: + '@craftzdog/react-native-buffer': 6.1.1(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0) + events: 3.3.0 + readable-stream: 4.7.0 + string_decoder: 1.3.0 + util: 0.12.5 + transitivePeerDependencies: + - react + - react-native + react-native-reanimated@4.1.3(@babel/core@7.28.4)(react-native-worklets@0.5.1(@babel/core@7.28.4)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0): dependencies: '@babel/core': 7.28.4 @@ -16652,6 +16743,14 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -17637,6 +17736,14 @@ snapshots: util-deprecate@1.0.2: {} + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.0 + is-typed-array: 1.1.15 + which-typed-array: 1.1.19 + utils-merge@1.0.1: {} uuid@11.1.0: {}