diff --git a/src/blobbi/core/hooks/useBlobbiMigration.ts b/src/blobbi/core/hooks/useBlobbiMigration.ts index f5a7aa4f..fe370309 100644 --- a/src/blobbi/core/hooks/useBlobbiMigration.ts +++ b/src/blobbi/core/hooks/useBlobbiMigration.ts @@ -9,6 +9,7 @@ import { toast } from '@/hooks/useToast'; import { KIND_BLOBBI_STATE, KIND_BLOBBONAUT_PROFILE, + BLOBBI_ECOSYSTEM_NAMESPACE, BLOBBONAUT_PROFILE_KINDS, getBlobbonautQueryDValues, buildMigrationTags, @@ -22,6 +23,7 @@ import { parseBlobbiEvent, parseBlobbonautEvent, parseStorageTags, + findCanonicalEquivalent, type BlobbiCompanion, type BlobbonautProfile, type StorageItem, @@ -295,6 +297,38 @@ export function useBlobbiMigration() { return null; }, [nostr]); + /** + * Fetch all companions for a user from relays, parse and deduplicate by d-tag. + * Used to find existing canonical equivalents before migrating a legacy Blobbi. + */ + const fetchAllCompanions = useCallback(async ( + pubkey: string, + ): Promise => { + const events = await nostr.query([{ + kinds: [KIND_BLOBBI_STATE], + authors: [pubkey], + '#b': [BLOBBI_ECOSYSTEM_NAMESPACE], + }]); + + // Deduplicate by d-tag (newest wins), same logic as useBlobbisCollection + const eventsByD = new Map(); + for (const event of events.filter(isValidBlobbiEvent)) { + const dTag = event.tags.find(([name]) => name === 'd')?.[1]; + if (!dTag) continue; + const existing = eventsByD.get(dTag); + if (!existing || event.created_at > existing.created_at) { + eventsByD.set(dTag, event); + } + } + + const companions: BlobbiCompanion[] = []; + for (const event of eventsByD.values()) { + const parsed = parseBlobbiEvent(event); + if (parsed) companions.push(parsed); + } + return companions; + }, [nostr]); + /** * Ensure a Blobbi is in canonical format before performing an action. * @@ -302,14 +336,19 @@ export function useBlobbiMigration() { * instead of using potentially stale cache data. This prevents state resets * caused by publishing over a newer event with stale cached data. * - * If the companion is legacy, it will be migrated first. + * If the companion is legacy, it checks for an existing canonical equivalent + * (by normalized name) before migrating. This prevents creating duplicate + * canonical events when interacting with a legacy Blobbi multiple times. + * * Returns the canonical companion to use for the action. * * Flow: * 1. Fetch fresh companion + profile from relays * 2. Check if Blobbi is legacy - * 3. If legacy: migrate Blobbi - * 4. Return the resolved canonical Blobbi with fresh data + * 3. If legacy: look for existing canonical equivalent by name + * 4. If found: reuse it (no migration needed) + * 5. If not found: migrate to canonical format + * 6. Return the resolved canonical Blobbi with fresh data * * All interaction handlers should call this before publishing events. */ @@ -332,7 +371,69 @@ export function useBlobbiMigration() { // Check if the companion needs migration if (companion.isLegacy) { - console.log('[Blobbi Migration] Legacy companion detected, migrating before action'); + console.log('[Blobbi Migration] Legacy companion detected, checking for existing canonical equivalent'); + + // Check if a canonical equivalent already exists (by migrated_from tag, + // name+base_color, or name-only fallback). This prevents duplicate migrations + // when interacting with a legacy Blobbi that was already migrated. + const allCompanions = await fetchAllCompanions(user.pubkey); + const existing = findCanonicalEquivalent(companion, allCompanions); + + if (existing) { + console.log('[Blobbi Migration] Found existing canonical equivalent:', existing.d, '— skipping migration'); + + // Update profile.has and current_companion to point to the canonical version + // (in case profile still references the legacy d-tag) + const hasLegacyInProfile = profile.has.includes(companion.d); + const hasCanonicalInProfile = profile.has.includes(existing.d); + + if (hasLegacyInProfile || !hasCanonicalInProfile) { + const updatedHas = migratePetInHas(profile.has, companion.d, existing.d); + const profileUpdates: Record = { has: updatedHas }; + if (profile.currentCompanion === companion.d) { + profileUpdates.current_companion = existing.d; + } + const profileTags = updateBlobbonautTags(profile.allTags, profileUpdates); + const profileEvent = await publishEvent({ + kind: KIND_BLOBBONAUT_PROFILE, + content: '', + tags: profileTags, + prev: profile.event, + }); + options.updateProfileEvent(profileEvent); + + // Update localStorage selection if it was pointing to legacy d + if (options.updateStoredSelectedD) { + options.updateStoredSelectedD(existing.d); + } + + // Update the canonical companion in query cache + options.updateCompanionEvent(existing.event); + + return { + wasMigrated: false, + companion: existing, + allTags: existing.allTags, + content: existing.event.content, + profileAllTags: profileTags, + profileEvent, + profileStorage: parseStorageTags(profileTags), + }; + } + + // Profile is already correct, just return the existing canonical companion + return { + wasMigrated: false, + companion: existing, + allTags: existing.allTags, + content: existing.event.content, + profileAllTags: profile.allTags, + profileEvent: profile.event, + profileStorage: profile.storage, + }; + } + + console.log('[Blobbi Migration] No canonical equivalent found, migrating'); // Use fresh data in migration options const migrationOptions = { ...options, companion, profile }; @@ -367,7 +468,7 @@ export function useBlobbiMigration() { profileEvent: profile.event, profileStorage: profile.storage, }; - }, [user?.pubkey, fetchFreshCompanion, fetchFreshProfile, migrateLegacyBlobbi]); + }, [user?.pubkey, fetchFreshCompanion, fetchFreshProfile, fetchAllCompanions, migrateLegacyBlobbi, publishEvent]); return { /** Migrate a legacy Blobbi to canonical format */ diff --git a/src/blobbi/core/lib/blobbi.ts b/src/blobbi/core/lib/blobbi.ts index 75ce711c..4bfd797a 100644 --- a/src/blobbi/core/lib/blobbi.ts +++ b/src/blobbi/core/lib/blobbi.ts @@ -1528,15 +1528,22 @@ export function buildMigrationTags( const now = Math.floor(Date.now() / 1000).toString(); // Start with required tags + const legacyD = getTagValue(legacyTags, 'd'); const newTags: string[][] = [ ['d', canonicalD], ['b', BLOBBI_ECOSYSTEM_NAMESPACE], ['seed', seed], ]; + // Store a back-reference to the legacy d-tag for future equivalence lookups. + // This is additive — current dedup logic uses name-based matching, but future + // versions can use this tag for stronger deterministic equivalence. + if (legacyD) { + newTags.push(['migrated_from', legacyD]); + } + // Preserve name with priority: name tag > legacy d-tag derived > fallback const nameTag = getTagValue(legacyTags, 'name'); - const legacyD = getTagValue(legacyTags, 'd'); const resolvedName = nameTag ?? (legacyD ? deriveNameFromLegacyD(legacyD) : 'Unnamed Blobbi'); newTags.push(['name', resolvedName]); @@ -1678,6 +1685,221 @@ export function migratePetInHas( return filtered; } +// ─── Legacy / Canonical Deduplication ────────────────────────────────────────── + +/** + * Normalize a Blobbi name for equivalence comparison. + * Lowercases and trims whitespace so "Jack", "jack", and " Jack " all match. + */ +export function normalizeBlobbiName(name: string): string { + return name.trim().toLowerCase(); +} + +/** + * Check whether a canonical companion is equivalent to a legacy companion. + * + * Equivalence priority (first match wins): + * 1. **migrated_from exact match**: canonical has a `migrated_from` tag that + * equals the legacy d-tag. This is the strongest signal — it was written + * during migration and is preserved across all subsequent updates. + * 2. **name + base_color match**: same normalized name AND same raw `base_color` + * tag value (both present and equal). Covers older canonical copies that + * were created before the `migrated_from` tag existed. + * 3. **name-only fallback**: same normalized name when the legacy event has no + * explicit `base_color` tag (too bare to compare). This is the weakest tier + * and only applies to genuinely old legacy events with no visual tags. + */ +function isCanonicalEquivalentToLegacy( + canonical: BlobbiCompanion, + legacyD: string, + legacyName: string, + legacyBaseColor: string | undefined, +): boolean { + // Priority 1: migrated_from exact match + const migratedFrom = getTagValue(canonical.event.tags, 'migrated_from'); + if (migratedFrom === legacyD) return true; + + // Priority 2: name + base_color (both must be present and equal) + const canonicalBaseColor = getTagValue(canonical.event.tags, 'base_color'); + if ( + normalizeBlobbiName(canonical.name) === legacyName && + legacyBaseColor !== undefined && + canonicalBaseColor !== undefined && + legacyBaseColor.toUpperCase() === canonicalBaseColor.toUpperCase() + ) { + return true; + } + + // Priority 3: name-only when legacy has no base_color to compare + if ( + normalizeBlobbiName(canonical.name) === legacyName && + legacyBaseColor === undefined + ) { + return true; + } + + return false; +} + +/** + * Filter out legacy companions that have been migrated to canonical format. + * + * A legacy companion is hidden when ALL of the following are true: + * 1. It is a legacy event (companion.isLegacy === true) + * 2. The legacy d-tag is NOT present in profile.has (confirming migration occurred) + * 3. A canonical equivalent exists, determined by (first match wins): + * a. migrated_from exact match on the legacy d-tag + * b. same normalized name + same raw base_color tag + * c. same normalized name (fallback when legacy has no base_color tag) + * + * After legacy filtering, a second pass collapses canonical → canonical + * duplicates that share the same `migrated_from` tag value (i.e. were both + * migrated from the same legacy d-tag due to a race condition). For each + * group the companion with the newest `created_at` is kept; the rest are + * hidden. Canonical companions without a `migrated_from` tag are always + * kept — no heuristic (name, color, etc.) grouping is applied. + * + * @param companions - All parsed companions (legacy + canonical) + * @param profileHas - The profile.has array of owned Blobbi d-tags + * @returns Filtered companions with migrated legacy entries and canonical + * duplicates removed + */ +export function filterMigratedLegacyCompanions( + companions: BlobbiCompanion[], + profileHas: string[], +): BlobbiCompanion[] { + // Collect canonical companions for equivalence checks + const canonicals = companions.filter((c) => !c.isLegacy); + + // If there are no canonical companions, nothing to filter + if (canonicals.length === 0) return companions; + + const hasSet = new Set(profileHas); + + const afterLegacyFilter = companions.filter((c) => { + // Keep all canonical companions unconditionally (deduped in next pass) + if (!c.isLegacy) return true; + + // Keep legacy companions that are still in profile.has (not yet migrated) + if (hasSet.has(c.d)) return true; + + // Check if any canonical companion is equivalent to this legacy one + const legacyName = normalizeBlobbiName(c.name); + const legacyBaseColor = getTagValue(c.event.tags, 'base_color'); + + const hasEquivalent = canonicals.some((canonical) => + isCanonicalEquivalentToLegacy(canonical, c.d, legacyName, legacyBaseColor), + ); + + // Hide if a canonical equivalent exists, keep otherwise + return !hasEquivalent; + }); + + // ── Canonical → canonical dedup ────────────────────────────────────────── + // Group canonical companions by `migrated_from` tag. Within each group, + // keep only the newest event (highest created_at). Canonicals without the + // tag are never grouped — they pass through untouched. + const canonicalWinners = collapseCanonicalDuplicates( + afterLegacyFilter.filter((c) => !c.isLegacy), + ); + const winnerDs = new Set(canonicalWinners.map((c) => c.d)); + + return afterLegacyFilter.filter((c) => { + // Legacy companions already survived the first pass — keep them + if (c.isLegacy) return true; + // Canonical companions must be in the winner set + return winnerDs.has(c.d); + }); +} + +/** + * Collapse canonical companions that were duplicated by a migration race. + * + * Two canonical companions are considered duplicates of the same logical + * Blobbi if and only if both carry a `migrated_from` tag with the same + * value. For each such group the companion with the newest `created_at` + * is kept; ties are broken by d-tag lexicographic order (deterministic). + * + * Canonical companions *without* a `migrated_from` tag are always kept — + * no heuristic grouping (name, color, etc.) is applied. + */ +function collapseCanonicalDuplicates( + canonicals: BlobbiCompanion[], +): BlobbiCompanion[] { + // Companions without migrated_from — always kept + const ungrouped: BlobbiCompanion[] = []; + // Group canonical companions by migrated_from value + const groups = new Map(); + + for (const c of canonicals) { + const migratedFrom = getTagValue(c.event.tags, 'migrated_from'); + if (!migratedFrom) { + ungrouped.push(c); + continue; + } + const group = groups.get(migratedFrom); + if (group) { + group.push(c); + } else { + groups.set(migratedFrom, [c]); + } + } + + // Pick the winner from each group: newest created_at, tie-break on d-tag + const winners: BlobbiCompanion[] = [...ungrouped]; + for (const group of groups.values()) { + let best = group[0]; + for (let i = 1; i < group.length; i++) { + const c = group[i]; + if ( + c.event.created_at > best.event.created_at || + (c.event.created_at === best.event.created_at && c.d > best.d) + ) { + best = c; + } + } + winners.push(best); + } + + return winners; +} + +/** + * Find an existing canonical companion that is equivalent to a legacy companion. + * + * Used by the migration guard to avoid creating duplicate canonical events. + * Uses the same equivalence priority as `filterMigratedLegacyCompanions`: + * 1. migrated_from exact match (strongest) + * 2. same normalized name + same raw base_color tag + * 3. same normalized name when legacy has no base_color (weakest) + * + * When multiple canonical companions match, the one with the newest + * created_at is returned (most up-to-date state). + * + * @param legacy - The legacy companion to find an equivalent for + * @param companions - All parsed companions + * @returns The best matching canonical companion, or undefined + */ +export function findCanonicalEquivalent( + legacy: BlobbiCompanion, + companions: BlobbiCompanion[], +): BlobbiCompanion | undefined { + const legacyName = normalizeBlobbiName(legacy.name); + const legacyBaseColor = getTagValue(legacy.event.tags, 'base_color'); + let best: BlobbiCompanion | undefined; + + for (const c of companions) { + if (c.isLegacy) continue; + if (!isCanonicalEquivalentToLegacy(c, legacy.d, legacyName, legacyBaseColor)) continue; + // Among multiple matches, prefer the newest (most up-to-date state) + if (!best || c.event.created_at > best.event.created_at) { + best = c; + } + } + + return best; +} + // ─── LocalStorage Cache Types ───────────────────────────────────────────────── export interface BlobbiBootCache { diff --git a/src/components/widgets/BlobbiWidget.tsx b/src/components/widgets/BlobbiWidget.tsx index 373c907d..3eb8c2d0 100644 --- a/src/components/widgets/BlobbiWidget.tsx +++ b/src/components/widgets/BlobbiWidget.tsx @@ -13,7 +13,7 @@ import { useBlobbiMigration } from '@/blobbi/core/hooks/useBlobbiMigration'; import { useBlobbiUseInventoryItem } from '@/blobbi/actions/hooks/useBlobbiUseInventoryItem'; import { isActionVisibleForStage, type InventoryAction, type BlobbiAction } from '@/blobbi/actions/lib/blobbi-action-utils'; import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay'; -import { KIND_BLOBBI_STATE, KIND_BLOBBONAUT_PROFILE, updateBlobbiTags, updateBlobbonautTags } from '@/blobbi/core/lib/blobbi'; +import { KIND_BLOBBI_STATE, KIND_BLOBBONAUT_PROFILE, updateBlobbiTags, updateBlobbonautTags, filterMigratedLegacyCompanions } from '@/blobbi/core/lib/blobbi'; import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay'; import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak'; import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile'; @@ -61,25 +61,39 @@ function getSelectedBlobbiKey(pubkey: string): string { /** Mini Blobbi widget with live stats and quick actions. */ export function BlobbiWidget() { const { user } = useCurrentUser(); - const { companions, companionsByD, isLoading, updateCompanionEvent } = useBlobbisCollection(); + const { companions, isLoading, updateCompanionEvent } = useBlobbisCollection(); const { profile, updateProfileEvent, invalidate: invalidateProfile } = useBlobbonautProfile(); const { ensureCanonicalBlobbiBeforeAction } = useBlobbiMigration(); const { mutateAsync: publishEvent } = useNostrPublish(); + // Filter out legacy companions that have been migrated to canonical format + const filteredCompanions = useMemo(() => { + if (!profile) return companions; + return filterMigratedLegacyCompanions(companions, profile.has); + }, [companions, profile]); + + const filteredCompanionsByD = useMemo(() => { + const record: Record = {}; + for (const c of filteredCompanions) { + record[c.d] = c; + } + return record; + }, [filteredCompanions]); + // Match BlobbiPage's selection logic: localStorage > profile.has > first companion const localStorageKey = user?.pubkey ? getSelectedBlobbiKey(user.pubkey) : 'blobbi:selected:d:none'; const [storedSelectedD, setStoredSelectedD] = useLocalStorage(localStorageKey, null); const companion = useMemo(() => { - if (!companions || companions.length === 0) return null; - if (storedSelectedD && companionsByD[storedSelectedD]) return companionsByD[storedSelectedD]; + if (!filteredCompanions || filteredCompanions.length === 0) return null; + if (storedSelectedD && filteredCompanionsByD[storedSelectedD]) return filteredCompanionsByD[storedSelectedD]; if (profile) { for (const d of profile.has) { - if (companionsByD[d]) return companionsByD[d]; + if (filteredCompanionsByD[d]) return filteredCompanionsByD[d]; } } - return companions[0]; - }, [companions, companionsByD, storedSelectedD, profile]); + return filteredCompanions[0]; + }, [filteredCompanions, filteredCompanionsByD, storedSelectedD, profile]); // Zero-arg wrapper for ensureCanonical (same pattern as BlobbiPage) const ensureCanonicalBeforeAction = useCallback(async () => { diff --git a/src/pages/BlobbiPage.tsx b/src/pages/BlobbiPage.tsx index 8088b78f..b427a518 100644 --- a/src/pages/BlobbiPage.tsx +++ b/src/pages/BlobbiPage.tsx @@ -39,6 +39,7 @@ import { KIND_BLOBBONAUT_PROFILE, updateBlobbiTags, updateBlobbonautTags, + filterMigratedLegacyCompanions, type BlobbiCompanion, type BlobbonautProfile, type StorageItem, @@ -201,7 +202,6 @@ function BlobbiContent() { // No dList needed — useBlobbisCollection() without args queries by author + ecosystem tag. // This ensures blobbis are never invisible due to a stale profile.has[] list. const { - companionsByD, companions, isLoading: collectionLoading, isFetching: collectionFetching, @@ -209,6 +209,22 @@ function BlobbiContent() { updateCompanionEvent, } = useBlobbisCollection(); + // STEP 2: Filter out legacy companions that have been migrated to canonical format. + // A legacy Blobbi is hidden when a canonical Blobbi with the same name exists AND + // the legacy d-tag is no longer in profile.has (confirming migration occurred). + const filteredCompanions = useMemo(() => { + if (!profile) return companions; + return filterMigratedLegacyCompanions(companions, profile.has); + }, [companions, profile]); + + const filteredCompanionsByD = useMemo(() => { + const record: Record = {}; + for (const c of filteredCompanions) { + record[c.d] = c; + } + return record; + }, [filteredCompanions]); + // STEP 5: localStorage for UI selection (user-scoped key) const localStorageKey = user?.pubkey ? getSelectedBlobbiKey(user.pubkey) : 'blobbi:selected:d:none'; const [storedSelectedD, setStoredSelectedD] = useLocalStorage(localStorageKey, null); @@ -225,33 +241,33 @@ function BlobbiContent() { // CRITICAL: Default selection must NEVER overwrite localStorage. // User selection persists only via handleSelectBlobbi, not via this computed value. const selectedD = useMemo(() => { - // Priority 1: localStorage selection (if it exists in loaded collection) + // Priority 1: localStorage selection (if it exists in filtered collection) // USER SELECTION ALWAYS WINS - this is the authoritative source - if (storedSelectedD && companionsByD[storedSelectedD]) { + if (storedSelectedD && filteredCompanionsByD[storedSelectedD]) { if (DEBUG_BLOBBI) { console.log('[BlobbiPage] selectedD: using localStorage selection:', storedSelectedD); } return storedSelectedD; } - // Priority 2: First item from profile.has that exists in companionsByD + // Priority 2: First item from profile.has that exists in filtered collection // This preserves the user's ordering preference from their profile if (profile) { for (const d of profile.has) { - if (companionsByD[d]) { + if (filteredCompanionsByD[d]) { if (DEBUG_BLOBBI) { console.log('[BlobbiPage] selectedD: using default from profile.has:', d, '(storedSelectedD was:', storedSelectedD, - storedSelectedD ? (companionsByD[storedSelectedD] ? 'exists' : 'NOT in companionsByD') : 'null', ')'); + storedSelectedD ? (filteredCompanionsByD[storedSelectedD] ? 'exists' : 'NOT in filteredCompanionsByD') : 'null', ')'); } return d; } } } - // Priority 3: First companion in the collection (covers blobbis not in profile.has) - if (companions.length > 0) { - const firstD = companions[0].d; + // Priority 3: First companion in the filtered collection + if (filteredCompanions.length > 0) { + const firstD = filteredCompanions[0].d; if (DEBUG_BLOBBI) { console.log('[BlobbiPage] selectedD: using first companion from collection:', firstD); } @@ -263,18 +279,18 @@ function BlobbiContent() { console.log('[BlobbiPage] selectedD: no valid selection available'); } return undefined; - }, [profile, storedSelectedD, companionsByD, companions]); + }, [profile, storedSelectedD, filteredCompanionsByD, filteredCompanions]); // NOTE: We intentionally do NOT auto-save the computed selectedD to localStorage. // This prevents the default selection from overwriting user selections during: // - WebSocket updates // - Query refetches - // - Race conditions where storedSelectedD is not yet in companionsByD + // - Race conditions where storedSelectedD is not yet in filteredCompanionsByD // // User selections are only persisted via handleSelectBlobbi (line ~232). - // Get the selected companion from the collection - const companion = selectedD ? companionsByD[selectedD] ?? null : null; + // Get the selected companion from the filtered collection + const companion = selectedD ? filteredCompanionsByD[selectedD] ?? null : null; // Debug log to confirm which Blobbi is rendered (dev only) useEffect(() => { @@ -698,12 +714,12 @@ function BlobbiContent() { // ─── CASE G: Companions loaded, but no valid selection ─── // Show selector to pick which pet to display - if (!selectedD && companions.length > 0) { + if (!selectedD && filteredCompanions.length > 0) { if (DEBUG_BLOBBI) console.log('[BlobbiPage] Showing: pet selector'); return ( <> setShowAdoptionFlow(true)} @@ -735,7 +751,7 @@ function BlobbiContent() { return ( <> setShowAdoptionFlow(true)} @@ -768,7 +784,7 @@ function BlobbiContent() { return (