SkateSpots/src/hooks/useStash.ts
SkateSpots d6713ba20e feat(stash): merge multi-device changes instead of clobbering
The stash is one replaceable NIP-44 blob, so a naive last-write-wins publish
silently dropped whatever another device added. Make it merge.

- StashItem.updatedAt (last edit, falls back to addedAt) and a top-level
  Stash.deleted[] of {id, at} tombstones so deletes aren't resurrected.
- export mergeStashes(): union by id, newest stamp wins, tombstones win at/after
  the last edit; tombstones pruned (180d / 500).
- fetchLatestStash() merges the live remote with the list already held (query
  cache -> closure) and toasts when the other device contributed.
- Writes now require a confirmed relay read: assertConfirmed() refuses an
  unconfirmed base so useNostrPublish can't queue a pre-signed blob that would
  overwrite a newer edit later. Reads still work offline from cached ciphertext.
- StashPage surfaces the refusal via onStashError on every mutation.

Docs: NIP.md multi-device merge section; AGENTS.md updated.
2026-10-01 09:57:19 +07:00

614 lines
24 KiB
TypeScript

import { useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useNostrLogin } from '@nostrify/react/login';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { toast } from './useToast';
import { hasNip44 as checkNip44, isInteractiveSigner } from '@/lib/signerCapabilities';
import { cacheStashEvent, getCachedStashEvent } from '@/lib/offlineCache';
import type { NostrEvent } from '@nostrify/nostrify';
// Stash item stored in encrypted NIP-51 list
export interface StashItem {
id: string;
title: string;
category: 'deck' | 'trucks' | 'wheels' | 'bearings' | 'shoes' | 'clothing' | 'protective' | 'complete' | 'other';
condition: 'new' | 'like_new' | 'good' | 'well_used';
size?: string;
brand?: string;
description?: string;
minPrice?: number; // Minimum sats willing to accept
maxPrice?: number; // Maximum sats willing to pay (for WTB)
images?: string[];
willingToTrade?: boolean;
willingToSell?: boolean;
addedAt: number;
// Visibility settings
visibility: 'private' | 'friends' | 'public';
// Optional geohash for local matching
geohash?: string;
// Item type: regular stash item or Want To Buy
itemType?: 'stash' | 'wtb';
// Rarity override (for special items)
rarity?: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
// Physical storage location
storageLocation?: 'home' | 'car' | 'skatepark' | 'warehouse' | 'storage_unit' | 'van' | 'friends' | 'parents' | 'wardrobe' | 'backpack' | 'sponsor' | 'other';
/**
* Last local modification time (epoch seconds). Used to resolve multi-device
* merge conflicts — the newest edit of an item wins. Legacy items don't have
* it; `addedAt` is the fallback (see `itemStamp`).
*/
updatedAt?: number;
}
export interface Stash {
items: StashItem[];
updatedAt: number;
/**
* Tombstones for deleted items. The stash is one replaceable blob, so a plain
* "remove from the array" can be *resurrected* by another device's stale copy
* during a merge. Recording when an item was deleted lets deletes win instead.
* Optional — older stashes simply have none.
*/
deleted?: Array<{ id: string; at: number }>;
}
// Kind 30001 is NIP-51 "Follow sets" - we use a private encrypted list
const STASH_KIND = 30001;
const STASH_D_TAG = 'skatespots-stash';
/** Epoch seconds an item was last touched (legacy items fall back to `addedAt`). */
function itemStamp(i: StashItem): number {
return i.updatedAt ?? i.addedAt ?? 0;
}
/** Tombstones older than this are dropped — a device offline longer than this
* could resurrect a very old delete, which is an acceptable trade. */
const TOMBSTONE_MAX_AGE = 180 * 24 * 60 * 60;
const TOMBSTONE_CAP = 500;
/**
* Merge two stash snapshots WITHOUT losing data.
*
* Why this exists: the stash is a single replaceable event whose content is the
* WHOLE list, so naive last-write-wins silently drops whatever another device
* added. This is the multi-device conflict guard. It is deliberately a *blob*
* merge, not per-item events: per-item events would mean N decrypts per load,
* and with Amber every decrypt is a popup.
*
* Rules:
* • union by item id — an item only one side knows about is kept;
* • on a collision the newest `updatedAt` (fallback `addedAt`) wins;
* • a tombstone (delete) wins when it is at/after the item's last edit;
* • tombstones are unioned (newest per id) and pruned.
*
* `merged` is true when the remote side contributed something (a new item, a
* newer edit, or a delete) — the caller can tell the user. For one device this
* is always a no-op with `merged === false`.
*/
export function mergeStashes(local: Stash, remote: Stash): { stash: Stash; merged: boolean } {
const localItems = new Map(local.items.map((i) => [i.id, i]));
const remoteItems = new Map(remote.items.map((i) => [i.id, i]));
const collectTombs = (s: Stash) => {
const m = new Map<string, number>();
for (const t of s.deleted ?? []) {
if (!t?.id) continue;
m.set(t.id, Math.max(m.get(t.id) ?? 0, t.at ?? 0));
}
return m;
};
const localTombs = collectTombs(local);
const remoteTombs = collectTombs(remote);
const tombs = new Map(localTombs);
for (const [id, at] of remoteTombs) tombs.set(id, Math.max(tombs.get(id) ?? 0, at));
const ids = new Set<string>([...localItems.keys(), ...remoteItems.keys()]);
const items: StashItem[] = [];
let merged = false;
for (const id of ids) {
const l = localItems.get(id);
const r = remoteItems.get(id);
let winner: StashItem;
if (!l) {
winner = r!;
merged = true; // item only the remote knew about
} else if (!r) {
winner = l;
} else if (itemStamp(r) > itemStamp(l)) {
winner = r;
merged = true; // the other device edited this item more recently
} else {
winner = l;
}
const tomb = tombs.get(id);
if (tomb !== undefined && tomb >= itemStamp(winner)) {
// Deleted at/after the last edit — keep it deleted.
if (l && remoteTombs.has(id) && !localTombs.has(id)) merged = true;
continue;
}
items.push(winner);
}
const now = Math.floor(Date.now() / 1000);
const deleted = [...tombs.entries()]
.filter(([, at]) => at > now - TOMBSTONE_MAX_AGE)
.sort((a, b) => b[1] - a[1])
.slice(0, TOMBSTONE_CAP)
.map(([id, at]) => ({ id, at }));
return { stash: { items, deleted, updatedAt: now }, merged };
}
/**
* Hook to manage user's private gear stash
* Uses NIP-44 encryption to store items privately
*/
/**
* @param options.enabled When false, the stash is NOT fetched/decrypted. Used
* by callers that mount this hook only for its data-access API (e.g. the
* global Sasquatch assistant via usePersonalContext) but must NOT trigger a
* decrypt — with Amber every decrypt is a popup, and this hook used to fire on
* every page because Sasquatch is mounted in the app-wide Layout. Defaults to
* true so the actual Stash page still loads normally. When disabled, `stash`
* still resolves from any previously-cached decrypt (React Query cache).
*/
export function useStash(options?: { enabled?: boolean }) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { logins } = useNostrLogin();
const queryClient = useQueryClient();
const { mutateAsync: createEvent } = useNostrPublish();
const callerEnabled = options?.enabled ?? true;
// Determine if nip44 is safely available WITHOUT touching any throwing getter.
const hasNip44 = useMemo(() => checkNip44(user, logins), [user, logins]);
// Amber prompts on the stash decrypt — keep it from re-running on window focus
// (e.g. every time Amber hands focus back) so it decrypts once, not repeatedly.
const interactive = isInteractiveSigner(user, logins);
// Fetch and decrypt stash
const { data: stash, isLoading, error } = useQuery({
queryKey: ['stash', user?.pubkey],
queryFn: async ({ signal }): Promise<Stash> => {
if (!user?.pubkey || !hasNip44) {
return { items: [], updatedAt: 0 };
}
const pubkey = user.pubkey;
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(10000)]);
// Decrypt handle first — an absent/broken signer means "no stash", not a crash.
let nip44Dec: typeof user.signer.nip44;
try { nip44Dec = user.signer?.nip44; } catch { return { items: [], updatedAt: 0 }; }
if (!nip44Dec) return { items: [], updatedAt: 0 };
// Prefer the live event; if the relays are unreachable (offline / outage)
// fall back to the encrypted copy we cached locally — decrypting needs no
// network, so the stash still works at the skatepark.
let event: NostrEvent | undefined;
try {
const events = await nostr.query([{
kinds: [STASH_KIND],
authors: [pubkey],
'#d': [STASH_D_TAG],
limit: 1,
}], { signal: combinedSignal });
event = events[0];
} catch {
// offline — the cache below covers it
}
if (!event) event = await getCachedStashEvent(pubkey);
if (!event) return { items: [], updatedAt: 0 };
// Keep the ciphertext for next time.
cacheStashEvent(pubkey, event).catch(() => {});
try {
// Decrypt the content (encrypted to self).
const decrypted = await nip44Dec.decrypt(pubkey, event.content);
return JSON.parse(decrypted) as Stash;
} catch (err) {
console.error('[Stash] Failed to decrypt stash:', err);
return { items: [], updatedAt: 0 };
}
},
enabled: callerEnabled && !!user?.pubkey && hasNip44,
staleTime: interactive ? Infinity : 60000,
// Keep the decrypted stash in memory for the session so navigating away and
// back doesn't re-open Amber (gcTime would otherwise evict it after 5 min).
gcTime: interactive ? Infinity : undefined,
refetchOnMount: !interactive,
refetchOnWindowFocus: false,
refetchOnReconnect: !interactive,
});
/**
* Re-fetch + decrypt the freshest stash from relays RIGHT BEFORE a mutation,
* rather than trusting the `stash` closure value.
*
* Why this matters (data-safety): the stash is one replaceable event whose
* content is the WHOLE list. Every add/update/remove rewrites that entire
* event. If we based the new list on a stale or not-yet-decrypted `stash`
* (which is legitimately `undefined` early, and — with Amber — may never have
* been decrypted because the query is gated / `staleTime: Infinity`), we'd
* publish a list missing everything the closure didn't know about and
* silently wipe the user's gear. So we always rebuild from a fresh decrypt.
*
* Falls back to the cached query data, then to an empty stash, only if the
* live fetch genuinely returns nothing.
*/
const fetchLatestStash = useCallback(async (): Promise<{ stash: Stash; confirmed: boolean }> => {
const fallback: Stash =
queryClient.getQueryData<Stash>(['stash', user?.pubkey ?? '']) ??
stash ?? { items: [], updatedAt: 0 };
if (!user?.pubkey || !hasNip44) return { stash: fallback, confirmed: false };
let nip44Dec: typeof user.signer.nip44;
try { nip44Dec = user.signer?.nip44; } catch { nip44Dec = undefined; }
if (!nip44Dec) {
// Can't decrypt now — return what we have, flagged unconfirmed so the
// caller refuses to write rather than risk a clobber.
return { stash: fallback, confirmed: false };
}
// `confirmed` means a relay actually answered this read. It's the signal the
// callers use to decide whether a write is safe (see assertConfirmed).
let confirmed = false;
let event: NostrEvent | undefined;
try {
const events = await nostr.query([{
kinds: [STASH_KIND],
authors: [user.pubkey],
'#d': [STASH_D_TAG],
limit: 1,
}], { signal: AbortSignal.timeout(10000) });
event = events[0];
confirmed = true; // the relay answered (possibly with "nothing")
} catch {
// unreachable — fall through to the cached ciphertext
}
if (!event) event = await getCachedStashEvent(user.pubkey);
if (!event) {
// No stash anywhere: safe to start fresh, but only "confirmed" if the
// relay actually answered.
return { stash: fallback, confirmed };
}
cacheStashEvent(user.pubkey, event).catch(() => {});
const remote = JSON.parse(await nip44Dec.decrypt(user.pubkey, event.content)) as Stash;
// Multi-device conflict guard. The list we already hold (React Query cache,
// then the hook's closure) can contain items the relay doesn't have yet —
// most commonly edits that were made a moment ago. Merge rather than let the
// remote silently overwrite them; deletes still win via tombstones. On a
// single device this is always a no-op.
const known = queryClient.getQueryData<Stash>(['stash', user.pubkey]) ?? stash;
if (known) {
const { stash: mergedStash, merged } = mergeStashes(known, remote);
if (merged) {
toast({
title: 'Merged changes from another device',
description: 'Your stash was edited elsewhere — both sets of changes were kept.',
});
}
return { stash: mergedStash, confirmed };
}
return { stash: remote, confirmed };
}, [user, hasNip44, nostr, queryClient, stash]);
/**
* Stash writes rewrite the WHOLE blob, so they must run against a just-read
* relay copy for the merge above to protect other devices' changes. If the
* relay couldn't be reached we refuse the write instead of letting
* `useNostrPublish` queue it: a queued blob is published later as-is, and
* could silently overwrite an edit another device made meanwhile. (Reads still
* work offline — only writes need a connection.)
*/
const assertConfirmed = (confirmed: boolean) => {
if (!confirmed) {
throw new Error("Couldn't reach the relays. Reconnect and try again — saving now could overwrite a newer change from another device.");
}
};
// Save stash (encrypt and publish)
const saveStash = useCallback(async (newStash: Stash) => {
if (!user?.pubkey || !hasNip44) {
throw new Error('Encryption not available');
}
// Access nip44 inside a try/catch at call time (signer is guaranteed by hasNip44 above)
let nip44Enc: typeof user.signer.nip44;
try { nip44Enc = user.signer?.nip44; } catch { throw new Error('Encryption not available'); }
if (!nip44Enc) throw new Error('Encryption not available');
// Encrypt to self
const encrypted = await nip44Enc.encrypt(user.pubkey, JSON.stringify(newStash));
// Publish as replaceable event
await createEvent({
kind: STASH_KIND,
content: encrypted,
tags: [
['d', STASH_D_TAG],
['alt', 'Private gear stash (encrypted)'],
],
});
// Keep the cache in sync with exactly what we just published, then invalidate.
queryClient.setQueryData(['stash', user.pubkey], newStash);
queryClient.invalidateQueries({ queryKey: ['stash', user.pubkey] });
}, [user, hasNip44, createEvent, queryClient]);
// Add item mutation
const addItem = useMutation({
mutationFn: async (item: Omit<StashItem, 'id' | 'addedAt'>) => {
const { stash: currentStash, confirmed } = await fetchLatestStash();
assertConfirmed(confirmed);
const now = Math.floor(Date.now() / 1000);
const newItem: StashItem = {
...item,
id: crypto.randomUUID(),
addedAt: now,
updatedAt: now,
};
const newStash: Stash = {
items: [...currentStash.items, newItem],
deleted: currentStash.deleted,
updatedAt: now,
};
await saveStash(newStash);
return newItem;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] });
},
});
// Update item mutation
const updateItem = useMutation({
mutationFn: async (item: StashItem) => {
const { stash: currentStash, confirmed } = await fetchLatestStash();
assertConfirmed(confirmed);
const now = Math.floor(Date.now() / 1000);
const stamped: StashItem = { ...item, updatedAt: now };
const newStash: Stash = {
items: currentStash.items.map(i => i.id === item.id ? stamped : i),
deleted: currentStash.deleted,
updatedAt: now,
};
await saveStash(newStash);
return stamped;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] });
},
});
// Remove item mutation
const removeItem = useMutation({
mutationFn: async (itemId: string) => {
const { stash: currentStash, confirmed } = await fetchLatestStash();
assertConfirmed(confirmed);
const now = Math.floor(Date.now() / 1000);
const newStash: Stash = {
items: currentStash.items.filter(i => i.id !== itemId),
// Record the delete so a merge with another device's stale copy can't
// resurrect it.
deleted: [...(currentStash.deleted ?? []), { id: itemId, at: now }],
updatedAt: now,
};
await saveStash(newStash);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] });
},
});
// Bulk import (Export/Import) — append items, skipping obvious duplicates, in
// ONE re-encrypt + publish rather than N.
const importItems = useMutation({
mutationFn: async (items: Array<Omit<StashItem, 'id' | 'addedAt'>>) => {
const { stash: currentStash, confirmed } = await fetchLatestStash();
assertConfirmed(confirmed);
const keyOf = (i: { title: string; category: string; size?: string }) =>
`${i.title.trim().toLowerCase()}|${i.category}|${(i.size ?? '').trim().toLowerCase()}`;
const have = new Set(currentStash.items.map(keyOf));
const now = Math.floor(Date.now() / 1000);
const fresh: StashItem[] = [];
for (const raw of items) {
if (!raw?.title || !raw?.category) continue;
const key = keyOf({ title: raw.title, category: raw.category, size: raw.size });
if (have.has(key)) continue;
have.add(key);
fresh.push({ ...raw, id: crypto.randomUUID(), addedAt: now, updatedAt: now });
}
if (fresh.length === 0) return { added: 0, skipped: items.length };
await saveStash({ items: [...currentStash.items, ...fresh], deleted: currentStash.deleted, updatedAt: now });
return { added: fresh.length, skipped: items.length - fresh.length };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] });
},
});
// Bulk actions — apply to many items in ONE re-encrypt + publish.
const bulkSetVisibility = useMutation({
mutationFn: async ({ ids, visibility }: { ids: string[]; visibility: StashItem['visibility'] }) => {
const { stash: current, confirmed } = await fetchLatestStash();
assertConfirmed(confirmed);
const now = Math.floor(Date.now() / 1000);
const changed: StashItem[] = [];
const next = current.items.map((i) => {
if (!ids.includes(i.id)) return i;
const updated = { ...i, visibility, updatedAt: now };
changed.push(updated);
return updated;
});
await saveStash({ items: next, deleted: current.deleted, updatedAt: now });
return { changed };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] });
},
});
const bulkSetForSale = useMutation({
mutationFn: async ({ ids, forSale }: { ids: string[]; forSale: boolean }) => {
const { stash: current, confirmed } = await fetchLatestStash();
assertConfirmed(confirmed);
const now = Math.floor(Date.now() / 1000);
const changed: StashItem[] = [];
const next = current.items.map((i) => {
if (!ids.includes(i.id)) return i;
const updated = { ...i, willingToSell: forSale, updatedAt: now };
changed.push(updated);
return updated;
});
await saveStash({ items: next, deleted: current.deleted, updatedAt: now });
return { changed };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] });
},
});
const bulkRemove = useMutation({
mutationFn: async (ids: string[]) => {
const { stash: current, confirmed } = await fetchLatestStash();
assertConfirmed(confirmed);
const now = Math.floor(Date.now() / 1000);
const removed = current.items.filter((i) => ids.includes(i.id));
await saveStash({
items: current.items.filter((i) => !ids.includes(i.id)),
deleted: [...(current.deleted ?? []), ...removed.map((i) => ({ id: i.id, at: now }))],
updatedAt: now,
});
return { removed };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stash', user?.pubkey] });
},
});
// Stats for RPG display
const stats = useMemo(() => {
if (!stash) return { total: 0, byCategory: {}, totalValue: 0 };
const byCategory: Record<string, number> = {};
let totalValue = 0;
for (const item of stash.items) {
byCategory[item.category] = (byCategory[item.category] || 0) + 1;
if (item.minPrice) totalValue += item.minPrice;
}
return {
total: stash.items.length,
byCategory,
totalValue,
};
}, [stash]);
return {
stash,
items: stash?.items || [],
stats,
isLoading,
error,
addItem,
updateItem,
removeItem,
importItems,
bulkSetVisibility,
bulkSetForSale,
bulkRemove,
isEncryptionAvailable: hasNip44,
};
}
// Category metadata for RPG display
export const STASH_CATEGORIES = {
deck: { emoji: '🛹', label: 'Decks', color: '#ff0040', rarity: 'common' },
trucks: { emoji: '🔧', label: 'Trucks', color: '#ff6600', rarity: 'common' },
wheels: { emoji: '⚙️', label: 'Wheels', color: '#ccff00', rarity: 'common' },
bearings: { emoji: '🔩', label: 'Bearings', color: '#00ff88', rarity: 'uncommon' },
shoes: { emoji: '👟', label: 'Shoes', color: '#00ccff', rarity: 'uncommon' },
clothing: { emoji: '👕', label: 'Clothing', color: '#9933ff', rarity: 'common' },
protective: { emoji: '🛡️', label: 'Protective', color: '#ff33cc', rarity: 'rare' },
complete: { emoji: '🎯', label: 'Completes', color: '#ffd700', rarity: 'epic' },
other: { emoji: '📦', label: 'Other', color: '#888888', rarity: 'common' },
} as const;
export const CONDITION_LABELS = {
new: { label: '🆕 New', color: '#00ff00', stars: 5 },
like_new: { label: '✨ Like New', color: '#ccff00', stars: 4 },
good: { label: '👍 Good', color: '#ffcc00', stars: 3 },
well_used: { label: '⚠️ Well Used', color: '#ff6600', stars: 2 },
} as const;
// RPG-style rarity tiers with multipliers for XP
export const RARITY_TIERS = {
common: { label: 'Common', color: '#9ca3af', xpMultiplier: 1, bgGradient: 'from-gray-500/20 to-gray-600/20' },
uncommon: { label: 'Uncommon', color: '#22c55e', xpMultiplier: 1.5, bgGradient: 'from-green-500/20 to-emerald-600/20' },
rare: { label: 'Rare', color: '#3b82f6', xpMultiplier: 2, bgGradient: 'from-blue-500/20 to-indigo-600/20' },
epic: { label: 'Epic', color: '#a855f7', xpMultiplier: 3, bgGradient: 'from-purple-500/20 to-violet-600/20' },
legendary: { label: 'Legendary', color: '#fbbf24', xpMultiplier: 5, bgGradient: 'from-yellow-400/20 to-orange-500/20' },
} as const;
// Calculate player level based on total items and value
export function calculateSkaterLevel(stats: { total: number; totalValue: number; byCategory: Record<string, number> }): {
level: number;
currentXP: number;
xpToNextLevel: number;
title: string;
progress: number;
} {
// XP formula: 100 per item + value/100
const xp = stats.total * 100 + Math.floor(stats.totalValue / 100);
// Level formula: sqrt(xp/100)
const level = Math.max(1, Math.floor(Math.sqrt(xp / 100)));
const xpForCurrentLevel = level * level * 100;
const xpForNextLevel = (level + 1) * (level + 1) * 100;
const currentXP = xp - xpForCurrentLevel;
const xpToNextLevel = xpForNextLevel - xpForCurrentLevel;
const progress = Math.min(100, (currentXP / xpToNextLevel) * 100);
// Titles based on level
const titles: Record<number, string> = {
1: 'Grom',
2: 'Pusher',
3: 'Street Rat',
5: 'Park Local',
8: 'Session Regular',
12: 'Spot Hunter',
15: 'Gear Head',
20: 'Skate Collector',
25: 'Board Hoarder',
30: 'Stash Master',
50: 'Legendary Collector',
};
let title = 'Grom';
for (const [lvl, t] of Object.entries(titles)) {
if (level >= parseInt(lvl)) title = t;
}
return { level, currentXP, xpToNextLevel, title, progress };
}