Files
eranos/src/hooks/useEncryptedSettings.ts
T
Mary Kate Fain f82adab05d Fix: add letter (kind 8211) notifications
Letters were completely absent from the notification pipeline — users had
to visit the Letters page to discover incoming letters. This integrates
kind 8211 into every layer of the notification system:

- useNotifications: query, grouping, and referenced-event exclusion
- useHasUnreadNotifications: unread dot indicator
- NotificationsPage: LetterNotification component with link to /letters
- NotificationSettings: toggleable Letters row
- notificationTemplates: web push template
- Android NotificationRelayService + NostrPoller: native push support
- EncryptedSettings + schema: letters preference field

Closes #188
2026-03-27 14:15:50 -05:00

248 lines
8.7 KiB
TypeScript

import { useRef } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { NostrFilter } from '@nostrify/nostrify';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from './useCurrentUser';
import type { Theme, FeedSettings, ContentWarningPolicy, SavedFeed } from '@/contexts/AppContext';
import type { ThemeConfig } from '@/themes';
import type { ContentFilter } from './useContentFilters';
import type { LetterPreferences } from '@/lib/letterTypes';
import { EncryptedSettingsSchema } from '@/lib/schemas';
/**
* Timestamp (ms) of last local encrypted-settings write this session.
* NostrSync uses this to avoid overwriting a local edit with a stale relay event.
*/
let lastWriteTs: number = 0;
/**
* Complete encrypted app settings stored in NIP-78
*/
export interface EncryptedSettings {
/** App theme preference */
theme?: Theme;
/** Custom theme config (colors, fonts, background) */
customTheme?: ThemeConfig;
/** Automatically publish custom theme changes to profile */
autoShareTheme?: boolean;
/** Whether to use app default relays in addition to user relays */
useAppRelays?: boolean;
/** Feed and sidebar content settings */
feedSettings?: FeedSettings;
/** Advanced content filters */
contentFilters?: ContentFilter[];
/** How to handle NIP-36 content-warning events */
contentWarningPolicy?: ContentWarningPolicy;
/** Whether the user has enabled push notifications */
notificationsEnabled?: boolean;
/** Timestamp of last viewed notification (Unix timestamp in seconds) */
notificationsCursor?: number;
/** Per-type notification preferences (all default to true/enabled) */
notificationPreferences?: {
reactions?: boolean;
reposts?: boolean;
zaps?: boolean;
mentions?: boolean;
comments?: boolean;
badges?: boolean;
letters?: boolean;
onlyFollowing?: boolean;
};
/** Last sync timestamp */
lastSync?: number;
/** Ordered list of sidebar item IDs (built-in + extra-kind) */
sidebarOrder?: string[];
/** Sidebar item ID to display on the homepage ("/") */
homePage?: string;
/** Whether the Global feed tab is shown */
showGlobalFeed?: boolean;
/** Whether the Community feed tab is shown */
showCommunityFeed?: boolean;
/** Community data: domain, label, user count, and NIP-05 JSON */
communityData?: {
domain: string;
label: string;
userCount: number;
nip05: Record<string, unknown>;
};
/** Custom CORS proxy URI template (only synced when non-empty) */
corsProxy?: string;
/** Custom favicon URI template (only synced when non-empty) */
faviconUrl?: string;
/** Custom link preview URI template (only synced when non-empty) */
linkPreviewUrl?: string;
/** Sentry DSN for error reporting (empty string = disabled) */
sentryDsn?: string;
/** Saved feed tabs created from the search page. */
savedFeeds?: SavedFeed[];
/** Letter preferences (stationery, font, frame, closing, signature, inbox filters) */
letterPreferences?: LetterPreferences;
}
/**
* Hook to manage all encrypted app settings using NIP-78 (kind 30078)
* Syncs settings across devices while keeping them private
*/
export function useEncryptedSettings() {
const { config } = useAppContext();
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
// Query the encrypted settings event
const query = useQuery({
queryKey: ['encryptedSettings', user?.pubkey],
queryFn: async () => {
if (!user) return null;
const filter: NostrFilter = {
kinds: [30078],
authors: [user.pubkey],
'#d': [`${config.appId}/metadata`],
limit: 1,
};
const events = await nostr.query([filter]);
if (events.length === 0) return null;
return events[0];
},
enabled: !!user,
staleTime: Infinity,
gcTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
// Parse settings from encrypted content
const settings = useQuery({
queryKey: ['parsedSettings', query.data?.id],
queryFn: async () => {
const event = query.data;
if (!event || !user) return null;
// Decrypt the content
if (!event.content || !user.signer.nip44) {
return null;
}
try {
const decrypted = await user.signer.nip44.decrypt(user.pubkey, event.content);
const json = JSON.parse(decrypted);
const result = EncryptedSettingsSchema.safeParse(json);
if (!result.success) {
console.warn('Encrypted settings failed validation, using partial data:', result.error.issues);
// Return whatever fields are valid rather than wiping everything
return (json ?? {}) as EncryptedSettings;
}
return result.data as EncryptedSettings;
} catch (error) {
console.error('Failed to decrypt settings:', error);
return null;
}
},
enabled: !!query.data && !!user,
staleTime: Infinity,
gcTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
// Tracks the latest optimistic settings so rapid successive mutations
// don't overwrite each other by reading stale cache data.
const pendingSettings = useRef<EncryptedSettings | null>(null);
// Update settings
const updateSettings = useMutation({
mutationFn: async (patch: Partial<EncryptedSettings>) => {
if (!user) throw new Error('User not logged in');
if (!user.signer.nip44) throw new Error('NIP-44 encryption not supported by signer');
// Use the latest pending settings if available, otherwise fall back to cache.
const currentSettings = pendingSettings.current ?? settings.data ?? {};
const updatedSettings: EncryptedSettings = {
...currentSettings,
...patch,
lastSync: Date.now(),
};
// Optimistically track so the next rapid mutation sees this state immediately
pendingSettings.current = updatedSettings;
// Encrypt the settings
const plaintext = JSON.stringify(updatedSettings);
const encrypted = await user.signer.nip44.encrypt(user.pubkey, plaintext);
// Sign the event
const unsignedEvent = {
kind: 30078,
content: encrypted,
tags: [
['d', `${config.appId}/metadata`],
['title', `${config.appName} Metadata`],
['client', config.appName, ...(config.client ? [config.client] : [])],
],
created_at: Math.floor(Date.now() / 1000),
};
const signedEvent = await user.signer.signEvent(unsignedEvent);
// Mark that we just wrote, so NostrSync doesn't fight us.
lastWriteTs = Date.now();
// Publish in background
nostr.event(signedEvent, { signal: AbortSignal.timeout(5000) }).catch((error) => {
console.error('Failed to publish encrypted settings:', error);
});
return { updatedSettings, signedEvent };
},
// Update cache in-place instead of refetching, which avoids
// NostrSync re-running and causing a re-render loop.
// Do NOT invalidate the encryptedSettings query here — doing so triggers a
// relay refetch that can return the old event before the new one propagates,
// which causes NostrSync to overwrite the theme the user just selected.
//
// Use the signed event's ID (not the old query event ID) so the parsed
// settings cache entry is keyed correctly and NostrSync picks it up.
onSuccess: ({ updatedSettings, signedEvent }) => {
queryClient.setQueryData(['encryptedSettings', user?.pubkey], signedEvent);
queryClient.setQueryData(['parsedSettings', signedEvent.id], updatedSettings);
// Cache is now up to date — pending ref no longer needed
pendingSettings.current = null;
},
});
// Initialize settings if they don't exist
const initializeSettings = async (initialSettings: Partial<EncryptedSettings>) => {
if (settings.data !== null || !user?.signer.nip44) {
return; // Already initialized or no encryption support
}
try {
await updateSettings.mutateAsync(initialSettings);
} catch (error) {
console.warn('Failed to initialize encrypted settings:', error);
}
};
return {
settings: settings.data,
isLoading: query.isLoading || settings.isLoading,
isError: query.isError || settings.isError,
error: query.error || settings.error,
updateSettings,
initializeSettings,
hasNip44Support: !!user?.signer.nip44,
lastSync: settings.data?.lastSync,
/** True if a local write happened recently. NostrSync should skip applying. */
recentlyWritten: () => Date.now() - lastWriteTs < 10_000,
};
}