diff --git a/app.config.ts b/app.config.ts index 6a8efb51c..e9ca9a289 100644 --- a/app.config.ts +++ b/app.config.ts @@ -11,7 +11,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ owner: 'eten-genesis', name: 'LangQuest', slug: 'langquest', - version: '1.1.0', + version: '1.2.1', orientation: 'portrait', icon: iconPath, scheme: 'langquest', diff --git a/components/AppDrawer.tsx b/components/AppDrawer.tsx index 101d448e6..42b4488e1 100644 --- a/components/AppDrawer.tsx +++ b/components/AppDrawer.tsx @@ -1,7 +1,9 @@ import { useAuth } from '@/contexts/AuthContext'; import { system } from '@/db/powersync/system'; import { useAppNavigation } from '@/hooks/useAppNavigation'; +import { useAttachmentStates } from '@/hooks/useAttachmentStates'; import { useLocalization } from '@/hooks/useLocalization'; +import { useNetworkStatus } from '@/hooks/useNetworkStatus'; import { useNotifications } from '@/hooks/useNotifications'; import { useLocalStore } from '@/store/localStore'; import { borderRadius, colors, fontSizes, spacing } from '@/styles/theme'; @@ -13,10 +15,13 @@ import { import { useRenderCounter } from '@/utils/performanceUtils'; import { selectAndInitiateRestore } from '@/utils/restoreUtils'; import { Ionicons } from '@expo/vector-icons'; -import React, { useState } from 'react'; +import { AttachmentState } from '@powersync/attachments'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import type { DimensionValue } from 'react-native'; import { ActivityIndicator, Alert, + Animated, Modal, ProgressBarAndroid, ScrollView, @@ -34,6 +39,60 @@ interface DrawerItemType { disabled?: boolean; } +// Shimmer component for grace period +const ShimmerBar: React.FC<{ width?: DimensionValue }> = ({ + width = '100%' +}) => { + const shimmerValue = useRef(new Animated.Value(0)).current; + const [containerWidth, setContainerWidth] = useState(100); // Default width + + useEffect(() => { + const shimmerAnimation = Animated.loop( + Animated.sequence([ + Animated.timing(shimmerValue, { + toValue: 1, + duration: 1000, + useNativeDriver: false + }), + Animated.timing(shimmerValue, { + toValue: 0, + duration: 1000, + useNativeDriver: false + }) + ]) + ); + + shimmerAnimation.start(); + + return () => { + shimmerAnimation.stop(); + }; + }, [shimmerValue]); + + const shimmerTranslate = shimmerValue.interpolate({ + inputRange: [0, 1], + outputRange: [-containerWidth, containerWidth] + }); + + return ( + { + setContainerWidth(event.nativeEvent.layout.width); + }} + > + + + ); +}; + export default function AppDrawer({ drawerIsVisible, setDrawerIsVisible @@ -55,6 +114,7 @@ export default function AppDrawer({ useRenderCounter('AppDrawer'); const systemReady = system.isInitialized(); + const isConnected = useNetworkStatus(); const [isBackingUp, setIsBackingUp] = useState(false); const [isRestoring, setIsRestoring] = useState(false); // Progress tracking states @@ -64,14 +124,118 @@ export default function AppDrawer({ 'backup' | 'restore' | null >(null); + // Animation and grace period states + const [showAttachmentProgress, setShowAttachmentProgress] = useState(false); + const [isInGracePeriod, setIsInGracePeriod] = useState(false); + const fadeAnim = useRef(new Animated.Value(0)).current; + const gracePeriodTimer = useRef(null); + const GRACE_PERIOD_MS = 3000; // 3 seconds grace period + // Get PowerSync status const powersyncStatus = systemReady ? system.powersync.currentStatus : null; // Get attachment sync progress from store - const attachmentSyncProgress = useLocalStore( + const _attachmentSyncProgress = useLocalStore( (state) => state.attachmentSyncProgress ); + // Get all attachment states for accurate progress tracking + const { attachmentStates, isLoading: attachmentStatesLoading } = + useAttachmentStates([]); + + // Calculate attachment progress stats + const attachmentProgress = useMemo(() => { + if (attachmentStatesLoading || attachmentStates.size === 0) { + return { + total: 0, + synced: 0, + downloading: 0, + queued: 0, + hasActivity: false + }; + } + + let synced = 0; + let downloading = 0; + let queued = 0; + const total = attachmentStates.size; + + for (const record of attachmentStates.values()) { + if (record.state === AttachmentState.SYNCED) { + synced++; + } else if (record.state === AttachmentState.QUEUED_DOWNLOAD) { + downloading++; + } else if (record.state === AttachmentState.QUEUED_SYNC) { + queued++; + } + } + + const hasActivity = downloading > 0 || queued > 0; + + return { + total, + synced, + downloading, + queued, + hasActivity, + unsynced: total - synced + }; + }, [attachmentStates, attachmentStatesLoading]); + + // Handle attachment progress visibility with grace period + useEffect(() => { + if (attachmentProgress.hasActivity) { + // Clear any existing timer + if (gracePeriodTimer.current) { + clearTimeout(gracePeriodTimer.current); + gracePeriodTimer.current = null; + } + + // Show the progress section if not already showing + if (!showAttachmentProgress) { + setShowAttachmentProgress(true); + setIsInGracePeriod(false); + + // Animate in + Animated.timing(fadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: false + }).start(); + } else { + // If we're in grace period and activity resumes, exit grace period + setIsInGracePeriod(false); + } + } else if (showAttachmentProgress && !isInGracePeriod) { + // Activity stopped, start grace period + setIsInGracePeriod(true); + + gracePeriodTimer.current = setTimeout(() => { + // Hide the progress section after grace period + Animated.timing(fadeAnim, { + toValue: 0, + duration: 300, + useNativeDriver: false + }).start(() => { + setShowAttachmentProgress(false); + setIsInGracePeriod(false); + }); + }, GRACE_PERIOD_MS); + } + + // Cleanup timer on unmount + return () => { + if (gracePeriodTimer.current) { + clearTimeout(gracePeriodTimer.current); + } + }; + }, [ + attachmentProgress.hasActivity, + showAttachmentProgress, + isInGracePeriod, + fadeAnim + ]); + // Use the notifications hook const { totalCount: notificationCount } = useNotifications(); @@ -361,25 +525,6 @@ export default function AppDrawer({ { backgroundColor: colors.background } ]} > - {/* - {JSON.stringify( - { - attachmentSyncProgress, - powersyncStatus, - syncOperation, - syncProgress, - syncTotal, - system, - isBackingUp, - isRestoring, - systemReady, - isOperationActive, - progressPercentage - }, - null, - 2 - )} - */} {/* System status and progress indicators */} {!systemReady && ( - - - {t('initializing')}... - + {isConnected ? ( + <> + + + {t('initializing')}... + + + ) : ( + <> + + + {t('offline')} + + + )} )} {/* File sync progress indicator */} @@ -421,15 +581,17 @@ export default function AppDrawer({ onPress={logPowerSyncStatus} > - {powersyncStatus?.connected - ? powersyncStatus.dataFlowStatus.downloading - ? 'Syncing...' - : powersyncStatus.hasSynced - ? `Last sync: ${powersyncStatus.lastSyncedAt?.toLocaleTimeString() || 'Unknown'}` - : 'Not synced' - : powersyncStatus?.connecting - ? 'Connecting...' - : 'Disconnected'} + {!isConnected + ? `${attachmentProgress.synced} files downloaded` + : powersyncStatus?.connected + ? powersyncStatus.dataFlowStatus.downloading + ? 'Syncing...' + : powersyncStatus.hasSynced + ? `Last sync: ${powersyncStatus.lastSyncedAt?.toLocaleTimeString() || 'Unknown'}` + : 'Not synced' + : powersyncStatus?.connecting + ? 'Connecting...' + : 'Disconnected'} {/* Progress bar for download progress */} @@ -443,33 +605,82 @@ export default function AppDrawer({ )} - {/* Attachment sync progress section */} - {(attachmentSyncProgress.downloading || - attachmentSyncProgress.uploading) && ( - + {/* Attachment sync progress section with graceful transitions */} + {showAttachmentProgress && ( + - {attachmentSyncProgress.downloading - ? `Downloading files: ${attachmentSyncProgress.downloadCurrent}/${attachmentSyncProgress.downloadTotal}` - : `Uploading files: ${attachmentSyncProgress.uploadCurrent}/${attachmentSyncProgress.uploadTotal}`} + {isInGracePeriod ? ( + <> + + Download complete + + + {' '} + ({attachmentProgress.synced}/ + {attachmentProgress.total} files) + + + ) : attachmentProgress.downloading > 0 && + attachmentProgress.queued > 0 ? ( + <> + + Downloading: {attachmentProgress.downloading} + + , + + Queued: {attachmentProgress.queued} + + + {' '} + ({attachmentProgress.synced}/ + {attachmentProgress.total} complete) + + + ) : attachmentProgress.downloading > 0 ? ( + <> + + Downloading: {attachmentProgress.downloading} files + + + {' '} + ({attachmentProgress.synced}/ + {attachmentProgress.total} complete) + + + ) : ( + <> + + Queued for download: {attachmentProgress.queued} files + + + {' '} + ({attachmentProgress.synced}/ + {attachmentProgress.total} complete) + + + )} - 0 - ? attachmentSyncProgress.downloadCurrent / - attachmentSyncProgress.downloadTotal - : 0 - : attachmentSyncProgress.uploadTotal > 0 - ? attachmentSyncProgress.uploadCurrent / - attachmentSyncProgress.uploadTotal + {isInGracePeriod ? ( + + ) : ( + 0 + ? attachmentProgress.synced / attachmentProgress.total : 0 - } - color={colors.primaryLight} - style={styles.attachmentProgressBar} - /> - + } + color={colors.primaryLight} + style={styles.attachmentProgressBar} + /> + )} + )} {/* Main drawer items */} @@ -700,5 +911,41 @@ const styles = StyleSheet.create({ height: 4, width: '100%', marginTop: spacing.xsmall + }, + downloadingText: { + color: colors.text, + fontSize: fontSizes.small + }, + separatorText: { + color: colors.text, + fontSize: fontSizes.small + }, + queuedText: { + color: colors.text, + fontSize: fontSizes.small + }, + progressText: { + color: colors.text, + fontSize: fontSizes.small + }, + completedText: { + color: colors.primary, + fontSize: fontSizes.small, + fontWeight: '600' + }, + // Shimmer effect styles + shimmerContainer: { + height: 4, + backgroundColor: colors.disabled, + borderRadius: 2, + overflow: 'hidden', + marginTop: spacing.xsmall + }, + shimmerBar: { + height: '100%', + width: '50%', + backgroundColor: colors.primaryLight, + borderRadius: 2, + opacity: 0.6 } }); diff --git a/components/AppHeader.tsx b/components/AppHeader.tsx index bb260d6e4..3fd5b38a6 100644 --- a/components/AppHeader.tsx +++ b/components/AppHeader.tsx @@ -1,6 +1,7 @@ import { useAppNavigation } from '@/hooks/useAppNavigation'; +import { useNetworkStatus } from '@/hooks/useNetworkStatus'; import { useNotifications } from '@/hooks/useNotifications'; -import { useIsSyncing } from '@/hooks/useSyncState'; +import { useSyncState } from '@/hooks/useSyncState'; import { colors, fontSizes, spacing } from '@/styles/theme'; import { Ionicons } from '@expo/vector-icons'; import React, { useEffect, useRef, useState } from 'react'; @@ -26,7 +27,11 @@ export default function AppHeader({ const [pressedIndex, setPressedIndex] = useState(null); const { totalCount: notificationCount } = useNotifications(); - const isSyncing = useIsSyncing(); + const { isDownloadOperationInProgress, isUpdateInProgress, isConnecting } = + useSyncState(); + const isSyncing = + isDownloadOperationInProgress || isUpdateInProgress || isConnecting; + const isConnected = useNetworkStatus(); // Animation for sync indicator const spinValue = useRef(new Animated.Value(0)).current; @@ -143,8 +148,16 @@ export default function AppHeader({ > - {/* Sync Indicator - Bottom Right Corner */} - {isSyncing && ( + {/* Network Status Indicator - Bottom Right Corner */} + {!isConnected ? ( + + + + ) : isSyncing ? ( - )} + ) : null} {/* Notification Badge - Top Right Corner */} {notificationCount > 0 && ( @@ -242,6 +255,22 @@ const styles = StyleSheet.create({ shadowOpacity: 0.3, shadowRadius: 2 }, + offlineIndicator: { + position: 'absolute', + bottom: 0, + right: 0, + width: 14, + height: 14, + borderRadius: 7, + backgroundColor: '#FF6B6B', // Orange-red for offline + justifyContent: 'center', + alignItems: 'center', + elevation: 2, // Android shadow + shadowColor: '#000', // iOS shadow + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.3, + shadowRadius: 2 + }, notificationBadge: { position: 'absolute', top: -2, diff --git a/components/AssetFilterModal.tsx b/components/AssetFilterModal.tsx index 2515ccfb6..2c0c3b466 100644 --- a/components/AssetFilterModal.tsx +++ b/components/AssetFilterModal.tsx @@ -1,6 +1,8 @@ import { CustomDropdown } from '@/components/CustomDropdown'; -import type { Asset } from '@/database_services/assetService'; -import type { Tag } from '@/database_services/tagService'; +import { + useInfiniteTagsByQuestIdAndCategory, + useTagCategoriesByQuestId +} from '@/hooks/db/useTags'; import { useLocalization } from '@/hooks/useLocalization'; import { borderRadius, @@ -10,9 +12,9 @@ import { spacing } from '@/styles/theme'; import { Ionicons } from '@expo/vector-icons'; -import { FlashList } from '@shopify/flash-list'; import React, { useEffect, useMemo, useState } from 'react'; import { + ScrollView, StyleSheet, Text, TouchableOpacity, @@ -21,9 +23,8 @@ import { } from 'react-native'; interface AssetFilterModalProps { - visible: boolean; onClose: () => void; - assets: (Asset & { tags: { tag: Tag }[] })[]; + questId: string; onApplyFilters: (filters: Record) => void; onApplySorting: (sorting: SortingOption[]) => void; initialFilters: Record; @@ -35,30 +36,117 @@ interface SortingOption { order: 'asc' | 'desc'; } -interface FilterListItem { - type: 'filter_section'; - section: { - id: string; - heading: string; - options: { id: string; label: string }[]; - }; -} +// Component for each category section with its own infinite loading +const CategorySection: React.FC<{ + category: string; + questId: string; + isExpanded: boolean; + onToggle: () => void; + selectedOptions: string[]; + onToggleOption: (optionId: string) => void; +}> = ({ + category, + questId, + isExpanded, + onToggle, + selectedOptions, + onToggleOption +}) => { + const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = + useInfiniteTagsByQuestIdAndCategory(questId, category); -interface SortingListItem { - type: 'sorting_row'; - index: number; -} + const tags = data.pages.flatMap((page) => page.data); -type ListItem = FilterListItem | SortingListItem; + // Process tags to extract options + const options = useMemo(() => { + return tags + .map((tag) => { + const [, option] = tag.name.split(':'); + return { + id: `${category.toLowerCase()}:${option?.toLowerCase() || ''}`, + label: option || '' + }; + }) + .filter((option) => option.label) + .sort((a, b) => { + // Check if both values can be parsed as numbers + const numA = parseInt(a.label, 10); + const numB = parseInt(b.label, 10); + + if (!isNaN(numA) && !isNaN(numB)) { + // Numeric sort + return numA - numB; + } else { + // Alphabetical sort + return a.label.localeCompare(b.label); + } + }); + }, [tags, category]); + + return ( + + + {category} + + + + {isExpanded && ( + + {options.map((option) => ( + onToggleOption(option.id)} + > + {option.label} + + {selectedOptions.includes(option.id) ? ( + + ) : ( + + )} + + + ))} + {isLoading && ( + + Loading options... + + )} + {hasNextPage && ( + fetchNextPage()} + disabled={isFetchingNextPage} + > + + {isFetchingNextPage ? 'Loading...' : 'Load More'} + + + )} + + )} + + ); +}; export const AssetFilterModal: React.FC = ({ onClose, - assets, + questId, onApplyFilters, onApplySorting, initialFilters, initialSorting }) => { + const { t } = useLocalization(); const [activeTab, setActiveTab] = useState<'filter' | 'sort'>('filter'); const [expandedSections, setExpandedSections] = useState([]); const [selectedOptions, setSelectedOptions] = @@ -66,55 +154,23 @@ export const AssetFilterModal: React.FC = ({ const [sortingOptions, setSortingOptions] = useState(initialSorting); - const { t } = useLocalization(); - - const tags = assets.flatMap((asset) => asset.tags.map((tag) => tag.tag)); - - const filterData = useMemo(() => { - const sections: Record> = {}; - - tags.forEach((tag) => { - const [heading, option] = tag.name.split(':'); - if (!heading) return; - sections[heading] ??= new Set(); - if (option) sections[heading]!.add(option); - }); - - return Object.entries(sections).map(([heading, options]) => { - // Convert options to array and sort them properly - const sortedOptions = Array.from(options).sort((a, b) => { - // Check if both values can be parsed as numbers - const numA = parseInt(a, 10); - const numB = parseInt(b, 10); - - if (!isNaN(numA) && !isNaN(numB)) { - // Numeric sort - return numA - numB; - } else { - // Alphabetical sort - return a.localeCompare(b); - } - }); + // Fetch tag categories for headers + const { tagCategories, isTagCategoriesLoading } = + useTagCategoriesByQuestId(questId); - return { - id: heading.toLowerCase(), - heading, - options: sortedOptions.map((option) => ({ - id: `${heading.toLowerCase()}:${option.toLowerCase()}`, - label: option - })) - }; - }); - }, [assets]); + // Create filter sections from tag categories + const categories = useMemo(() => { + return tagCategories?.tag_categories || []; + }, [tagCategories?.tag_categories]); + // Create sorting fields from categories const sortingFields = useMemo(() => { const fields = new Set(['name']); - tags.forEach((tag) => { - const category = tag.name.split(':')[0]; - if (category) fields.add(category); + categories.forEach((category) => { + fields.add(category); }); return Array.from(fields); - }, [tags]); + }, [categories]); useEffect(() => { setSelectedOptions(initialFilters); @@ -173,111 +229,6 @@ export const AssetFilterModal: React.FC = ({ onClose(); }; - const listData = useMemo((): ListItem[] => { - if (activeTab === 'filter') { - return filterData.map((section) => ({ - type: 'filter_section', - section - })); - } else { - return [0, 1, 2].map((index) => ({ - type: 'sorting_row', - index - })); - } - }, [activeTab, filterData]); - - const renderListItem = ({ item }: { item: ListItem }) => { - if (item.type === 'filter_section') { - const { section } = item; - return ( - - toggleSection(section.id)} - > - {section.heading} - - - - {expandedSections.includes(section.id) && - section.options.map((option) => ( - toggleOption(section.id, option.id)} - > - {option.label} - - {selectedOptions[section.id]?.includes(option.id) ? ( - - ) : ( - - )} - - - ))} - - ); - } else { - const { index } = item; - return ( - - - handleSortingChange(index, field, sortingOptions[index]?.order) - } - fullWidth={false} - search={false} - /> - - handleSortingChange( - index, - sortingOptions[index]?.field ?? null, - sortingOptions[index]?.order === 'asc' ? 'desc' : 'asc' - ) - } - > - - - {sortingOptions[index]?.field && ( - handleSortingChange(index, null)} - > - - - )} - - ); - } - }; - return ( @@ -326,18 +277,87 @@ export const AssetFilterModal: React.FC = ({ - - item.type === 'filter_section' - ? item.section.id - : `sorting_${item.index}` - } - // style={sharedStyles.modalContent} - showsVerticalScrollIndicator={false} - estimatedItemSize={200} - /> + + {isTagCategoriesLoading ? ( + + + Loading tag categories... + + + ) : activeTab === 'filter' ? ( + categories.map((category) => ( + toggleSection(category.toLowerCase())} + selectedOptions={ + selectedOptions[category.toLowerCase()] || [] + } + onToggleOption={(optionId) => + toggleOption(category.toLowerCase(), optionId) + } + /> + )) + ) : ( + // Sorting content + [0, 1, 2].map((index) => ( + + + handleSortingChange( + index, + field, + sortingOptions[index]?.order + ) + } + fullWidth={false} + search={false} + /> + + handleSortingChange( + index, + sortingOptions[index]?.field ?? null, + sortingOptions[index]?.order === 'asc' + ? 'desc' + : 'asc' + ) + } + > + + + {sortingOptions[index]?.field && ( + handleSortingChange(index, null)} + > + + + )} + + )) + )} + ( - + ( }} > {/* Asset title */} - + {/* Download indicator */} - + {/* Translation count/gems area */} @@ -41,30 +28,9 @@ export const AssetSkeleton = React.memo(() => ( gap: spacing.xsmall }} > - - - + + + )); diff --git a/components/DownloadConfirmationModal.tsx b/components/DownloadConfirmationModal.tsx new file mode 100644 index 000000000..9dc74014b --- /dev/null +++ b/components/DownloadConfirmationModal.tsx @@ -0,0 +1,131 @@ +import { colors } from '@/styles/theme'; +import React from 'react'; +import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +interface DownloadConfirmationModalProps { + visible: boolean; + onConfirm: () => void; + onCancel: () => void; + downloadType: 'project' | 'quest'; + stats: { + totalAssets: number; + totalTranslations?: number; + totalQuests?: number; + }; +} + +export const DownloadConfirmationModal: React.FC< + DownloadConfirmationModalProps +> = ({ visible, onConfirm, onCancel, downloadType, stats }) => { + const getConfirmationText = () => { + if (downloadType === 'project') { + return `Download this project for offline use?\n\nThis will download:\n• ${stats.totalQuests || 0} quests\n• ${stats.totalAssets || 0} assets\n• ${stats.totalTranslations || 0} translations`; + } else { + return `Download this quest for offline use?\n\nThis will download:\n• ${stats.totalAssets || 0} assets\n• ${stats.totalTranslations || 0} translations`; + } + }; + + return ( + + + + + Download {downloadType === 'project' ? 'Project' : 'Quest'} + + + {getConfirmationText()} + + + + Cancel + + + + Download + + + + + + ); +}; + +const styles = StyleSheet.create({ + centeredView: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.5)' + }, + modalView: { + margin: 20, + backgroundColor: colors.background, + borderRadius: 20, + padding: 25, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2 + }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + minWidth: 300 + }, + modalTitle: { + fontSize: 20, + fontWeight: 'bold', + color: colors.text, + marginBottom: 15, + textAlign: 'center' + }, + modalText: { + fontSize: 16, + color: colors.text, + textAlign: 'left', + marginBottom: 20, + lineHeight: 22 + }, + buttonContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + width: '100%', + gap: 10 + }, + button: { + borderRadius: 10, + padding: 12, + elevation: 2, + flex: 1 + }, + cancelButton: { + backgroundColor: colors.background, + borderWidth: 1, + borderColor: colors.disabled + }, + confirmButton: { + backgroundColor: colors.primary + }, + cancelButtonText: { + color: colors.text, + fontWeight: 'bold', + textAlign: 'center' + }, + confirmButtonText: { + color: colors.background, + fontWeight: 'bold', + textAlign: 'center' + } +}); diff --git a/components/DownloadIndicator.tsx b/components/DownloadIndicator.tsx index ce728d007..2d690c92a 100644 --- a/components/DownloadIndicator.tsx +++ b/components/DownloadIndicator.tsx @@ -9,58 +9,83 @@ import { TouchableOpacity, View } from 'react-native'; +import { DownloadConfirmationModal } from './DownloadConfirmationModal'; import { OfflineUndownloadWarning } from './OfflineUndownloadWarning'; interface DownloadIndicatorProps { - isDownloaded: boolean; + isFlaggedForDownload: boolean; isLoading: boolean; onPress: () => void; size?: number; // Enhanced props for quest download progress progressPercentage?: number; showProgress?: boolean; + // New props for download confirmation + downloadType?: 'project' | 'quest'; + stats?: { + totalAssets: number; + totalTranslations?: number; + totalQuests?: number; + }; } export const DownloadIndicator: React.FC = ({ - isDownloaded, + isFlaggedForDownload, isLoading, onPress, size = 24, progressPercentage = 0, - showProgress = false + showProgress = false, + downloadType, + stats }) => { const isConnected = useNetworkStatus(); - const isDisabled = !isConnected && !isDownloaded; + const isDisabled = !isConnected && !isFlaggedForDownload; const [showWarning, setShowWarning] = useState(false); + const [showConfirmation, setShowConfirmation] = useState(false); const handlePress = async () => { - console.log('isConnected', isConnected); - console.log('isDownloaded', isDownloaded); - if (!isConnected && isDownloaded) { + if (!isConnected && isFlaggedForDownload) { const showWarning = await storage.getOfflineUndownloadWarningEnabled(); - console.log('showWarning', showWarning); if (showWarning) { setShowWarning(true); return; } } + + // Show confirmation modal for project/quest downloads (not already downloaded) + if (downloadType && stats && !isFlaggedForDownload) { + setShowConfirmation(true); + return; + } + + // Direct download for assets or already downloaded items onPress(); }; - const handleConfirm = () => { + const handleConfirmDownload = () => { + setShowConfirmation(false); + onPress(); + }; + + const handleCancelDownload = () => { + setShowConfirmation(false); + }; + + const handleConfirmUndownload = () => { setShowWarning(false); onPress(); }; - const handleCancel = () => { + const handleCancelUndownload = () => { setShowWarning(false); }; // Determine icon and color based on state const getIconAndColor = () => { - if (isDownloaded) { + if (isFlaggedForDownload) { return { - name: 'arrow-down-circle' as const, + name: 'checkmark-circle' as const, color: colors.primary }; } @@ -90,7 +115,7 @@ export const DownloadIndicator: React.FC = ({ > {isLoading ? ( - ) : showProgress && progressPercentage > 0 && !isDownloaded ? ( + ) : showProgress && progressPercentage > 0 && !isFlaggedForDownload ? ( // Custom progress indicator for quests = ({ )} + + {/* Download confirmation modal */} + {downloadType && stats && ( + + )} + + {/* Offline undownload warning */} ); diff --git a/components/PrivateAccessGate.tsx b/components/PrivateAccessGate.tsx index a94dd83f2..f5ac728f3 100644 --- a/components/PrivateAccessGate.tsx +++ b/components/PrivateAccessGate.tsx @@ -131,7 +131,7 @@ export const PrivateAccessGate: React.FC = ({ const isMember = membershipLinks.length > 0; const existingRequest = existingRequests[0]; - const { isDownloaded: isProjectDownloaded, mutation } = useDownload( + const { isFlaggedForDownload: isProjectDownloaded, mutation } = useDownload( 'project', projectId ); diff --git a/components/ProjectMembershipModal.tsx b/components/ProjectMembershipModal.tsx index cb9d385b1..3212aa6d3 100644 --- a/components/ProjectMembershipModal.tsx +++ b/components/ProjectMembershipModal.tsx @@ -8,6 +8,7 @@ import { import { system } from '@/db/powersync/system'; import { useHybridQuery } from '@/hooks/useHybridQuery'; import { useLocalization } from '@/hooks/useLocalization'; +import { useUserPermissions } from '@/hooks/useUserPermissions'; import { borderRadius, colors, @@ -76,6 +77,26 @@ export const ProjectMembershipModal: React.FC = ({ }) => { const { t } = useLocalization(); const { currentUser } = useAuth(); + + // Get comprehensive user permissions for this project + const managePermissions = useUserPermissions(projectId, 'manage'); + const sendInvitePermissions = useUserPermissions( + projectId, + 'send_invite_section' + ); + const promotePermissions = useUserPermissions( + projectId, + 'promote_member_button' + ); + const removePermissions = useUserPermissions( + projectId, + 'remove_member_button' + ); + const withdrawInvitePermissions = useUserPermissions( + projectId, + 'withdraw_invite_button' + ); + const [activeTab, setActiveTab] = useState<'members' | 'invited'>('members'); const [inviteEmail, setInviteEmail] = useState(''); const [inviteAsOwner, setInviteAsOwner] = useState(false); @@ -211,9 +232,8 @@ export const ProjectMembershipModal: React.FC = ({ return true; }); - // Check if current user is an owner + // Check if current user is an owner (keep for compatibility with leave project logic) const currentUserMembership = members.find((m) => m.id === currentUser?.id); - const currentUserIsOwner = currentUserMembership?.role === 'owner'; // Count active owners const activeOwnerCount = members.filter((m) => m.role === 'owner').length; @@ -290,7 +310,7 @@ export const ProjectMembershipModal: React.FC = ({ }; const handleLeaveProject = () => { - if (activeOwnerCount <= 1 && currentUserIsOwner) { + if (activeOwnerCount <= 1 && managePermissions.hasAccess) { Alert.alert(t('error'), t('cannotLeaveAsOnlyOwner')); return; } @@ -535,9 +555,9 @@ export const ProjectMembershipModal: React.FC = ({ - {currentUserIsOwner && !isCurrentUser && ( + {!isCurrentUser && ( <> - {member.role === 'member' && ( + {member.role === 'member' && promotePermissions.hasAccess && ( @@ -551,7 +571,7 @@ export const ProjectMembershipModal: React.FC = ({ /> )} - {member.role === 'member' && ( + {member.role === 'member' && removePermissions.hasAccess && ( @@ -638,30 +658,32 @@ export const ProjectMembershipModal: React.FC = ({ - {currentUserIsOwner && invitation.status === 'expired' && ( - void handleResendInvitation(invitation.id)} - > - - - )} - {currentUserIsOwner && invitation.status !== 'withdrawn' && ( - void handleWithdrawInvitation(invitation.id)} - > - - - )} + {withdrawInvitePermissions.hasAccess && + invitation.status === 'expired' && ( + void handleResendInvitation(invitation.id)} + > + + + )} + {withdrawInvitePermissions.hasAccess && + invitation.status !== 'withdrawn' && ( + void handleWithdrawInvitation(invitation.id)} + > + + + )} ); @@ -705,7 +727,7 @@ export const ProjectMembershipModal: React.FC = ({ @@ -761,7 +783,7 @@ export const ProjectMembershipModal: React.FC = ({ - {currentUserIsOwner ? ( + {sendInvitePermissions.hasAccess ? ( <> {t('inviteMembers')} diff --git a/components/ProjectSkeleton.tsx b/components/ProjectSkeleton.tsx index 7aa3258f0..66a03f36e 100644 --- a/components/ProjectSkeleton.tsx +++ b/components/ProjectSkeleton.tsx @@ -1,10 +1,11 @@ -import { colors, sharedStyles, spacing } from '@/styles/theme'; +import { sharedStyles, spacing } from '@/styles/theme'; import React from 'react'; import { View } from 'react-native'; +import { Shimmer } from './Shimmer'; // Skeleton loader component for project cards export const ProjectSkeleton = React.memo(() => ( - + ( gap: spacing.xsmall }} > - {/* Privacy/membership icon placeholders */} - - + + {/* Download indicator */} - + {/* Language pair */} - {/* Description */} - - )); diff --git a/components/QuestCard.tsx b/components/QuestCard.tsx index 2690a4ca7..0fa9ce064 100644 --- a/components/QuestCard.tsx +++ b/components/QuestCard.tsx @@ -12,21 +12,21 @@ export const QuestCard: React.FC<{ project: Project; quest: Quest & { tags: { tag: Tag }[] }; }> = React.memo(({ quest, project }) => { - // Use the enhanced quest download status hook for better performance and progress info const { - isDownloaded, - isLoading: isDownloadStatusLoading, - progressPercentage, - totalAssets - } = useQuestDownloadStatus(quest.id); + isFlaggedForDownload, + isLoading: isDownloadLoading, + toggleDownload + } = useDownload('quest', quest.id); + + // Get quest download stats for confirmation modal + const { questClosure } = useQuestDownloadStatus(quest.id); // Keep the original download hook for the mutation functionality - const { isLoading: isDownloadMutationLoading, toggleDownload } = useDownload( + const { isLoading: isDownloadMutationLoading } = useDownload( 'quest', quest.id ); - - const isLoading = isDownloadStatusLoading || isDownloadMutationLoading; + const isLoading = isDownloadLoading || isDownloadMutationLoading; const handleDownloadToggle = useCallback(async () => { console.log( @@ -65,13 +65,21 @@ export const QuestCard: React.FC<{ onBypass={handleDownloadToggle} renderTrigger={({ onPress, hasAccess }) => ( 0 && !isDownloaded} + downloadType="quest" + stats={{ + totalAssets: questClosure?.total_assets || 0, + totalTranslations: questClosure?.total_translations || 0 + }} + // FIXME: for now, we are not showing download progress + // progressPercentage={progressPercentage} + // showProgress={totalAssets > 0 && !isDownloaded} /> )} /> diff --git a/components/QuestFilterModal.tsx b/components/QuestFilterModal.tsx index 9fe923a79..908263c19 100644 --- a/components/QuestFilterModal.tsx +++ b/components/QuestFilterModal.tsx @@ -1,5 +1,8 @@ import { CustomDropdown } from '@/components/CustomDropdown'; -import type { Tag } from '@/database_services/tagService'; +import { + useInfiniteTagsByProjectIdAndCategory, + useTagCategoriesByProjectId +} from '@/hooks/db/useTags'; import { useLocalization } from '@/hooks/useLocalization'; import { borderRadius, @@ -21,7 +24,7 @@ import { interface QuestFilterModalProps { onClose: () => void; - questTags: Record; + projectId: string; onApplyFilters: (filters: Record) => void; onApplySorting: (sorting: SortingOption[]) => void; initialFilters: Record; @@ -33,9 +36,111 @@ interface SortingOption { order: 'asc' | 'desc'; } +// Component for each category section with its own infinite loading +const CategorySection: React.FC<{ + category: string; + projectId: string; + isExpanded: boolean; + onToggle: () => void; + selectedOptions: string[]; + onToggleOption: (optionId: string) => void; +}> = ({ + category, + projectId, + isExpanded, + onToggle, + selectedOptions, + onToggleOption +}) => { + const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = + useInfiniteTagsByProjectIdAndCategory(projectId, category); + + const tags = data.pages.flatMap((page) => page.data); + + // Process tags to extract options + const options = useMemo(() => { + return tags + .map((tag) => { + const [, option] = tag.name.split(':'); + return { + id: `${category.toLowerCase()}:${option?.toLowerCase() || ''}`, + label: option || '' + }; + }) + .filter((option) => option.label) + .sort((a, b) => { + // Check if both values can be parsed as numbers + const numA = parseInt(a.label, 10); + const numB = parseInt(b.label, 10); + + if (!isNaN(numA) && !isNaN(numB)) { + // Numeric sort + return numA - numB; + } else { + // Alphabetical sort + return a.label.localeCompare(b.label); + } + }); + }, [tags, category]); + + return ( + + + {category} + + + + {isExpanded && ( + + {options.map((option) => ( + onToggleOption(option.id)} + > + {option.label} + + {selectedOptions.includes(option.id) ? ( + + ) : ( + + )} + + + ))} + {isLoading && ( + + Loading options... + + )} + {hasNextPage && ( + fetchNextPage()} + disabled={isFetchingNextPage} + > + + {isFetchingNextPage ? 'Loading...' : 'Load More'} + + + )} + + )} + + ); +}; + export const QuestFilterModal: React.FC = ({ onClose, - questTags, + projectId, onApplyFilters, onApplySorting, initialFilters, @@ -49,55 +154,23 @@ export const QuestFilterModal: React.FC = ({ const [sortingOptions, setSortingOptions] = useState(initialSorting); - const filterData = useMemo(() => { - const sections: Record> = {}; - - Object.values(questTags) - .flat() - .forEach((tag) => { - const [heading, option] = tag.name.split(':'); - if (!heading) return; - sections[heading] ??= new Set(); - if (option) sections[heading]!.add(option); - }); - - return Object.entries(sections).map(([heading, options]) => { - // Convert options to array and sort them properly - const sortedOptions = Array.from(options).sort((a, b) => { - // Check if both values can be parsed as numbers - const numA = parseInt(a, 10); - const numB = parseInt(b, 10); - - if (!isNaN(numA) && !isNaN(numB)) { - // Numeric sort - return numA - numB; - } else { - // Alphabetical sort - return a.localeCompare(b); - } - }); + // Fetch tag categories for headers + const { tagCategories, isTagCategoriesLoading } = + useTagCategoriesByProjectId(projectId); - return { - id: heading.toLowerCase(), - heading, - options: sortedOptions.map((option) => ({ - id: `${heading.toLowerCase()}:${option.toLowerCase()}`, - label: option - })) - }; - }); - }, [questTags]); + // Create filter sections from tag categories + const categories = useMemo(() => { + return tagCategories?.tag_categories || []; + }, [tagCategories?.tag_categories]); + // Create sorting fields from categories const sortingFields = useMemo(() => { const fields = new Set(['name']); - Object.values(questTags) - .flat() - .forEach((tag) => { - const category = tag.name.split(':')[0]; - if (category) fields.add(category); - }); + categories.forEach((category) => { + fields.add(category); + }); return Array.from(fields); - }, [questTags]); + }, [categories]); useEffect(() => { setSelectedOptions(initialFilters); @@ -205,107 +278,85 @@ export const QuestFilterModal: React.FC = ({ - {activeTab === 'filter' - ? filterData.map((section) => ( - - toggleSection(section.id)} - > - - {section.heading} - - - - - {expandedSections.includes(section.id) && - section.options.map((option) => ( - toggleOption(section.id, option.id)} - > - - {option.label} - - - {selectedOptions[section.id]?.includes( - option.id - ) ? ( - - ) : ( - - )} - - - ))} - - )) - : // Sorting content - [0, 1, 2].map((index) => ( - - - handleSortingChange( - index, - field, - sortingOptions[index]?.order - ) + {isTagCategoriesLoading ? ( + + + Loading tag categories... + + + ) : activeTab === 'filter' ? ( + categories.map((category) => ( + toggleSection(category.toLowerCase())} + selectedOptions={ + selectedOptions[category.toLowerCase()] || [] + } + onToggleOption={(optionId) => + toggleOption(category.toLowerCase(), optionId) + } + /> + )) + ) : ( + // Sorting content + [0, 1, 2].map((index) => ( + + + handleSortingChange( + index, + field, + sortingOptions[index]?.order + ) + } + fullWidth={false} + search={false} + /> + + handleSortingChange( + index, + sortingOptions[index]?.field ?? null, + sortingOptions[index]?.order === 'asc' + ? 'desc' + : 'asc' + ) + } + > + + + {sortingOptions[index]?.field && ( - handleSortingChange( - index, - sortingOptions[index]?.field ?? null, - sortingOptions[index]?.order === 'asc' - ? 'desc' - : 'asc' - ) - } + style={styles.removeButton} + onPress={() => handleSortingChange(index, null)} > - {sortingOptions[index]?.field && ( - handleSortingChange(index, null)} - > - - - )} - - ))} + )} + + )) + )} = ({ - width, - height, - backgroundColor, - highlightColor +export const Shimmer: React.FC = ({ + width = '100%', + height = 20, + borderRadius = 4, + style, + shimmerColors = [ + colors.inputBackground, + colors.backgroundSecondary, + colors.inputBackground + ] }) => { - const translateX = useSharedValue(-width); - - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: translateX.value }] - })); + const shimmerValue = useRef(new Animated.Value(0)).current; + const [containerWidth, setContainerWidth] = useState(100); // Default width useEffect(() => { - translateX.value = withRepeat( - withTiming(width, { duration: 1500 }), - -1, - false + const shimmerAnimation = Animated.loop( + Animated.timing(shimmerValue, { + toValue: 1, + duration: 1500, + useNativeDriver: false + }) ); - }, []); + + shimmerAnimation.start(); + + return () => { + shimmerAnimation.stop(); + }; + }, [shimmerValue]); + + const translateX = shimmerValue.interpolate({ + inputRange: [0, 1], + outputRange: [-containerWidth, containerWidth] + }); + + const shimmerStyle: ViewStyle = { + width, + height, + borderRadius, + backgroundColor: shimmerColors[0], + overflow: 'hidden', + ...style + }; return ( - + { + setContainerWidth(event.nativeEvent.layout.width); + }} + > ); }; - -const styles = StyleSheet.create({ - container: { - overflow: 'hidden' - }, - shimmer: { - position: 'absolute', - start: 0 - } -}); - -export default Shimmer; diff --git a/components/TranslationModal.tsx b/components/TranslationModal.tsx index 55880a2aa..7fc4b691e 100644 --- a/components/TranslationModal.tsx +++ b/components/TranslationModal.tsx @@ -29,7 +29,7 @@ import { import AudioPlayer from './AudioPlayer'; import { PrivateAccessGate } from './PrivateAccessGate'; import { ReportModal } from './ReportModal'; -import Shimmer from './Shimmer'; +import { Shimmer } from './Shimmer'; interface TranslationModalProps { translationId: string; @@ -353,8 +353,11 @@ export const TranslationModal: React.FC = ({ )} @@ -389,8 +392,11 @@ export const TranslationModal: React.FC = ({ )} diff --git a/components/questsComponents/QuestList.tsx b/components/questsComponents/QuestList.tsx index 4f4bf9c7f..f9410906c 100644 --- a/components/questsComponents/QuestList.tsx +++ b/components/questsComponents/QuestList.tsx @@ -6,9 +6,8 @@ import { useProjectById } from '@/hooks/db/useProjects'; import { useHybridSupabaseInfiniteQuery } from '@/hooks/useHybridSupabaseQuery'; import { colors, sharedStyles, spacing } from '@/styles/theme'; import type { SortingOption } from '@/views/QuestsView'; -import { filterQuests } from '@/views/QuestsView'; import { FlashList } from '@shopify/flash-list'; -import { eq } from 'drizzle-orm'; +import { and, eq, like, or } from 'drizzle-orm'; import React, { useMemo } from 'react'; import { ActivityIndicator, @@ -100,24 +99,60 @@ export const QuestList = React.memo( isLoading, isError, error, - refetch + refetch, + fetchNextPage, + hasNextPage } = useHybridSupabaseInfiniteQuery({ - queryKey: ['quests', 'by-project', projectId, sortField, sortOrder], + queryKey: [ + 'quests', + 'by-project', + projectId, + sortField, + sortOrder, + searchQuery, + activeFilters + ], onlineFn: async ({ pageParam, pageSize }) => { - const { data, error } = await system.supabaseConnector.client + let query = system.supabaseConnector.client .from('quest') .select('*, tags:quest_tag_link(tag(*))') - .eq('project_id', projectId) - .order('created_at', { ascending: false }) + .eq('project_id', projectId); + + // Add search filtering + if (searchQuery) { + query = query.or( + `name.ilike.%${searchQuery}%,description.ilike.%${searchQuery}%` + ); + } + + // Add ordering + query = query.order('created_at', { ascending: false }); + + // Add pagination + query = query .limit(pageSize) - .range(pageParam * pageSize, (pageParam + 1) * pageSize - 1) - .overrideTypes(); + .range(pageParam * pageSize, (pageParam + 1) * pageSize - 1); + + const { data, error } = await query.overrideTypes(); if (error) throw error; return data; }, - offlineFn: async ({ pageParam, pageSize }) => - await system.db.query.quest.findMany({ - where: eq(quest.project_id, projectId), + offlineFn: async ({ pageParam, pageSize }) => { + const baseCondition = eq(quest.project_id, projectId); + + // Add search filtering for offline + const whereConditions = searchQuery + ? and( + baseCondition, + or( + like(quest.name, `%${searchQuery}%`), + like(quest.description, `%${searchQuery}%`) + ) + ) + : baseCondition; + + return await system.db.query.quest.findMany({ + where: whereConditions, limit: pageSize, offset: pageParam * pageSize, with: { @@ -127,34 +162,15 @@ export const QuestList = React.memo( } } } - }), + }); + }, pageSize: 10 }); // Extract and memoize quests with tags - const { filteredQuests } = useMemo(() => { - const questsWithTags = infiniteData?.pages.length - ? infiniteData.pages.flatMap((page) => page.data) - : []; - - const tags = questsWithTags.reduce( - (acc, quest) => { - acc[quest.id] = quest.tags.map((tag) => tag.tag); - return acc; - }, - {} as Record - ); - - const filtered = - questsWithTags.length && - (searchQuery || Object.keys(activeFilters).length > 0) - ? filterQuests(questsWithTags, tags, searchQuery, activeFilters) - : questsWithTags; - - return { - filteredQuests: filtered - }; - }, [infiniteData?.pages, searchQuery, activeFilters]); + const questsWithTags = useMemo(() => { + return infiniteData.pages.flatMap((page) => page.data); + }, [infiniteData.pages]); // Show skeleton during initial load if (isLoading) { @@ -179,7 +195,7 @@ export const QuestList = React.memo( marginBottom: spacing.medium }} > - Error loading quests: {error.message} + Error loading quests: {error?.message} void refetch()} @@ -192,36 +208,43 @@ export const QuestList = React.memo( } return ( - ( - - )} - keyExtractor={(item: QuestWithTags) => item.id} - style={sharedStyles.list} - // Performance optimizations - removeClippedSubviews={true} - onEndReachedThreshold={0.3} - ListFooterComponent={ - isFetchingNextPage ? ( - - - - ) : null - } - refreshControl={ - void refetch()} - tintColor={colors.text} - /> - } - showsVerticalScrollIndicator={false} - /> + <> + ( + + )} + keyExtractor={(item: QuestWithTags) => item.id} + style={sharedStyles.list} + // Performance optimizations + removeClippedSubviews={true} + onEndReachedThreshold={0.3} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }} + ListFooterComponent={ + isFetchingNextPage && hasNextPage ? ( + + + + ) : null + } + refreshControl={ + void refetch()} + tintColor={colors.text} + /> + } + showsVerticalScrollIndicator={false} + /> + ); } ); diff --git a/components/questsComponents/QuestSkeleton.tsx b/components/questsComponents/QuestSkeleton.tsx index 520b3b0fe..98a11f282 100644 --- a/components/questsComponents/QuestSkeleton.tsx +++ b/components/questsComponents/QuestSkeleton.tsx @@ -1,10 +1,11 @@ -import { colors, sharedStyles, spacing } from '@/styles/theme'; +import { sharedStyles, spacing } from '@/styles/theme'; import React from 'react'; import { View } from 'react-native'; +import { Shimmer } from '../Shimmer'; // Skeleton loader component for better perceived performance export const QuestSkeleton = React.memo(() => ( - + ( gap: spacing.small }} > - - + + - - )); diff --git a/contexts/AuthContext.tsx b/contexts/AuthContext.tsx index 7f9070f1c..9fba4d5e1 100644 --- a/contexts/AuthContext.tsx +++ b/contexts/AuthContext.tsx @@ -57,23 +57,44 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const session = JSON.parse(sessionString) as Session | null; console.log('🔄 [AuthProvider] Session user ID:', session?.user.id); - console.log('🔄 [AuthProvider] Fetching profile from Supabase...'); - const { data: profile } = (await system.supabaseConnector.client - .from('profile') - .select('*') - .eq('id', session?.user.id) - .single()) as { data: Profile }; - console.log('🔄 [AuthProvider] Got profile:', !!profile); - setCurrentUser(profile); - - // Sync terms acceptance from profile to local store - if (profile?.terms_accepted && profile?.terms_accepted_at) { - const localStore = useLocalStore.getState(); - if (!localStore.dateTermsAccepted) { + if (session?.user.id) { + console.log( + '🔄 [AuthProvider] Getting profile (offline-first)...' + ); + // Use getUserProfile which checks local DB first, then falls back to online + const profile = await system.supabaseConnector.getUserProfile( + session.user.id + ); + console.log('🔄 [AuthProvider] Got profile:', !!profile); + + if (profile) { + // Validate that this is a real user profile with username or email + if (!profile.username && !profile.email) { + console.log( + '⚠️ [AuthProvider] Profile has no username or email - treating as invalid session' + ); + setCurrentUser(null); + return; + } + + setCurrentUser(profile); + + // Sync terms acceptance from profile to local store + if (profile.terms_accepted && profile.terms_accepted_at) { + const localStore = useLocalStore.getState(); + if (!localStore.dateTermsAccepted) { + console.log( + '🔄 [AuthProvider] Syncing terms acceptance from profile to local store' + ); + localStore.acceptTerms(); + } + } + } else { console.log( - '🔄 [AuthProvider] Syncing terms acceptance from profile to local store' + '⚠️ [AuthProvider] No profile found - keeping session but no user profile' ); - localStore.acceptTerms(); + // Don't set currentUser to null - keep the session alive + // The user can still access the app with cached data } } } else { @@ -84,6 +105,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } } catch (error) { console.error('❌ [AuthProvider] Error loading auth data:', error); + // Don't clear currentUser on error - maintain session persistence } finally { console.log('✅ [AuthProvider] Setting isLoading to false'); setIsLoading(false); @@ -106,10 +128,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { async (state: string, session: Session | null) => { debug('onAuthStateChange', state, session); - // always maintain a session + // If no session, just return without doing anything if (!session) { - await system.supabaseConnector.client.auth.signInAnonymously(); - setCurrentUser(null); + console.log( + '⚠️ [AuthProvider] No session detected, staying logged out' + ); return; } @@ -118,44 +141,64 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const profile = await system.supabaseConnector.getUserProfile( session.user.id ); - setCurrentUser(profile); - // Sync terms acceptance from profile to local store - if (profile?.terms_accepted && profile?.terms_accepted_at) { - const localStore = useLocalStore.getState(); - if (!localStore.dateTermsAccepted) { + if (profile) { + // Validate that this is a real user profile with username or email + if (!profile.username && !profile.email) { console.log( - '🔄 [AuthProvider] Syncing terms acceptance from profile to local store (auth state change)' + '⚠️ [AuthProvider] Profile has no username or email - treating as invalid session' ); - localStore.acceptTerms(); + setCurrentUser(null); + return; + } + + setCurrentUser(profile); + + // Sync terms acceptance from profile to local store + if (profile.terms_accepted && profile.terms_accepted_at) { + const localStore = useLocalStore.getState(); + if (!localStore.dateTermsAccepted) { + console.log( + '🔄 [AuthProvider] Syncing terms acceptance from profile to local store (auth state change)' + ); + localStore.acceptTerms(); + } } - } - // Only reinitialize attachment queues if system is already initialized - if (system.isInitialized()) { + // Only reinitialize attachment queues if system is already initialized + if (system.isInitialized()) { + console.log( + 'Reinitializing attachment queues after auth state change...' + ); + await Promise.all([ + system.tempAttachmentQueue?.init(), + system.permAttachmentQueue?.init() + ]); + console.log('Attachment queue reinitialization complete'); + } + } else { console.log( - 'Reinitializing attachment queues after auth state change...' + '⚠️ [AuthProvider] No profile found during auth state change - keeping current user' ); - await Promise.all([ - system.tempAttachmentQueue?.init(), - system.permAttachmentQueue?.init() - ]); - console.log('Attachment queue reinitialization complete'); + // Don't clear currentUser if profile fetch fails - user stays logged in } } catch (error) { - console.error('Error during auth state change:', error); - // Still set the user even if queue init fails - const profile = await system.supabaseConnector.getUserProfile( - session.user.id + console.error( + '❌ [AuthProvider] Error during auth state change:', + error ); - setCurrentUser(profile); - - // Sync terms acceptance from profile to local store (fallback) - if (profile?.terms_accepted && profile?.terms_accepted_at) { + // Don't clear currentUser on error - maintain session persistence + + // Still try to sync terms if we have a current user + if ( + currentUser && + currentUser.terms_accepted && + currentUser.terms_accepted_at + ) { const localStore = useLocalStore.getState(); if (!localStore.dateTermsAccepted) { console.log( - '🔄 [AuthProvider] Syncing terms acceptance from profile to local store (auth state change - fallback)' + '🔄 [AuthProvider] Syncing terms acceptance from current user (auth state change - fallback)' ); localStore.acceptTerms(); } diff --git a/db/drizzleSchema.ts b/db/drizzleSchema.ts index e4ed22675..e88f1c4bb 100644 --- a/db/drizzleSchema.ts +++ b/db/drizzleSchema.ts @@ -4,6 +4,7 @@ import { int, primaryKey, sqliteTable, + sqliteView, text } from 'drizzle-orm/sqlite-core'; import { reasonOptions } from './constants'; @@ -561,6 +562,57 @@ export const subscriptionRelations = relations(subscription, ({ one }) => ({ }) })); +// ==================================== +// VIEWS +// ==================================== + +// Asset tag categories view - extracts distinct tag categories (part before ':') for each quest via asset tags +export const asset_tag_categories = sqliteView('asset_tag_categories', { + quest_id: text('quest_id').notNull(), + tag_categories: text('tag_categories', { mode: 'json' }).$type() // SQLite stores as comma-separated string +}).as(sql` + SELECT + q.id AS quest_id, + GROUP_CONCAT(DISTINCT + CASE + WHEN INSTR(t.name, ':') > 0 + THEN SUBSTR(t.name, 1, INSTR(t.name, ':') - 1) + ELSE t.name + END + ) AS tag_categories + FROM quest q + JOIN quest_asset_link qal ON q.id = qal.quest_id + JOIN asset a ON qal.asset_id = a.id + JOIN asset_tag_link atl ON a.id = atl.asset_id + JOIN tag t ON atl.tag_id = t.id + GROUP BY q.id + ORDER BY q.id +`); + +// Quest tag categories view - extracts distinct tag categories for all quests in each project +export const quest_tag_categories = sqliteView('quest_tag_categories', { + project_id: text('project_id').notNull(), + tag_categories: text('tag_categories', { mode: 'json' }).$type() // SQLite stores as comma-separated string +}).as(sql` + SELECT + p.id AS project_id, + GROUP_CONCAT(DISTINCT + CASE + WHEN INSTR(t.name, ':') > 0 + THEN SUBSTR(t.name, 1, INSTR(t.name, ':') - 1) + ELSE t.name + END + ) AS tag_categories + FROM project p + JOIN quest q ON q.project_id = p.id + JOIN quest_asset_link qal ON q.id = qal.quest_id + JOIN asset a ON qal.asset_id = a.id + JOIN asset_tag_link atl ON a.id = atl.asset_id + JOIN tag t ON atl.tag_id = t.id + GROUP BY p.id + ORDER BY p.id +`); + // ==================================== // CLOSURE AND AGGREGATE TABLES // ==================================== diff --git a/db/powersync/AbstractSharedAttachmentQueue.ts b/db/powersync/AbstractSharedAttachmentQueue.ts index f62980147..33e65c9b6 100644 --- a/db/powersync/AbstractSharedAttachmentQueue.ts +++ b/db/powersync/AbstractSharedAttachmentQueue.ts @@ -1,3 +1,5 @@ +import { getAssetAudioContent, getAssetById } from '@/hooks/db/useAssets'; +import { getTranslationsByAssetId } from '@/hooks/db/useTranslations'; import { useLocalStore } from '@/store/localStore'; import type { AttachmentQueueOptions, @@ -66,7 +68,6 @@ export abstract class AbstractSharedAttachmentQueue extends AbstractAttachmentQu this.onAttachmentIdsChange((ids) => { void (async () => { const _ids = `${ids.map((id) => `'${id}'`).join(',')}`; - // console.debug(`Queuing for sync, attachment IDs: [${_ids}]`); if (this.initialSync) { this.initialSync = false; @@ -97,18 +98,12 @@ export abstract class AbstractSharedAttachmentQueue extends AbstractAttachmentQu id: id, state: AttachmentState.QUEUED_SYNC }); - console.debug( - `Attachment (${id}) not found in database, creating new record` - ); await this.saveToQueue(newRecord); } else if ( // 2. Attachment exists but needs to be converted to permanent storageType === 'permanent' && record.storage_type === 'temporary' ) { - console.debug( - `Converting temporary attachment (${id}) to permanent` - ); await this.update({ ...record, state: AttachmentState.QUEUED_SYNC, @@ -119,9 +114,6 @@ export abstract class AbstractSharedAttachmentQueue extends AbstractAttachmentQu !(await this.storage.fileExists(this.getLocalUri(record.local_uri))) ) { // 3. Attachment in database but no local file, mark as queued download - console.debug( - `Attachment (${id}) found in database but no local file, marking as queued download` - ); await this.update({ ...record, state: AttachmentState.QUEUED_DOWNLOAD @@ -259,50 +251,61 @@ export abstract class AbstractSharedAttachmentQueue extends AbstractAttachmentQu // Common method to identify all attachments related to an asset async getAllAssetAttachments(assetId: string): Promise { + // const queueType = + // this.getStorageType() === 'temporary' ? '[TEMP QUEUE]' : '[PERM QUEUE]'; + // console.log(`${queueType} Finding all attachments for asset: ${assetId}`); const attachmentIds: string[] = []; try { - // 1. Get the asset itself for images - using direct database query - const asset = await this.db.query.asset.findFirst({ - where: (asset, { eq }) => eq(asset.id, assetId), - columns: { images: true } - }); + // 1. Get the asset itself for images + const asset = await getAssetById(assetId); if (asset?.images) { + // console.log( + // `${queueType} Found ${asset.images.length} images in asset` + // ); attachmentIds.push(...asset.images); } - // 2. Get asset_content_link entries for audio - using direct database query - const assetContents = await this.db.query.asset_content_link.findMany({ - where: (asset_content_link, { eq }) => - eq(asset_content_link.asset_id, assetId), - columns: { audio_id: true } - }); + // 2. Get asset_content_link entries for audio + const assetContents = await getAssetAudioContent(assetId); const contentAudioIds = assetContents - .filter((content) => content.audio_id) + ?.filter((content) => content.audio_id) .map((content) => content.audio_id!); - if (contentAudioIds.length) { + if (contentAudioIds?.length) { + // console.log( + // `${queueType} Found ${contentAudioIds.length} audio files in asset_content_link` + // ); attachmentIds.push(...contentAudioIds); } - // 3. Get translations for the asset and their audio - using direct database query - const translations = await this.db.query.translation.findMany({ - where: (translation, { eq }) => eq(translation.asset_id, assetId), - columns: { audio: true } - }); + // 3. Get translations for the asset and their audio + const translations = await getTranslationsByAssetId(assetId); const translationAudioIds = translations - .filter((translation) => translation.audio) + ?.filter((translation) => translation.audio) .map((translation) => translation.audio!); - if (translationAudioIds.length) { + if (translationAudioIds?.length) { + // console.log( + // `${queueType} Found ${translationAudioIds.length} audio files in translations` + // ); attachmentIds.push(...translationAudioIds); } + // Log all found attachments + // console.log( + // `${queueType} Total attachments for asset ${assetId}: ${attachmentIds.length}` + // ); + return attachmentIds; } catch { + // console.error( + // `${queueType} Error getting attachments for asset ${assetId}:`, + // error + // ); return []; } } @@ -335,22 +338,58 @@ export abstract class AbstractSharedAttachmentQueue extends AbstractAttachmentQu try { console.log(`Downloading ${this.downloadQueue.size} attachments...`); - while (this.downloadQueue.size > 0) { - const id = this.downloadQueue.values().next().value as string; - this.downloadQueue.delete(id); - const record = await this.record(id); - if (!record) { - continue; - } - await this.downloadRecord(record); - downloaded++; - // Update progress + // Convert downloadQueue to array for concurrent processing + const idsArray = Array.from(this.downloadQueue); + this.downloadQueue.clear(); + + // Create a progress update function that's thread-safe + const updateProgress = () => { + downloaded++; useLocalStore.getState().setAttachmentSyncProgress({ downloadCurrent: downloaded, downloadTotal: totalToDownload }); + }; + + // Download with higher concurrency limit (8 simultaneous downloads) + const CONCURRENCY_LIMIT = 8; + + // Create a queue-based concurrent download system + const downloadQueue = [...idsArray]; + const downloadPromises: Promise[] = []; + + const processDownload = async (id: string): Promise => { + try { + const record = await this.record(id); + if (!record) { + updateProgress(); // Count as completed even if no record + return; + } + await this.downloadRecord(record); + updateProgress(); // Update progress after successful download + } catch (error) { + console.error(`Failed to download attachment ${id}:`, error); + updateProgress(); // Count as completed even if failed + } finally { + // Start next download if queue not empty + if (downloadQueue.length > 0) { + const nextId = downloadQueue.shift()!; + downloadPromises.push(processDownload(nextId)); + } + } + }; + + // Start initial batch of downloads + const initialBatch = downloadQueue.splice(0, CONCURRENCY_LIMIT); + + for (const id of initialBatch) { + downloadPromises.push(processDownload(id)); } + + // Wait for all downloads to complete + await Promise.allSettled(downloadPromises); + console.log('Finished downloading attachments'); } catch (e) { console.log('Downloads failed:', e); @@ -423,11 +462,11 @@ export abstract class AbstractSharedAttachmentQueue extends AbstractAttachmentQu } // Override trigger to use our progress-tracking methods - trigger() { - void this.uploadRecordsWithProgress(); - void this.downloadRecordsWithProgress(); - void this.expireCache(); - } + // trigger() { + // void this.uploadRecordsWithProgress(); + // void this.downloadRecordsWithProgress(); + // void this.expireCache(); + // } // Override watchDownloads to use our progress-tracking method watchDownloads() { diff --git a/db/powersync/AttachmentStateManager.ts b/db/powersync/AttachmentStateManager.ts deleted file mode 100644 index e51869df7..000000000 --- a/db/powersync/AttachmentStateManager.ts +++ /dev/null @@ -1,670 +0,0 @@ -import { getCurrentUser } from '@/contexts/AuthContext'; -import type { PowerSyncSQLiteDatabase } from '@powersync/drizzle-driver'; -import type { AbstractPowerSyncDatabase } from '@powersync/react-native'; -import type * as drizzleSchema from '../drizzleSchema'; - -interface AttachmentSource { - type: 'asset_images' | 'asset_content' | 'translations'; - assetId: string; - attachmentIds: string[]; -} - -const DEBUG_ATTACHMENT_STATE = false; -const debug = (...message: unknown[]) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (DEBUG_ATTACHMENT_STATE) { - console.log(...message); - } -}; - -export class AttachmentStateManager { - private static instance: AttachmentStateManager | null = null; - - private unifiedAttachmentIds = new Set(); - private updateInProgress = false; - private updateTimer: NodeJS.Timeout | null = null; - private lastUpdateTime = 0; - private attachmentSources = new Map(); - - // Track download operations to avoid lock contention - private downloadOperationInProgress = false; - private downloadOperationTimer: NodeJS.Timeout | null = null; - private pendingUpdates = new Set(); - - private constructor( - private db: PowerSyncSQLiteDatabase, - private powersync: AbstractPowerSyncDatabase - ) {} - - /** - * Get the singleton instance of AttachmentStateManager - */ - static getInstance( - db?: PowerSyncSQLiteDatabase, - powersync?: AbstractPowerSyncDatabase - ): AttachmentStateManager { - if (!AttachmentStateManager.instance) { - if (!db || !powersync) { - throw new Error( - 'AttachmentStateManager must be initialized with db and powersync on first call' - ); - } - AttachmentStateManager.instance = new AttachmentStateManager( - db, - powersync - ); - debug('[ATTACHMENT STATE] ✅ Singleton instance created'); - } - return AttachmentStateManager.instance; - } - - /** - * Initialize the singleton instance (called from system.ts) - */ - static initialize( - db: PowerSyncSQLiteDatabase, - powersync: AbstractPowerSyncDatabase - ): AttachmentStateManager { - if (AttachmentStateManager.instance) { - debug( - '[ATTACHMENT STATE] ⚠️ Instance already exists, returning existing instance' - ); - return AttachmentStateManager.instance; - } - AttachmentStateManager.instance = new AttachmentStateManager(db, powersync); - debug('[ATTACHMENT STATE] ✅ Singleton instance initialized'); - return AttachmentStateManager.instance; - } - - /** - * Destroy the singleton instance (for cleanup) - */ - static destroySingleton(): void { - if (AttachmentStateManager.instance) { - AttachmentStateManager.instance.destroy(); - AttachmentStateManager.instance = null; - debug('[ATTACHMENT STATE] 🗑️ Singleton instance destroyed'); - } - } - - /** - * Mark that a download operation is starting - * This prevents attachment collection during the operation to avoid lock contention - */ - markDownloadOperationStart(): void { - debug( - '🚫 [ATTACHMENT STATE] Download operation started - pausing attachment updates' - ); - this.downloadOperationInProgress = true; - - // Clear any existing timer - if (this.downloadOperationTimer) { - clearTimeout(this.downloadOperationTimer); - } - - // Auto-clear the download operation flag after 30 seconds (safety timeout) - this.downloadOperationTimer = setTimeout(() => { - debug( - '⏰ [ATTACHMENT STATE] Download operation timeout - resuming attachment updates' - ); - this.markDownloadOperationComplete(); - }, 30000); - } - - /** - * Mark that a download operation is complete - * This resumes attachment collection and processes any pending updates - */ - markDownloadOperationComplete(): void { - debug( - '✅ [ATTACHMENT STATE] Download operation completed - resuming attachment updates' - ); - this.downloadOperationInProgress = false; - - if (this.downloadOperationTimer) { - clearTimeout(this.downloadOperationTimer); - this.downloadOperationTimer = null; - } - - // Process any pending updates - if (this.pendingUpdates.size > 0) { - debug( - `🔄 [ATTACHMENT STATE] Processing ${this.pendingUpdates.size} pending updates: ${Array.from(this.pendingUpdates).join(', ')}` - ); - const pendingSources = Array.from(this.pendingUpdates); - this.pendingUpdates.clear(); - - // Process the most important update (quest_downloads takes priority) - const priorityOrder = [ - 'quest_downloads', - 'asset_downloads', - 'quest_asset_links', - 'asset_content', - 'translations' - ]; - const sortedSources = pendingSources.sort((a, b) => { - const aIndex = priorityOrder.indexOf(a); - const bIndex = priorityOrder.indexOf(b); - return (aIndex !== -1 ? aIndex : 999) - (bIndex !== -1 ? bIndex : 999); - }); - - // Only process the highest priority update to avoid cascading - if (sortedSources.length > 0) { - // We'll need to get the onUpdate callback, but we can't store it here - // Instead, we'll trigger through the normal debounce mechanism - debug( - `🎯 [ATTACHMENT STATE] Processing priority update: ${sortedSources[0]}` - ); - } - } - } - - /** - * Check if download operation is in progress - */ - isDownloadOperationInProgress(): boolean { - return this.downloadOperationInProgress; - } - - /** - * Get all attachment IDs for permanent storage (downloaded content) - * Uses consistent offline data source to prevent race conditions - * Only returns IDs that actually need to be managed (not already synced) - */ - async getUnifiedPermanentAttachmentIds(): Promise { - try { - const allAttachmentIds: string[] = []; - const processedAssetIds = new Set(); - const currentUser = getCurrentUser(); - - if (!currentUser?.id) { - debug( - '[ATTACHMENT STATE] No current user, returning empty attachment list' - ); - return []; - } - - debug( - '[ATTACHMENT STATE] 🔍 Collecting unified permanent attachment IDs...' - ); - - // Get all attachment records to check their sync status - const attachmentRecords = await this.powersync.getAll<{ - id: string; - state: number; - storage_type: string; - local_uri: string | null; - }>('SELECT id, state, storage_type, local_uri FROM attachments'); - - const attachmentStatusMap = new Map< - string, - { - state: number; - storage_type: string; - local_uri: string | null; - } - >(); - - for (const record of attachmentRecords) { - attachmentStatusMap.set(record.id, { - state: record.state, - storage_type: record.storage_type, - local_uri: record.local_uri - }); - } - - // 1. Get directly downloaded assets - const directAssets = await this.db.query.asset.findMany({ - where: (asset, { and, eq }) => - and(eq(asset.download_profiles, [currentUser.id])), - columns: { id: true, images: true } - }); - - debug( - `[ATTACHMENT STATE] Found ${directAssets.length} directly downloaded assets` - ); - - // 2. Get assets from downloaded quests - const downloadedQuests = await this.db.query.quest.findMany({ - where: (quest, { eq }) => eq(quest.download_profiles, [currentUser.id]), - columns: { id: true } - }); - - debug( - `[ATTACHMENT STATE] Found ${downloadedQuests.length} downloaded quests` - ); - - // Get quest-asset links for downloaded quests - const questAssetLinks = - downloadedQuests.length > 0 - ? await this.db.query.quest_asset_link.findMany({ - where: (link, { inArray }) => - inArray( - link.quest_id, - downloadedQuests.map((q) => q.id) - ), - columns: { asset_id: true } - }) - : []; - - debug( - `[ATTACHMENT STATE] Found ${questAssetLinks.length} assets in downloaded quests` - ); - - // Get asset details for quest assets - const questAssets = - questAssetLinks.length > 0 - ? await this.db.query.asset.findMany({ - where: (asset, { inArray }) => - inArray( - asset.id, - questAssetLinks.map((l) => l.asset_id) - ), - columns: { id: true, images: true } - }) - : []; - - // Combine and deduplicate assets - const allAssets = [...directAssets, ...questAssets]; - const assetSources = new Map(); - - // Helper function to check if an attachment needs to be managed - const needsManagement = (attachmentId: string): boolean => { - const record = attachmentStatusMap.get(attachmentId); - if (!record) { - // Not in attachments table yet - needs to be added - return true; - } - - // Check if it's temporary and needs to be converted to permanent - if (record.storage_type === 'temporary') { - return true; - } - - // Check if it's already synced and permanent - no need to manage - if (record.state >= 4 && record.storage_type === 'permanent') { - // AttachmentState.SYNCED = 4 - return false; - } - - // Everything else needs management - return true; - }; - - // Process each unique asset - for (const asset of allAssets) { - if (processedAssetIds.has(asset.id)) continue; - processedAssetIds.add(asset.id); - - const sources: AttachmentSource[] = []; - - // 1. Asset images - if (asset.images && asset.images.length > 0) { - const neededImages = asset.images.filter(needsManagement); - if (neededImages.length > 0) { - allAttachmentIds.push(...neededImages); - sources.push({ - type: 'asset_images', - assetId: asset.id, - attachmentIds: neededImages - }); - } - } - - // 2. Asset content audio - const assetContents = await this.db.query.asset_content_link.findMany({ - where: (content, { eq }) => eq(content.asset_id, asset.id), - columns: { audio_id: true } - }); - - const contentAudioIds = assetContents - .filter((content) => content.audio_id) - .map((content) => content.audio_id!) - .filter(needsManagement); - - if (contentAudioIds.length > 0) { - allAttachmentIds.push(...contentAudioIds); - sources.push({ - type: 'asset_content', - assetId: asset.id, - attachmentIds: contentAudioIds - }); - } - - // 3. Translation audio - const translations = await this.db.query.translation.findMany({ - where: (translation, { eq }) => eq(translation.asset_id, asset.id), - columns: { audio: true } - }); - - const translationAudioIds = translations - .filter((translation) => translation.audio) - .map((translation) => translation.audio!) - .filter(needsManagement); - - if (translationAudioIds.length > 0) { - allAttachmentIds.push(...translationAudioIds); - sources.push({ - type: 'translations', - assetId: asset.id, - attachmentIds: translationAudioIds - }); - } - - if (sources.length > 0) { - assetSources.set(asset.id, sources); - } - } - - // Store sources for debugging - this.attachmentSources = assetSources; - - // Deduplicate and return - const uniqueAttachmentIds = [...new Set(allAttachmentIds)]; - - debug( - `[ATTACHMENT STATE] ✅ Collected ${uniqueAttachmentIds.length} attachment IDs that need management from ${processedAssetIds.size} assets` - ); - - // Log sources breakdown - let totalImages = 0, - totalContent = 0, - totalTranslations = 0; - assetSources.forEach((sources) => { - sources.forEach((source) => { - switch (source.type) { - case 'asset_images': - totalImages += source.attachmentIds.length; - break; - case 'asset_content': - totalContent += source.attachmentIds.length; - break; - case 'translations': - totalTranslations += source.attachmentIds.length; - break; - } - }); - }); - - debug( - `[ATTACHMENT STATE] 📊 Breakdown: ${totalImages} images, ${totalContent} content audio, ${totalTranslations} translation audio (needing management)` - ); - - return uniqueAttachmentIds; - } catch (error) { - console.error( - '[ATTACHMENT STATE] ❌ Error collecting attachment IDs:', - error - ); - return []; - } - } - - /** - * Get attachment IDs for specific assets (used for temporary/viewing attachments) - * Consistent with permanent collection method - */ - async getAttachmentIdsForAssets(assetIds: string[]): Promise { - if (!assetIds.length) return []; - - try { - debug( - `[ATTACHMENT STATE] 🔍 Getting attachments for specific assets: ${assetIds.join(', ')}` - ); - - const allAttachmentIds: string[] = []; - - // Get assets - const assets = await this.db.query.asset.findMany({ - where: (asset, { inArray }) => inArray(asset.id, assetIds), - columns: { id: true, images: true } - }); - - for (const asset of assets) { - // 1. Asset images - if (asset.images && asset.images.length > 0) { - allAttachmentIds.push(...asset.images); - } - - // 2. Asset content audio - const assetContents = await this.db.query.asset_content_link.findMany({ - where: (content, { eq }) => eq(content.asset_id, asset.id), - columns: { audio_id: true } - }); - - const contentAudioIds = assetContents - .filter((content) => content.audio_id) - .map((content) => content.audio_id!); - - allAttachmentIds.push(...contentAudioIds); - - // 3. Translation audio - const translations = await this.db.query.translation.findMany({ - where: (translation, { eq }) => eq(translation.asset_id, asset.id), - columns: { audio: true } - }); - - const translationAudioIds = translations - .filter((translation) => translation.audio) - .map((translation) => translation.audio!); - - allAttachmentIds.push(...translationAudioIds); - } - - const uniqueAttachmentIds = [...new Set(allAttachmentIds)]; - debug( - `[ATTACHMENT STATE] ✅ Found ${uniqueAttachmentIds.length} attachments for ${assetIds.length} assets` - ); - - return uniqueAttachmentIds; - } catch (error) { - console.error( - '[ATTACHMENT STATE] ❌ Error getting asset attachment IDs:', - error - ); - return []; - } - } - - /** - * Debounced update method to prevent excessive calls - * Uses longer debounce during potential download operations - */ - updateWithDebounce( - onUpdate: (ids: string[]) => void, - triggerSource: string - ): void { - debug(`⏱️ [ATTACHMENT STATE] Update triggered by: ${triggerSource}`); - - // Skip updates if download operation is in progress to avoid lock contention - if (this.downloadOperationInProgress) { - debug( - `🚫 [ATTACHMENT STATE] Skipping update (${triggerSource}) - download operation in progress` - ); - this.pendingUpdates.add(triggerSource); - return; - } - - // Clear existing timer - if (this.updateTimer) { - clearTimeout(this.updateTimer); - } - - // Use longer debounce for download-related triggers to avoid lock contention - const isDownloadRelated = [ - 'quest_downloads', - 'asset_downloads', - 'quest_asset_links' - ].includes(triggerSource); - const debounceTime = isDownloadRelated ? 5000 : 1000; // 5 seconds for download operations, 1 second for others - - debug( - `⏱️ [ATTACHMENT STATE] Using ${debounceTime}ms debounce for trigger: ${triggerSource}` - ); - - // Set new debounced timer - this.updateTimer = setTimeout(() => { - void this.updateAttachmentState(onUpdate, triggerSource); - }, debounceTime); - } - - /** - * Mutex-protected update method - */ - private async updateAttachmentState( - onUpdate: (ids: string[]) => void, - triggerSource: string - ): Promise { - if (this.updateInProgress) { - debug( - `🔒 [ATTACHMENT STATE] Update already in progress, skipping trigger from ${triggerSource}` - ); - return; - } - - this.updateInProgress = true; - const updateStartTime = Date.now(); - - try { - debug( - `🔄 [ATTACHMENT STATE] Starting attachment state update (triggered by: ${triggerSource})` - ); - - const newAttachmentIds = await this.getUnifiedPermanentAttachmentIds(); - const newAttachmentSet = new Set(newAttachmentIds); - - // Check if the list has actually changed - const hasChanged = - newAttachmentSet.size !== this.unifiedAttachmentIds.size || - !newAttachmentIds.every((id) => this.unifiedAttachmentIds.has(id)); - - if (hasChanged) { - debug(`🔄 [ATTACHMENT STATE] ✅ ATTACHMENT LIST CHANGED!`); - debug( - `🔄 [ATTACHMENT STATE] Previous: ${this.unifiedAttachmentIds.size} attachments` - ); - debug( - `🔄 [ATTACHMENT STATE] New: ${newAttachmentIds.length} attachments` - ); - - // Show what changed - const previousIds = Array.from(this.unifiedAttachmentIds); - const added = newAttachmentIds.filter( - (id) => !this.unifiedAttachmentIds.has(id) - ); - const removed = previousIds.filter((id) => !newAttachmentSet.has(id)); - - if (added.length > 0) { - debug(`🔄 [ATTACHMENT STATE] ➕ Added ${added.length} attachments`); - } - if (removed.length > 0) { - debug( - `🔄 [ATTACHMENT STATE] ➖ Removed ${removed.length} attachments: ${removed.slice(0, 5).join(', ')}${removed.length > 5 ? '...' : ''}` - ); - } - - // Update the unified state - this.unifiedAttachmentIds = newAttachmentSet; - this.lastUpdateTime = updateStartTime; - - debug( - `🔄 [ATTACHMENT STATE] 📤 Calling PowerSync onUpdate with ${newAttachmentIds.length} attachments...` - ); - onUpdate(newAttachmentIds); - debug(`🔄 [ATTACHMENT STATE] ✅ PowerSync onUpdate completed`); - } else { - debug( - `🔄 [ATTACHMENT STATE] ⏭️ No change in attachment list (${newAttachmentIds.length} attachments), skipping PowerSync update` - ); - } - - const updateDuration = Date.now() - updateStartTime; - debug(`🔄 [ATTACHMENT STATE] Update completed in ${updateDuration}ms`); - } catch (error) { - console.error(`🔄 [ATTACHMENT STATE] ❌ Error during update:`, error); - } finally { - this.updateInProgress = false; - } - } - - /** - * Get current unified attachment IDs (synchronous) - */ - getCurrentAttachmentIds(): string[] { - return Array.from(this.unifiedAttachmentIds); - } - - /** - * Check if an update is currently in progress - */ - isUpdateInProgress(): boolean { - return this.updateInProgress; - } - - /** - * Get debug information about attachment sources - */ - getDebugInfo() { - return { - totalAttachments: this.unifiedAttachmentIds.size, - attachmentSources: Object.fromEntries(this.attachmentSources), - lastUpdateTime: this.lastUpdateTime, - updateInProgress: this.updateInProgress - }; - } - - /** - * Process any pending updates (called after download operations complete) - */ - processPendingUpdates(onUpdate: (ids: string[]) => void): void { - if (this.pendingUpdates.size > 0 && !this.downloadOperationInProgress) { - debug( - `🔄 [ATTACHMENT STATE] Processing ${this.pendingUpdates.size} pending updates: ${Array.from(this.pendingUpdates).join(', ')}` - ); - - // Process the highest priority update - const priorityOrder = [ - 'quest_downloads', - 'asset_downloads', - 'quest_asset_links', - 'asset_content', - 'translations' - ]; - const pendingSources = Array.from(this.pendingUpdates); - const sortedSources = pendingSources.sort((a, b) => { - const aIndex = priorityOrder.indexOf(a); - const bIndex = priorityOrder.indexOf(b); - return (aIndex !== -1 ? aIndex : 999) - (bIndex !== -1 ? bIndex : 999); - }); - - // Clear pending updates and process the highest priority one - this.pendingUpdates.clear(); - - if (sortedSources.length > 0) { - debug( - `🎯 [ATTACHMENT STATE] Processing deferred update: ${sortedSources[0]}` - ); - this.updateWithDebounce(onUpdate, `deferred_${sortedSources[0]}`); - } - } - } - - /** - * Cleanup method - */ - destroy() { - if (this.updateTimer) { - clearTimeout(this.updateTimer); - this.updateTimer = null; - } - if (this.downloadOperationTimer) { - clearTimeout(this.downloadOperationTimer); - this.downloadOperationTimer = null; - } - this.updateInProgress = false; - this.downloadOperationInProgress = false; - this.unifiedAttachmentIds.clear(); - this.attachmentSources.clear(); - this.pendingUpdates.clear(); - } -} diff --git a/db/powersync/PermAttachmentQueue.ts b/db/powersync/PermAttachmentQueue.ts index bb7108306..3af62eb12 100644 --- a/db/powersync/PermAttachmentQueue.ts +++ b/db/powersync/PermAttachmentQueue.ts @@ -6,43 +6,28 @@ import { AttachmentState } from '@powersync/attachments'; import type { PowerSyncSQLiteDatabase } from '@powersync/drizzle-driver'; import * as FileSystem from 'expo-file-system'; import type * as drizzleSchema from '../drizzleSchema'; +import { AppConfig } from '../supabase/AppConfig'; +// import { system } from '../powersync/system'; +import { getCurrentUser } from '@/contexts/AuthContext'; +import { isNotNull } from 'drizzle-orm'; import { AbstractSharedAttachmentQueue } from './AbstractSharedAttachmentQueue'; -import { AttachmentStateManager } from './AttachmentStateManager'; export class PermAttachmentQueue extends AbstractSharedAttachmentQueue { - // Use unified attachment state manager instead of local state - private attachmentStateManager: AttachmentStateManager; + // db: PowerSyncSQLiteDatabase; + // Track previous active downloads to detect changes + // previousActiveDownloads: { + // profile_id: string; + // asset_id: string; + // active: boolean; + // }[] = []; constructor( - options: Omit< - AttachmentQueueOptions, - 'onDownloadError' | 'onUploadError' - > & { + options: AttachmentQueueOptions & { db: PowerSyncSQLiteDatabase; - onDownloadError: ( - attachment: AttachmentRecord, - exception: { toString: () => string; status?: number } - ) => void; - onUploadError: ( - _attachment: AttachmentRecord, - _exception: { - error: string; - message: string; - statusCode: number; - } - ) => Promise<{ - retry: boolean; - }>; } ) { super(options); - this.db = options.db; - console.log( - '[PERM QUEUE] ✅ Initialized with unified attachment state manager' - ); - - // Get the singleton AttachmentStateManager - this.attachmentStateManager = AttachmentStateManager.getInstance(); + // this.db = options.db; } getStorageType(): 'permanent' | 'temporary' { @@ -50,105 +35,132 @@ export class PermAttachmentQueue extends AbstractSharedAttachmentQueue { } async init() { + console.log('PermAttachmentQueue init'); + if (!AppConfig.supabaseBucket) { + console.debug( + 'No Supabase bucket configured, skip setting up PermAttachmentQueue watches.' + ); + // Disable sync interval to prevent errors from trying to sync to a non-existent bucket + this.options.syncInterval = 0; // This is weird, shouldn't be here + return; + } + await super.init(); - console.log( - '[PERM QUEUE] ✅ Initialized with unified attachment state manager' - ); } - // Remove the old collectAllAttachmentIds method since we're using AttachmentStateManager - // Remove the old updateAttachmentIds method since we're using AttachmentStateManager - onAttachmentIdsChange(onUpdate: (ids: string[]) => void): void { - console.log( - '[PERM QUEUE] Setting up unified attachment watcher with AttachmentStateManager' - ); + console.log('onAttachmentIdsChange in PERM ATTACHMENT QUEUE'); - // Single coordinated watcher using AttachmentStateManager - const updateWithSource = (source: string) => { - void this.attachmentStateManager.updateWithDebounce(onUpdate, source); - }; + const currentUser = getCurrentUser(); - // Watch for changes in downloaded assets (direct downloads) - this.db.watch( - this.db.query.asset.findMany({ - columns: { id: true, download_profiles: true, images: true } - }), - { - onResult: () => { - console.log( - '[PERM QUEUE] Asset downloads changed, updating attachments via state manager' - ); - void updateWithSource('asset_downloads'); - } - } - ); + if (!currentUser) { + return; + } - // Watch for changes in downloaded quests (quest downloads) - this.db.watch( - this.db.query.quest.findMany({ - columns: { id: true, download_profiles: true } - }), - { - onResult: () => { - console.log( - '[PERM QUEUE] Quest downloads changed, updating attachments via state manager' - ); - void updateWithSource('quest_downloads'); - } + // Unified function to query all tables and update PowerSync with complete list + const refreshAllAttachments = async () => { + console.log('Refreshing all attachments from all tables'); + + try { + // Query all three tables fresh to get current state + const [assets, assetContentLinks, translations] = await Promise.all([ + this.db.query.asset.findMany({ + columns: { images: true }, + where: (asset) => isNotNull(asset.images) + }), + this.db.query.asset_content_link.findMany({ + columns: { audio_id: true }, + where: (asset_content_link) => + isNotNull(asset_content_link.audio_id) + }), + this.db.query.translation.findMany({ + columns: { audio: true }, + where: (translation) => isNotNull(translation.audio) + }) + ]); + + // Collect all attachment IDs + const assetImages = assets.flatMap((asset) => asset.images!); + const contentLinkAudioIds = assetContentLinks.map( + (link) => link.audio_id! + ); + const translationAudioIds = translations.map( + (translation) => translation.audio! + ); + + // Merge and deduplicate + const allAttachments = [ + ...assetImages, + ...contentLinkAudioIds, + ...translationAudioIds + ]; + const uniqueAttachments = [...new Set(allAttachments)]; + + console.log( + `Total unique attachments to sync: ${uniqueAttachments.length}`, + { + assetImages: assetImages.length, + contentLinkAudioIds: contentLinkAudioIds.length, + translationAudioIds: translationAudioIds.length + } + ); + + console.log( + 'RYDER: about to call onUpdate with ', + uniqueAttachments.length, + 'attachments' + ); + // Tell PowerSync which attachments to keep synced + onUpdate(uniqueAttachments); + } catch (error) { + console.error('Error refreshing attachments:', error); } - ); + }; - // Watch for changes in quest-asset links (affects which assets are in downloaded quests) + // Watch for changes in asset images - trigger full refresh this.db.watch( - this.db.query.quest_asset_link.findMany({ - columns: { quest_id: true, asset_id: true } + this.db.query.asset.findMany({ + columns: { images: true }, + where: (asset) => isNotNull(asset.images) }), { onResult: () => { - console.log( - '[PERM QUEUE] Quest-asset links changed, updating attachments via state manager' - ); - void updateWithSource('quest_asset_links'); + console.log('Asset images changed - triggering full refresh'); + void refreshAllAttachments(); } } ); - // Watch for changes in asset content links (affects downloaded assets) + // Watch for changes in asset content link audio - trigger full refresh this.db.watch( this.db.query.asset_content_link.findMany({ - columns: { asset_id: true, audio_id: true } + columns: { audio_id: true }, + where: (asset_content_link) => isNotNull(asset_content_link.audio_id) }), { onResult: () => { - console.log( - '[PERM QUEUE] Asset content changed, updating attachments via state manager' - ); - void updateWithSource('asset_content'); + console.log('Asset content links changed - triggering full refresh'); + void refreshAllAttachments(); } } ); - // Watch for changes in translations (affects downloaded assets) + // Watch for changes in translation audio - trigger full refresh this.db.watch( this.db.query.translation.findMany({ - columns: { asset_id: true, audio: true } + columns: { audio: true }, + where: (translation) => isNotNull(translation.audio) }), { onResult: () => { - console.log( - '[PERM QUEUE] Translations changed, updating attachments via state manager' - ); - void updateWithSource('translations'); + console.log('Translations changed - triggering full refresh'); + void refreshAllAttachments(); } } ); // Initial load - console.log( - '[PERM QUEUE] Running initial attachment ID collection via state manager' - ); - void updateWithSource('initial_load'); + void refreshAllAttachments(); } async deleteFromQueue(attachmentId: string): Promise { @@ -216,14 +228,4 @@ export class PermAttachmentQueue extends AbstractSharedAttachmentQueue { } }); } - - // Get debug info from the attachment state manager - getDebugInfo() { - return this.attachmentStateManager.getDebugInfo(); - } - - // Cleanup method - destroy() { - this.attachmentStateManager.destroy(); - } } diff --git a/db/powersync/TempAttachmentQueue.ts b/db/powersync/TempAttachmentQueue.ts index 547ad42d0..e0a050416 100644 --- a/db/powersync/TempAttachmentQueue.ts +++ b/db/powersync/TempAttachmentQueue.ts @@ -1,243 +1,110 @@ import type { + // AbstractAttachmentQueue, AttachmentQueueOptions, AttachmentRecord } from '@powersync/attachments'; import type { PowerSyncSQLiteDatabase } from '@powersync/drizzle-driver'; import type * as drizzleSchema from '../drizzleSchema'; +// import { system } from '../powersync/system'; +import { AppConfig } from '../supabase/AppConfig'; import { AbstractSharedAttachmentQueue } from './AbstractSharedAttachmentQueue'; -import { AttachmentStateManager } from './AttachmentStateManager'; export class TempAttachmentQueue extends AbstractSharedAttachmentQueue { private _onUpdateCallback: ((ids: string[]) => void) | null = null; - // Track currently viewed attachments - private currentTempAttachments = new Set(); - // Cleanup timer for temporary attachments - private cleanupTimer: NodeJS.Timeout | null = null; - // How long to keep temporary attachments (in milliseconds) - private static readonly TEMP_CACHE_DURATION: number = 5 * 60 * 1000; // 5 minutes - // Use unified attachment state manager for consistency - private attachmentStateManager: AttachmentStateManager; constructor( - options: Omit< - AttachmentQueueOptions, - 'onDownloadError' | 'onUploadError' - > & { + options: AttachmentQueueOptions & { db: PowerSyncSQLiteDatabase; - onDownloadError: ( - attachment: AttachmentRecord, - exception: { toString: () => string; status?: number } - ) => void; - onUploadError: ( - _attachment: AttachmentRecord, - _exception: { - error: string; - message: string; - statusCode: number; - } - ) => Promise<{ - retry: boolean; - }>; + // maxCacheSize?: number; } ) { - super(options); - this.db = options.db; - console.log( - '[TEMP QUEUE] ✅ Initialized with unified attachment state manager' - ); - - // Get the singleton AttachmentStateManager - this.attachmentStateManager = AttachmentStateManager.getInstance(); + super({ + ...options, + cacheLimit: options.cacheLimit ?? 50 // Default to 50 if not specified + }); } + // Implement the abstract method to identify this queue's storage type getStorageType(): 'permanent' | 'temporary' { return 'temporary'; } async init() { - await super.init(); - - // Start cleanup timer - this.cleanupTimer = setInterval(() => { - void this.cleanupOldTempAttachments(); - }, TempAttachmentQueue.TEMP_CACHE_DURATION); - - console.log( - '[TEMP QUEUE] ✅ Initialized with unified attachment state manager' - ); - } - - // Clean up temporary attachments that haven't been accessed recently - private async cleanupOldTempAttachments() { - try { - const cutoffTime = Date.now() - TempAttachmentQueue.TEMP_CACHE_DURATION; - - const oldTempAttachments = await this.powersync.getAll( - `SELECT * FROM ${this.table} - WHERE storage_type = 'temporary' - AND timestamp < ?`, - [cutoffTime] + console.log('TempAttachmentQueue init'); + if (!AppConfig.supabaseBucket) { + console.debug( + 'No Supabase bucket configured, skip setting up TempAttachmentQueue.' ); - - if (oldTempAttachments.length > 0) { - console.log( - `[TEMP QUEUE] Cleaning up ${oldTempAttachments.length} old temporary attachments` - ); - - await this.powersync.writeTransaction(async (tx) => { - for (const record of oldTempAttachments) { - await this.delete(record, tx); - this.currentTempAttachments.delete(record.id); - } - }); - - // Update the callback with current list - if (this._onUpdateCallback) { - this._onUpdateCallback([...this.currentTempAttachments]); - } - } - } catch (error) { - console.error('[TEMP QUEUE] Error during cleanup:', error); + // Disable sync interval to prevent errors from trying to sync to a non-existent bucket + this.options.syncInterval = 0; + return; } + + await super.init(); } + // Helper method to get the current user's ID + // async getCurrentUserId(): Promise { + // try { + // const { + // data: { session } + // } = await system.supabaseConnector.client.auth.getSession(); + // return session?.user?.id || null; + // } catch (error) { + // console.error('[TEMP QUEUE] Error getting current user ID:', error); + // return null; + // } + // } + // Modified to store the callback and initialize empty list onAttachmentIdsChange(onUpdate: (ids: string[]) => void): void { this._onUpdateCallback = onUpdate; - // Initialize with current temporary attachments - onUpdate([...this.currentTempAttachments]); + // Initialize with empty list + onUpdate([]); } - // Enhanced method to load attachments for an asset with proper tracking + // Simple method to load attachments for an asset async loadAssetAttachments(assetId: string): Promise { - try { - console.log(`[TEMP QUEUE] Loading attachments for asset: ${assetId}`); - - // Use unified AttachmentStateManager for consistency - const attachmentIds = - await this.attachmentStateManager.getAttachmentIdsForAssets([assetId]); - - if (attachmentIds.length === 0) { - console.log(`[TEMP QUEUE] No attachments found for asset ${assetId}`); - return; - } - - console.log( - `[TEMP QUEUE] Found ${attachmentIds.length} attachments for asset ${assetId}: ${attachmentIds.join(', ')}` - ); - - // Add new attachments to current set - let hasChanges = false; - for (const id of attachmentIds) { - if (!this.currentTempAttachments.has(id)) { - this.currentTempAttachments.add(id); - hasChanges = true; - } - } - - // Update PowerSync if there are changes - if (hasChanges && this._onUpdateCallback) { - console.log( - `[TEMP QUEUE] Updated temp attachments, now tracking ${this.currentTempAttachments.size} attachments` - ); - this._onUpdateCallback([...this.currentTempAttachments]); - } - } catch (error) { - console.error( - `[TEMP QUEUE] Error loading attachments for asset ${assetId}:`, - error - ); + const attachmentIds = await this.getAllAssetAttachments(assetId); + if (this._onUpdateCallback) { + this._onUpdateCallback(attachmentIds); } } - // Method to manually add specific attachment IDs - addTempAttachments(attachmentIds: string[]): void { - let hasChanges = false; + // Override expireCache to only count temporary attachments + async expireCache(): Promise { + // console.log('[TEMP QUEUE] Running expireCache'); - for (const id of attachmentIds) { - if (!this.currentTempAttachments.has(id)) { - this.currentTempAttachments.add(id); - hasChanges = true; - } - } + // Get all temporary attachments sorted by timestamp (descending) + const allTempAttachments = await this.powersync.getAll( + `SELECT * FROM ${this.table} + WHERE storage_type = 'temporary' + ORDER BY timestamp DESC` + ); - if (hasChanges && this._onUpdateCallback) { - console.log( - `[TEMP QUEUE] Added ${attachmentIds.length} temp attachments, now tracking ${this.currentTempAttachments.size} attachments` - ); - this._onUpdateCallback([...this.currentTempAttachments]); - } - } + const cacheLimit = this.options.cacheLimit ?? 50; // Default to 50 if undefined - // Method to remove specific attachment IDs from temp tracking - removeTempAttachments(attachmentIds: string[]): void { - let hasChanges = false; + // console.log( + // `[TEMP QUEUE] Max cache size: ${cacheLimit}, current size: ${allTempAttachments.length}` + // ); - for (const id of attachmentIds) { - if (this.currentTempAttachments.has(id)) { - this.currentTempAttachments.delete(id); - hasChanges = true; - } - } + if (allTempAttachments.length > cacheLimit) { + const attachmentsToDelete = allTempAttachments.slice(cacheLimit); - if (hasChanges && this._onUpdateCallback) { - console.log( - `[TEMP QUEUE] Removed ${attachmentIds.length} temp attachments, now tracking ${this.currentTempAttachments.size} attachments` - ); - this._onUpdateCallback([...this.currentTempAttachments]); - } - } - - // Clear all temporary attachments from tracking - clearTempAttachments(): void { - if (this.currentTempAttachments.size > 0) { - console.log( - `[TEMP QUEUE] Clearing all ${this.currentTempAttachments.size} temp attachments` - ); - this.currentTempAttachments.clear(); - - if (this._onUpdateCallback) { - this._onUpdateCallback([]); - } + // Delete the oldest attachments + await this.powersync.writeTransaction(async (tx) => { + for (const record of attachmentsToDelete) { + await this.delete(record, tx); + } + }); } } - // Get currently tracked temporary attachment IDs - getCurrentTempAttachments(): string[] { - return [...this.currentTempAttachments]; - } - async deleteFromQueue(attachmentId: string): Promise { const record = await this.record(attachmentId); if (record) { await this.delete(record); - this.currentTempAttachments.delete(attachmentId); - - // Update the callback - if (this._onUpdateCallback) { - this._onUpdateCallback([...this.currentTempAttachments]); - } - } - } - - // Get debug info - getDebugInfo() { - return { - currentTempAttachments: this.currentTempAttachments.size, - tempAttachmentsList: [...this.currentTempAttachments], - stateManager: this.attachmentStateManager.getDebugInfo() - }; - } - - // Cleanup method - destroy() { - if (this.cleanupTimer) { - clearInterval(this.cleanupTimer); - this.cleanupTimer = null; } - this.attachmentStateManager.destroy(); - this.currentTempAttachments.clear(); - this._onUpdateCallback = null; } } diff --git a/db/powersync/system.ts b/db/powersync/system.ts index 58cdd5315..6c55bf811 100644 --- a/db/powersync/system.ts +++ b/db/powersync/system.ts @@ -21,7 +21,6 @@ import Logger from 'js-logger'; import * as drizzleSchema from '../drizzleSchema'; import { AppConfig } from '../supabase/AppConfig'; import { SupabaseConnector } from '../supabase/SupabaseConnector'; -import { AttachmentStateManager } from './AttachmentStateManager'; import { PermAttachmentQueue } from './PermAttachmentQueue'; import { TempAttachmentQueue } from './TempAttachmentQueue'; import { ATTACHMENT_QUEUE_LIMITS } from './constants'; @@ -37,7 +36,6 @@ export class System { permAttachmentQueue: PermAttachmentQueue | undefined = undefined; tempAttachmentQueue: TempAttachmentQueue | undefined = undefined; db: PowerSyncSQLiteDatabase; - attachmentStateManager: AttachmentStateManager; // Add tracking for attachment queue initialization private attachmentQueuesInitialized = false; @@ -58,9 +56,16 @@ export class System { debugMode: false }); + const { + quest_tag_categories: _, + asset_tag_categories: _2, + ...tablesOnly + } = drizzleSchema; + + // When we first make our powersync instance, define the attachment table as an offline-only table this.powersync = new PowerSyncDatabase({ schema: new Schema([ - ...new DrizzleAppSchema(drizzleSchema).tables, + ...new DrizzleAppSchema(tablesOnly).tables, new AttachmentTable({ additionalColumns: [ new Column({ name: 'storage_type', type: ColumnType.TEXT }) @@ -74,12 +79,6 @@ export class System { schema: drizzleSchema }); - // Initialize the singleton AttachmentStateManager - this.attachmentStateManager = AttachmentStateManager.initialize( - this.db, - this.powersync - ); - if (AppConfig.supabaseBucket) { this.permAttachmentQueue = new PermAttachmentQueue({ powersync: this.powersync, @@ -375,9 +374,6 @@ export class System { } } - // Cleanup the AttachmentStateManager singleton - AttachmentStateManager.destroySingleton(); - // Disconnect PowerSync if (this.powersync.connected) { await this.powersync.disconnect(); diff --git a/db/supabase/SupabaseConnector.ts b/db/supabase/SupabaseConnector.ts index 1396714a2..55e0b20ac 100644 --- a/db/supabase/SupabaseConnector.ts +++ b/db/supabase/SupabaseConnector.ts @@ -143,20 +143,81 @@ export class SupabaseConnector implements PowerSyncBackendConnector { await this.system.db.select().from(profile).where(eq(profile.id, user)) )[0] as Profile | null; - if (localProfile) return localProfile; + if (localProfile) { + console.log('✅ [SupabaseConnector] Found local profile for user:', user); + return localProfile; + } - const { data: userData, error: userError } = await this.client - .from('profile') - .select('*') - .eq('id', user) - .single(); + // If no local profile, try to fetch from Supabase + console.log( + '🔄 [SupabaseConnector] No local profile, attempting online fetch for user:', + user + ); - if (userError) { - console.error('Error fetching user profile:', userError); - return null; - } + try { + const { data: userData, error: userError } = await this.client + .from('profile') + .select('*') + .eq('id', user) + .single(); + + if (userError) { + console.error( + '❌ [SupabaseConnector] Error fetching user profile from Supabase:', + userError + ); - return userData; + // For offline scenarios, create a minimal profile object to prevent logout + // This ensures the user stays logged in even when profile fetch fails + console.log( + '🔄 [SupabaseConnector] Creating minimal profile for offline user:', + user + ); + return { + id: user, + email: null, + username: null, + password: null, + avatar: null, + ui_language_id: null, + terms_accepted: false, + terms_accepted_at: null, + active: true, + created_at: new Date().toISOString(), + last_updated: new Date().toISOString() + } as Profile; + } + + console.log( + '✅ [SupabaseConnector] Successfully fetched profile from Supabase for user:', + user + ); + return userData; + } catch (error) { + console.error( + '❌ [SupabaseConnector] Network error while fetching profile:', + error + ); + + // For network errors (offline), create a minimal profile to prevent logout + console.log( + '🔄 [SupabaseConnector] Creating minimal profile due to network error for user:', + user + ); + return { + id: user, + email: null, + username: null, + password: null, + avatar: null, + ui_language_id: null, + terms_accepted: false, + terms_accepted_at: null, + active: true, + created_at: new Date().toISOString(), + last_updated: new Date().toISOString() + } as Profile; + } } async login(username: string, password: string) { diff --git a/debug_closure_issue.sql b/debug_closure_issue.sql new file mode 100644 index 000000000..a5b908dae --- /dev/null +++ b/debug_closure_issue.sql @@ -0,0 +1,86 @@ +-- Debug script to identify closure timeout issues +-- Run this in your Supabase SQL editor + +-- 1. Check the size of project_closure records +SELECT + project_id, + jsonb_array_length(COALESCE(quest_ids, '[]'::jsonb)) as quest_count, + jsonb_array_length(COALESCE(asset_ids, '[]'::jsonb)) as asset_count, + jsonb_array_length(COALESCE(translation_ids, '[]'::jsonb)) as translation_count, + jsonb_array_length(COALESCE(vote_ids, '[]'::jsonb)) as vote_count, + jsonb_array_length(COALESCE(tag_ids, '[]'::jsonb)) as tag_count, + jsonb_array_length(COALESCE(language_ids, '[]'::jsonb)) as language_count, + jsonb_array_length(COALESCE(quest_asset_link_ids, '[]'::jsonb)) as quest_asset_link_count, + jsonb_array_length(COALESCE(asset_content_link_ids, '[]'::jsonb)) as asset_content_link_count, + jsonb_array_length(COALESCE(quest_tag_link_ids, '[]'::jsonb)) as quest_tag_link_count, + jsonb_array_length(COALESCE(asset_tag_link_ids, '[]'::jsonb)) as asset_tag_link_count, + total_quests, + total_assets, + total_translations, + last_updated +FROM project_closure +ORDER BY + jsonb_array_length(COALESCE(quest_ids, '[]'::jsonb)) + + jsonb_array_length(COALESCE(asset_ids, '[]'::jsonb)) + + jsonb_array_length(COALESCE(translation_ids, '[]'::jsonb)) DESC +LIMIT 10; + +-- 2. Check for malformed JSONB data +SELECT + project_id, + quest_ids IS NULL as quest_ids_null, + asset_ids IS NULL as asset_ids_null, + translation_ids IS NULL as translation_ids_null, + vote_ids IS NULL as vote_ids_null, + tag_ids IS NULL as tag_ids_null, + language_ids IS NULL as language_ids_null, + quest_asset_link_ids IS NULL as quest_asset_link_ids_null, + asset_content_link_ids IS NULL as asset_content_link_ids_null, + quest_tag_link_ids IS NULL as quest_tag_link_ids_null, + asset_tag_link_ids IS NULL as asset_tag_link_ids_null +FROM project_closure +WHERE + quest_ids IS NULL OR + asset_ids IS NULL OR + translation_ids IS NULL OR + vote_ids IS NULL OR + tag_ids IS NULL OR + language_ids IS NULL OR + quest_asset_link_ids IS NULL OR + asset_content_link_ids IS NULL OR + quest_tag_link_ids IS NULL OR + asset_tag_link_ids IS NULL; + +-- 3. Check indexes on closure tables +SELECT + schemaname, + tablename, + indexname, + indexdef +FROM pg_indexes +WHERE tablename IN ('project_closure', 'quest_closure') +ORDER BY tablename, indexname; + +-- 4. Check indexes on download_profiles columns +SELECT + schemaname, + tablename, + indexname, + indexdef +FROM pg_indexes +WHERE indexdef LIKE '%download_profiles%' +ORDER BY tablename, indexname; + +-- 5. Test a simple JSONB operation on the largest closure +WITH largest_closure AS ( + SELECT * FROM project_closure + ORDER BY + jsonb_array_length(COALESCE(quest_ids, '[]'::jsonb)) + + jsonb_array_length(COALESCE(asset_ids, '[]'::jsonb)) DESC + LIMIT 1 +) +SELECT + project_id, + (SELECT COUNT(*) FROM (SELECT jsonb_array_elements_text(quest_ids)) AS t) as quest_ids_parsed, + (SELECT COUNT(*) FROM (SELECT jsonb_array_elements_text(asset_ids)) AS t) as asset_ids_parsed +FROM largest_closure; \ No newline at end of file diff --git a/hooks/db/useAssets.ts b/hooks/db/useAssets.ts index 308fad55f..e8f134b85 100644 --- a/hooks/db/useAssets.ts +++ b/hooks/db/useAssets.ts @@ -2,13 +2,25 @@ import type { translation, vote } from '@/db/drizzleSchema'; import { asset, asset_content_link, + asset_tag_link, quest, - quest_asset_link + quest_asset_link, + tag } from '@/db/drizzleSchema'; import { system } from '@/db/powersync/system'; import { toCompilableQuery } from '@powersync/drizzle-driver'; -import type { InferSelectModel } from 'drizzle-orm'; -import { and, asc, desc, eq, inArray, isNotNull } from 'drizzle-orm'; +import type { InferSelectModel, SQL } from 'drizzle-orm'; +import { + and, + asc, + desc, + eq, + inArray, + isNotNull, + like, + or, + sql +} from 'drizzle-orm'; import { useMemo } from 'react'; import { convertToFetchConfig, @@ -58,7 +70,7 @@ function getAssetByIdConfig(asset_id: string | string[]) { export async function getAssetById(asset_id: string) { return ( await hybridFetch(convertToFetchConfig(getAssetByIdConfig(asset_id))) - )?.[0]; + )[0]; } export function useAssetById(asset_id: string | undefined) { @@ -90,7 +102,7 @@ export function useAssetById(asset_id: string | undefined) { ) }); - const asset = assetArray?.[0] || null; + const asset = assetArray[0] || null; return { asset, isAssetLoading, ...rest }; } @@ -660,11 +672,13 @@ export function useInfiniteAssetsWithTagsAndContentByQuestId( quest_id: string, pageSize = 10, sortField?: string, - sortOrder?: 'asc' | 'desc' + sortOrder?: 'asc' | 'desc', + searchQuery?: string, + activeFilters?: Record ) { - // FIXED: Create stable query key with useMemo to prevent infinite loops const queryKey = useMemo(() => { - const baseKey = [ + // Filter out undefined values from query key to prevent null values + const queryKeyParts = [ 'assets', 'infinite', 'by-quest', @@ -672,12 +686,12 @@ export function useInfiniteAssetsWithTagsAndContentByQuestId( quest_id, pageSize ]; + if (sortField) queryKeyParts.push(sortField); + if (sortOrder) queryKeyParts.push(sortOrder); + if (searchQuery) queryKeyParts.push(searchQuery); + if (activeFilters) queryKeyParts.push(JSON.stringify(activeFilters)); - // Only add optional parameters if they have values - if (sortField) baseKey.push(sortField); - if (sortOrder) baseKey.push(sortOrder); - - return baseKey; + return queryKeyParts; }, [quest_id, pageSize, sortField, sortOrder]); return useHybridInfiniteQuery({ @@ -687,26 +701,73 @@ export function useInfiniteAssetsWithTagsAndContentByQuestId( const from = pageParam * pageSize; const to = from + pageSize - 1; - // Online query with proper pagination using Supabase range - let query = system.supabaseConnector.client - .from('quest_asset_link') - .select( + // Build tag name filters first to determine query structure + const tagNameFilters: string[] = []; + + if (activeFilters) { + Object.entries(activeFilters).forEach(([category, selectedValues]) => { + if (selectedValues.length > 0) { + selectedValues.forEach((fullValue) => { + // Extract just the value part after the colon (e.g., "Lucas" from "libro:lucas") + const valuePart = fullValue.split(':')[1] || fullValue; + const tagName = `${category}:${valuePart}`; + tagNameFilters.push(tagName); + }); + } + }); + } + + // Build query based on whether we have tag filtering + let query; + if (tagNameFilters.length > 0) { + // Use inner join syntax when filtering by tags + query = system.supabaseConnector.client + .from('quest_asset_link') + .select( + ` + asset:asset_id ( + *, + content:asset_content_link ( + * + ), + tags:asset_tag_link!inner ( + tag:tag_id!inner ( + * + ) + ) + ) ` - asset:asset_id ( - *, - content:asset_content_link ( - * - ), - tags:asset_tag_link ( - tag:tag_id ( + ) + .eq('quest_id', quest_id) + .in('asset_id.tags.tag.name', tagNameFilters); + } else { + // Use regular left join when no tag filtering + query = system.supabaseConnector.client + .from('quest_asset_link') + .select( + ` + asset:asset_id ( + *, + content:asset_content_link ( * + ), + tags:asset_tag_link ( + tag:tag_id ( + * + ) ) ) + ` ) - `, - { count: 'exact' } - ) - .eq('quest_id', quest_id); + .eq('quest_id', quest_id); + } + + // Add search functionality + if (searchQuery) { + const searchTerm = searchQuery.trim(); + // Search in asset names using Supabase's nested field filtering + query = query.filter('asset_id.name', 'ilike', `%${searchTerm}%`); + } // Add sorting if specified if (sortField && sortOrder) { @@ -768,14 +829,102 @@ export function useInfiniteAssetsWithTagsAndContentByQuestId( `[OfflineAssets] Loading page ${pageParam} for quest ${quest_id}, offset: ${offsetValue}` ); - const allAssets = await system.db.query.asset.findMany({ - where: inArray( - asset.id, - system.db + // Build the base subquery for assets in this quest + let assetIdsSubquery = system.db + .select({ asset_id: quest_asset_link.asset_id }) + .from(quest_asset_link) + .where(eq(quest_asset_link.quest_id, quest_id)); + + // Add search functionality for offline queries + if (searchQuery) { + const searchTerm = `%${searchQuery.trim()}%`; + + // Get asset IDs that match search criteria within this quest (asset names only) + const searchMatchingAssets = system.db + .select({ id: asset.id }) + .from(asset) + .where(like(asset.name, searchTerm)); + + // Filter the quest assets to only include those that match search + assetIdsSubquery = system.db + .select({ asset_id: quest_asset_link.asset_id }) + .from(quest_asset_link) + .where( + and( + eq(quest_asset_link.quest_id, quest_id), + inArray(quest_asset_link.asset_id, searchMatchingAssets) + ) + ); + } + + // Build tag filtering conditions for offline query using Drizzle SQL operators + const tagFilterConditions: SQL[] = []; + + if (activeFilters) { + Object.entries(activeFilters).forEach( + ([category, selectedValues]) => { + if (selectedValues.length > 0) { + // Create OR conditions for each value in this category + const categoryConditions = selectedValues.map((fullValue) => { + const valuePart = fullValue.split(':')[1] || fullValue; + // Use SQL template for case-insensitive matching + return sql`${tag.name} LIKE ${`${category}:${valuePart}`}`; + }); + + if (categoryConditions.length > 0) { + // Combine multiple values in the same category with OR + const condition = + categoryConditions.length === 1 + ? categoryConditions[0]! + : or(...categoryConditions); + + if (condition) { + tagFilterConditions.push(condition); + } + } + } + } + ); + } + + // Apply tag filtering to the asset subquery if needed + if (tagFilterConditions.length > 0) { + // Combine tag filter conditions + const combinedTagConditions = + tagFilterConditions.length === 1 + ? tagFilterConditions[0] + : and(...tagFilterConditions); + + // Only proceed if we have valid conditions + if (combinedTagConditions) { + // Create EXISTS subquery for tag filtering on assets + const tagFilterSubquery = system.db + .select({ assetId: asset_tag_link.asset_id }) + .from(asset_tag_link) + .innerJoin(tag, eq(tag.id, asset_tag_link.tag_id)) + .where( + and( + eq(asset_tag_link.active, true), + eq(tag.active, true), + combinedTagConditions + ) + ); + + // Filter the base subquery to only include assets that match tag criteria + assetIdsSubquery = system.db .select({ asset_id: quest_asset_link.asset_id }) .from(quest_asset_link) - .where(eq(quest_asset_link.quest_id, quest_id)) - ), + .where( + and( + eq(quest_asset_link.quest_id, quest_id), + inArray(quest_asset_link.asset_id, tagFilterSubquery) + ) + ); + } + } + + const allAssets = await system.db.query.asset.findMany({ + where: inArray(asset.id, assetIdsSubquery), with: { content: true, tags: { diff --git a/hooks/db/useQuests.ts b/hooks/db/useQuests.ts index d9a448b41..6a20c7dda 100644 --- a/hooks/db/useQuests.ts +++ b/hooks/db/useQuests.ts @@ -139,7 +139,7 @@ export function useQuestById(quest_id: string | undefined) { ) }); - const quest = questArray?.[0] || null; + const quest = questArray[0] || null; return { quest, isQuestLoading, ...rest }; } diff --git a/hooks/db/useTags.ts b/hooks/db/useTags.ts index 33b3f2379..94963aa00 100644 --- a/hooks/db/useTags.ts +++ b/hooks/db/useTags.ts @@ -1,9 +1,18 @@ -import { asset_tag_link, quest_tag_link, tag } from '@/db/drizzleSchema'; +import { + asset_tag_categories, + asset_tag_link, + quest, + quest_tag_categories, + quest_tag_link, + tag +} from '@/db/drizzleSchema'; import { system } from '@/db/powersync/system'; -import { toCompilableQuery } from '@powersync/drizzle-driver'; import type { InferSelectModel } from 'drizzle-orm'; -import { eq } from 'drizzle-orm'; -import { useHybridQuery } from '../useHybridQuery'; +import { and, eq, getTableColumns, like } from 'drizzle-orm'; +import { + useHybridSupabaseInfiniteQuery, + useHybridSupabaseQuery +} from '../useHybridSupabaseQuery'; export type Tag = InferSelectModel; @@ -12,28 +21,17 @@ export type Tag = InferSelectModel; * Fetches tags from Supabase (online) or local Drizzle DB (offline) */ export function useTags() { - const { db, supabaseConnector } = system; + const { db } = system; const { data: tags, isLoading: isTagsLoading, ...rest - } = useHybridQuery({ + } = useHybridSupabaseQuery({ queryKey: ['tags'], - onlineFn: async () => { - const { data, error } = await supabaseConnector.client - .from('tag') - .select('*') - .eq('active', true) - .overrideTypes(); - if (error) throw error; - return data; - }, - offlineQuery: toCompilableQuery( - db.query.tag.findMany({ - where: eq(tag.active, true) - }) - ) + query: db.query.tag.findMany({ + where: eq(tag.active, true) + }) }); return { tags, isTagsLoading, ...rest }; @@ -44,103 +42,454 @@ export function useTags() { * Fetches tags by quest ID from Supabase (online) or local Drizzle DB (offline) */ export function useTagsByQuestId(quest_id: string) { - const { db, supabaseConnector } = system; + const { db } = system; const { data: tags, isLoading: isTagsLoading, ...rest - } = useHybridQuery({ + } = useHybridSupabaseQuery({ queryKey: ['tags', 'by-quest', quest_id], - onlineFn: async () => { - // Get tags through junction table + query: db + .select({ + id: tag.id, + name: tag.name, + active: tag.active, + created_at: tag.created_at, + last_updated: tag.last_updated + }) + .from(tag) + .innerJoin(quest_tag_link, eq(tag.id, quest_tag_link.tag_id)) + .where(eq(quest_tag_link.quest_id, quest_id)), + enabled: !!quest_id + }); + + return { tags, isTagsLoading, ...rest }; +} + +/** + * Returns { tags, isLoading, error } + * Fetches tags by asset ID from Supabase (online) or local Drizzle DB (offline) + */ +export function useTagsByAssetId(asset_id: string) { + const { db } = system; + + const { + data: tags, + isLoading: isTagsLoading, + ...rest + } = useHybridSupabaseQuery({ + queryKey: ['tags', 'by-asset', asset_id], + query: db + .select({ + id: tag.id, + name: tag.name, + active: tag.active, + created_at: tag.created_at, + last_updated: tag.last_updated + }) + .from(tag) + .innerJoin(asset_tag_link, eq(tag.id, asset_tag_link.tag_id)) + .where(eq(asset_tag_link.asset_id, asset_id)), + enabled: !!asset_id + }); + + return { tags, isTagsLoading, ...rest }; +} + +/** + * Returns { tags, isLoading, error } + * Fetches tags by project ID from Supabase (online) or local Drizzle DB (offline) + * Gets all unique tags that belong to quests within the specified project + * OPTIMIZED VERSION - uses direct joins instead of chunked requests + */ +export function useTagsByProjectId(project_id: string) { + const { db, supabaseConnector } = system; + + const { + data: tags, + isLoading: isTagsLoading, + ...rest + } = useHybridSupabaseQuery({ + queryKey: ['tags', 'by-project', project_id], + onlineFn: async ({ signal }) => { + // First get quest IDs for the project + const { data: questIds, error: questError } = + await supabaseConnector.client + .from('quest') + .select('id') + .eq('project_id', project_id) + .abortSignal(signal) + .overrideTypes<{ id: string }[]>(); + + if (questError) throw questError; + if (!questIds.length) return []; + + // Split quest IDs into chunks to handle Supabase limits + const questIdValues = questIds.map((q) => q.id); + const chunkSize = 635; + const chunks: string[][] = []; + + for (let i = 0; i < questIdValues.length; i += chunkSize) { + chunks.push(questIdValues.slice(i, i + chunkSize)); + } + + // Make parallel requests for all chunks + const chunkPromises = chunks.map((chunk) => + supabaseConnector.client + .from('quest_tag_link') + .select('tag:tag_id(*)') + .in('quest_id', chunk) + .abortSignal(signal) + .overrideTypes<{ tag: Tag }[]>() + ); + + const results = await Promise.allSettled(chunkPromises); + + // Check for any failures + const failures = results.filter((result) => result.status === 'rejected'); + if (failures.length > 0) { + throw new Error(`Failed to fetch tags for ${failures.length} chunks`); + } + + // Extract unique tags efficiently from all successful results + const tagMap = new Map(); + results.forEach((result) => { + if (result.status === 'fulfilled' && result.value.data) { + result.value.data.forEach((item) => { + tagMap.set(item.tag.id, item.tag); + }); + } + }); + + return Array.from(tagMap.values()); + }, + offlineQuery: db + .selectDistinct({ + ...getTableColumns(tag) + }) + .from(tag) + .innerJoin(quest_tag_link, eq(tag.id, quest_tag_link.tag_id)) + .innerJoin(quest, eq(quest_tag_link.quest_id, quest.id)) + .where(eq(quest.project_id, project_id)), + enabled: !!project_id + }); + + return { tags, isTagsLoading, ...rest }; +} + +/** + * Returns { tagCategories, isLoading, error } + * Fetches tag categories by quest ID using the asset_tag_categories view + * This is more efficient for getting just the categories without individual tag details + */ +export function useTagCategoriesByQuestId(quest_id: string) { + const { db } = system; + + const { + data: tagCategories, + isLoading: isTagCategoriesLoading, + ...rest + } = useHybridSupabaseQuery({ + queryKey: ['tag-categories', 'by-quest', quest_id], + query: db + .select({ + tag_categories: asset_tag_categories.tag_categories + }) + .from(asset_tag_categories) + .where(eq(asset_tag_categories.quest_id, quest_id)), + gcTime: 0, + staleTime: 0, + enabled: !!quest_id + }); + + return { + tagCategories: tagCategories[0], + isTagCategoriesLoading, + ...rest + }; +} + +/** + * Returns { tagCategories, isLoading, error } + * Fetches tag categories by project ID using the quest_tag_categories view + * This is more efficient for getting just the categories without individual tag details + */ +export function useTagCategoriesByProjectId(project_id: string) { + const { db, supabaseConnector } = system; + + const { + data: tagCategories, + isLoading: isTagCategoriesLoading, + ...rest + } = useHybridSupabaseQuery({ + queryKey: ['tag-categories', 'by-project', project_id], + onlineFn: async ({ signal }) => { + // Use the optimized view for getting categories + const { data, error } = await supabaseConnector.client + .from('quest_tag_categories') + .select('tag_categories') + .eq('project_id', project_id) + .abortSignal(signal) + .single() + .overrideTypes<{ tag_categories: string[] | null }>(); + + if (error) throw error; + + return [data]; + }, + offlineQuery: db + .select({ + tag_categories: quest_tag_categories.tag_categories + }) + .from(quest_tag_categories) + .where(eq(quest_tag_categories.project_id, project_id)), + gcTime: 0, + staleTime: 0, + enabled: !!project_id + }); + + return { + tagCategories: tagCategories[0], + isTagCategoriesLoading, + ...rest + }; +} + +/** + * Returns { tags, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage } + * Fetches tags by project ID with infinite scrolling + * Gets all unique tags that belong to quests within the specified project + */ +export function useInfiniteTagsByProjectId(project_id: string) { + const { db, supabaseConnector } = system; + + return useHybridSupabaseInfiniteQuery({ + queryKey: ['tags', 'by-project-paginated', project_id], + onlineFn: async ({ pageParam, pageSize }) => { + // Use direct pagination approach with tags + const { data, error } = await supabaseConnector.client + .from('tag') + .select( + ` + id, + name, + active, + created_at, + last_updated, + download_profiles, + quest_tag_link!inner( + quest( + project_id + ) + ) + ` + ) + .eq('quest_tag_link.quest.project_id', project_id) + .eq('active', true) + .order('name') + .range(pageParam * pageSize, (pageParam + 1) * pageSize - 1) + .overrideTypes(); + + if (error) throw error; + console.log('data', 'test'); + console.log('data', data.length); + return data; + }, + offlineFn: async ({ pageParam, pageSize }) => { + return await db + .selectDistinct({ + ...getTableColumns(tag) + }) + .from(tag) + .innerJoin(quest_tag_link, eq(tag.id, quest_tag_link.tag_id)) + .innerJoin(quest, eq(quest_tag_link.quest_id, quest.id)) + .where(eq(quest.project_id, project_id)) + .limit(pageSize) + .offset(pageParam * pageSize) + .orderBy(tag.name); + }, + enabled: !!project_id, + pageSize: 10 + }); +} + +/** + * Returns { tags, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage } + * Fetches tags by quest ID with infinite scrolling + * Gets all unique tags that belong to the specified quest + */ +export function useInfiniteTagsByQuestId(quest_id: string) { + const { db, supabaseConnector } = system; + + return useHybridSupabaseInfiniteQuery({ + queryKey: ['tags', 'by-quest-paginated', quest_id], + onlineFn: async ({ pageParam, pageSize }) => { + // Use direct pagination approach with tags const { data, error } = await supabaseConnector.client - .from('quest_tag_link') + .from('tag') .select( ` - tag:tag_id ( - id, - name, - active, - created_at, - last_updated + id, + name, + active, + created_at, + last_updated, + download_profiles, + quest_tag_link!inner( + quest_id ) ` ) - .eq('quest_id', quest_id) - .overrideTypes<{ tag: Tag }[]>(); + .eq('quest_tag_link.quest_id', quest_id) + .eq('active', true) + .order('name') + .range(pageParam * pageSize, (pageParam + 1) * pageSize - 1) + .overrideTypes(); if (error) throw error; - return data.map((item) => item.tag).filter(Boolean); + return data; }, - offlineQuery: toCompilableQuery( - db + offlineFn: async ({ pageParam, pageSize }) => { + return await db .select({ - id: tag.id, - name: tag.name, - active: tag.active, - created_at: tag.created_at, - last_updated: tag.last_updated + ...getTableColumns(tag) }) .from(tag) .innerJoin(quest_tag_link, eq(tag.id, quest_tag_link.tag_id)) .where(eq(quest_tag_link.quest_id, quest_id)) - ), - enabled: !!quest_id + .limit(pageSize) + .offset(pageParam * pageSize) + .orderBy(tag.name); + }, + enabled: !!quest_id, + pageSize: 50 }); - - return { tags, isTagsLoading, ...rest }; } /** - * Returns { tags, isLoading, error } - * Fetches tags by asset ID from Supabase (online) or local Drizzle DB (offline) + * Returns { tags, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage } + * Fetches tags by quest ID and category with infinite scrolling + * Gets tags that belong to the specified quest and match the category */ -export function useTagsByAssetId(asset_id: string) { +export function useInfiniteTagsByQuestIdAndCategory( + quest_id: string, + category: string +) { const { db, supabaseConnector } = system; - const { - data: tags, - isLoading: isTagsLoading, - ...rest - } = useHybridQuery({ - queryKey: ['tags', 'by-asset', asset_id], - onlineFn: async () => { - // Get tags through junction table + return useHybridSupabaseInfiniteQuery({ + queryKey: ['tags', 'by-quest-category-paginated', quest_id, category], + onlineFn: async ({ pageParam, pageSize }) => { + // Use direct pagination approach with tags filtered by category const { data, error } = await supabaseConnector.client - .from('asset_tag_link') + .from('tag') .select( ` - tag:tag_id ( - id, - name, - active, - created_at, - last_updated + id, + name, + active, + created_at, + last_updated, + download_profiles, + quest_tag_link!inner( + quest_id ) ` ) - .eq('asset_id', asset_id) - .overrideTypes<{ tag: Tag }[]>(); + .eq('quest_tag_link.quest_id', quest_id) + .eq('active', true) + .like('name', `${category}:%`) + .order('name') + .range(pageParam * pageSize, (pageParam + 1) * pageSize - 1) + .overrideTypes(); if (error) throw error; - return data.map((item) => item.tag).filter(Boolean); + return data; }, - offlineQuery: toCompilableQuery( - db + offlineFn: async ({ pageParam, pageSize }) => { + const data = await db .select({ - id: tag.id, - name: tag.name, - active: tag.active, - created_at: tag.created_at, - last_updated: tag.last_updated + ...getTableColumns(tag) }) .from(tag) - .innerJoin(asset_tag_link, eq(tag.id, asset_tag_link.tag_id)) - .where(eq(asset_tag_link.asset_id, asset_id)) - ), - enabled: !!asset_id + .innerJoin(quest_tag_link, eq(tag.id, quest_tag_link.tag_id)) + .where( + and( + eq(quest_tag_link.quest_id, quest_id), + like(tag.name, `${category}:%`) + ) + ) + .limit(pageSize) + .offset(pageParam * pageSize) + .orderBy(tag.name); + + console.log('data', data); + return data; + }, + enabled: !!quest_id && !!category, + pageSize: 20 }); +} - return { tags, isTagsLoading, ...rest }; +/** + * Returns { tags, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage } + * Fetches tags by project ID and category with infinite scrolling + * Gets tags that belong to quests within the specified project and match the category + */ +export function useInfiniteTagsByProjectIdAndCategory( + project_id: string, + category: string +) { + const { db, supabaseConnector } = system; + + return useHybridSupabaseInfiniteQuery({ + queryKey: ['tags', 'by-project-category-paginated', project_id, category], + onlineFn: async ({ pageParam, pageSize }) => { + // Use direct pagination approach with tags filtered by category + const { data, error } = await supabaseConnector.client + .from('tag') + .select( + ` + id, + name, + active, + created_at, + last_updated, + download_profiles, + quest_tag_link!inner( + quest( + project_id + ) + ) + ` + ) + .eq('quest_tag_link.quest.project_id', project_id) + .eq('active', true) + .like('name', `${category}:%`) + .order('name') + .range(pageParam * pageSize, (pageParam + 1) * pageSize - 1) + .overrideTypes(); + + if (error) throw error; + return data; + }, + offlineFn: async ({ pageParam, pageSize }) => { + return await db + .selectDistinct({ + ...getTableColumns(tag) + }) + .from(tag) + .innerJoin(quest_tag_link, eq(tag.id, quest_tag_link.tag_id)) + .innerJoin(quest, eq(quest_tag_link.quest_id, quest.id)) + .where( + and(eq(quest.project_id, project_id), like(tag.name, `${category}:%`)) + ) + .limit(pageSize) + .offset(pageParam * pageSize) + .orderBy(tag.name); + }, + enabled: !!project_id && !!category, + pageSize: 20 + }); } diff --git a/hooks/db/useTranslations.ts b/hooks/db/useTranslations.ts index bbf3b6423..c321db218 100644 --- a/hooks/db/useTranslations.ts +++ b/hooks/db/useTranslations.ts @@ -9,18 +9,17 @@ import { vote } from '@/db/drizzleSchema'; import { system } from '@/db/powersync/system'; -import { toCompilableQuery } from '@powersync/drizzle-driver'; import { useQueryClient } from '@tanstack/react-query'; import type { InferSelectModel } from 'drizzle-orm'; import { and, eq, inArray, isNotNull, notInArray } from 'drizzle-orm'; import { useEffect } from 'react'; import { - convertToFetchConfig, - createHybridQueryConfig, - hybridFetch, - useHybridQuery, - useHybridRealtimeQuery -} from '../useHybridQuery'; + convertToSupabaseFetchConfig, + createHybridSupabaseQueryConfig, + hybridSupabaseFetch, + useHybridSupabaseQuery, + useHybridSupabaseRealtimeQuery +} from '../useHybridSupabaseQuery'; import { useNetworkStatus } from '../useNetworkStatus'; export type Translation = InferSelectModel; @@ -39,20 +38,21 @@ export type Project = InferSelectModel; function getTranslationsByAssetIdConfig(asset_id: string | string[]) { const currentUser = getCurrentUser(); const assetIds = Array.isArray(asset_id) ? asset_id : [asset_id]; - return createHybridQueryConfig({ + return createHybridSupabaseQueryConfig({ queryKey: [ 'translations', 'by-asset', asset_id, currentUser?.id || 'anonymous' ], - onlineFn: async () => { + onlineFn: async ({ signal }) => { if (!currentUser) { // No user logged in, return all translations const { data, error } = await system.supabaseConnector.client .from('translation') .select('*') .in('asset_id', assetIds) + .abortSignal(signal) .overrideTypes(); if (error) throw error; return data; @@ -63,12 +63,14 @@ function getTranslationsByAssetIdConfig(asset_id: string | string[]) { system.supabaseConnector.client .from('blocked_users') .select('blocked_id') - .eq('blocker_id', currentUser.id), + .eq('blocker_id', currentUser.id) + .abortSignal(signal), system.supabaseConnector.client .from('blocked_content') .select('content_id') .eq('profile_id', currentUser.id) .eq('content_table', 'translations') + .abortSignal(signal) ]); if (blockedUsersResult.error) throw blockedUsersResult.error; @@ -95,7 +97,9 @@ function getTranslationsByAssetIdConfig(asset_id: string | string[]) { query = query.not('id', 'in', `(${blockedContentIds.join(',')})`); } - const { data, error } = await query.overrideTypes(); + const { data, error } = await query + .abortSignal(signal) + .overrideTypes(); if (error) throw error; return data; }, @@ -143,14 +147,14 @@ function getTranslationsByAssetIdConfig(asset_id: string | string[]) { } export function getTranslationsByAssetId(asset_id: string) { - return hybridFetch( - convertToFetchConfig(getTranslationsByAssetIdConfig(asset_id)) + return hybridSupabaseFetch( + convertToSupabaseFetchConfig(getTranslationsByAssetIdConfig(asset_id)) ); } export function getTranslationsByAssetIds(asset_ids: string[]) { - return hybridFetch( - convertToFetchConfig(getTranslationsByAssetIdConfig(asset_ids)) + return hybridSupabaseFetch( + convertToSupabaseFetchConfig(getTranslationsByAssetIdConfig(asset_ids)) ); } @@ -222,18 +226,12 @@ export function useTranslationsByAssetId(asset_id: string) { data: translations, isLoading: isTranslationsLoading, ...rest - } = useHybridRealtimeQuery({ + } = useHybridSupabaseRealtimeQuery({ ...getTranslationsByAssetIdConfig(asset_id), - subscribeRealtime: (onChange) => { - const channel = system.supabaseConnector.client - .channel('public:translation') - .on( - 'postgres_changes', - { event: '*', schema: 'public', table: 'translation' }, - onChange - ); - channel.subscribe(); - return () => system.supabaseConnector.client.removeChannel(channel); + channelName: 'public:translation', + subscriptionConfig: { + table: 'translation', + schema: 'public' } }); @@ -250,20 +248,22 @@ export function useTranslationById( current_user_id?: string ) { const { db, supabaseConnector } = system; + const { currentUser } = useAuth(); const { data: translationArray, isLoading: isTranslationLoading, ...rest - } = useHybridQuery({ + } = useHybridSupabaseQuery({ queryKey: ['translation', translation_id, current_user_id || 'anonymous'], - onlineFn: async () => { + onlineFn: async ({ signal }) => { if (!current_user_id) { // No user logged in, return translation directly const { data, error } = await supabaseConnector.client .from('translation') .select('*') .eq('id', translation_id) + .abortSignal(signal) .overrideTypes(); if (error) throw error; return data; @@ -276,7 +276,8 @@ export function useTranslationById( .select('content_id') .eq('profile_id', current_user_id) .eq('content_id', translation_id) - .eq('content_table', 'translation'); + .eq('content_table', 'translation') + .abortSignal(signal); if (blockedError) throw blockedError; if (blockedContent.length > 0) { @@ -289,6 +290,7 @@ export function useTranslationById( .from('translation') .select('*') .eq('id', translation_id) + .abortSignal(signal) .overrideTypes(); if (translationError) throw translationError; @@ -302,7 +304,8 @@ export function useTranslationById( .from('blocked_users') .select('blocked_id') .eq('blocker_id', current_user_id) - .eq('blocked_id', translationData[0]?.creator_id || ''); + .eq('blocked_id', translationData[0]?.creator_id || '') + .abortSignal(signal); if (blockedUserError) throw blockedUserError; if (blockedUser.length > 0) { @@ -311,15 +314,42 @@ export function useTranslationById( return translationData; }, - offlineQuery: toCompilableQuery( - db.query.translation.findMany({ - where: eq(translation.id, translation_id) - }) - ), + offlineFn: async () => { + // Get blocked users and content for filtering + const [blockedUsers, blockedContent] = await Promise.all([ + system.db.query.blocked_users.findMany({ + where: eq(blocked_users.blocker_id, currentUser!.id), + columns: { + blocked_id: true + } + }), + system.db.query.blocked_content.findMany({ + where: and( + eq(blocked_content.profile_id, currentUser!.id), + eq(blocked_content.content_table, 'translations') + ), + columns: { + content_id: true + } + }) + ]); + + const blockedUserIds = blockedUsers.map((item) => item.blocked_id); + const blockedContentIds = blockedContent.map((item) => item.content_id); + + // Get all translations for this asset + return await db.query.translation.findMany({ + where: and( + eq(translation.id, translation_id), + notInArray(translation.id, blockedContentIds), + notInArray(translation.creator_id, blockedUserIds) + ) + }); + }, enabled: !!translation_id }); - const translationData = translationArray?.[0] || null; + const translationData = translationArray[0] || null; return { translation: translationData, isTranslationLoading, ...rest }; } @@ -410,14 +440,14 @@ export function useTranslationsWithVotesByAssetId(asset_id: string) { data: translationsWithVotes, isLoading: isTranslationsWithVotesLoading, ...rest - } = useHybridQuery({ + } = useHybridSupabaseQuery({ queryKey: [ 'translations-with-votes', 'by-asset', asset_id, currentUser?.id || 'anonymous' ], - onlineFn: async () => { + onlineFn: async ({ signal }) => { if (!currentUser) { // No user logged in, return all translations with votes const { data, error } = await supabaseConnector.client @@ -429,6 +459,7 @@ export function useTranslationsWithVotesByAssetId(asset_id: string) { ` ) .eq('asset_id', asset_id) + .abortSignal(signal) .overrideTypes<(Translation & { votes: Vote[] })[]>(); if (error) throw error; return data; @@ -439,12 +470,14 @@ export function useTranslationsWithVotesByAssetId(asset_id: string) { supabaseConnector.client .from('blocked_users') .select('blocked_id') - .eq('blocker_id', currentUser.id), + .eq('blocker_id', currentUser.id) + .abortSignal(signal), supabaseConnector.client .from('blocked_content') .select('content_id') .eq('profile_id', currentUser.id) .eq('content_table', 'translations') + .abortSignal(signal) ]); if (blockedUsersResult.error) throw blockedUsersResult.error; @@ -476,8 +509,9 @@ export function useTranslationsWithVotesByAssetId(asset_id: string) { query = query.not('id', 'in', `(${blockedContentIds.join(',')})`); } - const { data, error } = - await query.overrideTypes<(Translation & { votes: Vote[] })[]>(); + const { data, error } = await query + .abortSignal(signal) + .overrideTypes<(Translation & { votes: Vote[] })[]>(); if (error) throw error; return data; }, @@ -641,14 +675,14 @@ export function useTranslationsWithVotesAndLanguageByAssetId(asset_id: string) { data: translationsWithVotesAndLanguage, isLoading: isTranslationsWithVotesAndLanguageLoading, ...rest - } = useHybridQuery({ + } = useHybridSupabaseQuery({ queryKey: [ 'translations-with-votes-and-language', 'by-asset', asset_id, currentUser?.id || 'anonymous' ], - onlineFn: async () => { + onlineFn: async ({ signal }) => { if (!currentUser) { // No user logged in, return all translations with votes and language const { data, error } = await supabaseConnector.client @@ -657,10 +691,11 @@ export function useTranslationsWithVotesAndLanguageByAssetId(asset_id: string) { ` *, votes:vote (*), - target_language:language (*) + target_language:target_language_id (*) ` ) .eq('asset_id', asset_id) + .abortSignal(signal) .overrideTypes< (Translation & { votes: Vote[]; target_language: Language })[] >(); @@ -673,12 +708,14 @@ export function useTranslationsWithVotesAndLanguageByAssetId(asset_id: string) { supabaseConnector.client .from('blocked_users') .select('blocked_id') - .eq('blocker_id', currentUser.id), + .eq('blocker_id', currentUser.id) + .abortSignal(signal), supabaseConnector.client .from('blocked_content') .select('content_id') .eq('profile_id', currentUser.id) .eq('content_table', 'translations') + .abortSignal(signal) ]); if (blockedUsersResult.error) throw blockedUsersResult.error; @@ -698,7 +735,7 @@ export function useTranslationsWithVotesAndLanguageByAssetId(asset_id: string) { ` *, votes:vote (*), - target_language:language (*) + target_language:target_language_id (*) ` ) .eq('asset_id', asset_id); @@ -711,8 +748,9 @@ export function useTranslationsWithVotesAndLanguageByAssetId(asset_id: string) { query = query.not('id', 'in', `(${blockedContentIds.join(',')})`); } - const { data, error } = - await query.overrideTypes< + const { data, error } = await query + .abortSignal(signal) + .overrideTypes< (Translation & { votes: Vote[]; target_language: Language })[] >(); if (error) throw error; @@ -776,33 +814,23 @@ export function useTranslationsWithVotesAndLanguageByAssetId(asset_id: string) { } function getTranslationsWithAudioByAssetIdConfig(asset_id: string) { - return createHybridQueryConfig({ + return createHybridSupabaseQueryConfig({ queryKey: ['translations-with-audio', 'by-asset', asset_id], - onlineFn: async () => { - const { data, error } = await system.supabaseConnector.client - .from('translation') - .select('*') - .eq('asset_id', asset_id) - .not('audio', 'is', null) - .overrideTypes(); - if (error) throw error; - return data; - }, - offlineQuery: toCompilableQuery( - system.db.query.translation.findMany({ - where: and( - eq(translation.asset_id, asset_id), - isNotNull(translation.audio) - ) - }) - ), + query: system.db.query.translation.findMany({ + where: and( + eq(translation.asset_id, asset_id), + isNotNull(translation.audio) + ) + }), enabled: !!asset_id }); } export function getTranslationsWithAudioByAssetId(asset_id: string) { - return hybridFetch( - convertToFetchConfig(getTranslationsWithAudioByAssetIdConfig(asset_id)) + return hybridSupabaseFetch( + convertToSupabaseFetchConfig( + getTranslationsWithAudioByAssetIdConfig(asset_id) + ) ); } @@ -811,39 +839,40 @@ export function useTranslationsWithAudioByAssetId(asset_id: string) { data: translationsWithAudio, isLoading: isTranslationsWithAudioLoading, ...rest - } = useHybridQuery(getTranslationsWithAudioByAssetIdConfig(asset_id)); + } = useHybridSupabaseQuery(getTranslationsWithAudioByAssetIdConfig(asset_id)); return { translationsWithAudio, isTranslationsWithAudioLoading, ...rest }; } function getTranslationsWithAudioByAssetIdsConfig(asset_ids: string[]) { - return createHybridQueryConfig({ + return createHybridSupabaseQueryConfig({ queryKey: ['translations-with-audio', 'by-assets', asset_ids], - onlineFn: async () => { + onlineFn: async ({ signal }) => { const { data, error } = await system.supabaseConnector.client .from('translation') .select('*') .in('asset_id', asset_ids) .not('audio', 'is', null) + .abortSignal(signal) .overrideTypes(); if (error) throw error; return data; }, - offlineQuery: toCompilableQuery( - system.db.query.translation.findMany({ - where: and( - inArray(translation.asset_id, asset_ids), - isNotNull(translation.audio) - ) - }) - ), + offlineQuery: system.db.query.translation.findMany({ + where: and( + inArray(translation.asset_id, asset_ids), + isNotNull(translation.audio) + ) + }), enabled: !!asset_ids.length }); } export function getTranslationsWithAudioByAssetIds(asset_ids: string[]) { - return hybridFetch( - convertToFetchConfig(getTranslationsWithAudioByAssetIdsConfig(asset_ids)) + return hybridSupabaseFetch( + convertToSupabaseFetchConfig( + getTranslationsWithAudioByAssetIdsConfig(asset_ids) + ) ); } @@ -852,7 +881,9 @@ export function useTranslationsWithAudioByAssetIds(asset_ids: string[]) { data: translationsWithAudio, isLoading: isTranslationsWithAudioLoading, ...rest - } = useHybridQuery(getTranslationsWithAudioByAssetIdsConfig(asset_ids)); + } = useHybridSupabaseQuery( + getTranslationsWithAudioByAssetIdsConfig(asset_ids) + ); return { translationsWithAudio, isTranslationsWithAudioLoading, ...rest }; } @@ -863,30 +894,29 @@ export function useTranslationsWithAudioByAssetIds(asset_ids: string[]) { * Includes quest and project details */ export function useTranslationProjectInfo(asset_id: string | undefined) { - const { db } = system; + const { db, supabaseConnector } = system; const { - data: projectInfoArray, + data: projectInfo, isLoading: isProjectInfoLoading, ...rest - } = useHybridQuery({ - queryKey: ['translation-project', asset_id], - onlineFn: async () => { - if (!asset_id) return []; - - const { data, error } = await system.supabaseConnector.client + } = useHybridSupabaseQuery({ + queryKey: ['project-info', 'by-asset', asset_id], + onlineFn: async ({ signal }) => { + const { data, error } = await supabaseConnector.client .from('quest_asset_link') .select( ` *, quest:quest_id ( *, - project:project_id (*) + project:project_id(*) ) ` ) .eq('asset_id', asset_id) .limit(1) + .abortSignal(signal) .overrideTypes< (QuestAssetLink & { quest: Quest & { @@ -898,28 +928,19 @@ export function useTranslationProjectInfo(asset_id: string | undefined) { if (error) throw error; return data; }, - offlineFn: async () => { - if (!asset_id) return []; - - const result = await db.query.quest_asset_link.findFirst({ - where: eq(quest_asset_link.asset_id, asset_id), - with: { - quest: { - with: { - project: true - } + offlineQuery: db.query.quest_asset_link.findMany({ + where: eq(quest_asset_link.asset_id, asset_id!), + with: { + quest: { + with: { + project: true } } - }); - - return result ? [result] : []; - }, + }, + limit: 1 + }), enabled: !!asset_id }); - const projectInfo = Array.isArray(projectInfoArray) - ? projectInfoArray[0] - : projectInfoArray; - - return { projectInfo, isProjectInfoLoading, ...rest }; + return { projectInfo: projectInfo[0], isProjectInfoLoading, ...rest }; } diff --git a/hooks/db/useVotes.ts b/hooks/db/useVotes.ts index db50319eb..6b2c2253a 100644 --- a/hooks/db/useVotes.ts +++ b/hooks/db/useVotes.ts @@ -1,14 +1,13 @@ import { vote } from '@/db/drizzleSchema'; import { system } from '@/db/powersync/system'; -import { toCompilableQuery } from '@powersync/drizzle-driver'; import type { InferSelectModel } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm'; import { - convertToFetchConfig, - createHybridQueryConfig, - hybridFetch, - useHybridRealtimeQuery -} from '../useHybridQuery'; + convertToSupabaseFetchConfig, + createHybridSupabaseQueryConfig, + hybridSupabaseFetch, + useHybridSupabaseRealtimeQuery +} from '../useHybridSupabaseQuery'; export type Vote = InferSelectModel; @@ -26,7 +25,7 @@ export function useUserVoteForTranslation( data: voteArray, isLoading: isVoteLoading, ...rest - } = useHybridRealtimeQuery({ + } = useHybridSupabaseRealtimeQuery({ queryKey: ['vote', 'user', translation_id, user_id], onlineFn: async () => { const { data, error } = await supabaseConnector.client @@ -39,36 +38,28 @@ export function useUserVoteForTranslation( if (error) throw error; return data; }, - offlineQuery: toCompilableQuery( - db.query.vote.findMany({ - where: and( - eq(vote.translation_id, translation_id), - eq(vote.creator_id, user_id), - eq(vote.active, true) - ) - }) - ), - subscribeRealtime: (onChange) => { - const channel = supabaseConnector.client - .channel('public:vote') - .on( - 'postgres_changes', - { event: '*', schema: 'public', table: 'vote' }, - onChange - ); - channel.subscribe(); - return () => supabaseConnector.client.removeChannel(channel); + offlineQuery: db.query.vote.findMany({ + where: and( + eq(vote.translation_id, translation_id), + eq(vote.creator_id, user_id), + eq(vote.active, true) + ) + }), + channelName: 'public:vote', + subscriptionConfig: { + table: 'vote', + schema: 'public' }, enabled: !!translation_id && !!user_id }); - const userVote = voteArray?.[0] || null; + const userVote = voteArray[0] || null; return { vote: userVote, isVoteLoading, ...rest }; } function getVotesByTranslationIdConfig(translation_id: string) { - return createHybridQueryConfig({ + return createHybridSupabaseQueryConfig({ queryKey: ['votes', 'by-translation', translation_id], onlineFn: async () => { const { data, error } = await system.supabaseConnector.client @@ -80,21 +71,16 @@ function getVotesByTranslationIdConfig(translation_id: string) { if (error) throw error; return data; }, - offlineQuery: toCompilableQuery( - system.db.query.vote.findMany({ - where: and( - eq(vote.translation_id, translation_id), - eq(vote.active, true) - ) - }) - ), + offlineQuery: system.db.query.vote.findMany({ + where: and(eq(vote.translation_id, translation_id), eq(vote.active, true)) + }), enabled: !!translation_id }); } export function getVotesByTranslationId(translation_id: string) { - return hybridFetch( - convertToFetchConfig(getVotesByTranslationIdConfig(translation_id)) + return hybridSupabaseFetch( + convertToSupabaseFetchConfig(getVotesByTranslationIdConfig(translation_id)) ); } @@ -107,18 +93,12 @@ export function useVotesByTranslationId(translation_id: string) { data: votes, isLoading: isVotesLoading, ...rest - } = useHybridRealtimeQuery({ + } = useHybridSupabaseRealtimeQuery({ ...getVotesByTranslationIdConfig(translation_id), - subscribeRealtime: (onChange) => { - const channel = system.supabaseConnector.client - .channel('public:vote') - .on( - 'postgres_changes', - { event: '*', schema: 'public', table: 'vote' }, - onChange - ); - channel.subscribe(); - return () => system.supabaseConnector.client.removeChannel(channel); + channelName: 'public:vote', + subscriptionConfig: { + table: 'vote', + schema: 'public' } }); diff --git a/hooks/useAssetDownloadStatus.ts b/hooks/useAssetDownloadStatus.ts index f68f63143..f4edef1d7 100644 --- a/hooks/useAssetDownloadStatus.ts +++ b/hooks/useAssetDownloadStatus.ts @@ -1,148 +1,54 @@ import type { ExtendedAttachmentRecord } from '@/db/powersync/AbstractSharedAttachmentQueue'; -import { AttachmentStateManager } from '@/db/powersync/AttachmentStateManager'; -import { system } from '@/db/powersync/system'; -import { useHybridQuery } from '@/hooks/useHybridQuery'; import { AttachmentState } from '@powersync/attachments'; -import { useEffect } from 'react'; +import { useQuery } from '@powersync/tanstack-react-query'; +import { getAssetAttachmentIds } from '../utils/attachmentUtils'; -export function useAttachmentAssetDownloadStatus(assetIds: string[]) { - // Use unified AttachmentStateManager for consistent attachment ID collection - const attachmentStateManager = AttachmentStateManager.getInstance(); - - // Clean up on unmount - useEffect(() => { - return () => { - attachmentStateManager.destroy(); - }; - }, [attachmentStateManager]); - - // Get attachment IDs using unified approach - const { data: attachmentIds = [] } = useHybridQuery({ +export function useAssetDownloadStatus(assetIds: string[]) { + const { data: attachmentIds = [] } = useQuery({ queryKey: ['asset-attachments', assetIds], - onlineFn: async () => { - console.log( - `[ASSET DOWNLOAD STATUS] Getting attachment IDs for assets (ONLINE): ${assetIds.join(', ')}` - ); - const attachmentIds = - await attachmentStateManager.getAttachmentIdsForAssets(assetIds); - console.log( - `[ASSET DOWNLOAD STATUS] Found ${attachmentIds.length} attachment IDs (ONLINE): ${attachmentIds.join(', ')}` - ); - - // Return as array of objects for useHybridQuery compatibility - return attachmentIds.map((id) => ({ id })); - }, - offlineFn: async () => { - console.log( - `[ASSET DOWNLOAD STATUS] Getting attachment IDs for assets (OFFLINE): ${assetIds.join(', ')}` - ); - const attachmentIds = - await attachmentStateManager.getAttachmentIdsForAssets(assetIds); - console.log( - `[ASSET DOWNLOAD STATUS] Found ${attachmentIds.length} attachment IDs (OFFLINE): ${attachmentIds.join(', ')}` - ); - - // Return as array of objects for useHybridQuery compatibility - return attachmentIds.map((id) => ({ id })); - }, - enabled: assetIds.length > 0 + queryFn: () => getAssetAttachmentIds(assetIds) }); - // Extract the actual IDs from the wrapper objects - const actualAttachmentIds = attachmentIds.map((item) => item.id); - - // Get attachment states using PowerSync directly (since attachment table is managed by PowerSync, not Drizzle) - const { data: attachments = [] } = useHybridQuery({ - queryKey: ['attachments', actualAttachmentIds], - onlineFn: async () => { - console.log( - `[ASSET DOWNLOAD STATUS] Getting attachment states (ONLINE) for ${actualAttachmentIds.length} attachments` - ); - const { data, error } = await system.supabaseConnector.client - .from('attachment') - .select('*') - .in('id', actualAttachmentIds) - .eq('storage_type', 'permanent'); - if (error) throw error; - console.log( - `[ASSET DOWNLOAD STATUS] Retrieved ${data.length} attachment records (ONLINE)` - ); - return data as Record[]; - }, - offlineFn: async () => { - console.log( - `[ASSET DOWNLOAD STATUS] Getting attachment states (OFFLINE) for ${actualAttachmentIds.length} attachments` - ); - - // Use PowerSync direct query since attachment table is managed by PowerSync - if (actualAttachmentIds.length === 0) return []; - - const formattedIds = actualAttachmentIds.map((id) => `'${id}'`).join(','); - const result = await system.powersync.execute( - `SELECT * FROM attachments WHERE id IN (${formattedIds}) AND storage_type = 'permanent'` - ); - - const attachments: Record[] = []; - if (result.rows) { - for (let i = 0; i < result.rows.length; i++) { - const row = result.rows.item(i) as Record; - attachments.push(row); - } - } - - console.log( - `[ASSET DOWNLOAD STATUS] Retrieved ${attachments.length} attachment records (OFFLINE)` - ); - return attachments; - }, - enabled: actualAttachmentIds.length > 0 + const { data: attachments = [] } = useQuery({ + queryKey: ['attachments', attachmentIds], + query: `SELECT * FROM attachments WHERE id IN (${attachmentIds.map((id) => `'${id}'`).join(',')}) AND storage_type = 'permanent'`, + enabled: attachmentIds.length > 0 }); // If we have no attachments for any asset, consider it not downloaded - if (actualAttachmentIds.length === 0) { - console.log( - `[ASSET DOWNLOAD STATUS] No attachments found for assets: ${assetIds.join(', ')}` - ); + if (attachmentIds.length === 0) { + // console.log( + // 'Consider as not downloaded, no attachments found for assets', + // assetIds + // ); return { isDownloaded: false, isLoading: false }; } // Check if all attachments are either SYNCED or QUEUED_UPLOAD - console.log(`[ASSET DOWNLOAD STATUS] Checking download status:`); - console.log( - ` - Expected attachment IDs (${actualAttachmentIds.length}): ${actualAttachmentIds.join(', ')}` - ); - console.log( - ` - Found attachment records (${attachments.length}): ${attachments.map((a) => `${String(a.id)}:${String(a.state)}`).join(', ')}` - ); + // console.log( + // 'Attachment Ids found for assets with getAssetAttachmentIds', + // assetIds, + // attachmentIds + // ); + // console.log('Attachments found with query', attachments); // If we have fewer attachments than attachmentIds, some attachments are missing from attachments table - if (attachments.length < actualAttachmentIds.length) { - const missingIds = actualAttachmentIds.filter( - (id) => !attachments.some((a) => a.id === id) - ); - console.log( - `[ASSET DOWNLOAD STATUS] Missing attachment records for: ${missingIds.join(', ')}` - ); + if (attachments.length < attachmentIds.length) { + // console.log('Some attachments not found in database for assets', assetIds); return { isDownloaded: false, isLoading: false }; } - console.log('TYPES: attachments', { attachments }); - const isDownloaded = ( - attachments as unknown as ExtendedAttachmentRecord[] - ).every( + const isDownloaded = (attachments as ExtendedAttachmentRecord[]).every( (attachment) => attachment.state === AttachmentState.SYNCED || attachment.state === AttachmentState.QUEUED_UPLOAD ); - const isLoading = (attachments as unknown as ExtendedAttachmentRecord[]).some( + const isLoading = (attachments as ExtendedAttachmentRecord[]).some( (attachment) => attachment.state === AttachmentState.QUEUED_DOWNLOAD || attachment.state === AttachmentState.QUEUED_SYNC ); - console.log( - `[ASSET DOWNLOAD STATUS] Final status: isDownloaded=${isDownloaded}, isLoading=${isLoading}` - ); return { isDownloaded, isLoading }; } diff --git a/hooks/useAttachmentStates.ts b/hooks/useAttachmentStates.ts index 1459d7504..7f4bc1cd1 100644 --- a/hooks/useAttachmentStates.ts +++ b/hooks/useAttachmentStates.ts @@ -5,65 +5,79 @@ import type { QueryResult } from '@powersync/react-native'; import { useEffect, useRef, useState } from 'react'; import { system } from '../db/powersync/system'; -export function useAttachmentStates(attachmentIds: string[]) { +export function useAttachmentStates(attachmentIds: string[] = []) { const [attachmentStates, setAttachmentStates] = useState< Map >(new Map()); const [isLoading, setIsLoading] = useState(true); const abortControllerRef = useRef(null); const previousStatesRef = useRef>(new Map()); + const debounceTimeoutRef = useRef(null); useEffect(() => { - if (!attachmentIds.length) { - setIsLoading(false); - return; - } - // Abort any previous query abortControllerRef.current?.abort(); const abortController = new AbortController(); abortControllerRef.current = abortController; - const formattedIds = attachmentIds.map((id) => `'${id}'`).join(','); + // Build query based on whether we have specific IDs or want all records + const query = + attachmentIds.length > 0 + ? `SELECT * FROM ${ATTACHMENT_TABLE} WHERE id IN (${attachmentIds.map((id) => `'${id}'`).join(',')})` + : `SELECT * FROM ${ATTACHMENT_TABLE}`; system.powersync.watch( - `SELECT * FROM ${ATTACHMENT_TABLE} WHERE id IN (${formattedIds})`, + query, [], { onResult: (results: QueryResult) => { const newStates = new Map(); const currentPreviousStates = previousStatesRef.current; - results.rows?._array.forEach((row) => { - const record = row as unknown as AttachmentRecord; - newStates.set(record.id, record); + // Check if results and rows exist before accessing _array + if (results.rows?._array) { + results.rows._array.forEach((row) => { + const record = row as unknown as AttachmentRecord; + newStates.set(record.id, record); - // Only log significant state changes - const previousState = currentPreviousStates.get(record.id)?.state; - if (previousState !== undefined && previousState !== record.state) { - if (record.state === AttachmentState.SYNCED) { - console.log( - `💾 [ATTACHMENT] ✅ SYNCED: ${record.id} (was: ${previousState})` - ); - } else if (record.state === AttachmentState.QUEUED_SYNC) { - console.log( - `⏳ [ATTACHMENT] 🔄 QUEUED FOR DOWNLOAD: ${record.id} (was: ${previousState})` - ); - } else if (record.state === AttachmentState.QUEUED_DOWNLOAD) { - console.log( - `⬇️ [ATTACHMENT] 📥 DOWNLOADING: ${record.id} (was: ${previousState})` - ); - } else { - console.log( - `🔄 [ATTACHMENT] State changed: ${record.id} (${previousState} → ${record.state})` - ); + // Only log significant state changes + const previousState = currentPreviousStates.get(record.id)?.state; + if ( + previousState !== undefined && + previousState !== record.state + ) { + if (record.state === AttachmentState.SYNCED) { + console.log( + `💾 [ATTACHMENT] ✅ SYNCED: ${record.id} (was: ${previousState})` + ); + } else if (record.state === AttachmentState.QUEUED_SYNC) { + console.log( + `⏳ [ATTACHMENT] 🔄 QUEUED FOR DOWNLOAD: ${record.id} (was: ${previousState})` + ); + } else if (record.state === AttachmentState.QUEUED_DOWNLOAD) { + console.log( + `⬇️ [ATTACHMENT] 📥 DOWNLOADING: ${record.id} (was: ${previousState})` + ); + } else { + console.log( + `🔄 [ATTACHMENT] State changed: ${record.id} (${previousState} → ${record.state})` + ); + } } - } - }); + }); + } previousStatesRef.current = newStates; - setAttachmentStates(newStates); - setIsLoading(false); + + // Debounce the state updates to reduce render frequency + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + + debounceTimeoutRef.current = setTimeout(() => { + setAttachmentStates(new Map(newStates)); + setIsLoading(false); + }, 100); // 100ms debounce }, onError: (err) => console.error('useAttachmentStates watch error', err) }, @@ -72,9 +86,9 @@ export function useAttachmentStates(attachmentIds: string[]) { return () => { abortController.abort(); - // if (subscription && typeof subscription === 'function') { - // subscription(); - // } + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } }; }, [JSON.stringify(attachmentIds.sort())]); diff --git a/hooks/useDownloads.ts b/hooks/useDownloads.ts index 9438e2451..82aa13a30 100644 --- a/hooks/useDownloads.ts +++ b/hooks/useDownloads.ts @@ -2,7 +2,7 @@ import { getCurrentUser } from '@/contexts/AuthContext'; import { system } from '@/db/powersync/system'; import { useHybridQuery } from '@/hooks/useHybridQuery'; import { toCompilableQuery } from '@powersync/drizzle-driver'; -import type { UseQueryOptions } from '@tanstack/react-query'; +import type { UseMutationResult, UseQueryOptions } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { convertToFetchConfig, @@ -19,32 +19,6 @@ interface TreeNode { children?: TreeNode[]; } -// Type guard and interface for attachment state manager -interface AttachmentStateManager { - markDownloadOperationStart(): void; - markDownloadOperationComplete(): void; - processPendingUpdates(onUpdate: (ids: string[]) => void): void; -} - -interface AttachmentQueueWithStateManager { - attachmentStateManager?: AttachmentStateManager; - getDebugInfo?(): { stateManager?: AttachmentStateManager }; -} - -function getAttachmentStateManager(): AttachmentStateManager | null { - const permQueue = system.permAttachmentQueue as - | AttachmentQueueWithStateManager - | undefined; - if (!permQueue) return null; - - // Try to get state manager directly - if (permQueue.attachmentStateManager) { - return permQueue.attachmentStateManager; - } - - return null; -} - async function getDownloadTreeStructure() { const { data, error } = await system.supabaseConnector.client .rpc('get_download_tree_structure') @@ -130,12 +104,15 @@ function getDownloadStatusConfig( export function useDownloadStatus( recordTable: keyof typeof system.db.query, recordId: string -) { +): { + isFlaggedForDownload: boolean; + isLoading: boolean; +} { const { data, isLoading, ...rest } = useHybridQuery( getDownloadStatusConfig(recordTable, recordId) ); - return { isDownloaded: !!data?.[0]?.id, isLoading, ...rest }; + return { isFlaggedForDownload: !!data?.[0]?.id, isLoading, ...rest }; } /** @@ -153,15 +130,6 @@ export async function downloadRecord( `📡 [DOWNLOAD RPC] Starting downloadRecord for ${recordTable}:${recordId}` ); - // 🚫 PREVENT ATTACHMENT COLLECTION DURING DOWNLOAD - const stateManager = getAttachmentStateManager(); - if (stateManager) { - console.log( - '🚫 [DOWNLOAD RPC] Marking download operation start to prevent attachment collection' - ); - stateManager.markDownloadOperationStart(); - } - try { const currentUser = getCurrentUser(); if (!currentUser?.id) { @@ -257,19 +225,8 @@ export async function downloadRecord( `📡 [DOWNLOAD RPC] ✅ Successfully completed download_record RPC for ${recordTable}:${recordId}` ); } - } finally { - // ✅ RESUME ATTACHMENT COLLECTION AFTER DOWNLOAD - if (stateManager) { - console.log( - '✅ [DOWNLOAD RPC] Marking download operation complete - resuming attachment collection' - ); - stateManager.markDownloadOperationComplete(); - - // Process any pending updates - console.log( - '🔄 [DOWNLOAD RPC] Attachment updates will be processed when next triggered' - ); - } + } catch (error) { + console.error('Error during downloadRecord:', error); } } @@ -279,9 +236,17 @@ export async function downloadRecord( export function useDownload( recordTable: keyof typeof system.db.query, recordId: string -) { +): { + isFlaggedForDownload: boolean; + isLoading: boolean; + toggleDownload: () => Promise; + mutation: UseMutationResult; +} { const queryClient = useQueryClient(); - const { isDownloaded, isLoading } = useDownloadStatus(recordTable, recordId); + const { isFlaggedForDownload, isLoading } = useDownloadStatus( + recordTable, + recordId + ); const mutation = useMutation({ mutationFn: async (downloaded?: boolean) => @@ -298,7 +263,7 @@ export function useDownload( if (!recordId) return; console.log( - `🎯 [QUEST DOWNLOAD] Starting download for ${recordTable}:${recordId}` + `🎯 [DOWNLOAD] Starting download for ${recordTable}:${recordId}` ); const isCurrentlyDownloaded = await getDownloadStatus( @@ -307,28 +272,28 @@ export function useDownload( ); console.log( - `🎯 [QUEST DOWNLOAD] Current download status: ${isCurrentlyDownloaded ? 'DOWNLOADED' : 'NOT_DOWNLOADED'}` + `🎯 [DOWNLOAD] Current download status: ${isCurrentlyDownloaded ? 'DOWNLOADED' : 'NOT_DOWNLOADED'}` ); // TODO: re-enable undownloading when we have a way to remove the record from the download tree if (isCurrentlyDownloaded) { console.log( - `🎯 [QUEST DOWNLOAD] Already downloaded, skipping: ${recordTable}:${recordId}` + `🎯 [DOWNLOAD] Already downloaded, skipping: ${recordTable}:${recordId}` ); return; } console.log( - `🎯 [QUEST DOWNLOAD] Calling downloadRecord mutation for ${recordTable}:${recordId}` + `🎯 [DOWNLOAD] Calling downloadRecord mutation for ${recordTable}:${recordId}` ); await mutation.mutateAsync(false); // always download console.log( - `🎯 [QUEST DOWNLOAD] ✅ Download mutation completed for ${recordTable}:${recordId}` + `🎯 [DOWNLOAD] ✅ Download mutation completed for ${recordTable}:${recordId}` ); }; return { - isDownloaded: !!isDownloaded, + isFlaggedForDownload, isLoading: isLoading || mutation.isPending, toggleDownload, mutation diff --git a/hooks/useHybridQuery.ts b/hooks/useHybridQuery.ts index 3d71037a4..a2697bf29 100644 --- a/hooks/useHybridQuery.ts +++ b/hooks/useHybridQuery.ts @@ -1,3 +1,4 @@ +import { system } from '@/db/powersync/system'; import type { CompilableQuery } from '@powersync/react-native'; import { useQuery } from '@powersync/tanstack-react-query'; import type { RealtimePostgresChangesPayload } from '@supabase/supabase-js'; @@ -43,8 +44,8 @@ type HybridQueryConfig = ( /** * useHybridQuery * - * A hook that automatically chooses between an online query function and an offline Drizzle query, - * depending on network connectivity. Compatible with PowerSync/Drizzle/React Query stack. + * A hook that always queries local data first, then cloud data when available, + * and merges them with local data taking priority. Compatible with PowerSync/Drizzle/React Query stack. * * @example * const { data, isLoading, error } = useHybridQuery({ @@ -74,111 +75,135 @@ export function useHybridQuery>( ...restOptions } = options; const isOnline = useNetworkStatus(); - const queryClient = useQueryClient(); - // FIXED: Stabilize query keys with useMemo to prevent infinite loops - const stableQueryKeys = React.useMemo(() => { - // Filter out undefined/null values from the query key - const cleanQueryKey = queryKey.filter( - (key) => key !== undefined && key !== null - ); + // Filter out undefined/null values from the query key + const cleanQueryKey = queryKey.filter( + (key) => key !== undefined && key !== null + ); - // Use dual cache system for better offline/online separation - const hybridQueryKey = [...cleanQueryKey, isOnline ? 'online' : 'offline']; - const oppositeQueryKey = [ - ...cleanQueryKey, - isOnline ? 'offline' : 'online' - ]; + // Always query local data + const localQueryKey = [...cleanQueryKey, 'local']; + const cloudQueryKey = [...cleanQueryKey, 'cloud']; - return { hybridQueryKey, oppositeQueryKey }; - }, [queryKey, isOnline]); + // Determine local query function + const getLocalQueryFn = () => { + if ('offlineFn' in options && options.offlineFn) { + return options.offlineFn; + } else if ('offlineQuery' in options && options.offlineQuery) { + return async () => { + const offlineQuery = options.offlineQuery; + if (typeof offlineQuery === 'string') { + // For string queries, execute directly with system.powersync + const result = await system.powersync.execute(offlineQuery); + const rows: T[] = []; + if (result.rows) { + for (let i = 0; i < result.rows.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const item = result.rows.item(i); + if (item) { + rows.push(item as T); + } + } + } + return rows; + } + return await offlineQuery.execute(); + }; + } else { + throw new Error('Either offlineFn or offlineQuery must be provided'); + } + }; - const cachedOppositeData = queryClient.getQueryData( - stableQueryKeys.oppositeQueryKey - ); - const oppositeCachedQueryState = queryClient.getQueryState( - stableQueryKeys.oppositeQueryKey - ); + // Query local data (always enabled) + const localQuery = useQuery({ + queryKey: localQueryKey, + queryFn: getLocalQueryFn(), + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + refetchOnMount: false, + ...restOptions + }); - // Memoize the merged data to prevent infinite re-renders - const stableMergedData = React.useMemo(() => { - if (!cachedOppositeData) return undefined; + // Query cloud data (only when online) + const cloudQuery = useQuery({ + queryKey: cloudQueryKey, + queryFn: onlineFn, + enabled: isOnline, + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + refetchOnMount: false, + networkMode: 'always', + ...restOptions + }); - // Return the same reference if no changes - return cachedOppositeData; - }, [cachedOppositeData]); + // Merge data with local priority + const mergedData = React.useMemo(() => { + // Ensure we have arrays to work with, handling undefined/null cases + const localData = Array.isArray(localQuery.data) ? localQuery.data : []; + const cloudData = Array.isArray(cloudQuery.data) ? cloudQuery.data : []; - // Create a stable select function that only changes when user's select function changes - const stableSelect = React.useCallback( - (data: T[]) => { - if (!cachedOppositeData && !data.length) { - return select ? select([]) : []; - } + // If no data available yet, return empty array + if (localData.length === 0 && cloudData.length === 0) { + return []; + } - // Only merge if we have both datasets - if (cachedOppositeData && data.length > 0) { - const combinedMap = new Map(); + // Create a map of local data by ID for quick lookup + const localDataMap = new Map(localData.map((item) => [getId(item), item])); - // Add cached data first - cachedOppositeData.forEach((item) => { - combinedMap.set(getId(item), item); - }); + // Filter out cloud data that already exists in local data + const uniqueCloudData = cloudData.filter( + (item) => !localDataMap.has(getId(item)) + ); - // Override with fresh data - data.forEach((item) => { - combinedMap.set(getId(item), item); - }); + // Return local data first, then unique cloud data + return [...localData, ...uniqueCloudData]; + }, [localQuery.data, cloudQuery.data, getId]); - const mergedArray = Array.from(combinedMap.values()); - return select ? select(mergedArray) : mergedArray; - } + // Apply user's select function if provided + const finalData = React.useMemo(() => { + return select ? select(mergedData) : mergedData; + }, [mergedData, select]); - // If no cached data, just use current data - return select ? select(data) : data; + return { + data: finalData, + isLoading: localQuery.isLoading || (isOnline && cloudQuery.isLoading), + error: localQuery.error || cloudQuery.error, + isError: localQuery.isError || cloudQuery.isError, + isFetching: localQuery.isFetching || cloudQuery.isFetching, + isSuccess: localQuery.isSuccess, + refetch: () => { + void localQuery.refetch(); + if (isOnline) void cloudQuery.refetch(); }, - [cachedOppositeData, select, getId] - ); - - const sharedQueryOptions = { - queryKey: stableQueryKeys.hybridQueryKey, - initialData: stableMergedData, - initialDataUpdatedAt: oppositeCachedQueryState?.dataUpdatedAt, - select: stableSelect, - staleTime: 30 * 1000, // Consider data fresh for 30 seconds - gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes - refetchOnWindowFocus: false, // Prevent excessive refetching - refetchOnMount: false, // Don't refetch if we have data - ...restOptions + // Include other query result properties + dataUpdatedAt: Math.max( + localQuery.dataUpdatedAt, + cloudQuery.dataUpdatedAt || 0 + ), + errorUpdatedAt: Math.max( + localQuery.errorUpdatedAt, + cloudQuery.errorUpdatedAt || 0 + ), + failureCount: localQuery.failureCount + cloudQuery.failureCount, + failureReason: localQuery.failureReason || cloudQuery.failureReason, + fetchStatus: localQuery.fetchStatus, + isInitialLoading: + localQuery.isInitialLoading || (isOnline && cloudQuery.isInitialLoading), + isLoadingError: localQuery.isLoadingError || cloudQuery.isLoadingError, + isPaused: localQuery.isPaused || cloudQuery.isPaused, + isPlaceholderData: + localQuery.isPlaceholderData || cloudQuery.isPlaceholderData, + isRefetchError: localQuery.isRefetchError || cloudQuery.isRefetchError, + isRefetching: localQuery.isRefetching || cloudQuery.isRefetching, + isStale: localQuery.isStale || cloudQuery.isStale, + status: localQuery.isError + ? 'error' + : localQuery.isLoading + ? 'pending' + : 'success' }; - - const useOfflineQuery = () => { - if ('offlineFn' in options && options.offlineFn) { - return useQuery({ - queryFn: options.offlineFn, - ...sharedQueryOptions - }); - } else if ('offlineQuery' in options && options.offlineQuery) { - return useQuery({ - query: options.offlineQuery, - ...sharedQueryOptions - }); - } else { - throw new Error('Either offlineFn or offlineQuery must be provided'); - } - }; - - if (isOnline) { - return useQuery({ - ...sharedQueryOptions, - queryFn: onlineFn, - refetchOnReconnect: true, - refetchOnWindowFocus: false, // FIXED: Prevent excessive refetching on focus - refetchOnMount: false, // FIXED: Prevent refetch on every mount - networkMode: 'always' - }); - } - - return useOfflineQuery(); } /** @@ -202,9 +227,8 @@ type HybridRealtimeQueryOptions> = /** * useHybridRealtimeQuery * - * Like useHybridQuery, but also sets up a realtime subscription (e.g. Supabase) when online. - * The hook automatically handles cache updates via setQueryData based on realtime events. - * Note: This hook only supports the offlineQuery variant, not offlineFn. + * Always queries local data first, then cloud data when available, merges with local priority, + * and sets up realtime subscriptions to keep cloud data updated. * * @example * const { data, isLoading, error } = useHybridRealtimeQuery({ @@ -240,7 +264,7 @@ export function useHybridRealtimeQuery>({ // Extract the specific properties to avoid duplicates const { offlineFn, offlineQuery, onlineFn, ...otherOptions } = restOptions; - // Type-narrow the options to call the correct overload + // Use the base hybrid query with offline-first pattern const result = offlineFn ? useHybridQuery({ queryKey, @@ -264,12 +288,12 @@ export function useHybridRealtimeQuery>({ realtimeChannelRef.current = null; } - // Subscribe with automatic cache management + // Subscribe with automatic cache management for cloud data const subscription = subscribeRealtime((payload) => { const { eventType, new: newRow, old: oldRow } = payload; - const cacheKey = [...queryKey, true]; + const cloudCacheKey = [...queryKey, 'cloud']; - queryClient.setQueryData(cacheKey, (prev = []) => { + queryClient.setQueryData(cloudCacheKey, (prev = []) => { switch (eventType) { case 'INSERT': { const recordId = getId(newRow); @@ -342,8 +366,8 @@ type HybridFetchConfig> = ( /** * hybridFetch * - * A standalone function that automatically chooses between an online fetch function and an offline query, - * depending on network connectivity. Can be used outside of React components. + * A standalone function that always fetches local data first, then cloud data when available, + * and merges with local priority. Can be used outside of React components. * * @example * const projects = await hybridFetch({ @@ -362,31 +386,78 @@ type HybridFetchConfig> = ( * }); */ export async function hybridFetch>( - config: HybridFetchConfig + config: HybridFetchConfig & { + getId?: (record: T | Partial) => string | number; + } ) { - const { onlineFn, ...restConfig } = config; + const { + onlineFn, + getId = (record: T | Partial) => + (record as unknown as { id: string | number }).id, + ...restConfig + } = config; - const runOfflineQuery = async () => { + const runLocalQuery = async () => { if ('offlineFn' in restConfig && restConfig.offlineFn) { return await restConfig.offlineFn(); } else if ('offlineQuery' in restConfig) { - // For standalone usage, offlineQuery should be a CompilableQuery, not string - if (typeof restConfig.offlineQuery === 'string') { - throw new Error( - 'String queries not supported in standalone hybridFetch. Use offlineFn instead.' - ); + const offlineQuery = restConfig.offlineQuery; + if (typeof offlineQuery === 'string') { + // For string queries, execute directly with system.powersync + const result = await system.powersync.execute(offlineQuery); + const rows: T[] = []; + if (result.rows) { + for (let i = 0; i < result.rows.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const item = result.rows.item(i); + if (item) { + rows.push(item as T); + } + } + } + return rows; } else { - return await restConfig.offlineQuery.execute(); + return await offlineQuery.execute(); } } else { throw new Error('Either offlineFn or offlineQuery must be provided'); } }; + // Always fetch local data first + const localData = await runLocalQuery(); + + // Try to fetch cloud data if online const isOnline = getNetworkStatus(); - if (isOnline) return await onlineFn(); + let cloudData: T[] = []; + + if (isOnline) { + try { + cloudData = await onlineFn(); + } catch (error) { + console.warn( + 'hybridFetch: Cloud query failed, using local data only', + error + ); + } + } + + // Merge with local priority + const localDataArray = Array.isArray(localData) ? localData : []; + const cloudDataArray = Array.isArray(cloudData) ? cloudData : []; + + // Create a map of local data by ID for quick lookup + const localDataMap = new Map( + localDataArray.map((item) => [getId(item), item]) + ); + + // Filter out cloud data that already exists in local data + const uniqueCloudData = cloudDataArray.filter( + (item) => !localDataMap.has(getId(item)) + ); - return runOfflineQuery(); + // Return local data first, then unique cloud data + return [...localDataArray, ...uniqueCloudData]; } export function createHybridQueryConfig>( @@ -500,7 +571,6 @@ export function useHybridInfiniteQuery< T extends Record, TPageParam = unknown >(options: HybridInfiniteQueryOptions) { - const timestamp = performance.now(); const { queryKey, onlineFn, diff --git a/hooks/useHybridSupabaseQuery.ts b/hooks/useHybridSupabaseQuery.ts index dbb16a7a4..d26b10732 100644 --- a/hooks/useHybridSupabaseQuery.ts +++ b/hooks/useHybridSupabaseQuery.ts @@ -1,15 +1,13 @@ import { system } from '@/db/powersync/system'; import { toCompilableQuery } from '@powersync/drizzle-driver'; -import { useQuery as usePowerSyncQuery } from '@powersync/tanstack-react-query'; import type { RealtimePostgresChangesPayload } from '@supabase/supabase-js'; import type { QueryFunctionContext } from '@tanstack/react-query'; import { - keepPreviousData, useInfiniteQuery, useQueryClient, useQuery as useTanStackQuery } from '@tanstack/react-query'; -import React, { useEffect, useRef } from 'react'; +import React, { useEffect } from 'react'; import { getNetworkStatus, useNetworkStatus } from './useNetworkStatus'; /** @@ -115,8 +113,8 @@ type HybridSupabaseQueryOptions> = Omit< /** * useHybridSupabaseQuery * - * A hook that automatically chooses between an online Supabase SQLTR query and an offline Drizzle query, - * depending on network connectivity. Includes dual cache system for seamless online/offline transitions. + * A hook that always queries local/offline data first, then cloud data when available, + * and merges them with local data taking priority. Follows the offline-first pattern. * * @example * // Using unified query @@ -146,81 +144,93 @@ export function useHybridSupabaseQuery>( ...restOptions } = options; const isOnline = useNetworkStatus(); - const queryClient = useQueryClient(); // Filter out undefined/null values from the query key const cleanQueryKey = queryKey.filter( (key) => key !== undefined && key !== null ); - // Use dual cache system for better offline/online separation - const hybridQueryKey = [...cleanQueryKey, isOnline ? 'online' : 'offline']; - const oppositeQueryKey = [...cleanQueryKey, isOnline ? 'offline' : 'online']; - const cachedOppositeData = queryClient.getQueryData(oppositeQueryKey); - const oppositeCachedQueryState = - queryClient.getQueryState(oppositeQueryKey); - - // Memoize the merged data to prevent infinite re-renders - const stableMergedData = React.useMemo(() => { - if (!cachedOppositeData) return undefined; - return cachedOppositeData; - }, [cachedOppositeData]); - - // Create a stable select function that only changes when user's select function changes - const stableSelect = React.useCallback( - (data: T[]) => { - if (!cachedOppositeData && !data.length) { - return select ? select([]) : []; - } - - // Only merge if we have both datasets - if (cachedOppositeData && data.length > 0) { - const combinedMap = new Map(); - - // Add cached data first - cachedOppositeData.forEach((item) => { - combinedMap.set(getId(item), item); - }); - - // Override with fresh data - data.forEach((item) => { - combinedMap.set(getId(item), item); - }); - - const mergedArray = Array.from(combinedMap.values()); - return select ? select(mergedArray) : mergedArray; - } + // Always query local data + const localQueryKey = [...cleanQueryKey, 'local']; + const cloudQueryKey = [...cleanQueryKey, 'cloud']; - // If no cached data, just use current data - return select ? select(data) : data; - }, - [cachedOppositeData, select, getId] - ); - - const sharedQueryOptions = { - queryKey: hybridQueryKey, - initialData: stableMergedData, - initialDataUpdatedAt: oppositeCachedQueryState?.dataUpdatedAt, - select: stableSelect, - staleTime: 30 * 1000, // Consider data fresh for 30 seconds - gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes - refetchOnWindowFocus: false, // Prevent excessive refetching - refetchOnMount: false // Don't refetch if we have data + // Determine local query function + const getLocalQueryFn = () => { + if ('offlineFn' in options && options.offlineFn) { + return options.offlineFn; + } else if (options.offlineQuery) { + return async () => { + const offlineQuery = options.offlineQuery; + if (typeof offlineQuery === 'string') { + const result = await system.powersync.execute(offlineQuery); + const rows: T[] = []; + if (result.rows) { + for (let i = 0; i < result.rows.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const item = result.rows.item(i); + if (item) { + rows.push(item as T); + } + } + } + return rows; + } + return await toCompilableQuery(offlineQuery).execute(); + }; + } else if ('query' in options && options.query) { + return async () => { + const query = options.query; + if (typeof query === 'string') { + const result = await system.powersync.execute(query); + const rows: T[] = []; + if (result.rows) { + for (let i = 0; i < result.rows.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const item = result.rows.item(i); + if (item) { + rows.push(item as T); + } + } + } + return rows; + } + return await toCompilableQuery(query).execute(); + }; + } + throw new Error( + 'Either query, offlineQuery, or offlineFn must be provided' + ); }; - // Determine online query function - const getOnlineQueryFn = () => { + // Determine cloud query function + const getCloudQueryFn = () => { if ('onlineFn' in options && options.onlineFn) { return options.onlineFn; } else if ('query' in options && options.query) { return async () => { - const data = options.query.toSQL(); + const query = options.query; + if (typeof query === 'string') { + const result = await system.powersync.execute(query); + const rows: T[] = []; + if (result.rows) { + for (let i = 0; i < result.rows.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const item = result.rows.item(i); + if (item) { + rows.push(item as T); + } + } + } + return rows; + } + const data = query.toSQL(); const finalSql = substituteParams(data.sql, data.params); const response = await fetch( `${process.env.EXPO_PUBLIC_SUPABASE_URL}/functions/v1/sqltr`, { headers: { - Authorization: `Bearer ${process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY}` + Authorization: `Bearer ${process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY}`, + 'Content-Type': 'application/json' }, method: 'POST', body: JSON.stringify({ sql: finalSql }) @@ -232,51 +242,111 @@ export function useHybridSupabaseQuery>( throw new Error('Either query or onlineFn must be provided'); }; - if (isOnline) { - return useTanStackQuery({ - ...sharedQueryOptions, - ...restOptions, - queryFn: getOnlineQueryFn(), - refetchOnReconnect: true, - refetchOnWindowFocus: true, - refetchOnMount: true, - networkMode: 'always' - }); - } else { - // Handle offline query - if ('offlineFn' in options && options.offlineFn) { - return useTanStackQuery({ - ...sharedQueryOptions, - ...restOptions, - queryFn: options.offlineFn - }); - } else if (options.offlineQuery || options.query) { - const offlineQuery = options.offlineQuery ?? options.query; - return usePowerSyncQuery({ - ...sharedQueryOptions, - ...restOptions, - query: - typeof offlineQuery === 'string' - ? offlineQuery - : toCompilableQuery(offlineQuery) - }); - } else { - throw new Error( - 'Either query, offlineQuery, or offlineFn must be provided' - ); + // Query local data (always enabled) + const localQuery = useTanStackQuery({ + queryKey: localQueryKey, + queryFn: getLocalQueryFn(), + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + refetchOnMount: false, + ...restOptions + }); + + // Query cloud data (only when online) + const cloudQuery = useTanStackQuery({ + queryKey: cloudQueryKey, + queryFn: getCloudQueryFn(), + enabled: isOnline, + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + refetchOnMount: false, + networkMode: 'always', + ...restOptions + }); + + // Merge data with local priority + const mergedData = React.useMemo(() => { + // Ensure we have arrays to work with, handling undefined/null cases + const localData = Array.isArray(localQuery.data) ? localQuery.data : []; + const cloudData = Array.isArray(cloudQuery.data) ? cloudQuery.data : []; + + // If no data available yet, return empty array + if (localData.length === 0 && cloudData.length === 0) { + return []; } - } + + // Create a map of local data by ID for quick lookup + const localDataMap = new Map(localData.map((item) => [getId(item), item])); + + // Filter out cloud data that already exists in local data + const uniqueCloudData = cloudData.filter( + (item) => !localDataMap.has(getId(item)) + ); + + // Return local data first, then unique cloud data + return [...localData, ...uniqueCloudData]; + }, [localQuery.data, cloudQuery.data, getId]); + + // Apply user's select function if provided + const finalData = React.useMemo(() => { + return select ? select(mergedData) : mergedData; + }, [mergedData, select]); + + return { + data: finalData, + isLoading: localQuery.isLoading || (isOnline && cloudQuery.isLoading), + error: localQuery.error || cloudQuery.error, + isError: localQuery.isError || cloudQuery.isError, + isFetching: localQuery.isFetching || cloudQuery.isFetching, + isSuccess: localQuery.isSuccess, + refetch: () => { + void localQuery.refetch(); + if (isOnline) void cloudQuery.refetch(); + }, + // Include other query result properties + dataUpdatedAt: Math.max( + localQuery.dataUpdatedAt, + cloudQuery.dataUpdatedAt || 0 + ), + errorUpdatedAt: Math.max( + localQuery.errorUpdatedAt, + cloudQuery.errorUpdatedAt || 0 + ), + failureCount: localQuery.failureCount + cloudQuery.failureCount, + failureReason: localQuery.failureReason || cloudQuery.failureReason, + fetchStatus: localQuery.fetchStatus, + isInitialLoading: + localQuery.isInitialLoading || (isOnline && cloudQuery.isInitialLoading), + isLoadingError: localQuery.isLoadingError || cloudQuery.isLoadingError, + isPaused: localQuery.isPaused || cloudQuery.isPaused, + isPlaceholderData: + localQuery.isPlaceholderData || cloudQuery.isPlaceholderData, + isRefetchError: localQuery.isRefetchError || cloudQuery.isRefetchError, + isRefetching: localQuery.isRefetching || cloudQuery.isRefetching, + isStale: localQuery.isStale || cloudQuery.isStale, + status: localQuery.isError + ? 'error' + : localQuery.isLoading + ? 'pending' + : 'success' + }; } /** * Options for the realtime subscription */ interface RealtimeSubscriptionOptions> { - subscribeRealtime: ( - onChange: (payload: RealtimePostgresChangesPayload) => void - ) => () => Promise<'ok' | 'timed out' | 'error'> | (() => void); + channelName: string; + subscriptionConfig: { + table: string; + schema: string; + filter?: string; + }; /** * Function to get the ID of a record. Defaults to (record) => record.id */ + getId?: (record: T | Partial) => string | number; } /** @@ -288,8 +358,8 @@ type HybridSupabaseRealtimeQueryOptions> = /** * useHybridSupabaseRealtimeQuery * - * Like useHybridSupabaseQuery, but also sets up a realtime subscription when online. - * The hook automatically handles cache updates via setQueryData based on realtime events. + * Always queries local data first, then cloud data when available, merges with local priority, + * and sets up realtime subscriptions to keep cloud data updated. * * @example * const { data, isLoading, error } = useHybridSupabaseRealtimeQuery({ @@ -309,19 +379,17 @@ export function useHybridSupabaseRealtimeQuery< T extends Record >({ queryKey, - subscribeRealtime, + channelName, + subscriptionConfig, getId = (record: T | Partial) => (record as unknown as { id: string | number }).id, ...restOptions }: HybridSupabaseRealtimeQueryOptions) { const queryClient = useQueryClient(); - const realtimeChannelRef = useRef | null>(null); const isOnline = useNetworkStatus(); - // Use the base hybrid query + // Use the base hybrid query with offline-first pattern const result = useHybridSupabaseQuery({ queryKey, ...restOptions @@ -330,59 +398,62 @@ export function useHybridSupabaseRealtimeQuery< useEffect(() => { if (!isOnline) return; - // Unsubscribe previous - if (realtimeChannelRef.current) { - void realtimeChannelRef.current(); - realtimeChannelRef.current = null; - } - - // Subscribe with automatic cache management - const subscription = subscribeRealtime((payload) => { - const { eventType, new: newRow, old: oldRow } = payload; - const cacheKey = [...queryKey, 'online']; - - queryClient.setQueryData(cacheKey, (prev = []) => { - switch (eventType) { - case 'INSERT': { - const recordId = getId(newRow); - // Avoid duplicates - if (prev.some((record) => getId(record) === recordId)) { - return prev; - } - return [...prev, newRow]; - } - case 'UPDATE': { - const recordId = getId(newRow); - return prev.map((record) => - getId(record) === recordId ? newRow : record - ); - } - case 'DELETE': { - const recordId = getId(oldRow); - return prev.filter((record) => getId(record) !== recordId); - } - default: { - console.warn( - 'useHybridSupabaseRealtimeQuery: Unhandled event type', - eventType - ); - return prev; + const channel = system.supabaseConnector.client + .channel(channelName) + .on( + 'postgres_changes', + { ...subscriptionConfig, event: '*' }, + (payload: RealtimePostgresChangesPayload) => { + { + const { eventType, new: newRow, old: oldRow } = payload; + const cloudCacheKey = [...queryKey, 'cloud']; + + queryClient.setQueryData(cloudCacheKey, (prev = []) => { + switch (eventType) { + case 'INSERT': { + const recordId = getId(newRow); + // Avoid duplicates + if (prev.some((record) => getId(record) === recordId)) { + return prev; + } + return [...prev, newRow]; + } + case 'UPDATE': { + const recordId = getId(newRow); + return prev.map((record) => + getId(record) === recordId ? newRow : record + ); + } + case 'DELETE': { + const recordId = getId(oldRow); + return prev.filter((record) => getId(record) !== recordId); + } + default: { + console.warn( + 'useHybridSupabaseRealtimeQuery: Unhandled event type', + eventType + ); + return prev; + } + } + }); } } - }); - }); - - realtimeChannelRef.current = subscription; + ); + channel.subscribe(); return () => { - if (realtimeChannelRef.current) { - void realtimeChannelRef.current(); - realtimeChannelRef.current = null; - } + void channel.unsubscribe().then((value) => { + if (value === 'error' || value === 'timed out') + throw new Error( + `There was an issue unsubscribing from a realtime channel with queryKey ${JSON.stringify(queryKey)}` + ); + }); }; }, [ isOnline, - subscribeRealtime, + channelName, + subscriptionConfig, queryClient, getId, JSON.stringify(queryKey) @@ -420,8 +491,8 @@ type HybridSupabaseFetchConfig = ( /** * hybridSupabaseFetch * - * A standalone function that automatically chooses between online and offline queries, - * depending on network connectivity. Can be used outside of React components. + * A standalone function that always fetches local data first, then cloud data when available, + * and merges with local priority. Can be used outside of React components. * * @example * // Using unified query @@ -445,14 +516,23 @@ export async function hybridSupabaseFetch< >(config: HybridSupabaseFetchConfig) { const { queryKey: _queryKey, ...restConfig } = config; - const runOfflineQuery = async () => { + const runLocalQuery = async () => { if (restConfig.offlineFn) { return await restConfig.offlineFn(); } else if (restConfig.offlineQuery) { if (typeof restConfig.offlineQuery === 'string') { - return (await system.powersync.execute( - restConfig.offlineQuery - )) as unknown as T[]; + const result = await system.powersync.execute(restConfig.offlineQuery); + const rows: T[] = []; + if (result.rows) { + for (let i = 0; i < result.rows.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const item = result.rows.item(i); + if (item) { + rows.push(item as T); + } + } + } + return rows; } return await toCompilableQuery(restConfig.offlineQuery).execute(); } else if ('query' in restConfig && restConfig.query) { @@ -469,7 +549,7 @@ export async function hybridSupabaseFetch< } }; - const runOnlineQuery = async () => { + const runCloudQuery = async () => { if (restConfig.onlineFn) { return await restConfig.onlineFn(); } else if ('query' in restConfig) { @@ -497,16 +577,43 @@ export async function hybridSupabaseFetch< } }; + // Always fetch local data first + const localData = await runLocalQuery(); + + // Try to fetch cloud data if online const isOnline = getNetworkStatus(); + let cloudData: T[] = []; + if (isOnline) { try { - return await runOnlineQuery(); - } catch { - return await runOfflineQuery(); + cloudData = await runCloudQuery(); + } catch (error) { + console.warn( + 'hybridSupabaseFetch: Cloud query failed, using local data only', + error + ); } - } else { - return await runOfflineQuery(); } + + // Merge with local priority + const localDataArray = Array.isArray(localData) ? localData : []; + const cloudDataArray = Array.isArray(cloudData) ? cloudData : []; + + // Create a map of local data by ID for quick lookup + const localDataMap = new Map( + localDataArray.map((item) => [ + (item as unknown as { id: string | number }).id, + item + ]) + ); + + // Filter out cloud data that already exists in local data + const uniqueCloudData = cloudDataArray.filter( + (item) => !localDataMap.has((item as unknown as { id: string | number }).id) + ); + + // Return local data first, then unique cloud data + return [...localDataArray, ...uniqueCloudData]; } /** @@ -569,7 +676,8 @@ type HybridSupabaseInfiniteQueryOptions = Omit< /** * useHybridSupabaseInfiniteQuery * - * A hook that provides infinite scrolling with automatic online/offline switching for Supabase. + * A hook that provides infinite scrolling with offline-first pattern. + * Always queries local data first, then cloud data when available, and merges with local priority. * * @example * // Using unified query @@ -596,114 +704,142 @@ type HybridSupabaseInfiniteQueryOptions = Omit< export function useHybridSupabaseInfiniteQuery( options: HybridSupabaseInfiniteQueryOptions ) { - // const timestamp = performance.now(); - - const { queryKey, pageSize = 10, ...restOptions } = options; - + const { + queryKey, + pageSize = 10, + getId = (record: T | Partial) => + (record as unknown as { id: string | number }).id, + ...restOptions + } = options; const isOnline = useNetworkStatus(); - const queryClient = useQueryClient(); - // Use dual cache system for better offline/online separation - const hasInfinite = queryKey.includes('infinite'); - const baseKey = hasInfinite ? queryKey : [...queryKey, 'infinite']; - const cleanBaseKey = baseKey.filter( + // Filter out undefined/null values from the query key + const cleanQueryKey = queryKey.filter( (key: unknown) => key !== undefined && key !== null ); - const hybridQueryKey = [...cleanBaseKey, isOnline ? 'online' : 'offline']; - const oppositeQueryKey = [...cleanBaseKey, isOnline ? 'offline' : 'online']; - // Get cached data from opposite network state for initial data - const oppositeCachedQueryState = queryClient.getQueryState(oppositeQueryKey); + // Create separate query keys for local and cloud data + const hasInfinite = cleanQueryKey.includes('infinite'); + const baseKey = hasInfinite ? cleanQueryKey : [...cleanQueryKey, 'infinite']; + const localQueryKey = [...baseKey, 'local']; + const cloudQueryKey = [...baseKey, 'cloud']; - const sharedOptions = { - queryKey: hybridQueryKey, + // Local infinite query (always enabled) + const localQuery = useInfiniteQuery({ + queryKey: localQueryKey, initialPageParam: 0, getNextPageParam: (lastPage) => lastPage.nextCursor, getPreviousPageParam: (firstPage) => firstPage.nextCursor, - initialDataUpdatedAt: oppositeCachedQueryState?.dataUpdatedAt, - placeholderData: keepPreviousData, staleTime: 30 * 1000, gcTime: 10 * 60 * 1000, - networkMode: 'always', - refetchOnReconnect: false, refetchOnWindowFocus: false, refetchOnMount: false, - ...restOptions - } satisfies GetInfiniteQueryParam; - - // console.log('sharedOptions', sharedOptions); - return useInfiniteQuery({ - ...sharedOptions, + ...restOptions, queryFn: async (context) => { - let results: T[] = []; - const completeContext = { ...context, pageSize, pageParam: context.pageParam as number } satisfies InfiniteQueryContext; - if (isOnline) { - // Handle online query - if (options.onlineFn) { - results = await options.onlineFn(completeContext); - } else if ('query' in options) { - const sqlQuery = options.query(completeContext); - const data = - typeof sqlQuery === 'string' ? sqlQuery : sqlQuery.toSQL(); - const finalSql = - typeof data === 'string' - ? data - : substituteParams(data.sql, data.params); - const response = await fetch( - `${process.env.EXPO_PUBLIC_SUPABASE_URL}/functions/v1/sqltr`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ sql: finalSql }) + let results: T[] = []; + + // Handle local query + if (options.offlineFn) { + results = await options.offlineFn(completeContext); + } else if (options.offlineQuery) { + const sqlQuery = options.offlineQuery(completeContext); + const compiledQuery = toCompilableQuery(sqlQuery); + results = await compiledQuery.execute(); + } else if ('query' in options) { + const sqlQuery = options.query(completeContext); + if (typeof sqlQuery === 'string') { + const result = await system.powersync.execute(sqlQuery); + const rows: T[] = []; + if (result.rows) { + for (let i = 0; i < result.rows.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const item = result.rows.item(i); + if (item) { + rows.push(item as T); + } } - ); - if (!response.ok) { - const err = `${JSON.stringify(hybridQueryKey)} ${await response.text()} ${finalSql}`; - console.error(err); - throw new Error(err); } - results = (await response.json()) as T[]; + results = rows; } else { - throw new Error('Either query or onlineFn must be provided'); - } - } else { - // Handle offline query - if (options.offlineFn) { - results = await options.offlineFn(completeContext); - } else if (options.offlineQuery) { - const sqlQuery = options.offlineQuery(completeContext); const compiledQuery = toCompilableQuery(sqlQuery); results = await compiledQuery.execute(); - } else if ('query' in options) { - const sqlQuery = options.query(completeContext); - if (typeof sqlQuery === 'string') { - results = (await system.powersync.execute( - sqlQuery - )) as unknown as T[]; - } else { - const compiledQuery = toCompilableQuery(sqlQuery); - results = await compiledQuery.execute(); + } + } else { + throw new Error( + 'Either query, offlineQuery, or offlineFn must be provided' + ); + } + + return { + data: results, + nextCursor: + results.length === pageSize + ? completeContext.pageParam + 1 + : undefined, + hasMore: results.length === pageSize + } satisfies HybridPageData; + } + }); + + // Cloud infinite query (only when online) + const cloudQuery = useInfiniteQuery({ + queryKey: cloudQueryKey, + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + getPreviousPageParam: (firstPage) => firstPage.nextCursor, + enabled: isOnline, + staleTime: 30 * 1000, + gcTime: 10 * 60 * 1000, + refetchOnWindowFocus: false, + refetchOnMount: false, + networkMode: 'always', + ...restOptions, + queryFn: async (context) => { + const completeContext = { + ...context, + pageSize, + pageParam: context.pageParam as number + } satisfies InfiniteQueryContext; + + let results: T[] = []; + + // Handle cloud query + if (options.onlineFn) { + results = await options.onlineFn(completeContext); + } else if ('query' in options) { + const sqlQuery = options.query(completeContext); + const data = typeof sqlQuery === 'string' ? sqlQuery : sqlQuery.toSQL(); + const finalSql = + typeof data === 'string' + ? data + : substituteParams(data.sql, data.params); + const response = await fetch( + `${process.env.EXPO_PUBLIC_SUPABASE_URL}/functions/v1/sqltr`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ sql: finalSql }) } - } else { - throw new Error( - 'Either query, offlineQuery, or offlineFn must be provided' - ); + ); + if (!response.ok) { + const err = `${JSON.stringify(cloudQueryKey)} ${await response.text()} ${finalSql}`; + console.error(err); + throw new Error(err); } + results = (await response.json()) as T[]; + } else { + throw new Error('Either query or onlineFn must be provided'); } - // console.log(`data ${JSON.stringify(results)}`); - // console.log( - // `[${performance.now() - timestamp}ms] useHybridSupabaseInfiniteQuery (${isOnline ? 'online' : 'offline'}) ${JSON.stringify(hybridQueryKey)}` - // ); return { data: results, nextCursor: @@ -714,10 +850,89 @@ export function useHybridSupabaseInfiniteQuery( } satisfies HybridPageData; } }); + + // Merge pages with local priority + const mergedData = React.useMemo(() => { + const localPages = localQuery.data?.pages || []; + const cloudPages = cloudQuery.data?.pages || []; + + // Merge pages at the same level + const maxPages = Math.max(localPages.length, cloudPages.length); + const mergedPages: HybridPageData[] = []; + + for (let i = 0; i < maxPages; i++) { + const localPage = localPages[i]; + const cloudPage = cloudPages[i]; + + if (localPage && cloudPage) { + // Merge page data with local priority + const localData = Array.isArray(localPage.data) ? localPage.data : []; + const cloudData = Array.isArray(cloudPage.data) ? cloudPage.data : []; + + // Create a map of local data by ID for quick lookup + const localDataMap = new Map( + localData.map((item) => [getId(item), item]) + ); + + // Filter out cloud data that already exists in local data + const uniqueCloudData = cloudData.filter( + (item) => !localDataMap.has(getId(item)) + ); + + mergedPages.push({ + ...localPage, + data: [...localData, ...uniqueCloudData] + }); + } else if (localPage) { + mergedPages.push(localPage); + } else if (cloudPage) { + mergedPages.push(cloudPage); + } + } + + return { + pages: mergedPages, + pageParams: + localQuery.data?.pageParams || cloudQuery.data?.pageParams || [] + }; + }, [localQuery.data, cloudQuery.data, getId]); + + return { + data: mergedData, + fetchNextPage: () => { + void localQuery.fetchNextPage(); + if (isOnline) void cloudQuery.fetchNextPage(); + }, + fetchPreviousPage: () => { + void localQuery.fetchPreviousPage(); + if (isOnline) void cloudQuery.fetchPreviousPage(); + }, + hasNextPage: localQuery.hasNextPage || cloudQuery.hasNextPage, + hasPreviousPage: localQuery.hasPreviousPage || cloudQuery.hasPreviousPage, + isFetchingNextPage: + localQuery.isFetchingNextPage || cloudQuery.isFetchingNextPage, + isFetchingPreviousPage: + localQuery.isFetchingPreviousPage || cloudQuery.isFetchingPreviousPage, + isLoading: localQuery.isLoading || (isOnline && cloudQuery.isLoading), + isError: localQuery.isError || cloudQuery.isError, + error: localQuery.error || cloudQuery.error, + isFetching: localQuery.isFetching || cloudQuery.isFetching, + isSuccess: localQuery.isSuccess, + refetch: () => { + void localQuery.refetch(); + if (isOnline) void cloudQuery.refetch(); + }, + status: localQuery.isError + ? 'error' + : localQuery.isLoading + ? 'pending' + : 'success' + }; } /** - * Traditional paginated hybrid query with keepPreviousData for smooth page transitions + * Traditional paginated hybrid query with offline-first priority + * Always queries local data first, then cloud data when available, and merges with local priority. * Use this when you need discrete page navigation (Previous/Next buttons) */ export function useHybridSupabasePaginatedQuery< @@ -732,8 +947,7 @@ export function useHybridSupabasePaginatedQuery< return useHybridSupabaseQuery({ ...hybridOptions, - queryKey: [...hybridOptions.queryKey, 'paginated', page, pageSize], - placeholderData: keepPreviousData // Smooth page transitions + queryKey: [...hybridOptions.queryKey, 'paginated', page, pageSize] }); } @@ -778,8 +992,9 @@ type HybridSupabaseInfiniteRealtimeQueryOptions< /** * useHybridSupabaseInfiniteRealtimeQuery * - * Combines infinite scrolling with realtime subscriptions for Supabase. - * Automatically handles cache updates for paginated data when realtime events occur. + * Combines infinite scrolling with realtime subscriptions using offline-first pattern. + * Always queries local data first, then cloud data when available, merges with local priority, + * and automatically handles cache updates for cloud data when realtime events occur. * * @example * const { @@ -790,12 +1005,10 @@ type HybridSupabaseInfiniteRealtimeQueryOptions< * queryKey: ['assets', 'paginated', questId], * query: (pageParam) => db.select().from(assetTable).limit(20).offset(pageParam * 20), * pageSize: 20, - * subscribeRealtime: (onChange) => { - * const channel = system.supabaseConnector.client - * .channel('public:asset') - * .on('postgres_changes', { event: '*', schema: 'public', table: 'asset' }, onChange); - * channel.subscribe(); - * return () => system.supabaseConnector.client.removeChannel(channel) + * channelName: 'public:asset', + * subscriptionConfig: { + * table: 'asset', + * schema: 'public' * }, * getId: (asset) => asset.id, * }); @@ -804,150 +1017,151 @@ export function useHybridSupabaseInfiniteRealtimeQuery< T extends Record >({ queryKey, - subscribeRealtime, + channelName, + subscriptionConfig, getId = (record: T | Partial) => (record as unknown as { id: string | number }).id, ...restOptions }: HybridSupabaseInfiniteRealtimeQueryOptions) { const queryClient = useQueryClient(); - const realtimeChannelRef = useRef | null>(null); - const isOnline = useNetworkStatus(); - // Use the base hybrid infinite query + // Use the base hybrid infinite query with offline-first pattern const result = useHybridSupabaseInfiniteQuery({ queryKey, + getId, ...restOptions }); useEffect(() => { if (!isOnline) return; - // Unsubscribe previous - if (realtimeChannelRef.current) { - void realtimeChannelRef.current(); - realtimeChannelRef.current = null; - } - - // Subscribe with automatic cache management for infinite queries - const subscription = subscribeRealtime((payload) => { - const { eventType, new: newRow, old: oldRow } = payload; - - // Build the cache key for infinite queries - const hasInfinite = queryKey.includes('infinite'); - const baseKey = hasInfinite ? queryKey : [...queryKey, 'infinite']; - const cleanBaseKey = baseKey.filter( - (key: unknown) => key !== undefined && key !== null - ); - const cacheKey = [...cleanBaseKey, 'online']; - - queryClient.setQueryData<{ - pages: HybridPageData[]; - pageParams: number[]; - }>(cacheKey, (prev) => { - if (!prev) return prev; - - const newPages = [...prev.pages]; - - switch (eventType) { - case 'INSERT': { - const recordId = getId(newRow); - - // Check if record already exists in any page to avoid duplicates - const existsInPages = newPages.some((page) => - page.data.some((record) => getId(record) === recordId) - ); - - if (!existsInPages && newPages.length > 0) { - // Add to the first page (most recent data) - newPages[0] = { - ...newPages[0], - data: [newRow, ...newPages[0]!.data] - }; - } - break; - } - - case 'UPDATE': { - const recordId = getId(newRow); - - // Find and update the record in whichever page it exists - for (let i = 0; i < newPages.length; i++) { - const page = newPages[i]; - if (page) { - const recordIndex = page.data.findIndex( - (record) => getId(record) === recordId + const channel = system.supabaseConnector.client + .channel(channelName) + .on( + 'postgres_changes', + { ...subscriptionConfig, event: '*' }, + (payload: RealtimePostgresChangesPayload) => { + const { eventType, new: newRow, old: oldRow } = payload; + + // Build the cache key for cloud infinite queries + const cleanQueryKey = queryKey.filter( + (key: unknown) => key !== undefined && key !== null + ); + const hasInfinite = cleanQueryKey.includes('infinite'); + const baseKey = hasInfinite + ? cleanQueryKey + : [...cleanQueryKey, 'infinite']; + const cloudCacheKey = [...baseKey, 'cloud']; + + queryClient.setQueryData<{ + pages: HybridPageData[]; + pageParams: number[]; + }>(cloudCacheKey, (prev) => { + if (!prev) return prev; + + const newPages = [...prev.pages]; + + switch (eventType) { + case 'INSERT': { + const recordId = getId(newRow); + + // Check if record already exists in any page to avoid duplicates + const existsInPages = newPages.some((page) => + page.data.some((record) => getId(record) === recordId) ); - if (recordIndex !== -1) { - const newPageData = [...page.data]; - newPageData[recordIndex] = newRow; - newPages[i] = { - ...page, - data: newPageData + if (!existsInPages && newPages.length > 0) { + // Add to the first page (most recent data) + newPages[0] = { + ...newPages[0]!, + data: [newRow, ...newPages[0]!.data] }; - break; } + break; } - } - break; - } - - case 'DELETE': { - const recordId = getId(oldRow); - // Find and remove the record from whichever page it exists - for (let i = 0; i < newPages.length; i++) { - const page = newPages[i]; - if (page) { - const recordIndex = page.data.findIndex( - (record) => getId(record) === recordId - ); + case 'UPDATE': { + const recordId = getId(newRow); + + // Find and update the record in whichever page it exists + for (let i = 0; i < newPages.length; i++) { + const page = newPages[i]; + if (page) { + const recordIndex = page.data.findIndex( + (record) => getId(record) === recordId + ); + + if (recordIndex !== -1) { + const newPageData = [...page.data]; + newPageData[recordIndex] = newRow; + newPages[i] = { + ...page, + data: newPageData + }; + break; + } + } + } + break; + } - if (recordIndex !== -1) { - const newPageData = page.data.filter( - (record) => getId(record) !== recordId - ); - newPages[i] = { - ...page, - data: newPageData - }; - break; + case 'DELETE': { + const recordId = getId(oldRow); + + // Find and remove the record from whichever page it exists + for (let i = 0; i < newPages.length; i++) { + const page = newPages[i]; + if (page) { + const recordIndex = page.data.findIndex( + (record) => getId(record) === recordId + ); + + if (recordIndex !== -1) { + const newPageData = page.data.filter( + (record) => getId(record) !== recordId + ); + newPages[i] = { + ...page, + data: newPageData + }; + break; + } + } } + break; + } + + default: { + console.warn( + 'useHybridSupabaseInfiniteRealtimeQuery: Unhandled event type', + eventType + ); + break; } } - break; - } - default: { - console.warn( - 'useHybridSupabaseInfiniteRealtimeQuery: Unhandled event type', - eventType - ); - break; - } + return { + ...prev, + pages: newPages + }; + }); } - - return { - ...prev, - pages: newPages - }; - }); - }); - - realtimeChannelRef.current = subscription; + ); + channel.subscribe(); return () => { - if (realtimeChannelRef.current) { - void realtimeChannelRef.current(); - realtimeChannelRef.current = null; - } + void channel.unsubscribe().then((value) => { + if (value === 'error' || value === 'timed out') + throw new Error( + `There was an issue unsubscribing from a realtime channel with queryKey ${JSON.stringify(queryKey)}` + ); + }); }; }, [ isOnline, - subscribeRealtime, + channelName, + subscriptionConfig, queryClient, getId, JSON.stringify(queryKey) diff --git a/hooks/useSyncState.ts b/hooks/useSyncState.ts index 91d9d5fb7..99b724b1d 100644 --- a/hooks/useSyncState.ts +++ b/hooks/useSyncState.ts @@ -1,91 +1,134 @@ import { system } from '@/db/powersync/system'; +import { AttachmentState } from '@powersync/attachments'; import { useEffect, useState } from 'react'; +import { useAttachmentStates } from './useAttachmentStates'; interface SyncState { - isDownloadOperationInProgress: boolean; - isUpdateInProgress: boolean; isConnected: boolean; isConnecting: boolean; + isDownloadOperationInProgress: boolean; + isUpdateInProgress: boolean; + hasSynced: boolean | undefined; + lastSyncedAt: Date | undefined; + downloadError: Error | undefined; + uploadError: Error | undefined; + unsyncedAttachmentsCount: number; + isLoading: boolean; } -// Type guard and interface for attachment state manager -interface AttachmentStateManager { - isDownloadOperationInProgress(): boolean; - isUpdateInProgress(): boolean; -} +/** + * Returns the number of attachments that are not yet fully synced. + * @param attachmentIds Array of attachment IDs to check. + * @returns { unsyncedCount: number, isLoading: boolean } + */ +function useUnsyncedAttachmentsCount(): { + unsyncedCount: number; + isLoading: boolean; +} { + // get all attachment ids from the attachment table + + const { attachmentStates, isLoading } = useAttachmentStates([]); + + // Count attachments with state less than SYNCED + let unsyncedCount = 0; + if (!isLoading && attachmentStates.size > 0) { + for (const record of attachmentStates.values()) { + if (record.state < AttachmentState.SYNCED) { + unsyncedCount++; + } + } + } -interface AttachmentQueueWithStateManager { - attachmentStateManager?: AttachmentStateManager; + return { unsyncedCount, isLoading }; } -function getAttachmentStateManager(): AttachmentStateManager | null { - const permQueue = system.permAttachmentQueue as - | AttachmentQueueWithStateManager - | undefined; - if (!permQueue) return null; - - // Try to get state manager directly - if (permQueue.attachmentStateManager) { - return permQueue.attachmentStateManager; +function getCurrentSyncStateWithoutAttachments() { + try { + // Get the current sync status from PowerSync + const status = system.powersync.currentStatus; + + // Basic connection state + const isConnected = status.connected || false; + const isConnecting = status.connecting || false; + + // Data flow status for downloads and uploads + const dataFlow = status.dataFlowStatus; + const isDownloadOperationInProgress = dataFlow.downloading || false; + const isUpdateInProgress = dataFlow.uploading || false; + + // Sync history information + const hasSynced = status.hasSynced; + const lastSyncedAt = status.lastSyncedAt; + + // Error information + const downloadError = dataFlow.downloadError; + const uploadError = dataFlow.uploadError; + + return { + isConnected, + isConnecting, + isDownloadOperationInProgress, + isUpdateInProgress, + hasSynced, + lastSyncedAt, + downloadError, + uploadError + }; + } catch (error) { + console.warn('Error checking sync state:', error); + return { + isConnected: false, + isConnecting: false, + isDownloadOperationInProgress: false, + isUpdateInProgress: false, + hasSynced: undefined, + lastSyncedAt: undefined, + downloadError: undefined, + uploadError: undefined + }; } - - return null; } export function useSyncState(): SyncState { - const [syncState, setSyncState] = useState({ - isDownloadOperationInProgress: false, - isUpdateInProgress: false, - isConnected: false, - isConnecting: false - }); + // Call hooks at the top level + const { + unsyncedCount: unsyncedAttachmentsCount, + isLoading: attachmentDataLoading + } = useUnsyncedAttachmentsCount(); + + const [baseSyncState, setBaseSyncState] = useState(() => + getCurrentSyncStateWithoutAttachments() + ); useEffect(() => { - const checkSyncState = () => { - try { - // Check PowerSync connection status - const isConnected = system.powersync.connected || false; - const isConnecting = system.powersync.connecting || false; - - // Check AttachmentStateManager state if available - let isDownloadOperationInProgress = false; - let isUpdateInProgress = false; - - const stateManager = getAttachmentStateManager(); - if (stateManager) { - try { - isDownloadOperationInProgress = - stateManager.isDownloadOperationInProgress(); - isUpdateInProgress = stateManager.isUpdateInProgress(); - } catch (error) { - // Fail silently if we can't access the state manager methods - console.warn( - 'Could not access AttachmentStateManager state:', - error - ); - } - } - - setSyncState({ - isDownloadOperationInProgress, - isUpdateInProgress, - isConnected, - isConnecting - }); - } catch (error) { - console.warn('Error checking sync state:', error); + // Subscribe to PowerSync status changes + const unsubscribe = system.powersync.registerListener({ + statusChanged: () => { + setBaseSyncState(getCurrentSyncStateWithoutAttachments()); } - }; - - // Initial check - checkSyncState(); - - // Set up polling to check sync state periodically - const interval = setInterval(checkSyncState, 1000); // Check every second + }); - return () => clearInterval(interval); + return unsubscribe; }, []); + // Determine overall loading state based on: + // 1. PowerSync sync operations (connecting, downloading, uploading) + // 2. Unsynced attachments (< AttachmentState.SYNCED) + // 3. Whether attachment data is still loading + const isLoading = + attachmentDataLoading || // Attachment state data is still loading + baseSyncState.isConnecting || // PowerSync is connecting + baseSyncState.isDownloadOperationInProgress || // PowerSync is downloading + baseSyncState.isUpdateInProgress || // PowerSync is uploading + unsyncedAttachmentsCount > 0; // We have unsynced attachments + + // Combine base sync state with attachment data + const syncState: SyncState = { + ...baseSyncState, + unsyncedAttachmentsCount, + isLoading + }; + return syncState; } @@ -97,3 +140,19 @@ export function useIsSyncing(): boolean { useSyncState(); return isDownloadOperationInProgress || isUpdateInProgress || isConnecting; } + +/** + * Returns true if there are any sync errors + */ +export function useHasSyncErrors(): boolean { + const { downloadError, uploadError } = useSyncState(); + return !!(downloadError || uploadError); +} + +/** + * Returns the most recent sync error if any + */ +export function useSyncError(): Error | undefined { + const { downloadError, uploadError } = useSyncState(); + return downloadError || uploadError; +} diff --git a/hooks/useUserPermissions.ts b/hooks/useUserPermissions.ts index 2c434830b..026e7fa2a 100644 --- a/hooks/useUserPermissions.ts +++ b/hooks/useUserPermissions.ts @@ -1,9 +1,9 @@ -import { useAuth } from '@/contexts/AuthContext'; -import { profile_project_link, project } from '@/db/drizzleSchema'; +import { useSessionMemberships } from '@/contexts/SessionCacheContext'; +import { project } from '@/db/drizzleSchema'; import { system } from '@/db/powersync/system'; import { useHybridQuery } from '@/hooks/useHybridQuery'; import { toCompilableQuery } from '@powersync/drizzle-driver'; -import { and, eq } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; /** * # Access Control Constraints @@ -118,8 +118,14 @@ export function useUserPermissions( hasAccess: boolean; membership: MembershipRole; isMembershipLoading: boolean; + membershipData?: { + project_id: string; + membership: 'owner' | 'member'; + active: boolean; + }; } { - const { currentUser } = useAuth(); + const { getUserMembership, isUserMembershipsLoading } = + useSessionMemberships(); const { db } = system; // Don't run queries if project_id is empty or invalid @@ -149,35 +155,8 @@ export function useUserPermissions( enabled: shouldQueryPrivacy }); - // Query for membership status - const { data: membershipLinks = [] } = useHybridQuery({ - queryKey: ['membership-status', project_id, currentUser?.id], - onlineFn: async () => { - const { data } = await system.supabaseConnector.client - .from('profile_project_link') - .select('*') - .eq('profile_id', currentUser?.id || '') - .eq('project_id', project_id) - .eq('active', true); - return data as (typeof profile_project_link.$inferSelect)[]; - }, - offlineQuery: toCompilableQuery( - db.query.profile_project_link.findMany({ - where: and( - eq(profile_project_link.project_id, project_id), - eq(profile_project_link.profile_id, currentUser?.id || ''), - eq(profile_project_link.active, true) - ), - limit: 1 - }) - ), - enabled: isValidProjectId && !!currentUser?.id - }); - - // Get the first (and should be only) membership record - const membershipData = membershipLinks[0] as - | typeof profile_project_link.$inferSelect - | undefined; + // Get membership from session cache (more efficient and consistent) + const membershipData = getUserMembership(project_id); const isPrivate = knownIsPrivate ?? (projectData[0] as { private: boolean } | undefined)?.private ?? @@ -189,7 +168,8 @@ export function useUserPermissions( return { hasAccess: false, membership: undefined, - isMembershipLoading: false + isMembershipLoading: isUserMembershipsLoading, + membershipData: undefined }; } @@ -209,7 +189,8 @@ export function useUserPermissions( return { hasAccess: isLockVisible, membership, - isMembershipLoading: false + isMembershipLoading: isUserMembershipsLoading, + membershipData }; } @@ -228,7 +209,7 @@ export function useUserPermissions( return { hasAccess: true, membership, - isMembershipLoading: false + isMembershipLoading: isUserMembershipsLoading }; } @@ -240,6 +221,7 @@ export function useUserPermissions( return { hasAccess: hasRolePermission, membership, - isMembershipLoading: false + isMembershipLoading: isUserMembershipsLoading, + membershipData }; } diff --git a/package-lock.json b/package-lock.json index ca9d45845..e36e569a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "langquest", - "version": "1.0.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "langquest", - "version": "1.0.0", + "version": "1.2.0", "dependencies": { "@azure/core-asynciterator-polyfill": "^1.0.2", "@expo/vector-icons": "^14.0.2", diff --git a/package.json b/package.json index d3ae963c7..8e219235e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "langquest", "main": "expo-router/entry", - "version": "1.0.0", + "version": "1.2.0", "scripts": { "start": "expo start --dev-client", "generate-icon-types": "ts-node ./scripts/generate-icon-types.ts", diff --git a/services/localizations.ts b/services/localizations.ts index fdb90635e..4f06ddd11 100644 --- a/services/localizations.ts +++ b/services/localizations.ts @@ -235,6 +235,11 @@ export const localizations = { spanish: 'OK', brazilian_portuguese: 'OK' }, + offline: { + english: 'Offline', + spanish: 'Sin conexión', + brazilian_portuguese: 'Offline' + }, password: { english: 'Password', spanish: 'Contraseña', @@ -323,6 +328,11 @@ export const localizations = { spanish: 'Buscar recursos...', brazilian_portuguese: 'Buscar recursos...' }, + noAssetsFound: { + english: 'No assets found', + spanish: 'No se encontraron recursos', + brazilian_portuguese: 'Nenhum recurso encontrado' + }, searchQuests: { english: 'Search quests...', spanish: 'Buscar misiones...', @@ -1428,6 +1438,97 @@ export const localizations = { english: 'Restore Failed: {{error}}', spanish: 'Restauración Fallida: {{error}}', brazilian_portuguese: 'Restauração Falhou: {{error}}' + }, + projectInvitationTitle: { + english: 'Project Invitation', + spanish: 'Invitación al Proyecto', + brazilian_portuguese: 'Convite para o Projeto' + }, + joinRequestTitle: { + english: 'Join Request', + spanish: 'Solicitud de Unión', + brazilian_portuguese: 'Solicitação de Adesão' + }, + invitedYouToJoin: { + english: '{{sender}} invited you to join "{{project}}" as {{role}}', + spanish: '{{sender}} te invitó a unirte a "{{project}}" como {{role}}', + brazilian_portuguese: + '{{sender}} convidou você para participar de "{{project}}" como {{role}}' + }, + requestedToJoin: { + english: '{{sender}} requested to join "{{project}}" as {{role}}', + spanish: '{{sender}} solicitó unirse a "{{project}}" como {{role}}', + brazilian_portuguese: + '{{sender}} solicitou participar de "{{project}}" como {{role}}' + }, + downloadProjectLabel: { + english: 'Download Project', + spanish: 'Descargar Proyecto', + brazilian_portuguese: 'Baixar Projeto' + }, + projectNotAvailableOfflineWarning: { + english: 'Project will not be available offline without download', + spanish: 'El proyecto no estará disponible sin conexión sin descarga', + brazilian_portuguese: 'O projeto não estará disponível offline sem download' + }, + noNotificationsTitle: { + english: 'No Notifications', + spanish: 'Sin Notificaciones', + brazilian_portuguese: 'Sem Notificações' + }, + noNotificationsMessage: { + english: "You'll see project invitations and join requests here", + spanish: 'Aquí verás invitaciones a proyectos y solicitudes de unión', + brazilian_portuguese: + 'Aqui você verá convites para projetos e solicitações de participação' + }, + invitationAcceptedSuccessfully: { + english: 'Invitation accepted successfully', + spanish: 'Invitación aceptada exitosamente', + brazilian_portuguese: 'Convite aceito com sucesso' + }, + invitationDeclinedSuccessfully: { + english: 'Invitation declined', + spanish: 'Invitación rechazada', + brazilian_portuguese: 'Convite recusado' + }, + failedToAcceptInvite: { + english: 'Failed to accept invitation', + spanish: 'Error al aceptar invitación', + brazilian_portuguese: 'Falha ao aceitar convite' + }, + failedToDeclineInvite: { + english: 'Failed to decline invitation', + spanish: 'Error al rechazar invitación', + brazilian_portuguese: 'Falha ao recusar convite' + }, + invitationAcceptedDownloadFailed: { + english: 'Invitation accepted but download failed', + spanish: 'Invitación aceptada pero la descarga falló', + brazilian_portuguese: 'Convite aceito mas o download falhou' + }, + unknownProject: { + english: 'Unknown Project', + spanish: 'Proyecto Desconocido', + brazilian_portuguese: 'Projeto Desconhecido' + }, + ownerRole: { + english: 'owner', + spanish: 'propietario', + brazilian_portuguese: 'proprietário' + }, + memberRole: { + english: 'member', + spanish: 'miembro', + brazilian_portuguese: 'membro' + }, + offlineNotificationMessage: { + english: + 'You are offline. Any changes you make will sync when you are back online.', + spanish: + 'Estás sin conexión. Los cambios que hagas se sincronizarán cuando vuelvas a estar en línea.', + brazilian_portuguese: + 'Você está offline. Quaisquer alterações que você fizer serão sincronizadas quando você voltar a ficar online.' } } as const; diff --git a/supabase/migrations/20250710000000_include_project_in_download_quest_closure.sql b/supabase/migrations/20250710000000_include_project_in_download_quest_closure.sql new file mode 100644 index 000000000..cde293878 --- /dev/null +++ b/supabase/migrations/20250710000000_include_project_in_download_quest_closure.sql @@ -0,0 +1,175 @@ +-- Fix composite key issue in download_quest_closure function +-- quest_asset_link, quest_tag_link, and asset_tag_link use composite keys, not id columns + +CREATE OR REPLACE FUNCTION public.download_quest_closure(quest_id_param uuid, profile_id_param uuid) + RETURNS TABLE(table_name text, records_updated integer) + LANGUAGE plpgsql + SECURITY DEFINER +AS $function$ +DECLARE + closure_record quest_closure%ROWTYPE; + assets_updated INTEGER := 0; + translations_updated INTEGER := 0; + votes_updated INTEGER := 0; + tags_updated INTEGER := 0; + languages_updated INTEGER := 0; + quest_asset_links_updated INTEGER := 0; + asset_content_links_updated INTEGER := 0; + quest_tag_links_updated INTEGER := 0; + asset_tag_links_updated INTEGER := 0; + quests_updated INTEGER := 0; + quest_closures_updated INTEGER := 0; +BEGIN + -- Logging + RAISE NOTICE '[download_quest_closure] Starting for quest_id: %, profile_id: %', quest_id_param, profile_id_param; + + -- Get the complete closure record + SELECT * INTO closure_record + FROM quest_closure + WHERE quest_id = quest_id_param; + + IF closure_record.quest_id IS NULL THEN + RAISE EXCEPTION 'Quest closure not found for quest_id: %', quest_id_param; + END IF; + + -- Update quest itself (using UUID array operators) + UPDATE quest + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = quest_id_param; + GET DIAGNOSTICS quests_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated quest: % rows', quests_updated; + + -- Update assets + UPDATE asset + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.asset_ids))::UUID)); + GET DIAGNOSTICS assets_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated assets: % rows', assets_updated; + + -- Update translations + UPDATE translation + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.translation_ids))::UUID)); + GET DIAGNOSTICS translations_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated translations: % rows', translations_updated; + + -- Update votes + UPDATE vote + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.vote_ids))::UUID)); + GET DIAGNOSTICS votes_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated votes: % rows', votes_updated; + + -- Update tags + UPDATE tag + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.tag_ids))::UUID)); + GET DIAGNOSTICS tags_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated tags: % rows', tags_updated; + + -- Update languages + UPDATE language + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.language_ids))::UUID)); + GET DIAGNOSTICS languages_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated languages: % rows', languages_updated; + + -- Update quest_asset_link (using composite key quest_id + asset_id) + UPDATE quest_asset_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE quest_id = quest_id_param + AND asset_id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.asset_ids))::UUID)); + GET DIAGNOSTICS quest_asset_links_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated quest_asset_links: % rows', quest_asset_links_updated; + + -- Update asset_content_link (this has an id column) + UPDATE asset_content_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.asset_content_link_ids))::UUID)); + GET DIAGNOSTICS asset_content_links_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated asset_content_links: % rows', asset_content_links_updated; + + -- Update quest_tag_link (using composite key quest_id + tag_id) + UPDATE quest_tag_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE quest_id = quest_id_param + AND tag_id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.tag_ids))::UUID)); + GET DIAGNOSTICS quest_tag_links_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated quest_tag_links: % rows', quest_tag_links_updated; + + -- Update asset_tag_link (using composite key asset_id + tag_id) + UPDATE asset_tag_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE asset_id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.asset_ids))::UUID)) + AND tag_id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.tag_ids))::UUID)); + GET DIAGNOSTICS asset_tag_links_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated asset_tag_links: % rows', asset_tag_links_updated; + + -- Update the quest closure record itself to include this profile + UPDATE quest_closure + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE quest_id = quest_id_param; + GET DIAGNOSTICS quest_closures_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated quest_closure: % rows', quest_closures_updated; + + -- Logging + RAISE NOTICE '[download_quest_closure] Completed for quest_id: %, profile_id: %', quest_id_param, profile_id_param; + + -- Return summary of what was updated + RETURN QUERY + SELECT 'quest'::TEXT, quests_updated + UNION ALL + SELECT 'asset'::TEXT, assets_updated + UNION ALL + SELECT 'translation'::TEXT, translations_updated + UNION ALL + SELECT 'vote'::TEXT, votes_updated + UNION ALL + SELECT 'tag'::TEXT, tags_updated + UNION ALL + SELECT 'language'::TEXT, languages_updated + UNION ALL + SELECT 'quest_asset_link'::TEXT, quest_asset_links_updated + UNION ALL + SELECT 'asset_content_link'::TEXT, asset_content_links_updated + UNION ALL + SELECT 'quest_tag_link'::TEXT, quest_tag_links_updated + UNION ALL + SELECT 'asset_tag_link'::TEXT, asset_tag_links_updated + UNION ALL + SELECT 'quest_closure'::TEXT, quest_closures_updated; +END; +$function$; diff --git a/supabase/migrations/20250710142908_sync_closure_download_profiles.sql b/supabase/migrations/20250710142908_sync_closure_download_profiles.sql new file mode 100644 index 000000000..acd908471 --- /dev/null +++ b/supabase/migrations/20250710142908_sync_closure_download_profiles.sql @@ -0,0 +1,415 @@ +-- Sync download profiles across project_closure and quest_closure tables +-- When downloading a quest, also add user to project_closure and all sibling quest_closure records +-- When downloading a project, also add user to all quest_closure records + +-- Enhanced download_quest_closure function that also updates project_closure and sibling quest_closure records +CREATE OR REPLACE FUNCTION public.download_quest_closure(quest_id_param uuid, profile_id_param uuid) + RETURNS TABLE(table_name text, records_updated integer) + LANGUAGE plpgsql + SECURITY DEFINER +AS $function$ +DECLARE + closure_record quest_closure%ROWTYPE; + assets_updated INTEGER := 0; + translations_updated INTEGER := 0; + votes_updated INTEGER := 0; + tags_updated INTEGER := 0; + languages_updated INTEGER := 0; + quest_asset_links_updated INTEGER := 0; + asset_content_links_updated INTEGER := 0; + quest_tag_links_updated INTEGER := 0; + asset_tag_links_updated INTEGER := 0; + quests_updated INTEGER := 0; + quest_closures_updated INTEGER := 0; + project_closures_updated INTEGER := 0; + sibling_quest_closures_updated INTEGER := 0; +BEGIN + -- Logging + RAISE NOTICE '[download_quest_closure] Starting for quest_id: %, profile_id: %', quest_id_param, profile_id_param; + + -- Get the complete closure record + SELECT * INTO closure_record + FROM quest_closure + WHERE quest_id = quest_id_param; + + IF closure_record.quest_id IS NULL THEN + RAISE EXCEPTION 'Quest closure not found for quest_id: %', quest_id_param; + END IF; + + + -- Update assets + UPDATE asset + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.asset_ids))::UUID)); + GET DIAGNOSTICS assets_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated assets: % rows', assets_updated; + + -- Update translations + UPDATE translation + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.translation_ids))::UUID)); + GET DIAGNOSTICS translations_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated translations: % rows', translations_updated; + + -- Update votes + UPDATE vote + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.vote_ids))::UUID)); + GET DIAGNOSTICS votes_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated votes: % rows', votes_updated; + + -- Update tags + UPDATE tag + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.tag_ids))::UUID)); + GET DIAGNOSTICS tags_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated tags: % rows', tags_updated; + + -- Update languages + UPDATE language + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.language_ids))::UUID)); + GET DIAGNOSTICS languages_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated languages: % rows', languages_updated; + + -- Update quest_asset_link (using composite key quest_id + asset_id) + UPDATE quest_asset_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE quest_id = quest_id_param + AND asset_id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.asset_ids))::UUID)); + GET DIAGNOSTICS quest_asset_links_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated quest_asset_links: % rows', quest_asset_links_updated; + + -- Update asset_content_link (this has an id column) + UPDATE asset_content_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.asset_content_link_ids))::UUID)); + GET DIAGNOSTICS asset_content_links_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated asset_content_links: % rows', asset_content_links_updated; + + -- Update quest_tag_link (using composite key quest_id + tag_id) + UPDATE quest_tag_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE quest_id = quest_id_param + AND tag_id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.tag_ids))::UUID)); + GET DIAGNOSTICS quest_tag_links_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated quest_tag_links: % rows', quest_tag_links_updated; + + -- Update asset_tag_link (using composite key asset_id + tag_id) + UPDATE asset_tag_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE asset_id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.asset_ids))::UUID)) + AND tag_id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.tag_ids))::UUID)); + GET DIAGNOSTICS asset_tag_links_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated asset_tag_links: % rows', asset_tag_links_updated; + + -- Update quest itself (using UUID array operators) + UPDATE quest + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = quest_id_param; + GET DIAGNOSTICS quests_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated quest: % rows', quests_updated; + + -- Update the quest closure record itself to include this profile + UPDATE quest_closure + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE quest_id = quest_id_param; + GET DIAGNOSTICS quest_closures_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated quest_closure: % rows', quest_closures_updated; + + -- NEW: Also update all sibling quest_closure records for other quests in the same project + UPDATE quest_closure + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE project_id = closure_record.project_id + AND quest_id != quest_id_param; + GET DIAGNOSTICS sibling_quest_closures_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated sibling quest_closures: % rows', sibling_quest_closures_updated; + + -- Add sibling updates to total count + quest_closures_updated := quest_closures_updated + sibling_quest_closures_updated; + + -- NEW: Also update the project_closure record to include this profile + UPDATE project_closure + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE project_id = closure_record.project_id; + GET DIAGNOSTICS project_closures_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated project_closure: % rows', project_closures_updated; + + -- Logging + RAISE NOTICE '[download_quest_closure] Completed for quest_id: %, profile_id: %. Updated % quest_closures total (including siblings)', quest_id_param, profile_id_param, quest_closures_updated; + + -- Return summary of what was updated + RETURN QUERY + SELECT 'quest'::TEXT, quests_updated + UNION ALL + SELECT 'asset'::TEXT, assets_updated + UNION ALL + SELECT 'translation'::TEXT, translations_updated + UNION ALL + SELECT 'vote'::TEXT, votes_updated + UNION ALL + SELECT 'tag'::TEXT, tags_updated + UNION ALL + SELECT 'language'::TEXT, languages_updated + UNION ALL + SELECT 'quest_asset_link'::TEXT, quest_asset_links_updated + UNION ALL + SELECT 'asset_content_link'::TEXT, asset_content_links_updated + UNION ALL + SELECT 'quest_tag_link'::TEXT, quest_tag_links_updated + UNION ALL + SELECT 'asset_tag_link'::TEXT, asset_tag_links_updated + UNION ALL + SELECT 'quest_closure'::TEXT, quest_closures_updated + UNION ALL + SELECT 'project_closure'::TEXT, project_closures_updated; +END; +$function$; + +-- Enhanced download_project_closure function that also updates all quest_closure records +CREATE OR REPLACE FUNCTION public.download_project_closure(project_id_param uuid, profile_id_param uuid) + RETURNS TABLE(table_name text, records_updated integer) + LANGUAGE plpgsql + SECURITY DEFINER +AS $function$ +DECLARE + closure_record project_closure%ROWTYPE; + projects_updated INTEGER := 0; + quests_updated INTEGER := 0; + assets_updated INTEGER := 0; + translations_updated INTEGER := 0; + votes_updated INTEGER := 0; + tags_updated INTEGER := 0; + languages_updated INTEGER := 0; + quest_asset_links_updated INTEGER := 0; + asset_content_links_updated INTEGER := 0; + quest_tag_links_updated INTEGER := 0; + asset_tag_links_updated INTEGER := 0; + project_closures_updated INTEGER := 0; + quest_closures_updated INTEGER := 0; +BEGIN + -- Logging + RAISE NOTICE '[download_project_closure] Starting for project_id: %, profile_id: %', project_id_param, profile_id_param; + + -- Get the complete closure record + SELECT * INTO closure_record + FROM project_closure + WHERE project_id = project_id_param; + + IF closure_record.project_id IS NULL THEN + RAISE EXCEPTION 'Project closure not found for project_id: %', project_id_param; + END IF; + + -- Update project itself + UPDATE project + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = project_id_param; + GET DIAGNOSTICS projects_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated project: % rows', projects_updated; + + -- Update all quests + UPDATE quest + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.quest_ids))::UUID)); + GET DIAGNOSTICS quests_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated quests: % rows', quests_updated; + + -- Update assets + UPDATE asset + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.asset_ids))::UUID)); + GET DIAGNOSTICS assets_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated assets: % rows', assets_updated; + + -- Update translations + UPDATE translation + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.translation_ids))::UUID)); + GET DIAGNOSTICS translations_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated translations: % rows', translations_updated; + + -- Update votes + UPDATE vote + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.vote_ids))::UUID)); + GET DIAGNOSTICS votes_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated votes: % rows', votes_updated; + + -- Update tags + UPDATE tag + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.tag_ids))::UUID)); + GET DIAGNOSTICS tags_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated tags: % rows', tags_updated; + + -- Update languages + UPDATE language + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.language_ids))::UUID)); + GET DIAGNOSTICS languages_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated languages: % rows', languages_updated; + + -- Update quest_asset_link (these use composite keys stored as strings) + UPDATE quest_asset_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE (quest_id || '-' || asset_id) = ANY(ARRAY(SELECT jsonb_array_elements_text(closure_record.quest_asset_link_ids))); + GET DIAGNOSTICS quest_asset_links_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated quest_asset_links: % rows', quest_asset_links_updated; + + -- Update asset_content_link + UPDATE asset_content_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.asset_content_link_ids))::UUID)); + GET DIAGNOSTICS asset_content_links_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated asset_content_links: % rows', asset_content_links_updated; + + -- Update quest_tag_link (these use composite keys stored as strings) + UPDATE quest_tag_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE (quest_id || '-' || tag_id) = ANY(ARRAY(SELECT jsonb_array_elements_text(closure_record.quest_tag_link_ids))); + GET DIAGNOSTICS quest_tag_links_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated quest_tag_links: % rows', quest_tag_links_updated; + + -- Update asset_tag_link (these use composite keys stored as strings) + UPDATE asset_tag_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE (asset_id || '-' || tag_id) = ANY(ARRAY(SELECT jsonb_array_elements_text(closure_record.asset_tag_link_ids))); + GET DIAGNOSTICS asset_tag_links_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated asset_tag_links: % rows', asset_tag_links_updated; + + -- Update the project closure record itself to include this profile + UPDATE project_closure + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE project_id = project_id_param; + GET DIAGNOSTICS project_closures_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated project_closure: % rows', project_closures_updated; + + -- NEW: Also update all quest_closure records for quests in this project + UPDATE quest_closure + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE project_id = project_id_param; + GET DIAGNOSTICS quest_closures_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated quest_closures: % rows', quest_closures_updated; + + -- Logging + RAISE NOTICE '[download_project_closure] Completed for project_id: %, profile_id: %', project_id_param, profile_id_param; + + -- Return summary of what was updated + RETURN QUERY + SELECT 'project'::TEXT, projects_updated + UNION ALL + SELECT 'quest'::TEXT, quests_updated + UNION ALL + SELECT 'asset'::TEXT, assets_updated + UNION ALL + SELECT 'translation'::TEXT, translations_updated + UNION ALL + SELECT 'vote'::TEXT, votes_updated + UNION ALL + SELECT 'tag'::TEXT, tags_updated + UNION ALL + SELECT 'language'::TEXT, languages_updated + UNION ALL + SELECT 'quest_asset_link'::TEXT, quest_asset_links_updated + UNION ALL + SELECT 'asset_content_link'::TEXT, asset_content_links_updated + UNION ALL + SELECT 'quest_tag_link'::TEXT, quest_tag_links_updated + UNION ALL + SELECT 'asset_tag_link'::TEXT, asset_tag_links_updated + UNION ALL + SELECT 'project_closure'::TEXT, project_closures_updated + UNION ALL + SELECT 'quest_closure'::TEXT, quest_closures_updated; +END; +$function$; + +alter publication "powersync" add table only "public"."quest_closure"; + +alter publication "powersync" add table only "public"."project_closure"; + +ALTER PUBLICATION "supabase_realtime" ADD TABLE ONLY "public"."notification"; + +ALTER PUBLICATION "supabase_realtime" ADD TABLE ONLY "public"."request"; + +ALTER PUBLICATION "supabase_realtime" ADD TABLE ONLY "public"."invite"; + +ALTER PUBLICATION "supabase_realtime" ADD TABLE ONLY "public"."profile_project_link"; \ No newline at end of file diff --git a/supabase/migrations/20250710142909_add_project_to_download_closures_function.sql b/supabase/migrations/20250710142909_add_project_to_download_closures_function.sql new file mode 100644 index 000000000..41faefdc1 --- /dev/null +++ b/supabase/migrations/20250710142909_add_project_to_download_closures_function.sql @@ -0,0 +1,415 @@ +-- Sync download profiles across project_closure and quest_closure tables +-- When downloading a quest, also add user to project, project_closure and all sibling quest_closure records +-- When downloading a project, also add user to all quest_closure records + +-- Enhanced download_quest_closure function that also updates project, project_closure and sibling quest_closure records +CREATE OR REPLACE FUNCTION public.download_quest_closure(quest_id_param uuid, profile_id_param uuid) + RETURNS TABLE(table_name text, records_updated integer) + LANGUAGE plpgsql + SECURITY DEFINER +AS $function$ +DECLARE + closure_record quest_closure%ROWTYPE; + projects_updated INTEGER := 0; + assets_updated INTEGER := 0; + translations_updated INTEGER := 0; + votes_updated INTEGER := 0; + tags_updated INTEGER := 0; + languages_updated INTEGER := 0; + quest_asset_links_updated INTEGER := 0; + asset_content_links_updated INTEGER := 0; + quest_tag_links_updated INTEGER := 0; + asset_tag_links_updated INTEGER := 0; + quests_updated INTEGER := 0; + quest_closures_updated INTEGER := 0; + project_closures_updated INTEGER := 0; + sibling_quest_closures_updated INTEGER := 0; +BEGIN + -- Logging + RAISE NOTICE '[download_quest_closure] Starting for quest_id: %, profile_id: %', quest_id_param, profile_id_param; + + -- Get the complete closure record + SELECT * INTO closure_record + FROM quest_closure + WHERE quest_id = quest_id_param; + + IF closure_record.quest_id IS NULL THEN + RAISE EXCEPTION 'Quest closure not found for quest_id: %', quest_id_param; + END IF; + + -- Update project (parent of the quest) + UPDATE project + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = closure_record.project_id; + GET DIAGNOSTICS projects_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated project: % rows', projects_updated; + + -- Update assets + UPDATE asset + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.asset_ids))::UUID)); + GET DIAGNOSTICS assets_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated assets: % rows', assets_updated; + + -- Update translations + UPDATE translation + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.translation_ids))::UUID)); + GET DIAGNOSTICS translations_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated translations: % rows', translations_updated; + + -- Update votes + UPDATE vote + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.vote_ids))::UUID)); + GET DIAGNOSTICS votes_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated votes: % rows', votes_updated; + + -- Update tags + UPDATE tag + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.tag_ids))::UUID)); + GET DIAGNOSTICS tags_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated tags: % rows', tags_updated; + + -- Update languages + UPDATE language + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.language_ids))::UUID)); + GET DIAGNOSTICS languages_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated languages: % rows', languages_updated; + + -- Update quest_asset_link (using composite key quest_id + asset_id) + UPDATE quest_asset_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE quest_id = quest_id_param + AND asset_id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.asset_ids))::UUID)); + GET DIAGNOSTICS quest_asset_links_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated quest_asset_links: % rows', quest_asset_links_updated; + + -- Update asset_content_link (this has an id column) + UPDATE asset_content_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.asset_content_link_ids))::UUID)); + GET DIAGNOSTICS asset_content_links_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated asset_content_links: % rows', asset_content_links_updated; + + -- Update quest_tag_link (using composite key quest_id + tag_id) + UPDATE quest_tag_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE quest_id = quest_id_param + AND tag_id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.tag_ids))::UUID)); + GET DIAGNOSTICS quest_tag_links_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated quest_tag_links: % rows', quest_tag_links_updated; + + -- Update asset_tag_link (using composite key asset_id + tag_id) + UPDATE asset_tag_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE asset_id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.asset_ids))::UUID)) + AND tag_id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.tag_ids))::UUID)); + GET DIAGNOSTICS asset_tag_links_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated asset_tag_links: % rows', asset_tag_links_updated; + + -- Update quest itself (using UUID array operators) + UPDATE quest + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = quest_id_param; + GET DIAGNOSTICS quests_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated quest: % rows', quests_updated; + + -- Update the quest closure record itself to include this profile + UPDATE quest_closure + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE quest_id = quest_id_param; + GET DIAGNOSTICS quest_closures_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated quest_closure: % rows', quest_closures_updated; + + -- NEW: Also update all sibling quest_closure records for other quests in the same project + UPDATE quest_closure + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE project_id = closure_record.project_id + AND quest_id != quest_id_param; + GET DIAGNOSTICS sibling_quest_closures_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated sibling quest_closures: % rows', sibling_quest_closures_updated; + + -- Add sibling updates to total count + quest_closures_updated := quest_closures_updated + sibling_quest_closures_updated; + + -- NEW: Also update the project_closure record to include this profile + UPDATE project_closure + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE project_id = closure_record.project_id; + GET DIAGNOSTICS project_closures_updated = ROW_COUNT; + RAISE NOTICE '[download_quest_closure] Updated project_closure: % rows', project_closures_updated; + + -- Logging + RAISE NOTICE '[download_quest_closure] Completed for quest_id: %, profile_id: %. Updated project, % quest_closures total (including siblings), and project_closure', quest_id_param, profile_id_param, quest_closures_updated; + + -- Return summary of what was updated + RETURN QUERY + SELECT 'project'::TEXT, projects_updated + UNION ALL + SELECT 'quest'::TEXT, quests_updated + UNION ALL + SELECT 'asset'::TEXT, assets_updated + UNION ALL + SELECT 'translation'::TEXT, translations_updated + UNION ALL + SELECT 'vote'::TEXT, votes_updated + UNION ALL + SELECT 'tag'::TEXT, tags_updated + UNION ALL + SELECT 'language'::TEXT, languages_updated + UNION ALL + SELECT 'quest_asset_link'::TEXT, quest_asset_links_updated + UNION ALL + SELECT 'asset_content_link'::TEXT, asset_content_links_updated + UNION ALL + SELECT 'quest_tag_link'::TEXT, quest_tag_links_updated + UNION ALL + SELECT 'asset_tag_link'::TEXT, asset_tag_links_updated + UNION ALL + SELECT 'quest_closure'::TEXT, quest_closures_updated + UNION ALL + SELECT 'project_closure'::TEXT, project_closures_updated; +END; +$function$; + +-- Enhanced download_project_closure function that also updates all quest_closure records +CREATE OR REPLACE FUNCTION public.download_project_closure(project_id_param uuid, profile_id_param uuid) + RETURNS TABLE(table_name text, records_updated integer) + LANGUAGE plpgsql + SECURITY DEFINER +AS $function$ +DECLARE + closure_record project_closure%ROWTYPE; + projects_updated INTEGER := 0; + quests_updated INTEGER := 0; + assets_updated INTEGER := 0; + translations_updated INTEGER := 0; + votes_updated INTEGER := 0; + tags_updated INTEGER := 0; + languages_updated INTEGER := 0; + quest_asset_links_updated INTEGER := 0; + asset_content_links_updated INTEGER := 0; + quest_tag_links_updated INTEGER := 0; + asset_tag_links_updated INTEGER := 0; + project_closures_updated INTEGER := 0; + quest_closures_updated INTEGER := 0; +BEGIN + -- Logging + RAISE NOTICE '[download_project_closure] Starting for project_id: %, profile_id: %', project_id_param, profile_id_param; + + -- Get the complete closure record + SELECT * INTO closure_record + FROM project_closure + WHERE project_id = project_id_param; + + IF closure_record.project_id IS NULL THEN + RAISE EXCEPTION 'Project closure not found for project_id: %', project_id_param; + END IF; + + -- Update project itself + UPDATE project + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = project_id_param; + GET DIAGNOSTICS projects_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated project: % rows', projects_updated; + + -- Update all quests + UPDATE quest + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.quest_ids))::UUID)); + GET DIAGNOSTICS quests_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated quests: % rows', quests_updated; + + -- Update assets + UPDATE asset + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.asset_ids))::UUID)); + GET DIAGNOSTICS assets_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated assets: % rows', assets_updated; + + -- Update translations + UPDATE translation + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.translation_ids))::UUID)); + GET DIAGNOSTICS translations_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated translations: % rows', translations_updated; + + -- Update votes + UPDATE vote + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.vote_ids))::UUID)); + GET DIAGNOSTICS votes_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated votes: % rows', votes_updated; + + -- Update tags + UPDATE tag + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.tag_ids))::UUID)); + GET DIAGNOSTICS tags_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated tags: % rows', tags_updated; + + -- Update languages + UPDATE language + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.language_ids))::UUID)); + GET DIAGNOSTICS languages_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated languages: % rows', languages_updated; + + -- Update quest_asset_link (these use composite keys stored as strings) + UPDATE quest_asset_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE (quest_id || '-' || asset_id) = ANY(ARRAY(SELECT jsonb_array_elements_text(closure_record.quest_asset_link_ids))); + GET DIAGNOSTICS quest_asset_links_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated quest_asset_links: % rows', quest_asset_links_updated; + + -- Update asset_content_link + UPDATE asset_content_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE id = ANY(ARRAY(SELECT (jsonb_array_elements_text(closure_record.asset_content_link_ids))::UUID)); + GET DIAGNOSTICS asset_content_links_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated asset_content_links: % rows', asset_content_links_updated; + + -- Update quest_tag_link (these use composite keys stored as strings) + UPDATE quest_tag_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE (quest_id || '-' || tag_id) = ANY(ARRAY(SELECT jsonb_array_elements_text(closure_record.quest_tag_link_ids))); + GET DIAGNOSTICS quest_tag_links_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated quest_tag_links: % rows', quest_tag_links_updated; + + -- Update asset_tag_link (these use composite keys stored as strings) + UPDATE asset_tag_link + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE (asset_id || '-' || tag_id) = ANY(ARRAY(SELECT jsonb_array_elements_text(closure_record.asset_tag_link_ids))); + GET DIAGNOSTICS asset_tag_links_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated asset_tag_links: % rows', asset_tag_links_updated; + + -- Update the project closure record itself to include this profile + UPDATE project_closure + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE project_id = project_id_param; + GET DIAGNOSTICS project_closures_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated project_closure: % rows', project_closures_updated; + + -- NEW: Also update all quest_closure records for quests in this project + UPDATE quest_closure + SET download_profiles = CASE + WHEN download_profiles @> ARRAY[profile_id_param] THEN download_profiles + ELSE array_append(COALESCE(download_profiles, '{}'), profile_id_param) + END + WHERE project_id = project_id_param; + GET DIAGNOSTICS quest_closures_updated = ROW_COUNT; + RAISE NOTICE '[download_project_closure] Updated quest_closures: % rows', quest_closures_updated; + + -- Logging + RAISE NOTICE '[download_project_closure] Completed for project_id: %, profile_id: %', project_id_param, profile_id_param; + + -- Return summary of what was updated + RETURN QUERY + SELECT 'project'::TEXT, projects_updated + UNION ALL + SELECT 'quest'::TEXT, quests_updated + UNION ALL + SELECT 'asset'::TEXT, assets_updated + UNION ALL + SELECT 'translation'::TEXT, translations_updated + UNION ALL + SELECT 'vote'::TEXT, votes_updated + UNION ALL + SELECT 'tag'::TEXT, tags_updated + UNION ALL + SELECT 'language'::TEXT, languages_updated + UNION ALL + SELECT 'quest_asset_link'::TEXT, quest_asset_links_updated + UNION ALL + SELECT 'asset_content_link'::TEXT, asset_content_links_updated + UNION ALL + SELECT 'quest_tag_link'::TEXT, quest_tag_links_updated + UNION ALL + SELECT 'asset_tag_link'::TEXT, asset_tag_links_updated + UNION ALL + SELECT 'project_closure'::TEXT, project_closures_updated + UNION ALL + SELECT 'quest_closure'::TEXT, quest_closures_updated; +END; +$function$; diff --git a/supabase/migrations/20250710143000_make_images_text_array.sql b/supabase/migrations/20250710143000_make_images_text_array.sql new file mode 100644 index 000000000..f839b5985 --- /dev/null +++ b/supabase/migrations/20250710143000_make_images_text_array.sql @@ -0,0 +1,14 @@ +-- Migration: Change 'images' column in 'asset' table from text to text[] +alter table asset +alter column images TYPE text[] using case + when images is null then null + when images::text ~ '^\s*\[' then string_to_array( + trim( + both '[]' + from + images::text + ), + ',' + ) + else array[images] +end; \ No newline at end of file diff --git a/supabase/migrations/20250710143001_add_triggers_to_insert_download_profiles_vote_translate.sql b/supabase/migrations/20250710143001_add_triggers_to_insert_download_profiles_vote_translate.sql new file mode 100644 index 000000000..69f3ed943 --- /dev/null +++ b/supabase/migrations/20250710143001_add_triggers_to_insert_download_profiles_vote_translate.sql @@ -0,0 +1,37 @@ +-- Function to copy download_profiles from asset to translation +create or replace function copy_asset_download_profiles () RETURNS TRIGGER as $$ +BEGIN + -- Copy download_profiles from the linked asset to the new translation + SELECT download_profiles + INTO NEW.download_profiles + FROM public.asset + WHERE id = NEW.asset_id; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger that fires on INSERT to translation table +create +or REPLACE TRIGGER trigger_copy_asset_download_profiles BEFORE INSERT on public.translation for EACH row +execute FUNCTION copy_asset_download_profiles (); + +-- Function to copy download_profiles from asset grandparent to vote +create or replace function copy_asset_download_profiles_to_vote () RETURNS TRIGGER as $$ +BEGIN + -- Copy download_profiles from the asset (grandparent) to the new vote + -- vote -> translation -> asset + SELECT a.download_profiles + INTO NEW.download_profiles + FROM public.asset a + INNER JOIN public.translation t ON a.id = t.asset_id + WHERE t.id = NEW.translation_id; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger that fires on INSERT to vote table +create +or REPLACE TRIGGER trigger_copy_asset_download_profiles_to_vote BEFORE INSERT on public.vote for EACH row +execute FUNCTION copy_asset_download_profiles_to_vote (); \ No newline at end of file diff --git a/supabase/migrations/20250710143002_add_tag_category_views.sql b/supabase/migrations/20250710143002_add_tag_category_views.sql new file mode 100644 index 000000000..6ea492c06 --- /dev/null +++ b/supabase/migrations/20250710143002_add_tag_category_views.sql @@ -0,0 +1,72 @@ +-- Add asset_tag_categories materialized view +-- This view extracts distinct tag categories (part before ':') for each quest via asset tags +CREATE MATERIALIZED VIEW asset_tag_categories AS +SELECT + q.id AS quest_id, + array_agg(DISTINCT split_part(t.name, ':', 1)) AS tag_categories +FROM + quest q + JOIN quest_asset_link qal ON q.id = qal.quest_id + JOIN asset a ON qal.asset_id = a.id + JOIN asset_tag_link atl ON a.id = atl.asset_id + JOIN tag t ON atl.tag_id = t.id +GROUP BY + q.id +ORDER BY + q.id; + +-- Add quest_tag_categories materialized view +-- This view extracts distinct tag categories for all quests in each project +CREATE MATERIALIZED VIEW quest_tag_categories AS +SELECT + p.id AS project_id, + array_agg(DISTINCT split_part(t.name, ':', 1)) AS tag_categories +FROM + project p +JOIN + quest q ON q.project_id = p.id +JOIN + quest_asset_link qal ON q.id = qal.quest_id +JOIN + asset a ON qal.asset_id = a.id +JOIN + asset_tag_link atl ON a.id = atl.asset_id +JOIN + tag t ON atl.tag_id = t.id +GROUP BY + p.id +ORDER BY + p.id; + +-- Function to refresh asset_tag_categories materialized view +CREATE OR REPLACE FUNCTION refresh_asset_tag_categories() +RETURNS TRIGGER AS $$ +BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY asset_tag_categories; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +-- Function to refresh quest_tag_categories materialized view +CREATE OR REPLACE FUNCTION refresh_quest_tag_categories() +RETURNS TRIGGER AS $$ +BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY quest_tag_categories; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +-- Triggers for asset_tag_categories (refresh when asset_tag_link changes) +-- CREATE TRIGGER trigger_refresh_asset_tag_categories_on_asset_tag_link +-- AFTER INSERT OR UPDATE OR DELETE ON asset_tag_link +-- FOR EACH STATEMENT +-- EXECUTE FUNCTION refresh_asset_tag_categories(); + +-- -- Triggers for asset_tag_categories (refresh when quest_asset_link changes) +-- CREATE TRIGGER trigger_refresh_asset_tag_categories_on_quest_tag_link +-- AFTER INSERT OR UPDATE OR DELETE ON quest_tag_link +-- FOR EACH STATEMENT +-- EXECUTE FUNCTION refresh_asset_tag_categories(); + +-- Note: Row Level Security (RLS) is not supported on materialized views in PostgreSQL +-- Security will need to be handled at the application level or through wrapper views \ No newline at end of file diff --git a/supabase/seeds/public.sql b/supabase/seeds/public.sql index ed86f286a..9295021ab 100644 --- a/supabase/seeds/public.sql +++ b/supabase/seeds/public.sql @@ -85,7 +85,7 @@ INSERT INTO "public"."asset" ("id", "created_at", "last_updated", "name", "sourc ('4554299a-c42c-439a-903f-67c106c3b46e', '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', 'Lucas 1:3 (Zapoteco)', '7c37870b-7d52-4589-934f-576f03781263', NULL, true), ('5cfffc2f-e1d1-4418-a5d0-20988b322d35', '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', 'Lucas 2:2 (Mixteco)', '7c37870b-7d52-4589-934f-576f03781263', NULL, true), ('a513e5d6-126b-4725-9029-ec08c7f55a0a', '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', 'Lucas 1:1 (Mixteco)', '7c37870b-7d52-4589-934f-576f03781263', NULL, true), - ('13120777-7cef-4942-b2b8-37cd9f241c1b', '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', 'Lucas 1:2 (Mixteco)', '7c37870b-7d52-4589-934f-576f03781263', '["images/87aae958-21af-42c9-a42b-89ad96c9ab4b.jpg", "images/b74d0e60-3fd7-4a17-ab14-5cdcbe79b789.jpg"]', true); + ('13120777-7cef-4942-b2b8-37cd9f241c1b', '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', 'Lucas 1:2 (Mixteco)', '7c37870b-7d52-4589-934f-576f03781263', ARRAY['images/87aae958-21af-42c9-a42b-89ad96c9ab4b.jpg', 'images/b74d0e60-3fd7-4a17-ab14-5cdcbe79b789.jpg'], true); -- diff --git a/views/AssetDetailView.tsx b/views/AssetDetailView.tsx index 45328e0c8..698892d05 100644 --- a/views/AssetDetailView.tsx +++ b/views/AssetDetailView.tsx @@ -408,19 +408,38 @@ export default function AssetDetailView() { /> )} - {activeTab === 'image' && ( + {activeTab === 'image' && + (asset as unknown as { images: string | string[] }).images ? ( { - const localUri = attachmentStates.get(imageId)?.local_uri; - return localUri - ? system.permAttachmentQueue?.getLocalUri(localUri) - : null; - }) - .filter(Boolean) ?? [] + typeof asset?.images === 'string' + ? (asset.images as unknown as string) + .split(',') + .map((id) => id.trim()) + .filter(Boolean) + .map((imageId) => { + const localUri = + attachmentStates.get(imageId)?.local_uri; + return localUri + ? system.permAttachmentQueue?.getLocalUri(localUri) + : null; + }) + .filter(Boolean) + : Array.isArray(asset?.images) + ? asset.images + .map((imageId) => { + const localUri = + attachmentStates.get(imageId)?.local_uri; + return localUri + ? system.permAttachmentQueue?.getLocalUri(localUri) + : null; + }) + .filter(Boolean) + : [] } /> + ) : ( + No images )} @@ -533,22 +552,23 @@ export default function AssetDetailView() { - { - if (sortOption === 'voteCount') { + + { + if (sortOption === 'voteCount') { + return ( + calculateVoteCount(b.votes) - calculateVoteCount(a.votes) + ); + } return ( - calculateVoteCount(b.votes) - calculateVoteCount(a.votes) + new Date(b.created_at).getTime() - + new Date(a.created_at).getTime() ); - } - return ( - new Date(b.created_at).getTime() - - new Date(a.created_at).getTime() - ); - })} - renderItem={renderTranslationCard} - keyExtractor={(item) => item.id} - style={styles.translationsList} - /> + })} + renderItem={renderTranslationCard} + keyExtractor={(item) => item.id} + /> + @@ -733,7 +753,8 @@ const styles = StyleSheet.create({ marginVertical: spacing.medium }, translationHeader: { - paddingHorizontal: spacing.large + paddingHorizontal: spacing.large, + paddingBottom: spacing.medium }, alignmentContainer: { flexDirection: 'row', diff --git a/views/AssetsView.tsx b/views/AssetsView.tsx index 2efd491fc..10776c503 100644 --- a/views/AssetsView.tsx +++ b/views/AssetsView.tsx @@ -14,7 +14,6 @@ import { } from '@/contexts/SessionCacheContext'; import type { Asset } from '@/database_services/assetService'; import type { Tag } from '@/database_services/tagService'; -import type { asset_content_link } from '@/db/drizzleSchema'; import { useInfiniteAssetsWithTagsAndContentByQuestId } from '@/hooks/db/useAssets'; import { useProjectById } from '@/hooks/db/useProjects'; import { useQuestById } from '@/hooks/db/useQuests'; @@ -57,42 +56,7 @@ interface SortingOption { } // Helper functions outside component to prevent recreation -const filterAssets = ( - assets: Asset[], - assetTags: Record, - assetContents: Record, - searchQuery: string, - activeFilters: Record -) => { - return assets.filter((asset) => { - // Search filter - const assetContent = assetContents[asset.id] ?? []; - const matchesSearch = - asset.name.toLowerCase().includes(searchQuery.toLowerCase()) || - assetContent.some((content) => - content.text.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - // Tag filters - const assetTagList = assetTags[asset.id] ?? []; - const matchesFilters = Object.entries(activeFilters).every( - ([category, selectedOptions]) => { - if (selectedOptions.length === 0) return true; - return assetTagList.some((tag) => { - const [tagCategory, tagValue] = tag.name.split(':'); - return ( - tagCategory?.toLowerCase() === category.toLowerCase() && - selectedOptions.includes( - `${category.toLowerCase()}:${tagValue?.toLowerCase()}` - ) - ); - }); - } - ); - - return matchesSearch && matchesFilters; - }); -}; +// filterAssets function removed - filtering is now handled server-side in the hook // Memoized AssetCard component to prevent unnecessary re-renders const AssetCard = React.memo(({ asset }: { asset: Asset }) => { @@ -108,7 +72,7 @@ const AssetCard = React.memo(({ asset }: { asset: Asset }) => { const activeProject = cachedProject || freshProject; const { - isDownloaded, + isFlaggedForDownload, isLoading: isDownloadLoading, toggleDownload } = useDownload('asset', asset.id); @@ -136,10 +100,12 @@ const AssetCard = React.memo(({ asset }: { asset: Asset }) => { onBypass={handleDownloadToggle} renderTrigger={({ onPress, hasAccess }) => ( )} @@ -205,7 +171,9 @@ export default function AssetsView() { currentQuestId, 10, // pageSize activeSorting[0]?.field === 'name' ? activeSorting[0].field : undefined, - activeSorting[0]?.order + activeSorting[0]?.order, + searchQuery, // Add search query parameter + activeFilters // Add active filters for server-side filtering ); // Extract all assets from pages @@ -217,7 +185,7 @@ export default function AssetsView() { return infiniteData.pages.flatMap((page) => page.data); }, [infiniteData]); - // Apply client-side filtering and sorting + // Apply client-side sorting only - filtering is now handled server-side const filteredAssets = useMemo(() => { if (!allAssets.length) { return []; @@ -229,42 +197,33 @@ export default function AssetsView() { const startTime = performance.now(); const assetTagsRecord: Record = {}; - const assetContentsRecord: Record = {}; - // Build the records with proper typing + // Build the records with proper typing for sorting allAssets.forEach((asset) => { assetTagsRecord[asset.id] = asset.tags.map((tag) => tag.tag); - assetContentsRecord[asset.id] = asset.content; }); - const filtered = filterAssets( - allAssets, - assetTagsRecord, - assetContentsRecord, - searchQuery, - activeFilters - ); - // Apply additional sorting if needed (beyond what's handled by the query) + // Filtering is now handled server-side in the hook const result = sortItems( - filtered, + allAssets, activeSorting, (assetId: string) => assetTagsRecord[assetId] ?? [] ); const duration = performance.now() - startTime; console.log( - `🔍 [PERFORMANCE] filteredAssets calculation took ${duration.toFixed(2)}ms, filtered from ${allAssets.length} to ${result.length}` + `🔍 [PERFORMANCE] filteredAssets calculation took ${duration.toFixed(2)}ms, ${result.length} assets (filtering and search handled server-side)` ); return result; - }, [allAssets, searchQuery, activeFilters, activeSorting]); + }, [allAssets, activeSorting]); // Remove activeFilters dependency since it's handled server-side - const getActiveOptionsCount = () => { - const filterCount = Object.values(activeFilters).flat().length; - const sortCount = activeSorting.length; - return filterCount + sortCount; - }; + // const getActiveOptionsCount = () => { + // const filterCount = Object.values(activeFilters).flat().length; + // const sortCount = activeSorting.length; + // return filterCount + sortCount; + // }; const handleAssetPress = useCallback( (asset: Asset) => { @@ -418,7 +377,7 @@ export default function AssetsView() { value={searchQuery} onChangeText={setSearchQuery} /> - setIsFilterModalVisible(true)} style={styles.filterIcon} > @@ -430,7 +389,7 @@ export default function AssetsView() { )} - + */} @@ -442,6 +401,11 @@ export default function AssetsView() { onEndReached={handleLoadMore} onEndReachedThreshold={0.5} ListFooterComponent={renderFooter} + ListEmptyComponent={ + + {t('noAssetsFound')} + + } refreshControl={ setIsFilterModalVisible(false)} > - {allAssets.length > 0 && ( - setIsFilterModalVisible(false)} - assets={allAssets} - onApplyFilters={handleApplyFilters} - onApplySorting={handleApplySorting} - initialFilters={activeFilters} - initialSorting={activeSorting} - /> - )} + setIsFilterModalVisible(false)} + questId={currentQuestId} + onApplyFilters={handleApplyFilters} + onApplySorting={handleApplySorting} + initialFilters={activeFilters} + initialSorting={activeSorting} + /> {showQuestStats && quest && ( diff --git a/views/LoginView.tsx b/views/LoginView.tsx index 4cbe0bbe4..cc318b61e 100644 --- a/views/LoginView.tsx +++ b/views/LoginView.tsx @@ -9,6 +9,7 @@ import { LinearGradient } from 'expo-linear-gradient'; import React, { useEffect, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { + ActivityIndicator, Alert, KeyboardAvoidingView, Platform, @@ -40,6 +41,7 @@ export default function LoginView() { const [mode, setMode] = useState('sign-in'); const currentLanguage = useLocalStore((state) => state.language); const dateTermsAccepted = useLocalStore((state) => state.dateTermsAccepted); + const [isRegistering, setIsRegistering] = useState(false); const { control, @@ -102,7 +104,19 @@ export default function LoginView() { }; const onSubmitRegister = async (data: LoginFormData) => { + setIsRegistering(true); try { + const { + data: { session }, + error: sessionError + } = await supabaseConnector.client.auth.getSession(); + if (sessionError) throw sessionError; + if (!session) { + const { error: anonError } = + await supabaseConnector.client.auth.signInAnonymously(); + if (anonError) throw anonError; + } + if (!data.termsAccepted) { Alert.alert(t('error'), t('termsRequired')); return; @@ -145,6 +159,8 @@ export default function LoginView() { t('error'), error instanceof Error ? error.message : t('registrationFail') ); + } finally { + setIsRegistering(false); } }; @@ -511,10 +527,15 @@ export default function LoginView() { } ]} onPress={handleSubmit(onSubmit)} + disabled={mode === 'register' && isRegistering} > - - {getSubmitButtonText()} - + {mode === 'register' && isRegistering ? ( + + ) : ( + + {getSubmitButtonText()} + + )} {/* Mode switching links */} diff --git a/views/NotificationsView.tsx b/views/NotificationsView.tsx index 1a8b8e162..257fc05f6 100644 --- a/views/NotificationsView.tsx +++ b/views/NotificationsView.tsx @@ -1,16 +1,22 @@ import { useAuth } from '@/contexts/AuthContext'; import { useSessionCache } from '@/contexts/SessionCacheContext'; -import type { profile, project } from '@/db/drizzleSchema'; -import { invite, profile_project_link, request } from '@/db/drizzleSchema'; +import type { project } from '@/db/drizzleSchema'; +import { + invite, + profile, + profile_project_link, + request +} from '@/db/drizzleSchema'; import { system } from '@/db/powersync/system'; import { downloadRecord } from '@/hooks/useDownloads'; import { useHybridQuery } from '@/hooks/useHybridQuery'; import { useLocalization } from '@/hooks/useLocalization'; +import { useNetworkStatus } from '@/hooks/useNetworkStatus'; import { borderRadius, colors, fontSizes, spacing } from '@/styles/theme'; import { isExpiredByLastUpdated } from '@/utils/dateUtils'; import { Ionicons } from '@expo/vector-icons'; import { toCompilableQuery } from '@powersync/drizzle-driver'; -import { and, eq } from 'drizzle-orm'; +import { and, eq, inArray } from 'drizzle-orm'; import { LinearGradient } from 'expo-linear-gradient'; import React, { useEffect, useState } from 'react'; import { @@ -28,12 +34,10 @@ import { SafeAreaView } from 'react-native-safe-area-context'; // Type definitions for query results type InviteWithRelations = typeof invite.$inferSelect & { project: typeof project.$inferSelect | null; - sender: typeof profile.$inferSelect | null; }; type RequestWithRelations = typeof request.$inferSelect & { project: typeof project.$inferSelect | null; - sender: typeof profile.$inferSelect | null; }; interface NotificationItem { @@ -51,9 +55,16 @@ interface NotificationItem { last_updated: string; } +interface SenderProfile { + id: string; + username: string | null; + email: string | null; +} + export default function NotificationsView() { const { t } = useLocalization(); const { currentUser } = useAuth(); + const isConnected = useNetworkStatus(); const [processingIds, setProcessingIds] = useState>(new Set()); const [downloadToggles, setDownloadToggles] = useState< Record @@ -73,7 +84,7 @@ export default function NotificationsView() { ); }, [userMemberships]); - // Query for invite notifications (where user's email matches) + // Query for invite notifications (where user's email matches) - without sender relation const { data: inviteData = [], refetch: refetchInvites } = useHybridQuery({ queryKey: ['invite-notifications', currentUser?.email], onlineFn: async () => { @@ -82,8 +93,7 @@ export default function NotificationsView() { .select( ` *, - project!inner(id, name), - sender:profile!sender_profile_id(id, username, email) + project!inner(id, name) ` ) .eq('email', currentUser?.email || '') @@ -104,34 +114,125 @@ export default function NotificationsView() { eq(invite.active, true) ), with: { - project: true, - sender: true + project: true } }) ), enabled: !!currentUser?.email }); + // Get pending requests for owner projects (using session cache for owner project IDs) - without sender relation + const { data: requestData = [], refetch: refetchRequests } = useHybridQuery({ + queryKey: ['request-notifications', ownerProjectIds], + onlineFn: async () => { + const { data, error } = await system.supabaseConnector.client + .from('request') + .select( + ` + *, + project!inner(id, name) + ` + ) + .eq('status', 'pending') + .eq('active', true); + if (error) { + console.error('Request query error:', error); + throw error; + } + console.log('Request query result:', data); + return data as RequestWithRelations[]; + }, + offlineQuery: toCompilableQuery( + system.db.query.request.findMany({ + where: and(eq(request.status, 'pending'), eq(request.active, true)), + with: { + project: true + } + }) + ), + enabled: ownerProjectIds.length > 0 + }); + + // Filter to only include requests for projects where the user is an owner + const filteredRequestData = requestData.filter((item: RequestWithRelations) => + ownerProjectIds.includes(item.project_id) + ); + + // Get unique sender profile IDs from both invites and requests + const senderProfileIds = React.useMemo(() => { + const ids = [ + ...inviteData.map((invite) => invite.sender_profile_id), + ...filteredRequestData.map((request) => request.sender_profile_id) + ]; + return [...new Set(ids)]; // Remove duplicates + }, [inviteData, filteredRequestData]); + + // Query for sender profiles from local database + const { data: senderProfiles = [] } = useHybridQuery({ + queryKey: ['sender-profiles', senderProfileIds], + onlineFn: async () => { + // For online, we still use the local database since profiles are always synced + const profiles = await system.db.query.profile.findMany({ + where: inArray(profile.id, senderProfileIds) + }); + return profiles; + }, + offlineQuery: toCompilableQuery( + system.db.query.profile.findMany({ + where: inArray(profile.id, senderProfileIds) + }) + ), + enabled: senderProfileIds.length > 0 + }); + + // Create a map of profile ID to profile data for easy lookup + const senderProfileMap = React.useMemo(() => { + const map: Record = {}; + senderProfiles.forEach((senderProfile) => { + map[senderProfile.id] = senderProfile; + }); + return map; + }, [senderProfiles]); + const inviteNotifications: NotificationItem[] = inviteData.map( - (item: InviteWithRelations) => ({ - id: item.id, - type: 'invite' as const, - status: item.status, - email: item.email, - project_id: item.project_id, - project_name: item.project?.name || 'Unknown Project', - sender_profile_id: item.sender_profile_id, - sender_name: item.sender?.username || '', - sender_email: item.sender?.email || '', - as_owner: item.as_owner || false, - created_at: item.created_at, - last_updated: item.last_updated - }) + (item: InviteWithRelations) => { + const senderProfile = senderProfileMap[item.sender_profile_id]; + return { + id: item.id, + type: 'invite' as const, + status: item.status, + email: item.email, + project_id: item.project_id, + project_name: item.project?.name || t('unknownProject'), + sender_profile_id: item.sender_profile_id, + sender_name: senderProfile?.username || '', + sender_email: senderProfile?.email || '', + as_owner: item.as_owner || false, + created_at: item.created_at, + last_updated: item.last_updated + }; + } ); - // Query for existing project download statuses - removed since useProjectsDownloadStatus doesn't exist - // const projectIds = inviteNotifications.map((item) => item.project_id); - // const { projectStatuses } = useProjectsDownloadStatus(projectIds); + const requestNotifications: NotificationItem[] = filteredRequestData.map( + (item: RequestWithRelations) => { + const senderProfile = senderProfileMap[item.sender_profile_id]; + return { + id: item.id, + type: 'request' as const, + status: item.status, + email: undefined, + project_id: item.project_id, + project_name: item.project?.name || t('unknownProject'), + sender_profile_id: item.sender_profile_id, + sender_name: senderProfile?.username || '', + sender_email: senderProfile?.email || '', + as_owner: false, + created_at: item.created_at, + last_updated: item.last_updated + }; + } + ); // Memoize notification IDs to prevent unnecessary re-renders const notificationIds = React.useMemo( @@ -162,60 +263,6 @@ export default function NotificationsView() { }); }, [notificationIds]); - // Get pending requests for owner projects (using session cache for owner project IDs) - const { data: requestData = [], refetch: refetchRequests } = useHybridQuery({ - queryKey: ['request-notifications', ownerProjectIds], - onlineFn: async () => { - const { data, error } = await system.supabaseConnector.client - .from('request') - .select( - ` - *, - project!inner(id, name), - sender:profile!sender_profile_id(id, username, email) - ` - ) - .eq('status', 'pending') - .eq('active', true); - if (error) { - console.error('Request query error:', error); - throw error; - } - console.log('Request query result:', data); - return data as RequestWithRelations[]; - }, - offlineQuery: toCompilableQuery( - system.db.query.request.findMany({ - where: and(eq(request.status, 'pending'), eq(request.active, true)), - with: { - project: true, - sender: true - } - }) - ), - enabled: ownerProjectIds.length > 0 - }); - - // Filter to only include requests for projects where the user is an owner - const requestNotifications: NotificationItem[] = requestData - .filter((item: RequestWithRelations) => - ownerProjectIds.includes(item.project_id) - ) - .map((item: RequestWithRelations) => ({ - id: item.id, - type: 'request' as const, - status: item.status, - email: undefined, - project_id: item.project_id, - project_name: item.project?.name || 'Unknown Project', - sender_profile_id: item.sender_profile_id, - sender_name: item.sender?.username || '', - sender_email: item.sender?.email || '', - as_owner: false, - created_at: item.created_at, - last_updated: item.last_updated - })); - // Filter out expired notifications const validInviteNotifications = inviteNotifications.filter( (item) => !isExpiredByLastUpdated(item.last_updated) @@ -329,7 +376,7 @@ export default function NotificationsView() { downloadError ); // Don't fail the entire operation if download fails - Alert.alert('Warning', 'Invitation accepted but download failed'); + Alert.alert(t('warning'), t('invitationAcceptedDownloadFailed')); } } } else { @@ -409,7 +456,7 @@ export default function NotificationsView() { void refetchInvites(); void refetchRequests(); - Alert.alert('Success', 'Invitation accepted successfully'); + Alert.alert(t('success'), t('invitationAcceptedSuccessfully')); console.log('[handleAccept] Success - operation completed'); } catch (error) { console.error('[handleAccept] Error accepting invitation:', error); @@ -417,7 +464,7 @@ export default function NotificationsView() { message: error instanceof Error ? error.message : 'Unknown error', stack: error instanceof Error ? error.stack : undefined }); - Alert.alert('Error', 'Failed to accept invitation'); + Alert.alert(t('error'), t('failedToAcceptInvite')); } finally { console.log('[handleAccept] Cleaning up processing state...'); setProcessingIds((prev) => { @@ -461,10 +508,10 @@ export default function NotificationsView() { void refetchInvites(); void refetchRequests(); - Alert.alert('Success', 'Invitation declined'); + Alert.alert(t('success'), t('invitationDeclinedSuccessfully')); } catch (error) { console.error('Error declining invitation:', error); - Alert.alert('Error', 'Failed to decline invitation'); + Alert.alert(t('error'), t('failedToDeclineInvite')); } finally { setProcessingIds((prev) => { const newSet = new Set(prev); @@ -476,7 +523,7 @@ export default function NotificationsView() { const renderNotificationItem = (item: NotificationItem) => { const isProcessing = processingIds.has(item.id); - const roleText = item.as_owner ? 'owner' : 'member'; + const roleText = item.as_owner ? t('ownerRole') : t('memberRole'); const shouldDownload = downloadToggles[item.id] ?? true; console.log( @@ -498,14 +545,24 @@ export default function NotificationsView() { color={colors.primary} /> - {item.type === 'invite' ? 'Project Invitation' : 'Join Request'} + {item.type === 'invite' + ? t('projectInvitationTitle') + : t('joinRequestTitle')} {item.type === 'invite' - ? `${item.sender_name || item.sender_email} invited you to join "${item.project_name}" as ${roleText}` - : `${item.sender_name || item.sender_email} requested to join "${item.project_name}" as ${roleText}`} + ? t('invitedYouToJoin', { + sender: `${item.sender_name}${item.sender_email ? ` (${item.sender_email})` : ''}`, + project: item.project_name, + role: roleText + }) + : t('requestedToJoin', { + sender: `${item.sender_name}${item.sender_email ? ` (${item.sender_email})` : ''}`, + project: item.project_name, + role: roleText + })} @@ -516,7 +573,9 @@ export default function NotificationsView() { {item.type === 'invite' && ( - Download Project + + {t('downloadProjectLabel')} + { @@ -549,7 +608,7 @@ export default function NotificationsView() { - Project will not be available offline without download + {t('projectNotAvailableOfflineWarning')} )} @@ -613,6 +672,15 @@ export default function NotificationsView() { {t('notifications')} + {!isConnected && ( + + + + {t('offlineNotificationMessage')} + + + )} + - No Notifications + + {t('noNotificationsTitle')} + - You'll see project invitations and join requests here + {t('noNotificationsMessage')} ) : ( @@ -764,5 +834,21 @@ const styles = StyleSheet.create({ color: colors.alert, flex: 1, lineHeight: 16 + }, + offlineBanner: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(255, 255, 255, 0.1)', + paddingVertical: spacing.small, + paddingHorizontal: spacing.medium, + borderRadius: borderRadius.small, + marginBottom: spacing.medium, + borderLeftWidth: 4, + borderLeftColor: colors.alert + }, + offlineBannerText: { + fontSize: fontSizes.medium, + color: colors.text, + marginLeft: spacing.small } }); diff --git a/views/ProjectsView.tsx b/views/ProjectsView.tsx index 8cd22983e..2371c8b49 100644 --- a/views/ProjectsView.tsx +++ b/views/ProjectsView.tsx @@ -3,8 +3,6 @@ * Now works with state-driven navigation instead of routes */ -import { DownloadIndicator } from '@/components/DownloadIndicator'; -import { PrivateAccessGate } from '@/components/PrivateAccessGate'; import { ProjectSkeleton } from '@/components/ProjectSkeleton'; import { useAuth } from '@/contexts/AuthContext'; import { @@ -13,7 +11,7 @@ import { } from '@/contexts/SessionCacheContext'; import type { project } from '@/db/drizzleSchema'; import { useAppNavigation } from '@/hooks/useAppNavigation'; -import { useDownload } from '@/hooks/useDownloads'; +import { useDownload, useProjectDownloadStatus } from '@/hooks/useDownloads'; import { useLocalization } from '@/hooks/useLocalization'; import { borderRadius, @@ -43,11 +41,14 @@ const ProjectCard: React.FC<{ project: typeof project.$inferSelect }> = ({ // Use the new download hook const { - isDownloaded, + isFlaggedForDownload, isLoading: isDownloadLoading, toggleDownload } = useDownload('project', project.id); + // Get project download stats for confirmation modal + const { projectClosure } = useProjectDownloadStatus(project.id); + // Get languages from session cache instead of individual queries const sourceLanguage = getLanguageById(project.source_language_id); const targetLanguage = getLanguageById(project.target_language_id); @@ -98,7 +99,7 @@ const ProjectCard: React.FC<{ project: typeof project.$inferSelect }> = ({ )} - = ({ onBypass={handleDownloadToggle} renderTrigger={({ onPress, hasAccess }) => ( )} - /> + /> */} {sourceLanguage?.native_name ?? sourceLanguage?.english_name} →{' '} @@ -191,7 +200,8 @@ export default function ProjectsView() { } }, [hasNextPage, isFetching, fetchNextPage]); - if (isProjectsLoading && !filteredProjects.length) { + // Show skeleton loading for initial load or when no projects are available yet + if ((isProjectsLoading || isFetching) && !filteredProjects.length) { return ( diff --git a/views/QuestsView.tsx b/views/QuestsView.tsx index 256bb761c..b58f43a4a 100644 --- a/views/QuestsView.tsx +++ b/views/QuestsView.tsx @@ -13,7 +13,6 @@ import { useSessionProjects } from '@/contexts/SessionCacheContext'; import type { Quest } from '@/database_services/questService'; -import type { Tag } from '@/database_services/tagService'; import { useProjectById } from '@/hooks/db/useProjects'; import { useAppNavigation, @@ -44,51 +43,6 @@ export interface SortingOption { order: 'asc' | 'desc'; } -// Helper functions outside component to prevent recreation -export const filterQuests = ( - quests: T[], - questTags: Record, - searchQuery: string, - activeFilters: Record -) => { - if (!quests.length) return []; - - return quests.filter((quest) => { - // Early return if no filters - if (!searchQuery && Object.keys(activeFilters).length === 0) return true; - - // Search filter - if (searchQuery) { - const query = searchQuery.toLowerCase(); - const matchesSearch = - quest.name.toLowerCase().includes(query) || - (quest.description?.toLowerCase().includes(query) ?? false); - if (!matchesSearch) return false; - } - - // Tag filters - only check if there are active filters - if (Object.keys(activeFilters).length > 0) { - const matchesFilters = Object.entries(activeFilters).every( - ([category, selectedOptions]) => { - if (selectedOptions.length === 0) return true; - return questTags[quest.id]?.some((tag) => { - const [tagCategory, tagValue] = tag.name.split(':'); - return ( - tagCategory?.toLowerCase() === category.toLowerCase() && - selectedOptions.includes( - `${category.toLowerCase()}:${tagValue?.toLowerCase()}` - ) - ); - }); - } - ); - if (!matchesFilters) return false; - } - - return true; - }); -}; - export default function QuestsView() { const { t: _t } = useLocalization(); const { currentUser: _currentUser } = useAuth(); @@ -132,6 +86,10 @@ export default function QuestsView() { const cachedProject = getCachedProject(currentProjectId); const { project: freshProject } = useProjectById(currentProjectId); + // ✅ OPTIMIZATION: QuestFilterModal now handles its own tag categories fetching + // - Uses project_tag_categories view internally for efficient loading + // - Self-contained data management within the modal component + // Use cached project if available, otherwise use fresh data const selectedProject = cachedProject || freshProject; @@ -145,11 +103,11 @@ export default function QuestsView() { // Check if current user is an owner using session cache const isOwner = isUserOwner(currentProjectId); - const getActiveOptionsCount = () => { - const filterCount = Object.values(activeFilters).flat().length; - const sortCount = activeSorting.length; - return filterCount + sortCount; - }; + // const getActiveOptionsCount = () => { + // const filterCount = Object.values(activeFilters).flat().length; + // const sortCount = activeSorting.length; + // return filterCount + sortCount; + // }; const handleQuestPress = (quest: Quest) => { goToQuest({ @@ -206,7 +164,7 @@ export default function QuestsView() { onChangeText={setSearchQuery} placeholderTextColor={colors.textSecondary} /> - setIsFilterModalVisible(true)} style={QuestsScreenStyles.filterButton} > @@ -218,10 +176,10 @@ export default function QuestsView() { )} - + */} - {/* Quest list with Suspense boundary */} + {/* Quest list with Suspense boundary - SQL-based search now handled in QuestList */} }> setIsFilterModalVisible(false)} - questTags={{}} // Empty for now - could be improved later + projectId={currentProjectId} // Pass projectId to QuestFilterModal onApplyFilters={handleApplyFilters} onApplySorting={handleApplySorting} initialFilters={activeFilters}