From 76d39c58246a4e7ca29c5f9264066829cbfc4673 Mon Sep 17 00:00:00 2001 From: keeandev <38674879+keeandev@users.noreply.github.com> Date: Sat, 30 May 2026 17:58:48 -0500 Subject: [PATCH 01/14] feat: add linear import script to git ignore for now --- .gitignore | 2 ++ aso-skills | 1 + 2 files changed, 3 insertions(+) create mode 160000 aso-skills 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/aso-skills b/aso-skills new file mode 160000 index 00000000..dbb99d22 --- /dev/null +++ b/aso-skills @@ -0,0 +1 @@ +Subproject commit dbb99d2298ed0aa8bac7421073ea214a4556c6de From 786d7d9e0366c800d21f61b383375d44bac42b68 Mon Sep 17 00:00:00 2001 From: Keean <38674879+keeandev@users.noreply.github.com> Date: Sat, 30 May 2026 19:45:25 -0500 Subject: [PATCH 02/14] fix: improve performance in email entry field invite members - all glory to God (#879) * feat: add linear import script to git ignore for now * refactor: make invite and offload handlers React Compiler-compatible Co-authored-by: Cursor * refactor: isolate component to reduce rerenders for typing performance * fix: remove aso-skills repo --------- Co-authored-by: Cursor --- .gitignore | 2 + components/ProjectMembershipModal.tsx | 673 ++++++++++++++------------ views/new/ProjectDirectoryView.tsx | 54 ++- 3 files changed, 398 insertions(+), 331 deletions(-) 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/components/ProjectMembershipModal.tsx b/components/ProjectMembershipModal.tsx index f957f8c6..084365a0 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) => @@ -605,34 +697,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 +741,87 @@ 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; } + + RNAlert.alert(t('success'), t('invitationResent')); }; const handleDismissInvitedFromList = async (invitation: Invitation) => { @@ -834,9 +933,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 +968,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 +977,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 +1009,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 +1116,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 +1155,71 @@ 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 + + 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 +1508,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/views/new/ProjectDirectoryView.tsx b/views/new/ProjectDirectoryView.tsx index c2aceef1..7f54ef89 100644 --- a/views/new/ProjectDirectoryView.tsx +++ b/views/new/ProjectDirectoryView.tsx @@ -833,45 +833,49 @@ export default function ProjectDirectoryView() { setShowOffloadDrawer(false); setIsOffloading(true); + const offloadQuestId = questIdToDownload || ''; + const questIdForSyncCallback = questIdToDownload; + try { await offloadQuest({ - questId: questIdToDownload || '', + questId: offloadQuestId, verifiedIds: verificationState.verifiedIds, onProgress: (progress, message) => { 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 From e493deaae5e21cd2c4c818cf3ed61f42f3a709a0 Mon Sep 17 00:00:00 2001 From: Keean <38674879+keeandev@users.noreply.github.com> Date: Sat, 30 May 2026 23:07:02 -0500 Subject: [PATCH 03/14] fix(fia): key pericope cache and download queue by language code (#881) * fix(fia): key pericope cache and download queue by language code FIA steps were cached under pericope id only, so French content could load in English projects. Scope disk cache, queue dedupe, and hooks by (fiaLanguageCode, pericopeId). Sanitize legacy queue rows on hydrate. Fixes LQ-17 * fix: remove git submodule * style(fia): run prettier on LQ-17 files Co-authored-by: Cursor * fix: open fia drawer if content is not cached yet - can be temporary solution to make sure the migration to the new pericope cache structure works * fix: formatting --------- Co-authored-by: Cursor --- aso-skills | 1 - components/FiaStepDrawer.tsx | 17 +++- hooks/useFiaPericopeSteps.ts | 13 ++-- hooks/useProjectFiaLanguageCode.ts | 24 ++++++ services/FiaAttachmentQueue.ts | 120 ++++++++++++++++++----------- store/localStore.ts | 63 ++++++++++++--- utils/languoidLookups.ts | 8 ++ views/new/BibleAssetsView.tsx | 36 ++++++--- 8 files changed, 208 insertions(+), 74 deletions(-) delete mode 160000 aso-skills create mode 100644 hooks/useProjectFiaLanguageCode.ts diff --git a/aso-skills b/aso-skills deleted file mode 160000 index dbb99d22..00000000 --- a/aso-skills +++ /dev/null @@ -1 +0,0 @@ -Subproject commit dbb99d2298ed0aa8bac7421073ea214a4556c6de 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/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/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/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/store/localStore.ts b/store/localStore.ts index 95dfafc9..9482fe39 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) @@ -682,32 +700,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: () => @@ -762,11 +795,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 ( 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/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index 25ebbe12..27855c70 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -124,6 +124,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 +819,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 +837,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(() => { From 56ade81e839979915e90544cf906a0c9fbfae97e Mon Sep 17 00:00:00 2001 From: Keean <38674879+keeandev@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:06:04 -0500 Subject: [PATCH 04/14] feat: add new badge to translation/transcription cards (#882) --- components/NewHighlightBadge.tsx | 28 ++++ components/TranslationCard.tsx | 156 +++++++++++++---------- store/localStore.ts | 39 +++++- views/new/AssetCardItem.tsx | 11 +- views/new/NextGenAssetDetailView.tsx | 34 +++-- views/new/NextGenNewTranslationModal.tsx | 12 +- views/new/NextGenTranslationModalAlt.tsx | 12 +- views/new/NextGenTranslationsList.tsx | 51 ++++++-- 8 files changed, 242 insertions(+), 101 deletions(-) create mode 100644 components/NewHighlightBadge.tsx 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/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/store/localStore.ts b/store/localStore.ts index 9482fe39..8ae8da1f 100644 --- a/store/localStore.ts +++ b/store/localStore.ts @@ -335,6 +335,17 @@ export interface LocalState { /** Per-project invite rows hidden from the membership modal Invited list (local only). */ dismissedInvitedRows: Record; dismissInvitedRow: (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()( @@ -777,6 +788,27 @@ export const useLocalStore = create()( [projectId]: [...existing, inviteId] } }; + }), + + 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 }; }) }), { @@ -853,7 +885,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/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/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/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={() => ( Date: Wed, 3 Jun 2026 22:20:55 -0500 Subject: [PATCH 05/14] feat(analytics): identify PostHog users by supabase auth id Wire identify/reset to auth session and analytics consent so support can filter PostHog persons by user UUID. --- .eas/workflows/run-preview-tests.yml | 8 ++++ .eas/workflows/run-production-tests.yml | 8 ++++ .env.local.example | 1 + contexts/AuthContext.tsx | 5 +++ services/posthog.ts | 54 +++++++++++++++++++++++-- services/posthog.web.ts | 39 +++++++++++++++++- 6 files changed, 109 insertions(+), 6 deletions(-) diff --git a/.eas/workflows/run-preview-tests.yml b/.eas/workflows/run-preview-tests.yml index f94da9eb..0038cd58 100644 --- a/.eas/workflows/run-preview-tests.yml +++ b/.eas/workflows/run-preview-tests.yml @@ -59,6 +59,8 @@ jobs: type: repack environment: preview runs_on: linux-medium + env: + EXPO_PUBLIC_POSTHOG_DISABLED: 'true' params: build_id: ${{ needs.android_get_build.outputs.build_id }} @@ -69,6 +71,8 @@ jobs: type: build environment: preview runs_on: linux-medium + env: + EXPO_PUBLIC_POSTHOG_DISABLED: 'true' params: platform: android profile: preview @@ -80,6 +84,8 @@ jobs: type: repack environment: preview runs_on: macos-medium + env: + EXPO_PUBLIC_POSTHOG_DISABLED: 'true' params: build_id: ${{ needs.ios_get_build.outputs.build_id }} @@ -90,6 +96,8 @@ jobs: type: build environment: preview runs_on: macos-medium + env: + EXPO_PUBLIC_POSTHOG_DISABLED: 'true' params: platform: ios profile: preview-simulator diff --git a/.eas/workflows/run-production-tests.yml b/.eas/workflows/run-production-tests.yml index 66f9541a..aa0e6787 100644 --- a/.eas/workflows/run-production-tests.yml +++ b/.eas/workflows/run-production-tests.yml @@ -59,6 +59,8 @@ jobs: type: repack environment: production runs_on: linux-medium + env: + EXPO_PUBLIC_POSTHOG_DISABLED: 'true' params: build_id: ${{ needs.android_get_build.outputs.build_id }} @@ -69,6 +71,8 @@ jobs: type: build environment: production runs_on: linux-medium + env: + EXPO_PUBLIC_POSTHOG_DISABLED: 'true' params: platform: android profile: production-simulator @@ -80,6 +84,8 @@ jobs: type: repack environment: production runs_on: macos-medium + env: + EXPO_PUBLIC_POSTHOG_DISABLED: 'true' params: build_id: ${{ needs.ios_get_build.outputs.build_id }} @@ -90,6 +96,8 @@ jobs: type: build environment: production runs_on: macos-medium + env: + EXPO_PUBLIC_POSTHOG_DISABLED: 'true' params: platform: ios profile: production-simulator diff --git a/.env.local.example b/.env.local.example index cbd9fb56..740de121 100644 --- a/.env.local.example +++ b/.env.local.example @@ -28,6 +28,7 @@ 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 # Supabase Vault Secrets (for database triggers) 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/services/posthog.ts b/services/posthog.ts index 7bdde126..45afe3b9 100644 --- a/services/posthog.ts +++ b/services/posthog.ts @@ -4,6 +4,15 @@ import * as Device from 'expo-device'; import * as Updates from 'expo-updates'; import PostHog from 'posthog-react-native'; +function isPostHogDisabled() { + return ( + __DEV__ || // comment out when you want to test analytics locally (with eas env:pull ) + process.env.EXPO_PUBLIC_POSTHOG_DISABLED === 'true' || + !process.env.EXPO_PUBLIC_POSTHOG_HOST || + !process.env.EXPO_PUBLIC_POSTHOG_KEY + ); +} + // Simple initialization without circular dependency const createPostHogInstance = (optIn = false) => { return new PostHog(process.env.EXPO_PUBLIC_POSTHOG_KEY ?? 'phc_', { @@ -15,10 +24,7 @@ const createPostHogInstance = (optIn = false) => { }, enablePersistSessionIdAcrossRestart: true, defaultOptIn: optIn, - disabled: - __DEV__ || - !process.env.EXPO_PUBLIC_POSTHOG_HOST || - !process.env.EXPO_PUBLIC_POSTHOG_KEY, + disabled: isPostHogDisabled(), customStorage: AsyncStorage }); }; @@ -26,6 +32,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 +78,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..aa92eac2 100644 --- a/services/posthog.web.ts +++ b/services/posthog.web.ts @@ -37,6 +37,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 +77,8 @@ function changeAnalyticsState(newState: boolean) { } else { posthog.opt_out_capturing(); } + + syncPostHogIdentity(); } export const initializePostHogWithStore = () => { @@ -52,7 +87,7 @@ export const initializePostHogWithStore = () => { try { const shouldOptIn = !analyticsOptOut && !!dateTermsAccepted; - void changeAnalyticsState(shouldOptIn); + changeAnalyticsState(shouldOptIn); let previousOptOut = analyticsOptOut; let previousTermsDate = dateTermsAccepted; @@ -66,7 +101,7 @@ export const initializePostHogWithStore = () => { previousTermsDate = newTermsDate; const newShouldOptIn = !newOptOut && !!newTermsDate; - void changeAnalyticsState(newShouldOptIn); + changeAnalyticsState(newShouldOptIn); } }); From 253c01632ae95c5cc19933b09173d8ba6e4260f7 Mon Sep 17 00:00:00 2001 From: keeandev <38674879+keeandev@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:53:59 -0500 Subject: [PATCH 06/14] fix(analytics): allow PostHog on EAS CI builds for analytics testing Stop disabling analytics when CI is set and remove workflow EXPO_PUBLIC_POSTHOG_DISABLED overrides so preview and production simulator builds from EAS can be downloaded and exercised with real PostHog credentials. Local development stays off via __DEV__ unless keys are pulled with eas env:pull. Co-authored-by: Cursor --- .eas/workflows/run-preview-tests.yml | 8 -------- .eas/workflows/run-production-tests.yml | 8 -------- services/posthog.ts | 3 +-- 3 files changed, 1 insertion(+), 18 deletions(-) diff --git a/.eas/workflows/run-preview-tests.yml b/.eas/workflows/run-preview-tests.yml index 0038cd58..f94da9eb 100644 --- a/.eas/workflows/run-preview-tests.yml +++ b/.eas/workflows/run-preview-tests.yml @@ -59,8 +59,6 @@ jobs: type: repack environment: preview runs_on: linux-medium - env: - EXPO_PUBLIC_POSTHOG_DISABLED: 'true' params: build_id: ${{ needs.android_get_build.outputs.build_id }} @@ -71,8 +69,6 @@ jobs: type: build environment: preview runs_on: linux-medium - env: - EXPO_PUBLIC_POSTHOG_DISABLED: 'true' params: platform: android profile: preview @@ -84,8 +80,6 @@ jobs: type: repack environment: preview runs_on: macos-medium - env: - EXPO_PUBLIC_POSTHOG_DISABLED: 'true' params: build_id: ${{ needs.ios_get_build.outputs.build_id }} @@ -96,8 +90,6 @@ jobs: type: build environment: preview runs_on: macos-medium - env: - EXPO_PUBLIC_POSTHOG_DISABLED: 'true' params: platform: ios profile: preview-simulator diff --git a/.eas/workflows/run-production-tests.yml b/.eas/workflows/run-production-tests.yml index aa0e6787..66f9541a 100644 --- a/.eas/workflows/run-production-tests.yml +++ b/.eas/workflows/run-production-tests.yml @@ -59,8 +59,6 @@ jobs: type: repack environment: production runs_on: linux-medium - env: - EXPO_PUBLIC_POSTHOG_DISABLED: 'true' params: build_id: ${{ needs.android_get_build.outputs.build_id }} @@ -71,8 +69,6 @@ jobs: type: build environment: production runs_on: linux-medium - env: - EXPO_PUBLIC_POSTHOG_DISABLED: 'true' params: platform: android profile: production-simulator @@ -84,8 +80,6 @@ jobs: type: repack environment: production runs_on: macos-medium - env: - EXPO_PUBLIC_POSTHOG_DISABLED: 'true' params: build_id: ${{ needs.ios_get_build.outputs.build_id }} @@ -96,8 +90,6 @@ jobs: type: build environment: production runs_on: macos-medium - env: - EXPO_PUBLIC_POSTHOG_DISABLED: 'true' params: platform: ios profile: production-simulator diff --git a/services/posthog.ts b/services/posthog.ts index 45afe3b9..7cbc20d7 100644 --- a/services/posthog.ts +++ b/services/posthog.ts @@ -6,8 +6,7 @@ import PostHog from 'posthog-react-native'; function isPostHogDisabled() { return ( - __DEV__ || // comment out when you want to test analytics locally (with eas env:pull ) - process.env.EXPO_PUBLIC_POSTHOG_DISABLED === 'true' || + __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 ); From 5861caa0d248970c41bef14d36d30b8f98a4f80a Mon Sep 17 00:00:00 2001 From: Keean <38674879+keeandev@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:28:00 -0500 Subject: [PATCH 07/14] feat: posthog auto capture errors (#887) * feat: bump posthog to 3 day old version * feat(analytics): add PostHog error tracking and source map uploads Enable exception autocapture on native and web. Inject debug IDs via Metro, wire the Expo plugin for native builds, upload maps on EAS deploy/OTA, and add npm update scripts that publish then upload. --- .eas/workflows/deploy-to-preview.yml | 36 +- .eas/workflows/deploy-to-production.yml | 34 +- .env.local.example | 6 + app.config.ts | 3 +- metro.config.js | 4 +- package-lock.json | 449 ++++-------------------- package.json | 12 +- services/posthog.ts | 11 +- services/posthog.web.ts | 7 +- 9 files changed, 165 insertions(+), 397 deletions(-) diff --git a/.eas/workflows/deploy-to-preview.yml b/.eas/workflows/deploy-to-preview.yml index a342f660..8c42f2e9 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 @@ -83,17 +89,29 @@ jobs: name: Publish Android update needs: [get_android_build] if: ${{ needs.get_android_build.outputs.build_id }} - type: update environment: preview - params: - branch: preview - platform: android + env: + POSTHOG_CLI_PROJECT_ID: ${{ env.POSTHOG_CLI_PROJECT_ID }} + POSTHOG_CLI_API_KEY: ${{ env.POSTHOG_CLI_API_KEY }} + steps: + - uses: eas/checkout + - uses: eas/install_node_modules + - name: Publish OTA update + run: eas update --branch preview --platform android --non-interactive + - name: Upload PostHog source maps + run: npm run posthog:upload-sourcemaps publish_ios_update: name: Publish iOS update needs: [get_ios_build] if: ${{ needs.get_ios_build.outputs.build_id }} - type: update environment: preview - params: - branch: preview - platform: ios + env: + POSTHOG_CLI_PROJECT_ID: ${{ env.POSTHOG_CLI_PROJECT_ID }} + POSTHOG_CLI_API_KEY: ${{ env.POSTHOG_CLI_API_KEY }} + steps: + - uses: eas/checkout + - uses: eas/install_node_modules + - name: Publish OTA update + run: eas update --branch preview --platform ios --non-interactive + - name: Upload PostHog source maps + run: npm run posthog:upload-sourcemaps diff --git a/.eas/workflows/deploy-to-production.yml b/.eas/workflows/deploy-to-production.yml index 9a88627f..3787e7ff 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 @@ -84,17 +90,29 @@ jobs: name: Publish Android update needs: [get_android_build] if: ${{ needs.get_android_build.outputs.build_id }} - type: update environment: production - params: - branch: production - platform: android + env: + POSTHOG_CLI_PROJECT_ID: ${{ env.POSTHOG_CLI_PROJECT_ID }} + POSTHOG_CLI_API_KEY: ${{ env.POSTHOG_CLI_API_KEY }} + steps: + - uses: eas/checkout + - uses: eas/install_node_modules + - name: Publish OTA update + run: eas update --branch production --platform android --non-interactive + - name: Upload PostHog source maps + run: npm run posthog:upload-sourcemaps publish_ios_update: name: Publish iOS update needs: [get_ios_build] if: ${{ needs.get_ios_build.outputs.build_id }} - type: update environment: production - params: - branch: production - platform: ios + env: + POSTHOG_CLI_PROJECT_ID: ${{ env.POSTHOG_CLI_PROJECT_ID }} + POSTHOG_CLI_API_KEY: ${{ env.POSTHOG_CLI_API_KEY }} + steps: + - uses: eas/checkout + - uses: eas/install_node_modules + - name: Publish OTA update + run: eas update --branch production --platform ios --non-interactive + - name: Upload PostHog source maps + run: npm run posthog:upload-sourcemaps diff --git a/.env.local.example b/.env.local.example index 740de121..0c15a322 100644 --- a/.env.local.example +++ b/.env.local.example @@ -30,6 +30,12 @@ EXPO_PUBLIC_RESEND_LOCAL_INBOX_URL=http://LOCAL_IP:4000/inbox # 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) # These are used by handle_invite_trigger() function to call the send-email edge function diff --git a/app.config.ts b/app.config.ts index e8439205..b686ad58 100644 --- a/app.config.ts +++ b/app.config.ts @@ -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/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..17a146d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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,64 @@ "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/cli/node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "extraneous": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "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 +7055,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 +13195,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 +21330,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 +23386,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 +23402,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 +23417,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 +23457,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 +23695,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..26164f79 100644 --- a/package.json +++ b/package.json @@ -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/posthog.ts b/services/posthog.ts index 7cbc20d7..af2048ed 100644 --- a/services/posthog.ts +++ b/services/posthog.ts @@ -2,7 +2,7 @@ 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 ( @@ -12,6 +12,14 @@ function isPostHogDisabled() { ); } +const posthogErrorTracking = { + autocapture: { + uncaughtExceptions: true, + unhandledRejections: true, + console: ['error', 'warn'] + } +} satisfies PostHogOptions['errorTracking']; + // Simple initialization without circular dependency const createPostHogInstance = (optIn = false) => { return new PostHog(process.env.EXPO_PUBLIC_POSTHOG_KEY ?? 'phc_', { @@ -21,6 +29,7 @@ const createPostHogInstance = (optIn = false) => { maskAllImages: false, maskAllTextInputs: true }, + errorTracking: posthogErrorTracking, enablePersistSessionIdAcrossRestart: true, defaultOptIn: optIn, disabled: isPostHogDisabled(), diff --git a/services/posthog.web.ts b/services/posthog.web.ts index aa92eac2..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) { From bfb089d4c9458dff6a18d2201c1ca8e39c9a9370 Mon Sep 17 00:00:00 2001 From: keeandev <38674879+keeandev@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:45:25 -0500 Subject: [PATCH 08/14] fix: remove upload source maps in OTA update for now --- .eas/workflows/deploy-to-preview.yml | 28 +++++++------------------ .eas/workflows/deploy-to-production.yml | 28 +++++++------------------ 2 files changed, 16 insertions(+), 40 deletions(-) diff --git a/.eas/workflows/deploy-to-preview.yml b/.eas/workflows/deploy-to-preview.yml index 8c42f2e9..dec6eccf 100644 --- a/.eas/workflows/deploy-to-preview.yml +++ b/.eas/workflows/deploy-to-preview.yml @@ -89,29 +89,17 @@ jobs: name: Publish Android update needs: [get_android_build] if: ${{ needs.get_android_build.outputs.build_id }} + type: update environment: preview - env: - POSTHOG_CLI_PROJECT_ID: ${{ env.POSTHOG_CLI_PROJECT_ID }} - POSTHOG_CLI_API_KEY: ${{ env.POSTHOG_CLI_API_KEY }} - steps: - - uses: eas/checkout - - uses: eas/install_node_modules - - name: Publish OTA update - run: eas update --branch preview --platform android --non-interactive - - name: Upload PostHog source maps - run: npm run posthog:upload-sourcemaps + params: + branch: preview + platform: android publish_ios_update: name: Publish iOS update needs: [get_ios_build] if: ${{ needs.get_ios_build.outputs.build_id }} + type: update environment: preview - env: - POSTHOG_CLI_PROJECT_ID: ${{ env.POSTHOG_CLI_PROJECT_ID }} - POSTHOG_CLI_API_KEY: ${{ env.POSTHOG_CLI_API_KEY }} - steps: - - uses: eas/checkout - - uses: eas/install_node_modules - - name: Publish OTA update - run: eas update --branch preview --platform ios --non-interactive - - name: Upload PostHog source maps - run: npm run posthog:upload-sourcemaps + params: + branch: preview + platform: ios diff --git a/.eas/workflows/deploy-to-production.yml b/.eas/workflows/deploy-to-production.yml index 3787e7ff..e5c1e09e 100644 --- a/.eas/workflows/deploy-to-production.yml +++ b/.eas/workflows/deploy-to-production.yml @@ -90,29 +90,17 @@ jobs: name: Publish Android update needs: [get_android_build] if: ${{ needs.get_android_build.outputs.build_id }} + type: update environment: production - env: - POSTHOG_CLI_PROJECT_ID: ${{ env.POSTHOG_CLI_PROJECT_ID }} - POSTHOG_CLI_API_KEY: ${{ env.POSTHOG_CLI_API_KEY }} - steps: - - uses: eas/checkout - - uses: eas/install_node_modules - - name: Publish OTA update - run: eas update --branch production --platform android --non-interactive - - name: Upload PostHog source maps - run: npm run posthog:upload-sourcemaps + params: + branch: production + platform: android publish_ios_update: name: Publish iOS update needs: [get_ios_build] if: ${{ needs.get_ios_build.outputs.build_id }} + type: update environment: production - env: - POSTHOG_CLI_PROJECT_ID: ${{ env.POSTHOG_CLI_PROJECT_ID }} - POSTHOG_CLI_API_KEY: ${{ env.POSTHOG_CLI_API_KEY }} - steps: - - uses: eas/checkout - - uses: eas/install_node_modules - - name: Publish OTA update - run: eas update --branch production --platform ios --non-interactive - - name: Upload PostHog source maps - run: npm run posthog:upload-sourcemaps + params: + branch: production + platform: ios From c7edd96467f96f9efd56c021efcc4df914c519d1 Mon Sep 17 00:00:00 2001 From: Keean <38674879+keeandev@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:03:28 -0500 Subject: [PATCH 09/14] fix(invites): show re-invited users after dismissing withdrawn invite (#889) --- components/ProjectMembershipModal.tsx | 12 +++++++++++- store/localStore.ts | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/components/ProjectMembershipModal.tsx b/components/ProjectMembershipModal.tsx index 084365a0..813b62b1 100644 --- a/components/ProjectMembershipModal.tsx +++ b/components/ProjectMembershipModal.tsx @@ -427,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(() => { @@ -821,6 +829,7 @@ export const ProjectMembershipModal: React.FC = ({ return; } + undismissInvitedRow(projectId, inviteId); RNAlert.alert(t('success'), t('invitationResent')); }; @@ -1160,6 +1169,7 @@ export const ProjectMembershipModal: React.FC = ({ return false; } + undismissInvitedRow(projectId, existingInvite.id); RNAlert.alert(t('success'), t('invitationResent')); setIsSubmitting(false); return true; diff --git a/store/localStore.ts b/store/localStore.ts index 8ae8da1f..8dd6b986 100644 --- a/store/localStore.ts +++ b/store/localStore.ts @@ -335,6 +335,7 @@ 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. @@ -789,6 +790,19 @@ export const useLocalStore = create()( } }; }), + 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) => From 50f570223a4d7f057d498831fe2968eae47b8275 Mon Sep 17 00:00:00 2001 From: Keean <38674879+keeandev@users.noreply.github.com> Date: Thu, 4 Jun 2026 22:39:14 -0500 Subject: [PATCH 10/14] feat(assets): highlight publish button when local assets exist (#891) * feat(assets): highlight publish button when local assets exist * style(assets): use secondary styling for play button * style(assets): move publish button after share button --- components/PublishQuestButton.tsx | 79 +++++++++++++++++++++++++++++ views/new/BibleAssetsView.tsx | 80 +++++------------------------- views/new/NextGenAssetsView.tsx | 82 +++++-------------------------- 3 files changed, 105 insertions(+), 136 deletions(-) create mode 100644 components/PublishQuestButton.tsx 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/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index 27855c70..8f2fef70 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, @@ -4030,67 +4031,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/NextGenAssetsView.tsx b/views/new/NextGenAssetsView.tsx index 1c65fc81..2ae2f3b9 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'; @@ -1715,72 +1716,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 && ( )} From 5f0af9048357ae58f0748caca521e9bc07e99ace Mon Sep 17 00:00:00 2001 From: Keean <38674879+keeandev@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:31:39 -0500 Subject: [PATCH 11/14] fix(audio): play all on first open without silent skip (#892) --- .eas/workflows/run-preview-tests.yml | 7 +- .eas/workflows/run-production-tests.yml | 7 +- hooks/usePlayAllAudioController.ts | 231 +++++++++++++++--------- views/new/BibleAssetsView.tsx | 7 +- views/new/NextGenAssetsView.tsx | 7 +- 5 files changed, 167 insertions(+), 92 deletions(-) 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/hooks/usePlayAllAudioController.ts b/hooks/usePlayAllAudioController.ts index 6ab5a989..e8491f5f 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: true + }); +} + +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/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx index 8f2fef70..2c975148 100644 --- a/views/new/BibleAssetsView.tsx +++ b/views/new/BibleAssetsView.tsx @@ -3565,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 ] @@ -4150,6 +4154,7 @@ export default function BibleAssetsView() { return; } if (!isPlayAllRunning) { + playbackCheckpoint.clearPlayAllCheckpoint(); setShowPlayAllControls(true); void handlePlayAll(selectedForRecording); } diff --git a/views/new/NextGenAssetsView.tsx b/views/new/NextGenAssetsView.tsx index 2ae2f3b9..97335c67 100644 --- a/views/new/NextGenAssetsView.tsx +++ b/views/new/NextGenAssetsView.tsx @@ -1345,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 ]); @@ -1803,6 +1807,7 @@ export default function NextGenAssetsView() { setShowPlayAllControls(false); return; } + playbackCheckpoint.clearPlayAllCheckpoint(); void handlePlayAll(); }} className="rounded-full" From 1827e2fb9e8a60bb518d568b3512e3d9828d6889 Mon Sep 17 00:00:00 2001 From: keeandev <38674879+keeandev@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:41:13 -0500 Subject: [PATCH 12/14] chore: bump app version to 2.2.2 --- app.config.ts | 2 +- package-lock.json | 20 ++------------------ package.json | 2 +- 3 files changed, 4 insertions(+), 20 deletions(-) diff --git a/app.config.ts b/app.config.ts index b686ad58..aa5f25ad 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), diff --git a/package-lock.json b/package-lock.json index 17a146d0..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", @@ -6908,22 +6908,6 @@ "node": ">=8" } }, - "node_modules/@posthog/cli/node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", - "extraneous": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, "node_modules/@posthog/core": { "version": "1.30.5", "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.30.5.tgz", diff --git a/package.json b/package.json index 26164f79..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", From 96b22a35b7953fe6901989e97f11b0be7f75aeb8 Mon Sep 17 00:00:00 2001 From: Keean <38674879+keeandev@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:25:52 -0500 Subject: [PATCH 13/14] fix(supabase): add AUTH_SITE_URL to edge runtime secrets (#895) * fix(supabase): declare all edge function secrets in config.toml Add AUTH_SITE_URL and other missing secrets used by edge functions via Deno.env.get() to [edge_runtime.secrets] so they are available in production deployments. Missing secrets added: - AUTH_SITE_URL (send-email invite/auth links) - SUPABASE_SERVICE_ROLE_KEY (dashboard + feedback functions) - DASHBOARD_QUEUE_CRON_SECRET (dashboard queue cron auth) - SUPABASE_DB_URL / PS_DATA_SOURCE_URI (clone-worker, process-queue) - EMAIL_SOFT_BOUNCE_THRESHOLD (email suppression) - INVITE_MAX_RESEND_ATTEMPTS / INVITE_MAX_OUTBOUND_SENDS (invite guard) Co-authored-by: Keean * fix(supabase): drop auto-injected edge secrets from config Remove SUPABASE_SERVICE_ROLE_KEY and SUPABASE_DB_URL from [edge_runtime.secrets]; both are provided by the Supabase edge runtime. Co-authored-by: Keean * fix(supabase): only declare AUTH_SITE_URL in edge secrets for now Limit [edge_runtime.secrets] additions to variables present in .env.production and .env.preview. Remove other newly added secrets until their values are added to those env files. Co-authored-by: Keean --------- Co-authored-by: Cursor Agent Co-authored-by: Keean --- supabase/config.toml | 1 + 1 file changed, 1 insertion(+) 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)" From c840fdeaa809cebc647e09332a513c26566bebc2 Mon Sep 17 00:00:00 2001 From: Keean <38674879+keeandev@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:01:28 -0500 Subject: [PATCH 14/14] Disable background audio playback to drop FGS permissions (LQ-46) (#898) Set enableBackgroundPlayback: false in expo-audio plugin config and shouldPlayInBackground: false across all audio mode setup sites so Android builds no longer declare FOREGROUND_SERVICE_MEDIA_PLAYBACK. Co-authored-by: Cursor Agent Co-authored-by: Keean --- app.config.ts | 2 +- components/AudioPlayer.tsx | 2 +- contexts/AudioContext.tsx | 2 +- hooks/usePlayAllAudioController.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app.config.ts b/app.config.ts index aa5f25ad..922ce739 100644 --- a/app.config.ts +++ b/app.config.ts @@ -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', 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/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/hooks/usePlayAllAudioController.ts b/hooks/usePlayAllAudioController.ts index e8491f5f..f96c9501 100644 --- a/hooks/usePlayAllAudioController.ts +++ b/hooks/usePlayAllAudioController.ts @@ -91,7 +91,7 @@ async function ensureAudioPlaybackMode(): Promise { await setAudioModeAsync({ allowsRecording: false, playsInSilentMode: true, - shouldPlayInBackground: true + shouldPlayInBackground: false }); }