Merge branch 'fix/blobbi-legacy-new-deduplication' into 'main'
Fix Blobbi legacy/new-format deduplication to prevent infinite duplicates Closes #247 See merge request soapbox-pub/ditto!198
This commit is contained in:
@@ -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<BlobbiCompanion[]> => {
|
||||
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<string, NostrEvent>();
|
||||
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<string, string | string[]> = { 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 */
|
||||
|
||||
@@ -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<string, BlobbiCompanion[]>();
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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<string, BlobbiCompanion> = {};
|
||||
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<string | null>(localStorageKey, null);
|
||||
|
||||
const companion = useMemo<BlobbiCompanion | null>(() => {
|
||||
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 () => {
|
||||
|
||||
+33
-17
@@ -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<string, BlobbiCompanion> = {};
|
||||
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<string | null>(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 (
|
||||
<>
|
||||
<BlobbiSelectorPage
|
||||
companions={companions}
|
||||
companions={filteredCompanions}
|
||||
onSelect={handleSelectBlobbi}
|
||||
isLoading={companionFetching}
|
||||
onAdopt={() => setShowAdoptionFlow(true)}
|
||||
@@ -735,7 +751,7 @@ function BlobbiContent() {
|
||||
return (
|
||||
<>
|
||||
<BlobbiSelectorPage
|
||||
companions={companions}
|
||||
companions={filteredCompanions}
|
||||
onSelect={handleSelectBlobbi}
|
||||
isLoading={companionFetching}
|
||||
onAdopt={() => setShowAdoptionFlow(true)}
|
||||
@@ -768,7 +784,7 @@ function BlobbiContent() {
|
||||
return (
|
||||
<BlobbiDashboard
|
||||
companion={companion}
|
||||
companions={companions}
|
||||
companions={filteredCompanions}
|
||||
selectedD={selectedD}
|
||||
onSelectBlobbi={handleSelectBlobbi}
|
||||
onRest={handleRest}
|
||||
|
||||
Reference in New Issue
Block a user