diff --git a/.eas/workflows/deploy-to-preview.yml b/.eas/workflows/deploy-to-preview.yml index a342f660..dec6eccf 100644 --- a/.eas/workflows/deploy-to-preview.yml +++ b/.eas/workflows/deploy-to-preview.yml @@ -44,7 +44,7 @@ jobs: type: get-build environment: preview params: - fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash } + fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }} profile: preview build_android: name: Build Android @@ -52,6 +52,9 @@ jobs: if: ${{ !needs.get_android_build.outputs.build_id }} type: build environment: preview + env: + POSTHOG_CLI_PROJECT_ID: ${{ env.POSTHOG_CLI_PROJECT_ID }} + POSTHOG_CLI_API_KEY: ${{ env.POSTHOG_CLI_API_KEY }} params: platform: android profile: preview @@ -61,6 +64,9 @@ jobs: if: ${{ !needs.get_ios_build.outputs.build_id }} type: build environment: preview + env: + POSTHOG_CLI_PROJECT_ID: ${{ env.POSTHOG_CLI_PROJECT_ID }} + POSTHOG_CLI_API_KEY: ${{ env.POSTHOG_CLI_API_KEY }} params: platform: ios profile: preview diff --git a/.eas/workflows/deploy-to-production.yml b/.eas/workflows/deploy-to-production.yml index 9a88627f..e5c1e09e 100644 --- a/.eas/workflows/deploy-to-production.yml +++ b/.eas/workflows/deploy-to-production.yml @@ -52,6 +52,9 @@ jobs: if: ${{ !needs.get_android_build.outputs.build_id }} type: build environment: production + env: + POSTHOG_CLI_PROJECT_ID: ${{ env.POSTHOG_CLI_PROJECT_ID }} + POSTHOG_CLI_API_KEY: ${{ env.POSTHOG_CLI_API_KEY }} params: platform: android profile: production @@ -61,6 +64,9 @@ jobs: if: ${{ !needs.get_ios_build.outputs.build_id }} type: build environment: production + env: + POSTHOG_CLI_PROJECT_ID: ${{ env.POSTHOG_CLI_PROJECT_ID }} + POSTHOG_CLI_API_KEY: ${{ env.POSTHOG_CLI_API_KEY }} params: platform: ios profile: production diff --git a/.eas/workflows/run-preview-tests.yml b/.eas/workflows/run-preview-tests.yml index f94da9eb..0c468835 100644 --- a/.eas/workflows/run-preview-tests.yml +++ b/.eas/workflows/run-preview-tests.yml @@ -122,7 +122,7 @@ jobs: runs_on: macos-large params: record_screen: true - device_identifier: 'iPhone 16e' + device_identifier: 'iPhone 17' build_id: ${{ needs.ios_repack.outputs.build_id || needs.ios_build.outputs.build_id }} flow_path: [ @@ -131,9 +131,10 @@ jobs: '.maestro/flows/sign-in.yaml' ] hooks: - after_checkout: + before_maestro_tests: - run: | - DEVICE_ID=$(xcrun simctl list devices -j | jq -r '.devices[][] | select(.name=="iPhone 16e") | .udid') && \ + DEVICE_ID=$(xcrun simctl list devices -j | jq -r '.devices[][] | select(.state=="Booted") | .udid' | head -1) && \ + if [ -z "$DEVICE_ID" ]; then echo "No booted iOS simulator found"; exit 1; fi && \ echo "Booted device ID: $DEVICE_ID" && \ # Source - https://stackoverflow.com/a/76105538 # Posted by Jonathan. diff --git a/.eas/workflows/run-production-tests.yml b/.eas/workflows/run-production-tests.yml index 66f9541a..06042fac 100644 --- a/.eas/workflows/run-production-tests.yml +++ b/.eas/workflows/run-production-tests.yml @@ -122,7 +122,7 @@ jobs: runs_on: macos-large params: record_screen: true - device_identifier: 'iPhone 16e' + device_identifier: 'iPhone 17' build_id: ${{ needs.ios_repack.outputs.build_id || needs.ios_build.outputs.build_id }} flow_path: [ @@ -131,9 +131,10 @@ jobs: '.maestro/flows/sign-in.yaml' ] hooks: - after_checkout: + before_maestro_tests: - run: | - DEVICE_ID=$(xcrun simctl list devices -j | jq -r '.devices[][] | select(.name=="iPhone 16e") | .udid') && \ + DEVICE_ID=$(xcrun simctl list devices -j | jq -r '.devices[][] | select(.state=="Booted") | .udid' | head -1) && \ + if [ -z "$DEVICE_ID" ]; then echo "No booted iOS simulator found"; exit 1; fi && \ echo "Booted device ID: $DEVICE_ID" && \ # Source - https://stackoverflow.com/a/76105538 # Posted by Jonathan. diff --git a/.env.local.example b/.env.local.example index cbd9fb56..0c15a322 100644 --- a/.env.local.example +++ b/.env.local.example @@ -28,6 +28,13 @@ EXPO_PUBLIC_RESEND_LOCAL_INBOX_URL=http://LOCAL_IP:4000/inbox # Optional: PostHog Analytics (leave empty for local dev) # EXPO_PUBLIC_POSTHOG_KEY= # EXPO_PUBLIC_POSTHOG_HOST= +# EXPO_PUBLIC_POSTHOG_DISABLED=true + +# PostHog CLI — source map uploads (EAS Build / after `eas update`) +# Personal API key with error tracking write + organization read scopes. +# Set these in EAS environment secrets for preview/production (not in the app bundle). +# POSTHOG_CLI_PROJECT_ID= +# POSTHOG_CLI_API_KEY= # Supabase Vault Secrets (for database triggers) diff --git a/.gitignore b/.gitignore index 5fd91465..7d0c4d16 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,5 @@ cloud-services/**/.env.keys # Agent skills: Cursor auto-applies AGENTS.md/CLAUDE.md as always-on rules (use SKILL.md + COMPILED.md) .agents/skills/**/AGENTS.md .agents/skills/**/CLAUDE.md + +scripts/linear-import \ No newline at end of file diff --git a/app.config.ts b/app.config.ts index e8439205..922ce739 100644 --- a/app.config.ts +++ b/app.config.ts @@ -64,7 +64,7 @@ export default ({ config }: ConfigContext): ExpoConfig => owner: 'eten-genesis', name: getAppName(appVariant), slug: 'langquest', - version: '2.2.1', + version: '2.2.2', orientation: 'portrait', icon: iconLight, scheme: getScheme(appVariant), @@ -137,7 +137,7 @@ export default ({ config }: ConfigContext): ExpoConfig => // TODO: migrate existing localization to expo-localization 'expo-localization', 'expo-asset', - 'expo-audio', + ['expo-audio', { enableBackgroundPlayback: false }], 'expo-image', [ 'expo-build-properties', @@ -162,7 +162,8 @@ export default ({ config }: ConfigContext): ExpoConfig => 'expo-dev-client', 'expo-sharing', 'expo-sqlite', - ['testflight-dev-deploy', { enabled: appVariant === 'development' }] + ['testflight-dev-deploy', { enabled: appVariant === 'development' }], + 'posthog-react-native/expo' ], experiments: { typedRoutes: true, diff --git a/components/AudioPlayer.tsx b/components/AudioPlayer.tsx index cfa53fe1..ab05f715 100644 --- a/components/AudioPlayer.tsx +++ b/components/AudioPlayer.tsx @@ -50,7 +50,7 @@ const AudioPlayer: React.FC = ({ await setAudioModeAsync({ allowsRecording: false, playsInSilentMode: true, - shouldPlayInBackground: true + shouldPlayInBackground: false }); }; diff --git a/components/FiaStepDrawer.tsx b/components/FiaStepDrawer.tsx index 98badaea..e4a420f1 100644 --- a/components/FiaStepDrawer.tsx +++ b/components/FiaStepDrawer.tsx @@ -29,6 +29,7 @@ import type { FiaTerm } from '@/hooks/useFiaPericopeSteps'; import { useFiaPericopeSteps } from '@/hooks/useFiaPericopeSteps'; +import { useProjectFiaLanguageCode } from '@/hooks/useProjectFiaLanguageCode'; import { enqueue as enqueueFiaAttachment, isFiaPericopeCached, @@ -886,21 +887,31 @@ export function FiaStepDrawer({ ); const scrollRef = React.useRef(null); const dataLoadedRef = React.useRef(false); + const { fiaLanguageCode } = useProjectFiaLanguageCode( + pericopeId ? projectId : undefined + ); + const { data, isLoading, error } = useFiaPericopeSteps( pericopeId ? projectId : undefined, pericopeId ?? undefined ); - const attachmentStatus = useFiaAttachmentStatus(pericopeId); + const attachmentStatus = useFiaAttachmentStatus(pericopeId, fiaLanguageCode); const isAudioDownloading = attachmentStatus?.status === 'pending' || attachmentStatus?.status === 'downloading'; React.useEffect(() => { - if (open && pericopeId && projectId && !isFiaPericopeCached(pericopeId)) { + if ( + open && + pericopeId && + projectId && + fiaLanguageCode && + !isFiaPericopeCached(fiaLanguageCode, pericopeId) + ) { enqueueFiaAttachment(pericopeId, projectId); } - }, [open, pericopeId, projectId]); + }, [open, pericopeId, projectId, fiaLanguageCode]); const currentStep = data?.steps.find((s) => s.stepId === activeStep); const audioUrl = currentStep?.audioUrl ?? null; diff --git a/components/NewHighlightBadge.tsx b/components/NewHighlightBadge.tsx new file mode 100644 index 00000000..0025fa9f --- /dev/null +++ b/components/NewHighlightBadge.tsx @@ -0,0 +1,28 @@ +import { Text, TextClassContext } from '@/components/ui/text'; +import { cn } from '@/utils/styleUtils'; +import { Platform, View } from 'react-native'; + +/** Matches NEW badge on asset list cards (`AssetCardItem`). */ +export function NewHighlightBadge({ className }: { className?: string }) { + return ( + + + + NEW + + + + ); +} diff --git a/components/ProjectMembershipModal.tsx b/components/ProjectMembershipModal.tsx index f957f8c6..813b62b1 100644 --- a/components/ProjectMembershipModal.tsx +++ b/components/ProjectMembershipModal.tsx @@ -36,8 +36,9 @@ import { useUserPermissions } from '@/hooks/useUserPermissions'; import { useLocalStore } from '@/store/localStore'; import { DEFAULT_INVITE_MAX_RESEND_ATTEMPTS, - isEmailSuppressionActive, - inviteMaySendAnotherOutboundEmail + type EmailSuppressionSnapshot, + inviteMaySendAnotherOutboundEmail, + isEmailSuppressionActive } from '@/utils/inviteBounceGuard'; import { getInviteBounceReason, @@ -62,7 +63,7 @@ import { UserIcon, XIcon } from 'lucide-react-native'; -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Platform, Pressable, View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; @@ -121,6 +122,108 @@ const isValidEmail = (email: string): boolean => { const { db } = system; +interface ProjectInviteMembersSectionProps { + isVisible: boolean; + canInvite: boolean; + isSubmitting: boolean; + onSendInvitation: (email: string, asOwner: boolean) => Promise; +} + +function ProjectInviteMembersSection({ + isVisible, + canInvite, + isSubmitting, + onSendInvitation +}: ProjectInviteMembersSectionProps) { + const { t } = useLocalization(); + const [inviteEmail, setInviteEmail] = useState(''); + const [inviteAsOwner, setInviteAsOwner] = useState(false); + + useEffect(() => { + if (!isVisible) { + setInviteEmail(''); + setInviteAsOwner(false); + } + }, [isVisible]); + + const isInviteButtonEnabled = inviteEmail.trim() && isValidEmail(inviteEmail); + + const handleSend = async () => { + if (!isInviteButtonEnabled || isSubmitting) return; + const success = await onSendInvitation(inviteEmail, inviteAsOwner); + if (success) { + setInviteEmail(''); + setInviteAsOwner(false); + } + }; + + if (!canInvite) { + return ( + + + + {t('onlyOwnersCanInvite')} + + + ); + } + + return ( + <> + + {t('inviteMembers')} + + { + if (isInviteButtonEnabled && !isSubmitting) { + void handleSend(); + } + }} + returnKeyType="done" + keyboardType="email-address" + autoCapitalize="none" + className="mb-2" + /> + + setInviteAsOwner(!inviteAsOwner)} + > + + + + + + + + + {t('ownerTooltip')} + + + + + + ); +} + export const ProjectMembershipModal: React.FC = ({ isVisible, onClose, @@ -159,8 +262,6 @@ export const ProjectMembershipModal: React.FC = ({ setActiveTab(initialTab); } }, [isVisible, initialTab]); - const [inviteEmail, setInviteEmail] = useState(''); - const [inviteAsOwner, setInviteAsOwner] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); // All operations on invites, requests, and notifications go through synced tables or Supabase @@ -309,15 +410,6 @@ export const ProjectMembershipModal: React.FC = ({ } ); - // Create a map of profile ID to profile for receivers - const _receiverProfileMap = React.useMemo(() => { - const map: Record = {}; - receiverProfiles.forEach((p) => { - map[p.id] = p; - }); - return map; - }, [receiverProfiles]); - const invitations: Invitation[] = React.useMemo(() => { return invites .filter((inv) => @@ -335,11 +427,19 @@ export const ProjectMembershipModal: React.FC = ({ state.dismissedInvitedRows[projectId] ?? EMPTY_DISMISSED_INVITE_IDS ); const dismissInvitedRow = useLocalStore((state) => state.dismissInvitedRow); + const undismissInvitedRow = useLocalStore( + (state) => state.undismissInvitedRow + ); const visibleInvitations = React.useMemo(() => { if (dismissedInvitedRowIds.length === 0) return invitations; const hidden = new Set(dismissedInvitedRowIds); - return invitations.filter((inv) => !hidden.has(inv.id)); + return invitations.filter( + (inv) => + !hidden.has(inv.id) || + // Re-invited rows reuse the same id; show again once active. + inv.status === 'pending' + ); }, [invitations, dismissedInvitedRowIds]); const senderProfileIds = React.useMemo(() => { @@ -605,34 +705,41 @@ export const ProjectMembershipModal: React.FC = ({ return null; } - try { - // Find the invitation first - const invitation = invitations.find((i) => i.id === inviteId); + const invitation = invitations.find((i) => i.id === inviteId); + const receiverProfileId = invitation?.receiver_profile_id; + const reportWithdrawError = (error: unknown) => { + console.error('Error withdrawing invitation:', error); + RNAlert.alert(t('error'), t('failedToWithdrawInvitation')); + }; + + try { await db .update(invite_synced) .set({ status: 'withdrawn', last_updated: new Date().toISOString() }) .where(eq(invite_synced.id, inviteId)); - - // Also deactivate any profile_project_link_synced if exists - if (invitation?.receiver_profile_id) { - await db - .update(profile_project_link_synced) - .set({ active: false, last_updated: new Date().toISOString() }) - .where( - and( - eq( - profile_project_link_synced.profile_id, - invitation.receiver_profile_id - ), - eq(profile_project_link_synced.project_id, projectId) - ) - ); - } // void refetchInvitations(); // Removed refetch } catch (error) { - console.error('Error withdrawing invitation:', error); - RNAlert.alert(t('error'), t('failedToWithdrawInvitation')); + reportWithdrawError(error); + return; + } + + if (!receiverProfileId) { + return; + } + + try { + await db + .update(profile_project_link_synced) + .set({ active: false, last_updated: new Date().toISOString() }) + .where( + and( + eq(profile_project_link_synced.profile_id, receiverProfileId), + eq(profile_project_link_synced.project_id, projectId) + ) + ); + } catch (error) { + reportWithdrawError(error); } }; @@ -642,87 +749,88 @@ export const ProjectMembershipModal: React.FC = ({ return null; } - try { - // Find the invitation first - const invitation = invitations.find((i) => i.id === inviteId); - if (!invitation) return; + const invitation = invitations.find((i) => i.id === inviteId); + if (!invitation) return; - const normalized = invitation.email.trim().toLowerCase(); - const { data: globalSupRow } = await system.supabaseConnector.client - .from('email_suppression') - .select('suppressed_at, soft_suppressed_at, expires_at, deactivated_at') - .eq('normalized_email', normalized) - .maybeSingle(); + const normalized = invitation.email.trim().toLowerCase(); + const { data: globalSupRow } = await system.supabaseConnector.client + .from('email_suppression') + .select('suppressed_at, soft_suppressed_at, expires_at, deactivated_at') + .eq('normalized_email', normalized) + .maybeSingle(); - if (isEmailSuppressionActive(globalSupRow)) { - RNAlert.alert( - t('error'), - t( - getInviteSendBlockedMessageKey({ - bounceType: invitation.bounce_type, - bounceReason: invitation.bounce_reason, - globallySuppressed: true - }) - ) - ); - return; - } + if (isEmailSuppressionActive(globalSupRow)) { + RNAlert.alert( + t('error'), + t( + getInviteSendBlockedMessageKey({ + bounceType: invitation.bounce_type, + bounceReason: invitation.bounce_reason, + globallySuppressed: true + }) + ) + ); + return; + } - if ( - inviteBounceBlocksRetry( - invitation.email_status, - invitation.bounce_type, - invitation.bounce_reason + if ( + inviteBounceBlocksRetry( + invitation.email_status, + invitation.bounce_type, + invitation.bounce_reason + ) + ) { + RNAlert.alert( + t('error'), + t( + getInviteSendBlockedMessageKey({ + emailStatus: invitation.email_status, + bounceType: invitation.bounce_type, + bounceReason: invitation.bounce_reason + }) ) - ) { - RNAlert.alert( - t('error'), - t( - getInviteSendBlockedMessageKey({ - emailStatus: invitation.email_status, - bounceType: invitation.bounce_type, - bounceReason: invitation.bounce_reason - }) - ) - ); - return; - } + ); + return; + } - if (!inviteMaySendAnotherOutboundEmail(invitation.count, false)) { - RNAlert.alert(t('error'), t('maxInviteAttemptsReached')); - return; - } + if (!inviteMaySendAnotherOutboundEmail(invitation.count, false)) { + RNAlert.alert(t('error'), t('maxInviteAttemptsReached')); + return; + } - // Check if we can re-invite - if ((invitation.count || 0) < MAX_INVITE_ATTEMPTS) { - // Update existing invitation to pending and increment count - await db - .update(invite_synced) - .set({ - status: 'pending', - count: (invitation.count || 0) + 1, - last_updated: new Date().toISOString(), - sender_profile_id: currentUser.id, - resend_email_id: null, - email_status: null, - email_sent_at: null, - email_delivered_at: null, - email_bounced_at: null, - bounce_type: null, - bounce_reason: null, - bounce_notice_dismissed_at: null - }) - .where(eq(invite_synced.id, inviteId)); + const inviteCount = invitation.count || 0; + if (inviteCount >= MAX_INVITE_ATTEMPTS) { + RNAlert.alert(t('error'), t('maxInviteAttemptsReached')); + return; + } - // void refetchInvitations(); // Removed refetch - RNAlert.alert(t('success'), t('invitationResent')); - } else { - RNAlert.alert(t('error'), t('maxInviteAttemptsReached')); - } + try { + await db + .update(invite_synced) + .set({ + status: 'pending', + count: inviteCount + 1, + last_updated: new Date().toISOString(), + sender_profile_id: currentUser.id, + resend_email_id: null, + email_status: null, + email_sent_at: null, + email_delivered_at: null, + email_bounced_at: null, + bounce_type: null, + bounce_reason: null, + bounce_notice_dismissed_at: null + }) + .where(eq(invite_synced.id, inviteId)); + // void refetchInvitations(); // Removed refetch } catch (error) { console.error('Error resending invitation:', error); RNAlert.alert(t('error'), t('failedToResendInvitation')); + return; } + + undismissInvitedRow(projectId, inviteId); + RNAlert.alert(t('success'), t('invitationResent')); }; const handleDismissInvitedFromList = async (invitation: Invitation) => { @@ -834,9 +942,8 @@ export const ProjectMembershipModal: React.FC = ({ } catch (error) { console.error('Error approving request:', error); RNAlert.alert(t('error'), t('failedToApproveRequest')); - } finally { - setIsSubmitting(false); } + setIsSubmitting(false); })(); } } @@ -870,9 +977,8 @@ export const ProjectMembershipModal: React.FC = ({ } catch (error) { console.error('Error denying request:', error); RNAlert.alert(t('error'), t('failedToDenyRequest')); - } finally { - setIsSubmitting(false); } + setIsSubmitting(false); })(); } } @@ -880,20 +986,28 @@ export const ProjectMembershipModal: React.FC = ({ ); }; - const handleSendInvitation = async () => { - // Guard clause: Don't render if currentUser is null - if (!currentUser) { - return; - } + const handleSendInvitation = useCallback( + async (inviteEmail: string, inviteAsOwner: boolean): Promise => { + if (!currentUser) { + return false; + } - if (!isValidEmail(inviteEmail)) { - RNAlert.alert(t('error'), t('enterValidEmail')); - return; - } + if (!isValidEmail(inviteEmail)) { + RNAlert.alert(t('error'), t('enterValidEmail')); + return false; + } + + setIsSubmitting(true); + + const reportSendInvitationError = (error: unknown) => { + console.error('Error sending invitation:', error); + RNAlert.alert( + t('error'), + error instanceof Error ? error.message : t('failedToSendInvitation') + ); + setIsSubmitting(false); + }; - setIsSubmitting(true); - try { - // Check if the email belongs to an existing member/owner const existingMember = sortedMembers.find( (member) => member.email.toLowerCase() === inviteEmail.toLowerCase() ); @@ -904,67 +1018,95 @@ export const ProjectMembershipModal: React.FC = ({ t('emailAlreadyMemberMessage', { role: t(existingMember.role) }) ); setIsSubmitting(false); - return; + return false; } const normalizedInput = inviteEmail.trim().toLowerCase(); - // Check for any existing invitation (including declined, withdrawn, expired) - const existingInvites = await db.query.invite.findMany({ - where: and( - eq(invite.email, inviteEmail.trim()), - eq(invite.project_id, projectId) - ), - with: { - receiver: true - } - }); - const existingInvite = existingInvites[0]; + type InviteQueryRow = Awaited< + ReturnType + >[number]; + let existingInvite: InviteQueryRow | undefined; + try { + const existingInvites = await db.query.invite.findMany({ + where: and( + eq(invite.email, inviteEmail.trim()), + eq(invite.project_id, projectId) + ), + with: { + receiver: true + } + }); + existingInvite = existingInvites[0]; + } catch (error) { + reportSendInvitationError(error); + return false; + } - const { data: globalSupOnSend } = await system.supabaseConnector.client - .from('email_suppression') - .select('suppressed_at, soft_suppressed_at, expires_at, deactivated_at') - .eq('normalized_email', normalizedInput) - .maybeSingle(); + const existingInviteBounceType = existingInvite?.bounce_type; + const existingInviteBounceReason = existingInvite?.bounce_reason; + + let globalSupOnSend: EmailSuppressionSnapshot | null | undefined; + try { + const { data } = await system.supabaseConnector.client + .from('email_suppression') + .select( + 'suppressed_at, soft_suppressed_at, expires_at, deactivated_at' + ) + .eq('normalized_email', normalizedInput) + .maybeSingle(); + globalSupOnSend = data; + } catch (error) { + reportSendInvitationError(error); + return false; + } if (isEmailSuppressionActive(globalSupOnSend)) { RNAlert.alert( t('error'), t( getInviteSendBlockedMessageKey({ - bounceType: existingInvite?.bounce_type, - bounceReason: existingInvite?.bounce_reason, + bounceType: existingInviteBounceType, + bounceReason: existingInviteBounceReason, globallySuppressed: true }) ) ); setIsSubmitting(false); - return; + return false; } if (existingInvite) { - // Check if the invitee has an inactive profile_project_link_synced let hasInactiveLink = false; - if (existingInvite.receiver_profile_id) { - const profileLinks = await db.query.profile_project_link.findMany({ - where: (table) => - and( - eq(table.profile_id, existingInvite.receiver_profile_id!), - eq(table.project_id, projectId) - ) - }); - hasInactiveLink = - profileLinks.some((link) => !link.active) || - profileLinks.length === 0; + const receiverProfileId = existingInvite.receiver_profile_id; + if (receiverProfileId) { + let profileLinks: Awaited< + ReturnType + >; + try { + profileLinks = await db.query.profile_project_link.findMany({ + where: (table) => + and( + eq(table.profile_id, receiverProfileId), + eq(table.project_id, projectId) + ) + }); + } catch (error) { + reportSendInvitationError(error); + return false; + } + const hasAnyInactiveLink = profileLinks.some((link) => !link.active); + const hasNoProfileLinks = profileLinks.length === 0; + hasInactiveLink = hasAnyInactiveLink || hasNoProfileLinks; } - // Check if we can re-invite - if ( + const canReinvite = ['declined', 'withdrawn', 'expired'].includes( existingInvite.status ) || - (existingInvite.status === 'accepted' && hasInactiveLink) // Allow reinvitation if user was removed after accepting - ) { + (existingInvite.status === 'accepted' && hasInactiveLink); + + if (canReinvite) { if ( inviteBounceBlocksRetry( existingInvite.email_status, @@ -983,22 +1125,27 @@ export const ProjectMembershipModal: React.FC = ({ ) ); setIsSubmitting(false); - return; + return false; } if (!inviteMaySendAnotherOutboundEmail(existingInvite.count, false)) { RNAlert.alert(t('error'), t('maxInviteAttemptsReached')); setIsSubmitting(false); - return; + return false; } - if ((existingInvite.count || 0) < MAX_INVITE_ATTEMPTS) { - // Update existing invitation - // Only increment count if previous invite was declined (user actively rejected) - // Don't count: accepted+inactive (successful then removed), withdrawn (sender cancelled), expired (timed out) - const newCount = - existingInvite.status === 'declined' - ? (existingInvite.count || 0) + 1 - : existingInvite.count || 0; + const inviteCount = existingInvite.count || 0; + if (inviteCount >= MAX_INVITE_ATTEMPTS) { + RNAlert.alert(t('error'), t('maxInviteAttemptsReached')); + setIsSubmitting(false); + return false; + } + + const newCount = + existingInvite.status === 'declined' + ? inviteCount + 1 + : inviteCount; + + try { await db .update(invite_synced) .set({ @@ -1017,78 +1164,72 @@ export const ProjectMembershipModal: React.FC = ({ bounce_notice_dismissed_at: null }) .where(eq(invite_synced.id, existingInvite.id)); - - setInviteEmail(''); - setInviteAsOwner(false); - // void refetchInvitations(); // Removed refetch - RNAlert.alert(t('success'), t('invitationResent')); - return; - } else { - RNAlert.alert(t('error'), t('maxInviteAttemptsReached')); - setIsSubmitting(false); - return; + } catch (error) { + reportSendInvitationError(error); + return false; } - } else { - // Invitation is still pending or in another active state - if ( - existingInvite.status === 'pending' && - inviteBounceBlocksRetry( - existingInvite.email_status, - existingInvite.bounce_type, - existingInvite.bounce_reason + + undismissInvitedRow(projectId, existingInvite.id); + RNAlert.alert(t('success'), t('invitationResent')); + setIsSubmitting(false); + return true; + } + + const isPending = existingInvite.status === 'pending'; + if ( + isPending && + inviteBounceBlocksRetry( + existingInvite.email_status, + existingInvite.bounce_type, + existingInvite.bounce_reason + ) + ) { + RNAlert.alert( + t('error'), + t( + getInviteSendBlockedMessageKey({ + emailStatus: existingInvite.email_status, + bounceType: existingInvite.bounce_type, + bounceReason: existingInvite.bounce_reason + }) ) - ) { - RNAlert.alert( - t('error'), - t( - getInviteSendBlockedMessageKey({ - emailStatus: existingInvite.email_status, - bounceType: existingInvite.bounce_type, - bounceReason: existingInvite.bounce_reason - }) - ) - ); - setIsSubmitting(false); - return; - } - if ( - existingInvite.status === 'pending' && - !inviteMaySendAnotherOutboundEmail(existingInvite.count, false) - ) { - RNAlert.alert(t('error'), t('maxInviteAttemptsReached')); - setIsSubmitting(false); - return; - } - RNAlert.alert(t('error'), t('invitationAlreadySent')); + ); setIsSubmitting(false); - return; + return false; } + if ( + isPending && + !inviteMaySendAnotherOutboundEmail(existingInvite.count, false) + ) { + RNAlert.alert(t('error'), t('maxInviteAttemptsReached')); + setIsSubmitting(false); + return false; + } + RNAlert.alert(t('error'), t('invitationAlreadySent')); + setIsSubmitting(false); + return false; } - // Create new invitation - await db.insert(invite_synced).values({ - sender_profile_id: currentUser.id, - email: inviteEmail, - project_id: projectId, - status: 'pending', - as_owner: inviteAsOwner, - count: 1 - }); + try { + await db.insert(invite_synced).values({ + sender_profile_id: currentUser.id, + email: inviteEmail, + project_id: projectId, + status: 'pending', + as_owner: inviteAsOwner, + count: 1 + }); + } catch (error) { + reportSendInvitationError(error); + return false; + } - setInviteEmail(''); - setInviteAsOwner(false); - // void refetchInvitations(); // Removed refetch RNAlert.alert(t('success'), t('invitationSent')); - } catch (error) { - console.error('Error sending invitation:', error); - RNAlert.alert( - t('error'), - error instanceof Error ? error.message : t('failedToSendInvitation') - ); - } finally { setIsSubmitting(false); - } - }; + return true; + }, + [currentUser, projectId, sortedMembers, t] + ); const renderMember = (member: Member) => { // Guard clause: Don't render if currentUser is null @@ -1377,8 +1518,6 @@ export const ProjectMembershipModal: React.FC = ({ ); }; - const isInviteButtonEnabled = inviteEmail.trim() && isValidEmail(inviteEmail); - return ( = ({ {/* Invite Section */} - {sendInvitePermissions.hasAccess ? ( - <> - - {t('inviteMembers')} - - { - if (isInviteButtonEnabled && !isSubmitting) { - void handleSendInvitation(); - } - }} - returnKeyType="done" - keyboardType="email-address" - autoCapitalize="none" - className="mb-2" - /> - - setInviteAsOwner(!inviteAsOwner)} - > - - - - - - - - - {t('ownerTooltip')} - - - - - - ) : ( - - - - {t('onlyOwnersCanInvite')} - - - )} + )} diff --git a/components/PublishQuestButton.tsx b/components/PublishQuestButton.tsx new file mode 100644 index 00000000..27727ef3 --- /dev/null +++ b/components/PublishQuestButton.tsx @@ -0,0 +1,79 @@ +import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/ui/icon'; +import { useLocalization } from '@/hooks/useLocalization'; +import { cn } from '@/utils/styleUtils'; +import RNAlert from '@blazejkustra/react-native-alert'; +import { CloudUpload } from 'lucide-react-native'; +import React from 'react'; + +interface PublishQuestButtonProps { + questName?: string; + disabled?: boolean; + isPublishing: boolean; + isOnline: boolean; + isMember: boolean; + hasLocalAssets?: boolean; + onPublish: () => void; +} + +export function PublishQuestButton({ + questName, + disabled, + isPublishing, + isOnline, + isMember, + hasLocalAssets = false, + onPublish +}: PublishQuestButtonProps) { + const { t } = useLocalization(); + + const handlePress = () => { + if (!isOnline) { + RNAlert.alert(t('error'), t('cannotPublishWhileOffline')); + return; + } + + if (!isMember) { + RNAlert.alert(t('error'), t('membersOnlyPublish')); + return; + } + + const displayQuestName = questName || 'this chapter'; + + RNAlert.alert( + t('publishChapter'), + t('publishChapterMessage').replace('{questName}', displayQuestName), + [ + { text: t('cancel'), style: 'cancel' }, + { + text: t('publish'), + style: 'default', + isPreferred: true, + onPress: onPublish + } + ] + ); + }; + + const isHighlighted = hasLocalAssets; + + return ( + + ); +} diff --git a/components/TranslationCard.tsx b/components/TranslationCard.tsx index f69dc091..8bf86b2c 100644 --- a/components/TranslationCard.tsx +++ b/components/TranslationCard.tsx @@ -11,6 +11,7 @@ import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags'; import { cn } from '@/utils/styleUtils'; import { ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react-native'; import { View } from 'react-native'; +import { NewHighlightBadge } from '@/components/NewHighlightBadge'; import AudioPlayer from './AudioPlayer'; import { Button } from './ui/button'; @@ -21,6 +22,7 @@ interface TranslationCardProps { handleTranslationPress: (id: string) => void; onTranscribe?: (uri: string) => void; isTranscribing?: boolean; + isHighlighted?: boolean; } export const TranslationCard = ({ @@ -29,7 +31,8 @@ export const TranslationCard = ({ handleTranslationPress, previewText, onTranscribe, - isTranscribing = false + isTranscribing = false, + isHighlighted = false }: TranslationCardProps) => { const { t } = useLocalization(); const currentLayer = useStatusContext(); @@ -41,6 +44,10 @@ export const TranslationCard = ({ const hasAudio = asset.audio && asset.audio.length > 0 && audioSegments.length > 0; + const showText = Boolean(previewText || !hasAudio); + const isAudioOnlyCard = hasAudio && !showText; + const showBadgeAbove = isHighlighted && !isAudioOnlyCard; + const showBadgeWithVotes = isHighlighted && isAudioOnlyCard; const handleCardPress = () => { if (asset.id) { @@ -62,20 +69,75 @@ export const TranslationCard = ({ invisible && 'opacity-20' )} > - - {/* Left side: Content */} - - {/* Text preview — hidden for audio-only cards; shown for text cards and truly empty cards */} - {(previewText || !hasAudio) && ( - - {previewText || t('noText')} - + + + {showBadgeAbove && ( + + + )} - {/* Audio Player */} + + {showBadgeWithVotes && } + + {showText && ( + + {previewText || t('noText')} + + )} + + + + 0 && 'text-green-700 dark:text-green-400' + )} + /> + 0 && + 'text-green-700 dark:text-green-400', + asset.net_votes < 0 && 'text-red-700 dark:text-red-400', + asset.net_votes === 0 && 'text-muted-foreground' + )} + > + {asset.net_votes > 0 ? '+' : ''} + {asset.net_votes} + + 0 && 'text-red-700 dark:text-red-400' + )} + /> + + {SHOW_DEV_ELEMENTS && ( + + {asset.up_votes}↑ {asset.down_votes}↓ + + )} + + + {hasAudio && ( )} - - {/* Dev info */} - {SHOW_DEV_ELEMENTS && ( - - - {asset.source === 'cloud' ? '🌐' : '💾'} - - - - V: {asset.visible ? '🟢' : '🔴'} - - - - A: {asset.active ? '🟢' : '🔴'} - - - )} - {/* Right side: Votes */} - - {/* Vote display */} - - 0 && 'text-green-700 dark:text-green-400' - )} - /> - 0 && 'text-green-700 dark:text-green-400', - asset.net_votes < 0 && 'text-red-700 dark:text-red-400', - asset.net_votes === 0 && 'text-muted-foreground' - )} - > - {asset.net_votes > 0 ? '+' : ''} - {asset.net_votes} + {SHOW_DEV_ELEMENTS && ( + + + {asset.source === 'cloud' ? '🌐' : '💾'} - 0 && 'text-red-700 dark:text-red-400' - )} - /> - - - {/* Dev vote breakdown */} - {SHOW_DEV_ELEMENTS && ( - - {asset.up_votes}↑ {asset.down_votes}↓ + + + V: {asset.visible ? '🟢' : '🔴'} - )} - + + + A: {asset.active ? '🟢' : '🔴'} + + + )} diff --git a/contexts/AudioContext.tsx b/contexts/AudioContext.tsx index 78bac801..91202481 100644 --- a/contexts/AudioContext.tsx +++ b/contexts/AudioContext.tsx @@ -317,7 +317,7 @@ export function AudioProvider({ children }: { children: React.ReactNode }) { await setAudioModeAsync({ allowsRecording: false, playsInSilentMode: true, - shouldPlayInBackground: true + shouldPlayInBackground: false }); // Resolve through file cache (downloads remote files on first access, serves local copy thereafter) diff --git a/contexts/AuthContext.tsx b/contexts/AuthContext.tsx index 833baf98..ecbaae68 100644 --- a/contexts/AuthContext.tsx +++ b/contexts/AuthContext.tsx @@ -1,4 +1,5 @@ import { system } from '@/db/powersync/system'; +import { setPostHogUserId } from '@/services/posthog'; import { useLocalStore } from '@/store/localStore'; import { getSupabaseAuthKey } from '@/utils/supabaseUtils'; import RNAlert from '@blazejkustra/react-native-alert'; @@ -390,6 +391,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // Now update session state (only after we've determined effectiveSession) setSession(effectiveSession); system.supabaseConnector.updateSession(effectiveSession); + setPostHogUserId(effectiveSession?.user.id ?? null); if (effectiveSession) { console.log( @@ -426,6 +428,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // Update session for sign in events setSession(session); system.supabaseConnector.updateSession(session); + setPostHogUserId(session?.user.id ?? null); console.log('[AuthContext] User signed in'); const detectedSessionType = getSessionType(session); @@ -451,6 +454,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // Update session for password recovery setSession(session); system.supabaseConnector.updateSession(session); + setPostHogUserId(session?.user.id ?? null); console.log('[AuthContext] Password recovery session'); setSessionType('password-reset'); @@ -464,6 +468,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { console.log('[AuthContext] User signed out (dev mode)'); setSession(null); system.supabaseConnector.updateSession(null); + setPostHogUserId(null); setSessionType(null); await cleanupSystem(); // Set system ready for anonymous browsing after sign out diff --git a/hooks/useFiaPericopeSteps.ts b/hooks/useFiaPericopeSteps.ts index 3404f4af..a44c7538 100644 --- a/hooks/useFiaPericopeSteps.ts +++ b/hooks/useFiaPericopeSteps.ts @@ -2,6 +2,7 @@ import { getCachedFiaPericope, useFiaAttachmentStatus } from '@/services/FiaAttachmentQueue'; +import { useProjectFiaLanguageCode } from '@/hooks/useProjectFiaLanguageCode'; import { useQuery } from '@tanstack/react-query'; // --- Types matching the edge function response --- @@ -62,19 +63,21 @@ export function useFiaPericopeSteps( projectId: string | undefined, pericopeId: string | undefined ) { - const attachmentStatus = useFiaAttachmentStatus(pericopeId); + const { fiaLanguageCode } = useProjectFiaLanguageCode(projectId); + + const attachmentStatus = useFiaAttachmentStatus(pericopeId, fiaLanguageCode); // completedAt changes when the queue finishes, causing the query to re-run // and pick up the freshly-cached file const cacheVersion = attachmentStatus?.completedAt ?? 0; const { data, isLoading, error } = useQuery({ - queryKey: ['fia-pericope-steps', projectId, pericopeId, cacheVersion], + queryKey: ['fia-pericope-steps', fiaLanguageCode, pericopeId, cacheVersion], queryFn: (): FiaPericopeStepsResponse | null => { - if (!pericopeId) return null; - return getCachedFiaPericope(pericopeId); + if (!pericopeId || !fiaLanguageCode) return null; + return getCachedFiaPericope(fiaLanguageCode, pericopeId); }, - enabled: !!projectId && !!pericopeId, + enabled: !!fiaLanguageCode && !!pericopeId, staleTime: Infinity, gcTime: 1000 * 60 * 60, refetchInterval: false, diff --git a/hooks/usePlayAllAudioController.ts b/hooks/usePlayAllAudioController.ts index 6ab5a989..f96c9501 100644 --- a/hooks/usePlayAllAudioController.ts +++ b/hooks/usePlayAllAudioController.ts @@ -1,4 +1,9 @@ -import { createAudioPlayer, type AudioPlayer } from 'expo-audio'; +import { getCachedAudioUri } from '@/utils/audioCache'; +import { + createAudioPlayer, + setAudioModeAsync, + type AudioPlayer +} from 'expo-audio'; import React from 'react'; import type { PlayAllCheckpoint } from './useAudioPlaybackCheckpoint'; @@ -77,8 +82,62 @@ type PlayAllJumpTarget = { const DEFAULT_SEEK_STEP_MS = 5000; const SEEK_DEBOUNCE_MS = 500; -const PLAYER_LOAD_TIMEOUT_MS = 2500; +const PLAYER_LOAD_TIMEOUT_MS = 5000; const PLAYER_LOAD_POLL_INTERVAL_MS = 20; +/** Tolerance when inferring segment end from position (iOS can miss didJustFinish). */ +const SEGMENT_END_TOLERANCE_SECONDS = 0.25; + +async function ensureAudioPlaybackMode(): Promise { + await setAudioModeAsync({ + allowsRecording: false, + playsInSilentMode: true, + shouldPlayInBackground: false + }); +} + +function isSegmentPlaybackComplete( + status: PlaybackStatus, + hasStartedPlaying: boolean +): boolean { + if (!hasStartedPlaying) { + return false; + } + + if (status.didJustFinish) { + return true; + } + + const duration = status.duration; + const currentTime = status.currentTime; + if ( + !status.playing && + Number.isFinite(duration) && + duration > 0 && + Number.isFinite(currentTime) && + currentTime >= duration - SEGMENT_END_TOLERANCE_SECONDS + ) { + return true; + } + + return false; +} + +async function resolvePlaylistUris( + playlist: PlayAllPlaylistItem[] +): Promise { + const resolved: PlayAllPlaylistItem[] = []; + for (const item of playlist) { + const uris: string[] = []; + for (const uri of item.uris) { + if (!uri) { + continue; + } + uris.push(await getCachedAudioUri(uri)); + } + resolved.push({ ...item, uris }); + } + return resolved; +} export function usePlayAllAudioController({ checkpointStore, @@ -606,6 +665,10 @@ export function usePlayAllAudioController({ let shouldUseInitialCheckpoint = shouldResume; try { + await ensureAudioPlaybackMode(); + const resolvedPlaylist = await resolvePlaylistUris(playlist); + currentPlaylistRef.current = resolvedPlaylist; + const waitForPlayerLoaded = async (player: AudioPlayer) => { return await new Promise((resolve) => { if (player.isLoaded) { @@ -629,68 +692,20 @@ export function usePlayAllAudioController({ }); }; - const preloadItemDurations = async ( - item: PlayAllPlaylistItem, - itemIndex: number - ) => { - const cachedDurations = - itemSegmentDurationsRef.current.get(itemIndex); - if ( - cachedDurations && - cachedDurations.length === item.uris.length && - cachedDurations.every((duration) => duration > 0) - ) { - return; - } - - const resolvedDurations = new Array(item.uris.length).fill(0); - for (let idx = 0; idx < item.uris.length; idx++) { - const uri = item.uris[idx]; - if (!uri) { - continue; - } - let tempPlayer: AudioPlayer | null = null; - try { - tempPlayer = createAudioPlayer(uri); - const isLoaded = await waitForPlayerLoaded(tempPlayer); - if (isLoaded && tempPlayer.isLoaded && tempPlayer.duration > 0) { - const durationMs = tempPlayer.duration * 1000; - resolvedDurations[idx] = durationMs; - segmentDurationMsMapRef.current.set( - `${itemIndex}:${idx}`, - durationMs - ); - } - } catch { - // Ignore preload failure, runtime playback status can still fill duration. - } finally { - try { - tempPlayer?.release(); - } catch { - // Ignore release failures for temporary preloading players. - } - } - } - - itemSegmentDurationsRef.current.set(itemIndex, resolvedDurations); - }; - for ( let itemIndex = resolvedStartItemIndex; - itemIndex < playlist.length; + itemIndex < resolvedPlaylist.length; ) { if (!isPlayAllRunningRef.current) { stopPlayAll('cancelled'); return; } - const item = playlist[itemIndex]; + const item = resolvedPlaylist[itemIndex]; if (!item || item.uris.length === 0) { continue; } - await preloadItemDurations(item, itemIndex); - notifyCurrentAssetChange({ assetId: item.assetId, itemIndex, @@ -767,31 +782,23 @@ export function usePlayAllAudioController({ currentSegmentResolveRef.current = settle; - try { - const player = createAudioPlayer(uri); - currentPlayerRef.current = player; - player.play(); - - if (resumePositionMs > 0) { - const seekInterval = setInterval(() => { - if (!player.isLoaded) { - return; + void (async () => { + try { + const player = createAudioPlayer(uri, { + updateInterval: 100, + keepAudioSessionActive: true + }); + currentPlayerRef.current = player; + let segmentCompletionHandled = false; + let segmentHasStartedPlaying = false; + + const handleSegmentPlaybackStatus = ( + playbackStatus: PlaybackStatus + ) => { + if (playbackStatus.playing) { + segmentHasStartedPlaying = true; } - clearInterval(seekInterval); - player.seekTo(resumePositionMs / 1000); - }, 20); - - const seekTimeoutId = setTimeout(() => { - clearInterval(seekInterval); - seekTimeoutIdsRef.current.delete(seekTimeoutId); - }, 2000); - seekTimeoutIdsRef.current.add(seekTimeoutId); - } - currentPlayerSubscriptionRef.current = player.addListener( - 'playbackStatusUpdate', - (status) => { - const playbackStatus = status as PlaybackStatus; lastStatusItemIndexRef.current = itemIndex; lastStatusUriIndexRef.current = uriIndex; const statusCurrentTimeMs = @@ -850,7 +857,7 @@ export function usePlayAllAudioController({ item, itemIndex, uriIndex, - playlistLength: playlist.length, + playlistLength: resolvedPlaylist.length, assetPositionMs, assetDurationMs }); @@ -864,9 +871,19 @@ export function usePlayAllAudioController({ }); } - if (!playbackStatus.didJustFinish) { + if ( + !isSegmentPlaybackComplete( + playbackStatus, + segmentHasStartedPlaying + ) + ) { + return; + } + + if (segmentCompletionHandled) { return; } + segmentCompletionHandled = true; const isLastUriInItem = uriIndex >= item.uris.length - 1; const nextItemIndex = isLastUriInItem @@ -874,7 +891,7 @@ export function usePlayAllAudioController({ : itemIndex; const nextUriIndex = isLastUriInItem ? 0 : uriIndex + 1; - if (nextItemIndex < playlist.length) { + if (nextItemIndex < resolvedPlaylist.length) { checkpointStore.savePlayAllCheckpoint( { playlistKey: effectivePlaylistKey, @@ -890,12 +907,58 @@ export function usePlayAllAudioController({ releaseCurrentPlayer(); settle(); + }; + + currentPlayerSubscriptionRef.current = player.addListener( + 'playbackStatusUpdate', + (status) => { + handleSegmentPlaybackStatus(status as PlaybackStatus); + } + ); + + player.play(); + + const isLoaded = await waitForPlayerLoaded(player); + if (!isLoaded && !player.isLoaded) { + console.warn( + '[PlayAll] Segment failed to load before playback:', + uri.slice(0, 80) + ); + releaseCurrentPlayer(); + settle(); + return; } - ); - } catch { - releaseCurrentPlayer(); - settle(); - } + + if (!segmentHasStartedPlaying && !player.playing) { + player.play(); + } + + if (resumePositionMs > 0) { + const seekInterval = setInterval(() => { + if (!player.isLoaded) { + return; + } + clearInterval(seekInterval); + player.seekTo(resumePositionMs / 1000); + }, 20); + + const seekTimeoutId = setTimeout(() => { + clearInterval(seekInterval); + seekTimeoutIdsRef.current.delete(seekTimeoutId); + }, 2000); + seekTimeoutIdsRef.current.add(seekTimeoutId); + } + + if (player.isLoaded) { + handleSegmentPlaybackStatus( + player.currentStatus as PlaybackStatus + ); + } + } catch { + releaseCurrentPlayer(); + settle(); + } + })(); }); const jumpTarget = @@ -904,7 +967,7 @@ export function usePlayAllAudioController({ shouldUseInitialCheckpoint = false; itemIndex = Math.max( 0, - Math.min(jumpTarget.itemIndex, playlist.length - 1) + Math.min(jumpTarget.itemIndex, resolvedPlaylist.length - 1) ); jumpedToAnotherItem = true; break; diff --git a/hooks/useProjectFiaLanguageCode.ts b/hooks/useProjectFiaLanguageCode.ts new file mode 100644 index 00000000..8a1b13ce --- /dev/null +++ b/hooks/useProjectFiaLanguageCode.ts @@ -0,0 +1,24 @@ +/** + * Resolves the FIA API language code (e.g. "eng", "fra") for a project's source languoid. + */ + +import { useProjectSourceLanguoid } from '@/hooks/useProjectSourceLanguoid'; +import { lookupFiaLanguageCode } from '@/utils/languoidLookups'; +import { useQuery } from '@tanstack/react-query'; + +export function useProjectFiaLanguageCode(projectId: string | undefined) { + const { sourceLanguoidId, isLoading: sourceLoading } = + useProjectSourceLanguoid(projectId ?? ''); + + const { data: fiaLanguageCode, isLoading: codeLoading } = useQuery({ + queryKey: ['fia-language-code', sourceLanguoidId], + queryFn: () => lookupFiaLanguageCode(sourceLanguoidId!), + enabled: !!sourceLanguoidId, + staleTime: Infinity + }); + + return { + fiaLanguageCode: fiaLanguageCode ?? null, + isLoading: sourceLoading || codeLoading + }; +} diff --git a/metro.config.js b/metro.config.js index ae361de7..b9db652e 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,9 +1,9 @@ -const { getDefaultConfig } = require('expo/metro-config'); +const { getPostHogExpoConfig } = require('posthog-react-native/metro'); const { withNativeWind } = require('nativewind/metro'); /** @type {import('expo/metro-config').MetroConfig} */ -const config = getDefaultConfig(__dirname); +const config = getPostHogExpoConfig(__dirname); // DO NOT PUSH MJS TO ASSET EXTS SEPERATELY - DUPLICATE EXTENSIONS BREAK THE ENTIRE APP // config.resolver.assetExts.push('mjs'); diff --git a/package-lock.json b/package-lock.json index 60add30d..3bf3fcd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "langquest", - "version": "2.2.1", + "version": "2.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "langquest", - "version": "2.2.1", + "version": "2.2.2", "hasInstallScript": true, "dependencies": { "@azure/core-asynciterator-polyfill": "^1.0.2", @@ -86,9 +86,9 @@ "lucide-react-native": "^0.563.0", "moti": "^0.30.0", "nativewind": "^4.2.1", - "posthog-js": "^1.345.1", - "posthog-react-native": "^4.31.0", - "posthog-react-native-session-replay": "^1.2.4", + "posthog-js": "^1.376.6", + "posthog-react-native": "^4.46.4", + "posthog-react-native-session-replay": "^1.6.0", "react": "19.2.0", "react-compiler-runtime": "^1.0.0", "react-dom": "19.2.0", @@ -126,6 +126,7 @@ "@eslint/compat": "^1.2.4", "@libsql/client": "^0.17.0", "@modelcontextprotocol/sdk": "^1.0.4", + "@posthog/cli": "^0.7.16", "@react-native-community/cli": "latest", "@total-typescript/ts-reset": "^0.6.1", "@types/bidirectional-map": "^1.0.4", @@ -6583,252 +6584,6 @@ "react-native": ">=0.54.0" } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", - "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-http": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz", - "integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.208.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-exporter-base": "0.208.0", - "@opentelemetry/otlp-transformer": "0.208.0", - "@opentelemetry/sdk-logs": "0.208.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz", - "integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-transformer": "0.208.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz", - "integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.208.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-logs": "0.208.0", - "@opentelemetry/sdk-metrics": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0", - "protobufjs": "^7.3.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/resources": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz", - "integrity": "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.5.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz", - "integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-logs": { - "version": "0.208.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz", - "integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.208.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", - "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", - "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", - "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/@oxc-resolver/binding-android-arm-eabi": { "version": "11.19.1", "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.19.1.tgz", @@ -7124,19 +6879,48 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@posthog/cli": { + "version": "0.7.16", + "resolved": "https://registry.npmjs.org/@posthog/cli/-/cli-0.7.16.tgz", + "integrity": "sha512-0dk2Q/Yj9ZwB6iWk1bHH9bXE0zHXBHAMuc0XaCFaEwALnw0C48R03iAbIccPADMqWo/nA35b6nhvcEqTwKX/5Q==", + "dev": true, + "hasInstallScript": true, + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "bin": { + "posthog-cli": "run-posthog-cli.js" + }, + "engines": { + "node": ">=14.14", + "npm": ">=6" + } + }, + "node_modules/@posthog/cli/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/@posthog/core": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.23.2.tgz", - "integrity": "sha512-zTDdda9NuSHrnwSOfFMxX/pyXiycF4jtU1kTr8DL61dHhV+7LF6XF1ndRZZTuaGGbfbb/GJYkEsjEX9SXfNZeQ==", + "version": "1.30.5", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.30.5.tgz", + "integrity": "sha512-NGBRhqyMOpiSKd8xtg8dckUxh+kB02LdtnxKYW7ngycJmgM4GBGzSDaATbMQDKaNS9m2FJ9QRZ8nJyelGCoqNQ==", "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.6" + "@posthog/types": "1.379.2" } }, "node_modules/@posthog/types": { - "version": "1.357.1", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.357.1.tgz", - "integrity": "sha512-ZeQPMDeT3o1LhKN3xzMX+2uUVjYjG8lRehGs/hKhlhGpNUwOYaf9amsi+yGv/Z1RaaY+C5jz3HliNOjB68WEng==", + "version": "1.379.2", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.379.2.tgz", + "integrity": "sha512-quGXOeNewmaGqTxE/eH8MgM/niW1jfgCyzh4bwpXFpDwuQPvWA8WkCFo6k1oKGYV03Go0udw49aGUz6s58cRsA==", "license": "MIT" }, "node_modules/@powersync/attachments": { @@ -7255,70 +7039,6 @@ "@powersync/common": "^1.47.0" } }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -13459,9 +13179,9 @@ } }, "node_modules/dompurify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", - "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", + "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -21594,12 +21314,6 @@ "node": ">=6" } }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -23656,20 +23370,15 @@ "license": "MIT" }, "node_modules/posthog-js": { - "version": "1.357.1", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.357.1.tgz", - "integrity": "sha512-rniJPIhEl/gJvKb3Vi5YbMhVV2iFT1X1U6KTj3kZkjSxX5dd7Abz5BF+Cx1WepzN8wQ96w2Zt7Iuq9bQv/VCfg==", + "version": "1.379.2", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.379.2.tgz", + "integrity": "sha512-uQbUrbzvRfYaj11B9VsfVXvRVp6hnpWi1jTwfICzKfrjR4l1Id49YHXLS3oDuKZYXzfjaqjfGNQHVnfPRlBHTA==", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/api-logs": "^0.208.0", - "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", - "@opentelemetry/resources": "^2.2.0", - "@opentelemetry/sdk-logs": "^0.208.0", - "@posthog/core": "1.23.2", - "@posthog/types": "1.357.1", + "@posthog/core": "1.30.5", + "@posthog/types": "1.379.2", "core-js": "^3.38.1", - "dompurify": "^3.3.1", + "dompurify": "^3.3.2", "fflate": "^0.4.8", "preact": "^10.28.2", "query-selector-shadow-dom": "^1.0.1", @@ -23677,12 +23386,13 @@ } }, "node_modules/posthog-react-native": { - "version": "4.37.1", - "resolved": "https://registry.npmjs.org/posthog-react-native/-/posthog-react-native-4.37.1.tgz", - "integrity": "sha512-EHgAC6XkYPKGSOYJUgv5WSoNNAiU+dZdwZSiREKy04qqBr/a3o5ByMvkb8TeJcCE6A/ZFD4YPsXf+01VGmWfvA==", + "version": "4.46.11", + "resolved": "https://registry.npmjs.org/posthog-react-native/-/posthog-react-native-4.46.11.tgz", + "integrity": "sha512-KZgx/o8nNJO4Hfw9PzLjfvBEghy0UAzgq+RTdOrIBX0hS9WOMTjQiaEISL1tXE/jpwfIoAREoo12rYQBo0XFLA==", "license": "MIT", "dependencies": { - "@posthog/core": "1.23.2" + "@posthog/core": "1.30.5", + "@posthog/types": "1.379.2" }, "peerDependencies": { "@react-native-async-storage/async-storage": ">=1.0.0", @@ -23691,7 +23401,7 @@ "expo-device": ">= 4.0.0", "expo-file-system": ">= 13.0.0", "expo-localization": ">= 11.0.0", - "posthog-react-native-session-replay": ">= 1.5.0", + "posthog-react-native-session-replay": ">= 1.6.0", "react-native-device-info": ">= 10.0.0", "react-native-localize": ">= 3.0.0", "react-native-navigation": ">= 6.0.0", @@ -23731,13 +23441,16 @@ }, "react-native-safe-area-context": { "optional": true + }, + "react-native-svg": { + "optional": true } } }, "node_modules/posthog-react-native-session-replay": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/posthog-react-native-session-replay/-/posthog-react-native-session-replay-1.5.1.tgz", - "integrity": "sha512-T5HPkH5rfJgNMudBEWxePbpFsGF3N5Do4A2qJFJeKVikTwIBLGOfYf2LnmJWLHwNJb22iqPDY/THDDF9mg2e9Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/posthog-react-native-session-replay/-/posthog-react-native-session-replay-1.6.0.tgz", + "integrity": "sha512-OCaei77mtgg7JT+TgHSCgpWeKq2XXENUOPNxGbjhXZa/aJpptOW5VsBqjtH4BPzM2c1veS1DK4/Fb/uV4Rb3cg==", "license": "MIT", "peerDependencies": { "react": "*", @@ -23966,30 +23679,6 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, - "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/package.json b/package.json index 92df8562..cf3fad73 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "langquest", "main": "expo-router/entry", - "version": "2.2.1", + "version": "2.2.2", "scripts": { "start": "expo start --dev-client", "start:offline:android": "npm run start -- --localhost --android", @@ -15,6 +15,11 @@ "export:dev:web": "EXPO_ATLAS=true npm run export:web -- --dev", "export:dev:all": "EXPO_ATLAS=true npm run export:all -- --dev", "export:analyze": "npx expo-atlas .expo/atlas.jsonl", + "posthog:upload-sourcemaps": "posthog-cli hermes upload --directory dist", + "update": "eas update && npm run posthog:upload-sourcemaps", + "update:preview": "eas update --branch preview && npm run posthog:upload-sourcemaps", + "update:production": "eas update --branch production && npm run posthog:upload-sourcemaps", + "update:development": "eas update --branch development && npm run posthog:upload-sourcemaps", "generate-env": "npx tsx ./scripts/generate-env.ts", "reset-project": "node ./scripts/reset-project.js", "clean-install": "git clean -xdf node_modules package-lock.json && npm i", @@ -145,9 +150,9 @@ "lucide-react-native": "^0.563.0", "moti": "^0.30.0", "nativewind": "^4.2.1", - "posthog-js": "^1.345.1", - "posthog-react-native": "^4.31.0", - "posthog-react-native-session-replay": "^1.2.4", + "posthog-js": "^1.376.6", + "posthog-react-native": "^4.46.4", + "posthog-react-native-session-replay": "^1.6.0", "react": "19.2.0", "react-compiler-runtime": "^1.0.0", "react-dom": "19.2.0", @@ -185,6 +190,7 @@ "@eslint/compat": "^1.2.4", "@libsql/client": "^0.17.0", "@modelcontextprotocol/sdk": "^1.0.4", + "@posthog/cli": "^0.7.16", "@react-native-community/cli": "latest", "@total-typescript/ts-reset": "^0.6.1", "@types/bidirectional-map": "^1.0.4", diff --git a/services/FiaAttachmentQueue.ts b/services/FiaAttachmentQueue.ts index 92aa698d..9b0bb201 100644 --- a/services/FiaAttachmentQueue.ts +++ b/services/FiaAttachmentQueue.ts @@ -9,9 +9,11 @@ import { system } from '@/db/powersync/system'; import type { FiaPericopeStepsResponse } from '@/hooks/useFiaPericopeSteps'; -import type { FiaAttachmentQueueItem } from '@/store/localStore'; +import type { + FiaAttachmentQueueItem, + FiaPericopeCacheKey +} from '@/store/localStore'; import { useLocalStore } from '@/store/localStore'; -import { useShallow } from 'zustand/react/shallow'; import { getCachedAudioUri } from '@/utils/audioCache'; import { downloadFile, @@ -21,16 +23,14 @@ import { readFileText, writeFile } from '@/utils/fileUtils'; -import { - lookupFiaLanguageCode, - lookupSourceLanguoidId -} from '@/utils/languoidLookups'; +import { lookupFiaLanguageCodeForProject } from '@/utils/languoidLookups'; +import { useShallow } from 'zustand/react/shallow'; const FIA_DIR = 'fia_attachments'; const IMAGES_DIR = `${FIA_DIR}/images`; -function pericopeDataPath(pericopeId: string): string { - return `${getDocumentDirectory()}/${FIA_DIR}/${pericopeId}/response.json`; +function pericopeDataPath(fiaLanguageCode: string, pericopeId: string): string { + return `${getDocumentDirectory()}/${FIA_DIR}/${fiaLanguageCode}/${pericopeId}/response.json`; } function imageCachePath(url: string): string { @@ -79,18 +79,29 @@ function extractAudioUrls(data: FiaPericopeStepsResponse): string[] { return urls; } +function queueKey(item: FiaPericopeCacheKey): FiaPericopeCacheKey { + return { + fiaLanguageCode: item.fiaLanguageCode, + pericopeId: item.pericopeId + }; +} + // --------------------------------------------------------------------------- // Public cache access (used by hooks and components) // --------------------------------------------------------------------------- -export function isFiaPericopeCached(pericopeId: string): boolean { - return fileExists(pericopeDataPath(pericopeId)); +export function isFiaPericopeCached( + fiaLanguageCode: string, + pericopeId: string +): boolean { + return fileExists(pericopeDataPath(fiaLanguageCode, pericopeId)); } export function getCachedFiaPericope( + fiaLanguageCode: string, pericopeId: string ): FiaPericopeStepsResponse | null { - const path = pericopeDataPath(pericopeId); + const path = pericopeDataPath(fiaLanguageCode, pericopeId); if (!fileExists(path)) return null; try { return JSON.parse(readFileText(path)) as FiaPericopeStepsResponse; @@ -134,7 +145,23 @@ function pendingItems(): FiaAttachmentQueueItem[] { } export function enqueue(pericopeId: string, projectId: string) { - getStore().enqueueFiaAttachment({ pericopeId, projectId }); + void enqueueInternal(pericopeId, projectId); +} + +async function enqueueInternal(pericopeId: string, projectId: string) { + const fiaLanguageCode = await lookupFiaLanguageCodeForProject(projectId); + if (!fiaLanguageCode) { + console.error( + `[FiaAttachmentQueue] Cannot enqueue ${pericopeId}: no FIA language code for project ${projectId}` + ); + return; + } + + getStore().enqueueFiaAttachment({ + pericopeId, + projectId, + fiaLanguageCode + }); void processQueue(); } @@ -144,7 +171,7 @@ export function initializeFiaQueue() { (i) => i.status === 'downloading' ); for (const item of stuck) { - store.updateFiaAttachment(item.pericopeId, { status: 'pending' }); + store.updateFiaAttachment(queueKey(item), { status: 'pending' }); } if (pendingItems().length > 0) { console.log( @@ -171,52 +198,61 @@ async function processQueue() { } async function processItem(item: FiaAttachmentQueueItem) { - const { pericopeId, projectId } = item; + const { pericopeId, fiaLanguageCode } = item; + const key = queueKey(item); const store = getStore(); - if (isFiaPericopeCached(pericopeId)) { - store.updateFiaAttachment(pericopeId, { + if (isFiaPericopeCached(fiaLanguageCode, pericopeId)) { + store.updateFiaAttachment(key, { status: 'completed', completedAt: Date.now() }); return; } - store.updateFiaAttachment(pericopeId, { status: 'downloading' }); - console.log(`[FiaAttachmentQueue] Processing ${pericopeId}...`); + store.updateFiaAttachment(key, { status: 'downloading' }); + console.log( + `[FiaAttachmentQueue] Processing ${fiaLanguageCode}/${pericopeId}...` + ); try { ensureDir(`${getDocumentDirectory()}/${FIA_DIR}`); + ensureDir(`${getDocumentDirectory()}/${FIA_DIR}/${fiaLanguageCode}`); - const data = await fetchPericopeSteps(projectId, pericopeId); + const data = await fetchPericopeSteps(fiaLanguageCode, pericopeId); if (!data) throw new Error('Empty response from edge function'); const imageUrls = extractImageUrls(data); console.log( - `[FiaAttachmentQueue] Downloading ${imageUrls.length} images for ${pericopeId}...` + `[FiaAttachmentQueue] Downloading ${imageUrls.length} images for ${fiaLanguageCode}/${pericopeId}...` ); await downloadImages(imageUrls); const audioUrls = extractAudioUrls(data); console.log( - `[FiaAttachmentQueue] Downloading ${audioUrls.length} step audio files for ${pericopeId}...` + `[FiaAttachmentQueue] Downloading ${audioUrls.length} step audio files for ${fiaLanguageCode}/${pericopeId}...` ); await downloadAudioFiles(audioUrls); - writeFile(pericopeDataPath(pericopeId), JSON.stringify(data)); + writeFile( + pericopeDataPath(fiaLanguageCode, pericopeId), + JSON.stringify(data) + ); - getStore().updateFiaAttachment(pericopeId, { + getStore().updateFiaAttachment(key, { status: 'completed', completedAt: Date.now() }); console.log( - `[FiaAttachmentQueue] ✓ ${pericopeId} (${imageUrls.length} images, ${audioUrls.length} audio)` + `[FiaAttachmentQueue] ✓ ${fiaLanguageCode}/${pericopeId} (${imageUrls.length} images, ${audioUrls.length} audio)` ); } catch (e) { const msg = e instanceof Error ? e.message : String(e); - console.error(`[FiaAttachmentQueue] ✗ ${pericopeId}: ${msg}`); - getStore().updateFiaAttachment(pericopeId, { + console.error( + `[FiaAttachmentQueue] ✗ ${fiaLanguageCode}/${pericopeId}: ${msg}` + ); + getStore().updateFiaAttachment(key, { status: 'failed', error: msg }); @@ -237,23 +273,9 @@ function fetchWithTimeout( } async function fetchPericopeSteps( - projectId: string, + fiaLanguageCode: string, pericopeId: string ): Promise { - console.log( - `[FiaAttachmentQueue] Looking up languoid for project ${projectId}...` - ); - const sourceLanguoidId = await lookupSourceLanguoidId(projectId); - if (!sourceLanguoidId) - throw new Error('Could not find source languoid for project'); - - console.log( - `[FiaAttachmentQueue] Looking up FIA code for languoid ${sourceLanguoidId}...` - ); - const fiaCode = await lookupFiaLanguageCode(sourceLanguoidId); - if (!fiaCode) - throw new Error(`No FIA language code for languoid ${sourceLanguoidId}`); - const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL; if (!supabaseUrl) throw new Error('EXPO_PUBLIC_SUPABASE_URL is not configured'); @@ -268,7 +290,7 @@ async function fetchPericopeSteps( const url = `${supabaseUrl}/functions/v1/fia-pericope-steps`; console.log( - `[FiaAttachmentQueue] Fetching edge function: ${url} (fiaCode=${fiaCode}, pericopeId=${pericopeId})` + `[FiaAttachmentQueue] Fetching edge function: ${url} (fiaCode=${fiaLanguageCode}, pericopeId=${pericopeId})` ); const res = await fetchWithTimeout( @@ -279,7 +301,7 @@ async function fetchPericopeSteps( 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ pericopeId, fiaLanguageCode: fiaCode }) + body: JSON.stringify({ pericopeId, fiaLanguageCode }) }, FETCH_TIMEOUT_MS ); @@ -290,7 +312,7 @@ async function fetchPericopeSteps( } console.log( - `[FiaAttachmentQueue] Edge function responded OK for ${pericopeId}` + `[FiaAttachmentQueue] Edge function responded OK for ${fiaLanguageCode}/${pericopeId}` ); return res.json(); } @@ -355,12 +377,16 @@ function scheduleRetry() { // Hook for components to observe a single pericope's status // --------------------------------------------------------------------------- -export function useFiaAttachmentStatus(pericopeId: string | undefined) { +export function useFiaAttachmentStatus( + pericopeId: string | undefined, + fiaLanguageCode: string | null | undefined +) { const status = useLocalStore( useShallow((state) => { - if (!pericopeId) return undefined; + if (!pericopeId || !fiaLanguageCode) return undefined; const item = state.fiaAttachmentQueue.find( - (i) => i.pericopeId === pericopeId + (i) => + i.pericopeId === pericopeId && i.fiaLanguageCode === fiaLanguageCode ); if (!item) return undefined; return { diff --git a/services/posthog.ts b/services/posthog.ts index 7bdde126..af2048ed 100644 --- a/services/posthog.ts +++ b/services/posthog.ts @@ -2,7 +2,23 @@ import { useLocalStore } from '@/store/localStore'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as Device from 'expo-device'; import * as Updates from 'expo-updates'; -import PostHog from 'posthog-react-native'; +import PostHog, { PostHogOptions } from 'posthog-react-native'; + +function isPostHogDisabled() { + return ( + __DEV__ || // comment out when you want to test analytics locally (with preview environment eas env:pull ) + !process.env.EXPO_PUBLIC_POSTHOG_HOST || + !process.env.EXPO_PUBLIC_POSTHOG_KEY + ); +} + +const posthogErrorTracking = { + autocapture: { + uncaughtExceptions: true, + unhandledRejections: true, + console: ['error', 'warn'] + } +} satisfies PostHogOptions['errorTracking']; // Simple initialization without circular dependency const createPostHogInstance = (optIn = false) => { @@ -13,12 +29,10 @@ const createPostHogInstance = (optIn = false) => { maskAllImages: false, maskAllTextInputs: true }, + errorTracking: posthogErrorTracking, enablePersistSessionIdAcrossRestart: true, defaultOptIn: optIn, - disabled: - __DEV__ || - !process.env.EXPO_PUBLIC_POSTHOG_HOST || - !process.env.EXPO_PUBLIC_POSTHOG_KEY, + disabled: isPostHogDisabled(), customStorage: AsyncStorage }); }; @@ -26,6 +40,44 @@ const createPostHogInstance = (optIn = false) => { // Initialize PostHog with basic settings immediately (no circular dependency) const posthog = createPostHogInstance(); +let pendingPostHogUserId: string | null = null; +let lastIdentifiedPostHogUserId: string | null = null; + +function getAnalyticsOptIn() { + const { dateTermsAccepted, analyticsOptOut } = useLocalStore.getState(); + return !analyticsOptOut && !!dateTermsAccepted; +} + +/** + * Links PostHog persons to Supabase auth user ids for field troubleshooting. + * Call from AuthContext when session changes; identity is applied only when analytics is opted in. + */ +export const syncPostHogIdentity = async () => { + if (isPostHogDisabled()) { + return; + } + + const shouldOptIn = getAnalyticsOptIn(); + + try { + if (shouldOptIn && pendingPostHogUserId) { + await posthog.identify(pendingPostHogUserId); + lastIdentifiedPostHogUserId = pendingPostHogUserId; + } else if (lastIdentifiedPostHogUserId !== null) { + posthog.reset(); + lastIdentifiedPostHogUserId = null; + } + } catch (error) { + console.warn('Failed to sync PostHog identity:', error); + } +}; + +/** Set the authenticated user id (or null). Re-syncs identity when consent allows. */ +export const setPostHogUserId = (userId: string | null) => { + pendingPostHogUserId = userId; + void syncPostHogIdentity(); +}; + const changeAnalyticsState = async (newState: boolean) => { if (newState) { await posthog.optIn(); @@ -34,6 +86,8 @@ const changeAnalyticsState = async (newState: boolean) => { } else { await posthog.optOut(); } + + await syncPostHogIdentity(); }; /** diff --git a/services/posthog.web.ts b/services/posthog.web.ts index 86774a34..2e0679a4 100644 --- a/services/posthog.web.ts +++ b/services/posthog.web.ts @@ -19,7 +19,12 @@ function createPostHogInstance(optIn = false) { try { posthog.init(process.env.EXPO_PUBLIC_POSTHOG_KEY ?? 'phc_', { api_host: `${process.env.EXPO_PUBLIC_POSTHOG_HOST}/relay-Mx9k`, - opt_out_capturing_by_default: isDisabled() + opt_out_capturing_by_default: isDisabled(), + capture_exceptions: { + capture_unhandled_errors: true, + capture_unhandled_rejections: true, + capture_console_errors: true + } }); if (optIn) { @@ -37,6 +42,39 @@ function createPostHogInstance(optIn = false) { // Initialize immediately with conservative defaults; we'll wire consent via store below createPostHogInstance(false); +let pendingPostHogUserId: string | null = null; +let lastIdentifiedPostHogUserId: string | null = null; + +function getAnalyticsOptIn() { + const { dateTermsAccepted, analyticsOptOut } = useLocalStore.getState(); + return !analyticsOptOut && !!dateTermsAccepted; +} + +export const syncPostHogIdentity = () => { + if (isDisabled()) { + return; + } + + const shouldOptIn = getAnalyticsOptIn(); + + try { + if (shouldOptIn && pendingPostHogUserId) { + posthog.identify(pendingPostHogUserId); + lastIdentifiedPostHogUserId = pendingPostHogUserId; + } else if (lastIdentifiedPostHogUserId !== null) { + posthog.reset(); + lastIdentifiedPostHogUserId = null; + } + } catch (error) { + console.warn('Failed to sync PostHog identity (web):', error); + } +}; + +export const setPostHogUserId = (userId: string | null) => { + pendingPostHogUserId = userId; + syncPostHogIdentity(); +}; + function changeAnalyticsState(newState: boolean) { if (isDisabled()) return; if (newState) { @@ -44,6 +82,8 @@ function changeAnalyticsState(newState: boolean) { } else { posthog.opt_out_capturing(); } + + syncPostHogIdentity(); } export const initializePostHogWithStore = () => { @@ -52,7 +92,7 @@ export const initializePostHogWithStore = () => { try { const shouldOptIn = !analyticsOptOut && !!dateTermsAccepted; - void changeAnalyticsState(shouldOptIn); + changeAnalyticsState(shouldOptIn); let previousOptOut = analyticsOptOut; let previousTermsDate = dateTermsAccepted; @@ -66,7 +106,7 @@ export const initializePostHogWithStore = () => { previousTermsDate = newTermsDate; const newShouldOptIn = !newOptOut && !!newTermsDate; - void changeAnalyticsState(newShouldOptIn); + changeAnalyticsState(newShouldOptIn); } }); diff --git a/store/localStore.ts b/store/localStore.ts index 95dfafc9..8dd6b986 100644 --- a/store/localStore.ts +++ b/store/localStore.ts @@ -43,8 +43,12 @@ export type FiaAttachmentStatus = | 'completed' | 'failed'; -export interface FiaAttachmentQueueItem { +export interface FiaPericopeCacheKey { + fiaLanguageCode: string; pericopeId: string; +} + +export interface FiaAttachmentQueueItem extends FiaPericopeCacheKey { projectId: string; status: FiaAttachmentStatus; error?: string; @@ -52,6 +56,19 @@ export interface FiaAttachmentQueueItem { completedAt?: number; } +/** Drops pre-LQ-17 queue rows that lack fiaLanguageCode (pericope-only dedupe era). */ +function sanitizeFiaAttachmentQueue(queue: unknown): FiaAttachmentQueueItem[] { + if (!Array.isArray(queue)) return []; + return queue.filter( + (item): item is FiaAttachmentQueueItem => + !!item && + typeof item === 'object' && + typeof (item as FiaAttachmentQueueItem).pericopeId === 'string' && + typeof (item as FiaAttachmentQueueItem).fiaLanguageCode === 'string' && + typeof (item as FiaAttachmentQueueItem).projectId === 'string' + ); +} + // AsyncStorage keys for user preferences export const OFFLINE_UNDOWNLOAD_WARNING_KEY = '@offline_undownload_warning'; @@ -301,12 +318,13 @@ export interface LocalState { enqueueFiaAttachment: (item: { pericopeId: string; projectId: string; + fiaLanguageCode: string; }) => void; updateFiaAttachment: ( - pericopeId: string, + key: FiaPericopeCacheKey, update: Partial ) => void; - removeFiaAttachment: (pericopeId: string) => void; + removeFiaAttachment: (key: FiaPericopeCacheKey) => void; clearCompletedFiaAttachments: () => void; // Invite members banner dismissal tracking (projectId -> timestamp) @@ -317,6 +335,18 @@ export interface LocalState { /** Per-project invite rows hidden from the membership modal Invited list (local only). */ dismissedInvitedRows: Record; dismissInvitedRow: (projectId: string, inviteId: string) => void; + undismissInvitedRow: (projectId: string, inviteId: string) => void; + + /** + * Ephemeral "NEW" labels for translation/transcription cards in asset detail. + * Keyed by source asset id; cleared when leaving asset detail (not persisted). + */ + newChildAssetsInDetailView: Record; + markNewChildAssetInDetailView: ( + sourceAssetId: string, + childAssetId: string + ) => void; + clearNewChildAssetsInDetailView: (sourceAssetId: string) => void; } export const useLocalStore = create()( @@ -682,32 +712,47 @@ export const useLocalStore = create()( // FIA attachment queue fiaAttachmentQueue: [], - enqueueFiaAttachment: ({ pericopeId, projectId }) => + enqueueFiaAttachment: ({ pericopeId, projectId, fiaLanguageCode }) => set((state) => { - if (state.fiaAttachmentQueue.some((i) => i.pericopeId === pericopeId)) + if ( + state.fiaAttachmentQueue.some( + (i) => + i.pericopeId === pericopeId && + i.fiaLanguageCode === fiaLanguageCode + ) + ) { return state; + } return { fiaAttachmentQueue: [ ...state.fiaAttachmentQueue, { pericopeId, projectId, + fiaLanguageCode, status: 'pending' as const, enqueuedAt: Date.now() } ] }; }), - updateFiaAttachment: (pericopeId, update) => + updateFiaAttachment: (key, update) => set((state) => ({ fiaAttachmentQueue: state.fiaAttachmentQueue.map((item) => - item.pericopeId === pericopeId ? { ...item, ...update } : item + item.pericopeId === key.pericopeId && + item.fiaLanguageCode === key.fiaLanguageCode + ? { ...item, ...update } + : item ) })), - removeFiaAttachment: (pericopeId) => + removeFiaAttachment: (key) => set((state) => ({ fiaAttachmentQueue: state.fiaAttachmentQueue.filter( - (i) => i.pericopeId !== pericopeId + (i) => + !( + i.pericopeId === key.pericopeId && + i.fiaLanguageCode === key.fiaLanguageCode + ) ) })), clearCompletedFiaAttachments: () => @@ -744,6 +789,40 @@ export const useLocalStore = create()( [projectId]: [...existing, inviteId] } }; + }), + undismissInvitedRow: (projectId, inviteId) => + set((state) => { + const existing = state.dismissedInvitedRows[projectId]; + if (!existing?.includes(inviteId)) return state; + const nextIds = existing.filter((id) => id !== inviteId); + const nextDismissed = { ...state.dismissedInvitedRows }; + if (nextIds.length === 0) { + delete nextDismissed[projectId]; + } else { + nextDismissed[projectId] = nextIds; + } + return { dismissedInvitedRows: nextDismissed }; + }), + + newChildAssetsInDetailView: {}, + markNewChildAssetInDetailView: (sourceAssetId, childAssetId) => + set((state) => { + const existing = + state.newChildAssetsInDetailView[sourceAssetId] ?? []; + if (existing.includes(childAssetId)) return state; + return { + newChildAssetsInDetailView: { + ...state.newChildAssetsInDetailView, + [sourceAssetId]: [...existing, childAssetId] + } + }; + }), + clearNewChildAssetsInDetailView: (sourceAssetId) => + set((state) => { + if (!state.newChildAssetsInDetailView[sourceAssetId]) return state; + const next = { ...state.newChildAssetsInDetailView }; + delete next[sourceAssetId]; + return { newChildAssetsInDetailView: next }; }) }), { @@ -762,11 +841,23 @@ export const useLocalStore = create()( delete state.enableLanguoidLinkSuggestions; delete state.enableProjectLanguoidSuggestions; } + if ('fiaAttachmentQueue' in state) { + state.fiaAttachmentQueue = sanitizeFiaAttachmentQueue( + state.fiaAttachmentQueue + ); + } return persistedState as LocalState; }, onRehydrateStorage: () => async (state) => { console.log('rehydrating local store', state); if (state) { + const sanitizedQueue = sanitizeFiaAttachmentQueue( + state.fiaAttachmentQueue + ); + if (sanitizedQueue.length !== state.fiaAttachmentQueue.length) { + useLocalStore.setState({ fiaAttachmentQueue: sanitizedQueue }); + } + state.setTheme(state.theme); // Validate and clamp VAD threshold if invalid if ( @@ -808,7 +899,12 @@ export const useLocalStore = create()( Object.fromEntries( Object.entries(state).filter( ([key]) => - !['_hasHydrated', 'systemReady', 'currentUser'].includes(key) + ![ + '_hasHydrated', + 'systemReady', + 'currentUser', + 'newChildAssetsInDetailView' + ].includes(key) ) ) } diff --git a/supabase/config.toml b/supabase/config.toml index 2065551b..c50b0426 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -323,6 +323,7 @@ policy = "oneshot" inspector_port = 8083 [edge_runtime.secrets] +AUTH_SITE_URL = "env(AUTH_SITE_URL)" SEND_EMAIL_HOOK_SECRET = "env(SEND_EMAIL_HOOK_SECRET)" RESEND_API_KEY = "env(RESEND_API_KEY)" RESEND_WEBHOOK_SECRET = "env(RESEND_WEBHOOK_SECRET)" diff --git a/utils/languoidLookups.ts b/utils/languoidLookups.ts index 73320ebc..4af6e9c0 100644 --- a/utils/languoidLookups.ts +++ b/utils/languoidLookups.ts @@ -78,6 +78,14 @@ export async function lookupFiaLanguageCode( return data.value; } +export async function lookupFiaLanguageCodeForProject( + projectId: string +): Promise { + const sourceLanguoidId = await lookupSourceLanguoidId(projectId); + if (!sourceLanguoidId) return null; + return lookupFiaLanguageCode(sourceLanguoidId); +} + export async function lookupIso639_3( languoidId: string ): Promise { diff --git a/views/new/AssetCardItem.tsx b/views/new/AssetCardItem.tsx index 90ea9f76..e5c71156 100644 --- a/views/new/AssetCardItem.tsx +++ b/views/new/AssetCardItem.tsx @@ -1,4 +1,5 @@ import { DownloadIndicator } from '@/components/DownloadIndicator'; +import { NewHighlightBadge } from '@/components/NewHighlightBadge'; // import { Badge } from '@/components/ui/badge'; import { Card, @@ -12,8 +13,8 @@ import { LayerType, useStatusContext } from '@/contexts/StatusContext'; // import type { Tag } from '@/database_services/tagCache'; // import { tagService } from '@/database_services/tagService'; import type { asset as asset_type } from '@/db/drizzleSchema'; -import { useNavigationHelpers } from '@/hooks/useNavigation'; import { useLocalization } from '@/hooks/useLocalization'; +import { useNavigationHelpers } from '@/hooks/useNavigation'; // import { useTagStore } from '@/hooks/useTagStore'; import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags'; import type { AttachmentRecord } from '@powersync/attachments'; @@ -374,13 +375,7 @@ const AssetCardItemComponent: React.FC = ({ {/* Actions: Edit name + Open details (hidden in selection mode) */} {/* Highlight indicator badge */} - {isHighlighted && ( - - - NEW - - - )} + {isHighlighted && } {!isSelectionMode && !isPublished && diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index 25ebbe12..2c975148 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -71,6 +71,7 @@ import { useHybridData } from './useHybridData'; import { AssetListSkeleton } from '@/components/AssetListSkeleton'; import { ExportButton } from '@/components/ExportButton'; +import { PublishQuestButton } from '@/components/PublishQuestButton'; import type { FiaDrawerState } from '@/components/FiaStepDrawer'; import { FiaStepDrawer, @@ -124,6 +125,8 @@ import { AppConfig } from '@/db/supabase/AppConfig'; import { useAssetsByQuest, useLocalAssetsByQuest } from '@/hooks/db/useAssets'; import { useBlockedAssetsCount } from '@/hooks/useBlockedCount'; import { useFiaPericopeSteps } from '@/hooks/useFiaPericopeSteps'; +import { useProjectFiaLanguageCode } from '@/hooks/useProjectFiaLanguageCode'; +import { isFiaPericopeCached } from '@/services/FiaAttachmentQueue'; import { useQuestOffloadVerification } from '@/hooks/useQuestOffloadVerification'; import { useHasUserReported } from '@/hooks/useReports'; import { useUndoHistory } from '@/hooks/useUndoHistory'; @@ -817,6 +820,16 @@ export default function BibleAssetsView() { ? (fiaMetaExtracted?.pericopeId ?? null) : null; + const { fiaLanguageCode } = useProjectFiaLanguageCode( + fiaPericopeId ? projectId : undefined + ); + + const needsFiaRecache = Boolean( + fiaPericopeId && + fiaLanguageCode && + !isFiaPericopeCached(fiaLanguageCode, fiaPericopeId) + ); + // Fetch all FIA steps (only for FIA pericope quests) const { data: fiaStepsData, isLoading: fiaStepsLoading } = useFiaPericopeSteps( @@ -825,18 +838,24 @@ export default function BibleAssetsView() { ); // Auto-open FIA steps drawer once per quest per session. - // Once the user closes it, don't reopen (even after navigating to recording and back). + // Open immediately when guide content must be downloaded (e.g. post LQ-17 recache). + // When cached, open after data is ready. If the user dismissed the drawer, don't reopen + // unless content is missing and needs a fresh download. React.useEffect(() => { - if ( - fiaPericopeId && - questId && - fiaStepsData && - !fiaStepsLoading && - !fiaDrawerDismissedQuests.has(questId) - ) { + if (!fiaPericopeId || !questId) return; + + const dismissed = fiaDrawerDismissedQuests.has(questId); + if (dismissed && !needsFiaRecache) return; + + if (needsFiaRecache) { + setShowFiaTextDrawer(true); + return; + } + + if (fiaStepsData && !fiaStepsLoading) { setShowFiaTextDrawer(true); } - }, [fiaPericopeId, questId, fiaStepsData, fiaStepsLoading]); + }, [fiaPericopeId, questId, needsFiaRecache, fiaStepsData, fiaStepsLoading]); // Build the ordered verse sequence for FIA pericopes (null for standard chapters) const pericopeSequence = React.useMemo(() => { @@ -3546,12 +3565,16 @@ export default function BibleAssetsView() { return; } - await togglePlayAll({ playlist }); + await togglePlayAll({ + playlist, + playlistKey: questId ? `bible-assets:${questId}` : undefined + }); }, [ assets, getAssetAudioUris, isIndividualPlayerActive, + questId, selectedAssetIds, togglePlayAll ] @@ -4012,67 +4035,15 @@ export default function BibleAssetsView() { // Only show publish/export buttons for authenticated users currentUser && ( <> - + isPublishing={isPublishing} + isOnline={isOnline} + isMember={isMember} + hasLocalAssets={assets.length > 0} + onPublish={() => publishQuest()} + /> {questId && projectId && ( {assets.length > 0 && ( )} diff --git a/views/new/NextGenAssetDetailView.tsx b/views/new/NextGenAssetDetailView.tsx index d0dd3448..e84b80a6 100644 --- a/views/new/NextGenAssetDetailView.tsx +++ b/views/new/NextGenAssetDetailView.tsx @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unnecessary-condition */ import { AssetSettingsModal } from '@/components/AssetSettingsModal'; +import { NewHighlightBadge } from '@/components/NewHighlightBadge'; import { AssetSkeleton } from '@/components/AssetSkeleton'; import ImageCarousel from '@/components/ImageCarousel'; import { ReportModal } from '@/components/NewReportModal'; @@ -308,12 +309,31 @@ export default function NextGenAssetDetailView() { [questData?.metadata, activeRecordingSessionId] ); + const markNewChildAssetInDetailView = useLocalStore( + (state) => state.markNewChildAssetInDetailView + ); + const clearNewChildAssetsInDetailView = useLocalStore( + (state) => state.clearNewChildAssetsInDetailView + ); + + // Refetch quest on focus; keep cleanup separate so refetch identity changes + // don't clear ephemeral NEW badges after creating translations. useFocusEffect( React.useCallback(() => { void refetchQuest(); }, [refetchQuest]) ); + useFocusEffect( + React.useCallback(() => { + return () => { + if (assetId) { + clearNewChildAssetsInDetailView(assetId); + } + }; + }, [assetId, clearNewChildAssetsInDetailView]) + ); + // Get target languoid_id from project_language_link const { data: targetLanguoidLink = [] } = useHybridData<{ languoid_id: string | null; @@ -717,8 +737,10 @@ export default function NextGenAssetDetailView() { ); } - const handleTranslationSuccess = () => { - // Refresh the translations list by forcing a re-render + const handleTranslationSuccess = (newAssetId: string) => { + if (assetId) { + markNewChildAssetInDetailView(assetId, newAssetId); + } setShowNewTranslationModal(false); setTranslationsRefreshKey((prev) => prev + 1); }; @@ -854,13 +876,7 @@ export default function NextGenAssetDetailView() { {activeAsset.name} {/* New badge for recently recorded assets */} - {isHighlighted && ( - - - NEW - - - )} + {isHighlighted && } {Boolean(projectData?.private) && ( diff --git a/views/new/NextGenAssetsView.tsx b/views/new/NextGenAssetsView.tsx index 1c65fc81..97335c67 100644 --- a/views/new/NextGenAssetsView.tsx +++ b/views/new/NextGenAssetsView.tsx @@ -75,6 +75,7 @@ import { useHybridData } from './useHybridData'; import { AssetListSkeleton } from '@/components/AssetListSkeleton'; import { ExportButton } from '@/components/ExportButton'; +import { PublishQuestButton } from '@/components/PublishQuestButton'; import { ModalDetails } from '@/components/ModalDetails'; import { ReportModal } from '@/components/NewReportModal'; import { PrivateAccessGate } from '@/components/PrivateAccessGate'; @@ -1344,11 +1345,15 @@ export default function NextGenAssetsView() { } setShowPlayAllControls(true); - await togglePlayAll({ playlist }); + await togglePlayAll({ + playlist, + playlistKey: questId ? `nextgen-assets:${questId}` : undefined + }); }, [ assets, getAssetAudioUris, isIndividualPlayerActive, + questId, selectedAssetIds, togglePlayAll ]); @@ -1715,72 +1720,15 @@ export default function NextGenAssetsView() { // Only show publish/record buttons for authenticated users currentUser && ( - - {/* */} + isPublishing={isPublishing} + isOnline={isOnline} + isMember={isMember} + hasLocalAssets={assets.length > 0} + onPublish={() => publishQuest()} + /> {questId && projectId && ( {assets.length > 0 && ( )} diff --git a/views/new/NextGenNewTranslationModal.tsx b/views/new/NextGenNewTranslationModal.tsx index 4142d4c6..0b475857 100644 --- a/views/new/NextGenNewTranslationModal.tsx +++ b/views/new/NextGenNewTranslationModal.tsx @@ -82,7 +82,7 @@ type AssetContent = typeof asset_content_link.$inferSelect; interface NextGenNewTranslationModalProps { visible: boolean; onClose: () => void; - onSuccess?: () => void; + onSuccess?: (newAssetId: string) => void; assetId: string; assetName?: string | null; assetContent?: AssetContent[]; @@ -506,7 +506,7 @@ export default function NextGenNewTranslationModal({ console.log( `[CREATE ${contentType.toUpperCase()}] Starting transaction... (isLocalSource: ${isLocalSource})` ); - await system.db.transaction(async (tx) => { + const newAssetId = await system.db.transaction(async (tx) => { const [newAsset] = await tx .insert(resolveTable('asset', tableOptions)) .values({ @@ -552,12 +552,16 @@ export default function NextGenNewTranslationModal({ asset_id: newAsset.id, download_profiles: [currentUser.id] }); + + return newAsset.id; }); + + return newAssetId; }, - onSuccess: () => { + onSuccess: (newAssetId) => { Keyboard.dismiss(); form.reset(defaultValues); - onSuccess?.(); + onSuccess?.(newAssetId); onClose(); }, onError: (error) => { diff --git a/views/new/NextGenTranslationModalAlt.tsx b/views/new/NextGenTranslationModalAlt.tsx index cb203f73..eeed8bdf 100644 --- a/views/new/NextGenTranslationModalAlt.tsx +++ b/views/new/NextGenTranslationModalAlt.tsx @@ -60,6 +60,8 @@ interface NextGenTranslationModalProps { onOpenChange: (open: boolean) => void; assetId: string; onVoteSuccess?: () => void; + /** Called when edit-submit creates a new translation/transcription (duplicate). */ + onChildAssetCreated?: (newAssetId: string) => void; canVote?: boolean; isPrivateProject?: boolean; projectId?: string; @@ -162,6 +164,7 @@ export default function NextGenTranslationModal({ onOpenChange, assetId, onVoteSuccess, + onChildAssetCreated, isPrivateProject = false, projectId, projectName @@ -334,7 +337,7 @@ export default function NextGenTranslationModal({ `[CREATE ${contentTypeToCreate.toUpperCase()}] Starting transaction... (isLocalSource: ${isLocalSource})` ); - await system.db.transaction(async (tx) => { + const newAssetId = await system.db.transaction(async (tx) => { // Create a new asset that points to the original source asset const [newAsset] = await tx .insert(resolveTable('asset', tableOptions)) @@ -391,10 +394,15 @@ export default function NextGenTranslationModal({ asset_id: newAsset.id, download_profiles: [currentUser.id] }); + + return newAsset.id; }); + + return newAssetId; }, - onSuccess: () => { + onSuccess: (newAssetId) => { setIsEditing(false); + onChildAssetCreated?.(newAssetId); onVoteSuccess?.(); // Refresh the list onOpenChange(false); }, diff --git a/views/new/NextGenTranslationsList.tsx b/views/new/NextGenTranslationsList.tsx index b0ce115c..31e71226 100644 --- a/views/new/NextGenTranslationsList.tsx +++ b/views/new/NextGenTranslationsList.tsx @@ -22,7 +22,9 @@ import { ShieldOffIcon, ThumbsUpIcon } from 'lucide-react-native'; -import React, { useState } from 'react'; +import { useLocalStore } from '@/store/localStore'; +import React, { useMemo, useState } from 'react'; +import { useShallow } from 'zustand/react/shallow'; import { ActivityIndicator, View } from 'react-native'; import NextGenTranslationModal from './NextGenTranslationModalAlt'; @@ -74,6 +76,24 @@ export default function NextGenTranslationsList({ // Count blocked translations const blockedCount = useBlockedTranslationsCount(assetId); + const newChildAssetIds = useLocalStore( + useShallow((state) => state.newChildAssetsInDetailView[assetId] ?? []) + ); + const newChildAssetIdSet = useMemo( + () => new Set(newChildAssetIds), + [newChildAssetIds] + ); + const markNewChildAssetInDetailView = useLocalStore( + (state) => state.markNewChildAssetInDetailView + ); + + const handleChildAssetCreated = React.useCallback( + (newAssetId: string) => { + markNewChildAssetInDetailView(assetId, newAssetId); + }, + [assetId, markNewChildAssetInDetailView] + ); + const isPrivateProject = projectData?.private || false; const canVote = canVoteProp !== undefined ? canVoteProp : !isPrivateProject; @@ -112,6 +132,24 @@ export default function NextGenTranslationsList({ setSortOrder((prev) => (prev === 'desc' ? 'asc' : 'desc')); }; + const renderTranslationItem = React.useCallback( + ({ item }: { item: WithSource }) => ( + + ), + [ + getAudioSegments, + getPreviewText, + handleTranslationPress, + newChildAssetIdSet + ] + ); + return ( {/* Header with sort options */} @@ -223,18 +261,12 @@ export default function NextGenTranslationsList({ data={assets} key={`${assets.length}-${sortOption}-${sortOrder}`} keyExtractor={(item) => item.id} + extraData={newChildAssetIds} recycleItems estimatedItemSize={120} maintainVisibleContentPosition contentContainerStyle={{ gap: 12 }} - renderItem={({ item }) => ( - - )} + renderItem={renderTranslationItem} ListEmptyComponent={() => ( { console.log(`🗑️ [Offload Progress] ${progress}%: ${message}`); } }); + } catch (error) { + console.error('🗑️ [Offload] Failed:', error); + RNAlert.alert(t('error'), t('offloadError')); + setIsOffloading(false); + setQuestIdToDownload(null); + return; + } - console.log('🗑️ [Offload] Complete - registering sync callback...'); + console.log('🗑️ [Offload] Complete - registering sync callback...'); - // Register callback to invalidate queries after PowerSync sync completes - if (questIdToDownload) { - syncCallbackService.registerCallback(questIdToDownload, async () => { - console.log('🗑️ [Offload] Sync completed - invalidating queries'); - - // Invalidate all quest queries for this project - await queryClient.invalidateQueries({ - queryKey: ['quests', 'infinite', 'for-project', projectId] - }); + if (questIdForSyncCallback) { + syncCallbackService.registerCallback(questIdForSyncCallback, async () => { + console.log('🗑️ [Offload] Sync completed - invalidating queries'); - await queryClient.invalidateQueries({ - queryKey: ['quests', 'offline', 'for-project', projectId] - }); + await queryClient.invalidateQueries({ + queryKey: ['quests', 'infinite', 'for-project', projectId] + }); - await queryClient.invalidateQueries({ - queryKey: ['quests', 'cloud', 'for-project', projectId] - }); + await queryClient.invalidateQueries({ + queryKey: ['quests', 'offline', 'for-project', projectId] + }); - console.log('🗑️ [Offload] Queries invalidated - UI will refresh'); + await queryClient.invalidateQueries({ + queryKey: ['quests', 'cloud', 'for-project', projectId] }); - } - } catch (error) { - console.error('🗑️ [Offload] Failed:', error); - RNAlert.alert(t('error'), t('offloadError')); - } finally { - setIsOffloading(false); - setQuestIdToDownload(null); + + console.log('🗑️ [Offload] Queries invalidated - UI will refresh'); + }); } + + setIsOffloading(false); + setQuestIdToDownload(null); }; // Handle offload cancellation