Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-synced-local-tweaks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Restore locally imported CSS tweaks when syncing settings between devices.
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,13 @@ function UploadedTweakCard({ data }: { data: Extract<SuccessfulUpload, { role: '
const existing = favorites.find((favorite) => favorite.fullUrl === data.fullUrl);
if (existing) {
return favorites.map((favorite) =>
favorite.fullUrl === data.fullUrl && pinned ? { ...favorite, pinned: true } : favorite
favorite.fullUrl === data.fullUrl
? {
...favorite,
pinned: pinned ? true : favorite.pinned,
cssText: favorite.cssText ?? data.cssText,
}
: favorite
);
}
return [
Expand All @@ -77,6 +83,7 @@ function UploadedTweakCard({ data }: { data: Extract<SuccessfulUpload, { role: '
basename: data.basename,
pinned,
importedLocal: true,
cssText: data.cssText,
},
];
},
Expand Down
141 changes: 141 additions & 0 deletions src/app/features/settings/cosmetics/ThemeCatalogSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { getCachedThemeCss, putCachedThemeCss } = vi.hoisted(() => ({
getCachedThemeCss: vi.fn<() => Promise<string | undefined>>(),
putCachedThemeCss: vi.fn<() => Promise<void>>(),
}));

type TweakFavorite = {
fullUrl: string;
displayName: string;
basename: string;
importedLocal: boolean;
cssText?: string;
};

const settings = {
themeRemoteFavorites: [],
themeRemoteTweakFavorites: [
{
fullUrl: 'sable-import://tweak/restored/full.sable.css',
displayName: 'Fallback name',
basename: 'restored',
importedLocal: true,
cssText: '/*\n@sable-tweak\nname: Restored tweak\n*/\n.restored {}',
},
] as TweakFavorite[],
themeRemoteEnabledTweakFullUrls: [],
useSystemTheme: false,
themeRemoteManualFullUrl: undefined,
themeRemoteLightFullUrl: undefined,
themeRemoteDarkFullUrl: undefined,
themeChatSableWidgetsEnabled: false,
themeChatAutoPreviewApprovedUrls: [],
themeChatAutoPreviewAnyUrl: false,
};

vi.mock('$state/hooks/settings', () => ({
useSetting: (_atom: unknown, key: keyof typeof settings) => [
settings[key],
vi.fn<(value: unknown) => void>(),
],
}));
vi.mock('$state/settings', () => ({
settingsAtom: {},
getSettings: () => ({
iconCompactSizePx: 16,
iconInlineSizePx: 20,
iconToolbarSizePx: 24,
iconEmptySizePx: 32,
}),
}));
vi.mock('$hooks/useClientConfig', () => ({ useClientConfig: () => ({}) }));
vi.mock('$utils/share', () => ({
shareText: vi.fn<(text: string) => Promise<boolean>>().mockResolvedValue(false),
}));
vi.mock('../../../theme/cache', () => ({
getCachedThemeCss,
putCachedThemeCss,
}));

import { ThemeCatalogSettings } from './ThemeCatalogSettings';

describe('ThemeCatalogSettings saved tweaks', () => {
beforeEach(() => {
getCachedThemeCss.mockRejectedValue(new Error('no cache'));
putCachedThemeCss.mockRejectedValue(new Error('no cache'));
});

afterEach(() => {
settings.themeRemoteTweakFavorites = [
{
fullUrl: 'sable-import://tweak/restored/full.sable.css',
displayName: 'Fallback name',
basename: 'restored',
importedLocal: true,
cssText: '/*\n@sable-tweak\nname: Restored tweak\n*/\n.restored {}',
},
];
getCachedThemeCss.mockReset().mockRejectedValue(new Error('no cache'));
putCachedThemeCss.mockReset().mockRejectedValue(new Error('no cache'));
});

it('lists restored embedded local CSS when the cache is unavailable', async () => {
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ThemeCatalogSettings mode="local" />
</QueryClientProvider>
);

fireEvent.click(screen.getByText('Tweaks (1)'));
expect(await screen.findByText('Restored tweak')).toBeInTheDocument();
});

it('shows zero and migration guidance for a fresh body-less legacy tweak', async () => {
settings.themeRemoteTweakFavorites = [
{
fullUrl: 'sable-import://tweak/legacy/full.sable.css',
displayName: 'Legacy',
basename: 'legacy',
importedLocal: true,
},
];
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ThemeCatalogSettings mode="local" />
</QueryClientProvider>
);
expect(screen.getByText('Tweaks (0)')).toBeInTheDocument();
fireEvent.click(screen.getByText('Tweaks (0)'));
expect(await screen.findByText(/waiting to migrate/)).toBeInTheDocument();
});

it('counts a cache-resolved legacy tweak without migration guidance', async () => {
settings.themeRemoteTweakFavorites = [
{
fullUrl: 'sable-import://tweak/legacy/full.sable.css',
displayName: 'Legacy',
basename: 'legacy',
importedLocal: true,
},
];
getCachedThemeCss.mockResolvedValue('/*\n@sable-tweak\nname: Cached legacy\n*/\n.cached {}');
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ThemeCatalogSettings mode="local" />
</QueryClientProvider>
);
fireEvent.click(screen.getByText('Tweaks (0)'));
expect(await screen.findByText('Cached legacy')).toBeInTheDocument();
expect(screen.getByText('Tweaks (1)')).toBeInTheDocument();
expect(screen.queryByText(/waiting to migrate/)).not.toBeInTheDocument();
});
});
43 changes: 38 additions & 5 deletions src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ import {
type ThemePair,
type TweakCatalogEntry,
} from '../../../theme/catalog';
import { isLocalImportBundledUrl, isLocalImportThemeUrl } from '../../../theme/localImportUrls';
import {
isLocalImportBundledUrl,
isLocalImportThemeUrl,
isLocalImportTweakUrl,
} from '../../../theme/localImportUrls';
import { resolveSavedTweakCss } from '../../../theme/syncedTweakCss';
import { isThirdPartyThemeUrl } from '../../../theme/themeApproval';
import { themeCatalogListingBaseUrl } from '../../../theme/catalogDefaults';
import {
Expand Down Expand Up @@ -579,14 +584,17 @@ export function ThemeCatalogSettings({
});

const localTweaksQuery = useQuery({
queryKey: ['theme-local-tweaks', tweakFavorites.map((f) => f.fullUrl).join('|')],
queryKey: [
'theme-local-tweaks',
tweakFavorites.map((f) => `${f.fullUrl}:${f.cssText ?? ''}`).join('|'),
],
enabled: showSavedLibrary && tweakFavorites.length > 0,
staleTime: 10 * 60_000,
queryFn: async (): Promise<LocalTweakRow[]> => {
const rows = await Promise.all(
tweakFavorites.map(async (fav) => {
try {
let text = (await getCachedThemeCss(fav.fullUrl)) ?? '';
let text = await resolveSavedTweakCss(fav);
if (!isLocalImportBundledUrl(fav.fullUrl)) {
try {
const res = await fetch(fav.fullUrl, { mode: 'cors' });
Expand Down Expand Up @@ -616,6 +624,23 @@ export function ThemeCatalogSettings({
return rows.filter((r): r is LocalTweakRow => Boolean(r));
},
});
const resolvedTweakUrls = new Set(
localTweaksQuery.isSuccess ? localTweaksQuery.data.map((row) => row.fullUrl) : []
);
const provisionalTweakCount = tweakFavorites.filter(
(favorite) => !isLocalImportTweakUrl(favorite.fullUrl) || Boolean(favorite.cssText)
).length;
const savedTweakCount = localTweaksQuery.isSuccess
? resolvedTweakUrls.size
: provisionalTweakCount;
const unresolvedLegacyTweakCount = localTweaksQuery.isSuccess
? tweakFavorites.filter(
(favorite) =>
isLocalImportTweakUrl(favorite.fullUrl) &&
!favorite.cssText &&
!resolvedTweakUrls.has(favorite.fullUrl)
).length
: 0;

const removeFavorite = useCallback(
(fullUrl: string) => {
Expand Down Expand Up @@ -1103,7 +1128,7 @@ export function ThemeCatalogSettings({
radii="Pill"
onClick={() => setSavedSection('tweaks')}
>
<Text size="B300">Tweaks ({tweakFavorites.length})</Text>
<Text size="B300">Tweaks ({savedTweakCount})</Text>
</Chip>
</Box>
<Box direction="Row" gap="100" alignItems="Center">
Expand Down Expand Up @@ -1263,9 +1288,17 @@ export function ThemeCatalogSettings({
paddingRight: toRem(4),
}}
>
{localTweaksQuery.data.length > 0 && unresolvedLegacyTweakCount > 0 && (
<Text size="T300" priority="300">
{unresolvedLegacyTweakCount} saved local tweak
{unresolvedLegacyTweakCount === 1 ? ' is' : 's are'} waiting to migrate.
</Text>
)}
{localTweaksQuery.data.length === 0 ? (
<Text size="T300" priority="300">
Could not load tweak CSS. Check the URL or your connection.
{unresolvedLegacyTweakCount === tweakFavorites.length
? 'Some saved local tweaks are waiting to migrate. Open Sable on a device that still has them to finish syncing.'
: 'Could not load tweak CSS. Check the URL or your connection.'}
</Text>
) : (
localTweaksQuery.data.map((row) => {
Expand Down
1 change: 1 addition & 0 deletions src/app/features/settings/cosmetics/ThemeImportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export function ThemeImportModal({ open, onClose }: ThemeImportModalProps) {
basename: r.basename,
pinned: true,
importedLocal: r.importedLocal,
cssText: r.cssText,
};
const nextEnabled = enabledTweakFullUrls.includes(r.fullUrl)
? [...enabledTweakFullUrls]
Expand Down
3 changes: 2 additions & 1 deletion src/app/features/settings/general/General.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1424,6 +1424,7 @@ function Sync() {
? `Last synced at ${dayjs(lastSynced).format('HH:mm:ss')}`
: 'Not yet synced this session',
syncing: 'Syncing…',
partial: 'Settings synced, but some local tweaks were too large to sync.',
error: 'Sync failed — will retry on next change',
};

Expand Down Expand Up @@ -1456,7 +1457,7 @@ function Sync() {
<SettingTile
title="Sync settings across devices"
focusId="sync-across-devices"
description="Store your settings in your Matrix account so they follow you to any Sable instance. Notification and zoom preferences are kept per-device."
description="Store your settings in your Matrix account so they follow you to any Sable instance. Locally imported tweak CSS is uploaded as unencrypted account data readable by your homeserver. Notification and zoom preferences are kept per-device."
after={<Switch variant="Primary" value={syncEnabled} onChange={setSyncEnabled} />}
/>
{syncEnabled && (
Expand Down
Loading
Loading