Merge branch 'main' of gitlab.com:soapbox-pub/ditto-mew
# Conflicts: # src/components/AppProvider.tsx # src/hooks/useTheme.ts
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user