diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index 3f2ad041..cd25c150 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useEffect } from 'react'; +import { ReactNode, useLayoutEffect } from 'react'; import { z } from 'zod'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { AppContext, type AppConfig, type AppContextType, type Theme, type RelayMetadata } from '@/contexts/AppContext'; @@ -117,7 +117,7 @@ export function AppProvider(props: AppProviderProps) { * When theme is "custom", uses the provided customTheme tokens. */ function useApplyTheme(theme: Theme, customTheme: ThemeTokens | undefined) { - useEffect(() => { + useLayoutEffect(() => { function apply() { const resolved = resolveTheme(theme); let tokens: ThemeTokens; @@ -138,6 +138,7 @@ function useApplyTheme(theme: Theme, customTheme: ThemeTokens | undefined) { document.head.appendChild(el); } el.textContent = css; + document.documentElement.className = resolved; // Now that CSS variables are set, the inline body background from // theme.js is no longer needed — bg-background will resolve correctly. document.body.removeAttribute('style'); diff --git a/src/components/ContentSettings.tsx b/src/components/ContentSettings.tsx index 1474b662..b9a4ca3d 100644 --- a/src/components/ContentSettings.tsx +++ b/src/components/ContentSettings.tsx @@ -236,18 +236,13 @@ function KindBadge({ kind }: { kind: number }) { function SubKindRow({ sub, parentEnabled }: { sub: SubKindDef; parentEnabled: boolean }) { const { feedSettings, updateFeedSettings } = useFeedSettings(); - const { updateSettings, settings: nostrSettings } = useEncryptedSettings(); + const { updateSettings } = useEncryptedSettings(); const { user } = useCurrentUser(); const handleToggle = async (key: string, value: boolean) => { updateFeedSettings({ [key]: value }); if (user) { - // Merge against the Nostr-cached feed settings (most up-to-date persisted - // state) rather than the local React state, which may lag behind when - // multiple toggles fire in quick succession. - const baseFeed = nostrSettings?.feedSettings ?? feedSettings; - const updatedFeedSettings = { ...baseFeed, [key]: value }; - await updateSettings.mutateAsync({ feedSettings: updatedFeedSettings }); + await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, [key]: value } }); } }; @@ -284,7 +279,7 @@ function SubKindRow({ sub, parentEnabled }: { sub: SubKindDef; parentEnabled: bo function ContentTypeRow({ def }: { def: ExtraKindDef }) { const { feedSettings, updateFeedSettings } = useFeedSettings(); - const { updateSettings, settings: nostrSettings } = useEncryptedSettings(); + const { updateSettings } = useEncryptedSettings(); const { user } = useCurrentUser(); const icon = ICONS[def.route ?? String(def.kind)] ?? ; const hasSubKinds = !!def.subKinds; @@ -293,12 +288,7 @@ function ContentTypeRow({ def }: { def: ExtraKindDef }) { const handleToggle = async (key: string, value: boolean) => { updateFeedSettings({ [key]: value }); if (user) { - // Merge against the Nostr-cached feed settings (most up-to-date persisted - // state) rather than the local React state, which may lag behind when - // multiple toggles fire in quick succession. - const baseFeed = nostrSettings?.feedSettings ?? feedSettings; - const updatedFeedSettings = { ...baseFeed, [key]: value }; - await updateSettings.mutateAsync({ feedSettings: updatedFeedSettings }); + await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, [key]: value } }); } }; diff --git a/src/components/NostrSync.tsx b/src/components/NostrSync.tsx index cdb28a74..dab0c532 100644 --- a/src/components/NostrSync.tsx +++ b/src/components/NostrSync.tsx @@ -95,6 +95,9 @@ export function NostrSync() { // the immediate write window, e.g. before the new event propagates back). if (recentlyWritten()) { console.log('Skipping settings sync - recent write'); + // Advance the cursor so this snapshot is never re-applied once the write + // window expires and the effect fires again. + lastSyncedTimestamp.current = remoteSync; return; } diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 0d5a6fe7..3d9beb17 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -1,4 +1,4 @@ -import { Link, useNavigate } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { MessageCircle, Zap, MoreHorizontal, Play, Radio, Users } from 'lucide-react'; import { RepostIcon } from '@/components/icons/RepostIcon'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; @@ -155,7 +155,6 @@ function encodeEventId(event: NostrEvent): string { } export function NoteCard({ event, className, repostedBy, compact, threaded }: NoteCardProps) { - const navigate = useNavigate(); const { config } = useAppContext(); const { user } = useCurrentUser(); const author = useAuthor(event.pubkey); diff --git a/src/hooks/useEncryptedSettings.ts b/src/hooks/useEncryptedSettings.ts index e3311b74..af7a68b7 100644 --- a/src/hooks/useEncryptedSettings.ts +++ b/src/hooks/useEncryptedSettings.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useNostr } from '@nostrify/react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import type { NostrFilter } from '@nostrify/nostrify'; @@ -120,19 +120,27 @@ export function useEncryptedSettings() { refetchOnReconnect: false, }); + // Tracks the latest optimistic settings so rapid successive mutations + // don't overwrite each other by reading stale cache data. + const pendingSettings = useRef(null); + // Update settings const updateSettings = useMutation({ mutationFn: async (patch: Partial) => { if (!user) throw new Error('User not logged in'); if (!user.signer.nip44) throw new Error('NIP-44 encryption not supported by signer'); - const currentSettings = settings.data || {}; + // 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); @@ -172,6 +180,8 @@ export function useEncryptedSettings() { 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; }, }); diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index beffd35e..56fe279d 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import { useNostr } from '@nostrify/react'; import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import { Capacitor } from '@capacitor/core'; @@ -175,10 +175,18 @@ export function useNotifications(): NotificationData { }, [data?.pages]); // Only use cursor if settings have actually loaded, otherwise null - const notificationsCursor = settings !== undefined && settings !== null + const remoteCursor = settings !== undefined && settings !== null ? (settings.notificationsCursor ?? 0) : null; + // Optimistic local cursor — updated immediately on markAsRead so that + // newNotificationIds collapses to empty before the query cache catches up, + // preventing the NotificationsPage effect from re-triggering markAsRead. + const optimisticCursor = useRef(null); + const notificationsCursor = optimisticCursor.current !== null + ? Math.max(optimisticCursor.current, remoteCursor ?? 0) + : remoteCursor; + // Build set of unread notification IDs const newNotificationIds = useMemo(() => { if (notificationsCursor === null) return new Set(); @@ -199,12 +207,18 @@ export function useNotifications(): NotificationData { if (newestTimestamp <= notificationsCursor) return; + // Update optimistic cursor immediately so unread state clears before + // the query cache updates, preventing re-trigger loops. + optimisticCursor.current = newestTimestamp; + try { await updateSettings.mutateAsync({ notificationsCursor: newestTimestamp, }); } catch (error) { console.error('Failed to mark notifications as read:', error); + // Roll back optimistic cursor on failure + optimisticCursor.current = null; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [user?.pubkey, items.length, notificationsCursor]); diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index 465517cc..3d3aac61 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -4,6 +4,7 @@ import { useAppContext } from "@/hooks/useAppContext"; import { useEncryptedSettings } from "@/hooks/useEncryptedSettings"; import { useCurrentUser } from "@/hooks/useCurrentUser"; import { useRef, useCallback } from "react"; +import { builtinThemes, buildThemeCss, resolveTheme } from "@/themes"; /** * Hook to get and set the active theme. @@ -32,6 +33,27 @@ export function useTheme() { /** Switch to a builtin theme (light, dark, system) or to "custom" (preserving existing customTheme). */ const setTheme = useCallback((theme: Theme) => { + // Disable all transitions while swapping theme to prevent animated flicker + const noTransition = document.createElement('style'); + noTransition.textContent = '*, *::before, *::after { transition: none !important; }'; + document.head.appendChild(noTransition); + + // Apply CSS vars synchronously before React re-renders to eliminate flicker + const resolved = resolveTheme(theme); + const css = buildThemeCss(builtinThemes[resolved as keyof typeof builtinThemes] ?? builtinThemes.dark); + let el = document.getElementById('theme-vars') as HTMLStyleElement | null; + if (!el) { + el = document.createElement('style'); + el.id = 'theme-vars'; + document.head.appendChild(el); + } + el.textContent = css; + document.documentElement.className = resolved; + + // Re-enable transitions after the browser has painted the new theme + requestAnimationFrame(() => noTransition.remove()); + + // Update local immediately updateConfig((currentConfig) => ({ ...currentConfig, theme,