f82adab05d
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
248 lines
8.7 KiB
TypeScript
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,
|
|
};
|
|
}
|