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,