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
)}
- {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}