SkateSpots/src/components/AppProvider.tsx
SkateSpots 1ef35c14b8 fix(dms): DM inbox relays were wiped on every load, so DMs went nowhere
Root cause: AppProvider validates the persisted config with
AppConfigSchema.partial().parse(), and RelayMetadataSchema only declared
'relays' + 'updatedAt'. Zod drops unknown keys by default, so dmRelays (and
localRelays + blockedRelays) were SILENTLY STRIPPED on every page load. The DM
inbox relays (kind 10050) are usually not in a user's NIP-65 list, so DM reads
and writes fell back to NIP-65 relays - where the other side isn't listening -
and messages looked like they never went through.

- Declare localRelays/blockedRelays/dmRelays in RelayMetadataSchema.
- Send: publish gift wraps in PARALLEL with per-relay timeouts and resolve on
  the first relay that accepts (a dead relay or auth.nostr1.com no longer
  stalls the send); deliver to the recipient's NIP-65 WRITE relays when they
  have no discoverable 10050 (the old fallback was dead code because our own DM
  relays are always non-empty); discovery of 10050/10002 now runs in parallel.
- Read: query all DM relays in parallel with per-relay timeouts. Previously one
  slow/dead relay burned the shared 15s budget and then the fired abort made
  every remaining relay fail instantly.

Verified with an nsec login + a hostile relay list (dead + auth-required):
before, the wrap reached only write relays and the recipient found 0; after, it
lands on the configured inbox relays and both the recipient and our own copy are
found. Send worst case 20s -> 4.5s.
2026-09-27 19:58:54 +07:00

160 lines
No EOL
5 KiB
TypeScript

import { ReactNode, useEffect } from 'react';
import { z } from 'zod';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { safeJson } from '@/lib/safeJson';
import { AppContext, type AppConfig, type AppContextType, type Theme, type RelayMetadata } from '@/contexts/AppContext';
interface AppProviderProps {
children: ReactNode;
/** Application storage key */
storageKey: string;
/** Default app configuration */
defaultConfig: AppConfig;
}
// Zod schema for RelayMetadata validation
const RelayMetadataSchema = z.object({
relays: z.array(z.object({
url: z.string().url(),
read: z.boolean(),
write: z.boolean(),
})),
updatedAt: z.number(),
// These MUST be declared. Zod strips keys it doesn't know, so omitting them
// silently wiped the user's app-local settings on every load: their NIP-17 DM
// inbox relays (kind 10050), local relays and the relay blocklist. With
// `dmRelays` gone, DM reads/writes fell back to the NIP-65 list — which does
// NOT include the DM relays — so inbox messages looked like they "never
// arrived".
localRelays: z.array(z.string()).optional(),
blockedRelays: z.array(z.string()).optional(),
dmRelays: z.array(z.string()).optional(),
}) satisfies z.ZodType<RelayMetadata>;
// Zod schema for AppConfig validation
const AppConfigSchema = z.object({
theme: z.enum(['dark', 'light', 'system']),
relayMetadata: RelayMetadataSchema,
useSkateRelay: z.boolean().optional(),
useCitrineRelay: z.boolean().optional(),
}) satisfies z.ZodType<Partial<AppConfig>>;
export function AppProvider(props: AppProviderProps) {
const {
children,
storageKey,
defaultConfig,
} = props;
// App configuration state with localStorage persistence
const [rawConfig, setConfig] = useLocalStorage<Partial<AppConfig>>(
storageKey,
{},
{
serialize: JSON.stringify,
deserialize: (value: string) => {
// Guard the parse: a corrupted localStorage blob must not throw before
// the Zod schema runs (that would crash-loop the app on boot).
const parsed = safeJson<unknown>(value, {});
return AppConfigSchema.partial().parse(parsed);
}
}
);
// Generic config updater with callback pattern
const updateConfig = (updater: (currentConfig: Partial<AppConfig>) => Partial<AppConfig>) => {
setConfig(updater);
};
// Merge configs with special handling for relay metadata
const config = { ...defaultConfig, ...rawConfig };
// Force dark theme and seed default relays only for brand-new users
useEffect(() => {
let needsUpdate = false;
const updates: Partial<AppConfig> = {};
// Only reset relays to defaults if the user has never synced their own NIP-65
// relay list (updatedAt === 0 means no NIP-65 event has ever been fetched).
// If updatedAt > 0, the user has their own relay list — never overwrite it.
if (!config.relayMetadata.relays || config.relayMetadata.relays.length === 0) {
if (config.relayMetadata.updatedAt === 0) {
updates.relayMetadata = defaultConfig.relayMetadata;
needsUpdate = true;
}
}
// Force dark theme (app is dark-only)
if (config.theme !== 'dark') {
updates.theme = 'dark';
needsUpdate = true;
}
if (needsUpdate) {
updateConfig((current) => ({ ...current, ...updates }));
}
// Intentionally run once on mount (seed defaults / force dark theme).
// Adding config/updateConfig deps would re-run the effect after it writes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Ensure relay list is never empty - fallback to default if no relays configured
if (!config.relayMetadata.relays || config.relayMetadata.relays.length === 0) {
config.relayMetadata = defaultConfig.relayMetadata;
}
const appContextValue: AppContextType = {
config,
updateConfig,
};
// Apply theme effects to document
useApplyTheme(config.theme);
return (
<AppContext.Provider value={appContextValue}>
{children}
</AppContext.Provider>
);
}
/**
* Hook to apply theme changes to the document root
*/
function useApplyTheme(theme: Theme) {
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
// Handle system theme changes when theme is set to "system"
useEffect(() => {
if (theme !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
const systemTheme = mediaQuery.matches ? 'dark' : 'light';
root.classList.add(systemTheme);
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
}