Merge branch 'main' of gitlab.com:soapbox-pub/ditto-mew

# Conflicts:
#	src/components/AppProvider.tsx
#	src/hooks/useTheme.ts
This commit is contained in:
Alex Gleason
2026-02-24 22:09:09 -06:00
7 changed files with 61 additions and 22 deletions
+3 -2
View File
@@ -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');
+4 -14
View File
@@ -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)] ?? <Palette className="size-5" />;
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 } });
}
};
+3
View File
@@ -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;
}
+1 -2
View File
@@ -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);
+12 -2
View File
@@ -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<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');
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;
},
});
+16 -2
View File
@@ -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<number | null>(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<string>();
@@ -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]);
+22
View File
@@ -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,