// Root-mounted popup for desktop-sync conflicts: fires the moment a sync run // detects NEW conflicts (auto or manual) instead of waiting for the user to // wander into Settings. The once-per-session bookkeeping lives in // desktopSyncStore (conflictPromptVisible) so this component is a pure // derivation of store state — no set-state-in-effect (React Compiler rule). // Suppressed while the user is already on the sync screen; if they leave it // without resolving, the one pending reminder still shows. import { useState } from 'react'; import { Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { router, usePathname } from 'expo-router'; import { Text } from '@/components/Text'; import { radius, spacing } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; import { formatRelativeTime } from '@/lib/format'; import { buildSyncConflictResolutionPreview, buildSyncPlaylistEntryDiff, syncPlaylistToSnapshot, } from '@/shared/sync/conflictPreview'; import { useDesktopSyncStore } from '@/stores/desktopSyncStore'; import type { DesktopSyncConflictResolution, DesktopSyncPlaylistConflict, SyncPlaylistSnapshot, } from '@/types/desktopSync'; const RESOLUTION_LABELS: Record = { desktop: 'Use desktop version', phone: 'Use phone version', both: 'Keep both playlists', merge: 'Combine songs', }; function resolutionOptions(conflict: DesktopSyncPlaylistConflict): DesktopSyncConflictResolution[] { return conflict.playlistKind === 'dynamic' ? ['desktop', 'phone', 'both'] : ['desktop', 'phone', 'both', 'merge']; } function sideSubtitle(snapshot: SyncPlaylistSnapshot): string { const count = snapshot.kind === 'dynamic' ? 'Dynamic playlist' : `${snapshot.trackCount} song${snapshot.trackCount === 1 ? '' : 's'}`; return `${count} · edited ${formatRelativeTime(snapshot.updatedAt)}`; } function diffLine(desktop: SyncPlaylistSnapshot, phone: SyncPlaylistSnapshot): string { if (desktop.kind !== 'normal' || phone.kind !== 'normal') { return desktop.dynamicRules === phone.dynamicRules ? 'The playlist details do not match.' : 'The playlist rules do not match.'; } const diff = buildSyncPlaylistEntryDiff(desktop.entries, phone.entries); const parts = [ diff.desktopOnlyCount > 0 ? `${diff.desktopOnlyCount} only on desktop` : null, diff.phoneOnlyCount > 0 ? `${diff.phoneOnlyCount} only on phone` : null, diff.movedCount > 0 ? `${diff.movedCount} in a different order` : null, ].filter((part): part is string => part !== null); return parts.length > 0 ? parts.join(' · ') : 'The playlists have the same songs.'; } export function SyncConflictPrompt() { const styles = useStyles(); const ripple = useRipple(); const colors = useColors(); const conflicts = useDesktopSyncStore((s) => s.conflicts); const status = useDesktopSyncStore((s) => s.status); const promptVisible = useDesktopSyncStore((s) => s.conflictPromptVisible); const dismissConflictPrompt = useDesktopSyncStore((s) => s.dismissConflictPrompt); const resolveConflict = useDesktopSyncStore((s) => s.resolveConflict); const pathname = usePathname(); const [choice, setChoice] = useState<{ syncUid: string; resolution: DesktopSyncConflictResolution; } | null>(null); const visible = promptVisible && conflicts.length > 0 && pathname !== '/desktop-sync'; if (!visible) return null; const count = conflicts.length; const firstConflict = conflicts[0]; const busy = status === 'syncing'; const desktopSnapshot = syncPlaylistToSnapshot(firstConflict.remote); const phoneSnapshot = syncPlaylistToSnapshot(firstConflict.local); const options = resolutionOptions(firstConflict); const selectedResolution = choice?.syncUid === firstConflict.syncUid && options.includes(choice.resolution) ? choice.resolution : null; const preview = selectedResolution ? buildSyncConflictResolutionPreview(selectedResolution, desktopSnapshot, phoneSnapshot) : null; const review = () => { dismissConflictPrompt(); router.push('/desktop-sync' as never); }; const confirm = () => { if (!selectedResolution || busy) return; dismissConflictPrompt(); void resolveConflict(firstConflict, selectedResolution); }; return ( Sync conflict{count === 1 ? '' : 's'} {count > 1 ? ( 1 of {count} ) : null} “{firstConflict.localName}” is different on desktop and this phone. Desktop {desktopSnapshot.name} {sideSubtitle(desktopSnapshot)} This phone {phoneSnapshot.name} {sideSubtitle(phoneSnapshot)} {diffLine(desktopSnapshot, phoneSnapshot)} {options.map((resolution) => ( setChoice({ syncUid: firstConflict.syncUid, resolution })} > {RESOLUTION_LABELS[resolution]} ))} {preview ? preview.title : 'Choose what should happen'} {preview ? preview.detail : 'Nothing changes until you confirm.'} {count > 1 ? ( Review all {count} conflicts ) : null} Not now Confirm ); } const useStyles = createThemedStyles((colors) => ({ backdrop: { flex: 1, backgroundColor: colors.backdrop, alignItems: 'center', justifyContent: 'center', padding: spacing.xl, }, card: { width: '100%', maxWidth: 400, maxHeight: '88%', borderRadius: radius.md, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.glassBorder, backgroundColor: colors.bgSecondary, padding: spacing.lg, gap: spacing.sm, }, scrollContent: { gap: spacing.md, }, header: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, }, titleBlock: { flex: 1, }, title: { flex: 1, }, body: { lineHeight: 21, }, sideSummaryGrid: { flexDirection: 'row', gap: spacing.sm, }, sideSummaryCard: { flex: 1, minWidth: 0, borderRadius: radius.sm, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.glassBorder, backgroundColor: colors.bgTertiary, padding: spacing.sm, gap: 2, }, choiceList: { gap: spacing.sm, }, choiceRow: { minHeight: 42, borderRadius: radius.sm, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.glassBorder, backgroundColor: colors.bgTertiary, paddingHorizontal: spacing.md, alignItems: 'flex-start', justifyContent: 'center', }, choiceRowSelected: { borderColor: colors.accent, backgroundColor: colors.glassHighlight, }, previewBox: { borderRadius: radius.sm, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.glassBorder, backgroundColor: colors.bgTertiary, padding: spacing.md, gap: spacing.xs, }, reviewLink: { alignSelf: 'flex-start', }, actions: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm, marginTop: spacing.xs, }, secondaryButton: { minHeight: 44, borderRadius: radius.sm, paddingHorizontal: spacing.lg, alignItems: 'center', justifyContent: 'center', }, primaryButton: { minHeight: 44, borderRadius: radius.sm, backgroundColor: colors.accent, paddingHorizontal: spacing.lg, alignItems: 'center', justifyContent: 'center', }, disabled: { opacity: 0.5, }, }));