diff --git a/.changeset/improve-sable-theme-management.md b/.changeset/improve-sable-theme-management.md new file mode 100644 index 0000000000..0133afd83b --- /dev/null +++ b/.changeset/improve-sable-theme-management.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Add uploaded Sable CSS theme loading, automatic catalog updates, and a cleaner theme browser with full CSS viewing. diff --git a/src/app/components/RenderMessageContent.test.tsx b/src/app/components/RenderMessageContent.test.tsx index 666beab506..5ce02d13d2 100644 --- a/src/app/components/RenderMessageContent.test.tsx +++ b/src/app/components/RenderMessageContent.test.tsx @@ -2,8 +2,19 @@ import { render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MsgType } from '$types/matrix-sdk'; import { ClientConfigProvider } from '$hooks/useClientConfig'; +import { MatrixClientProvider } from '$hooks/useMatrixClient'; import { RenderMessageContent } from './RenderMessageContent'; +vi.mock('./message/content/UploadedSableCssContent', () => ({ + UploadedSableCssContent: ({ body }: { body: string }) => ( +
{body}
+ ), +})); + +vi.mock('$hooks/useMediaAuthentication', () => ({ + useMediaAuthentication: () => false, +})); + vi.mock('./url-preview', () => ({ UrlPreviewHolder: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -30,6 +41,23 @@ function renderMessage(body: string) { ); } +function renderFileMessage(content: Record) { + return render( + + + content as never} + htmlReactParserOptions={{}} + linkifyOpts={{}} + /> + + + ); +} + beforeEach(() => { vi.stubGlobal('location', { origin: 'https://app.example' } as Location); }); @@ -92,4 +120,16 @@ describe('RenderMessageContent', () => { expect(screen.getByTestId('url-preview-holder')).toBeInTheDocument(); expect(screen.getByTestId('url-preview-card')).toHaveTextContent('https://example.com)'); }); + + it('detects an uploaded Sable theme by filename when the body is a caption', () => { + renderFileMessage({ + msgtype: MsgType.File, + body: 'A theme you might like', + filename: 'amethyst.sable.css', + url: 'mxc://example/amethyst', + info: { mimetype: 'text/css', size: 1024 }, + }); + + expect(screen.getByTestId('uploaded-sable-css')).toHaveTextContent('amethyst.sable.css'); + }); }); diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 57c607eac1..5c5538a96c 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -31,6 +31,7 @@ import { RenderBody, ThumbnailContent, UnsupportedContent, + UploadedSableCssContent, VideoContent, } from './message'; import { @@ -42,6 +43,7 @@ import { youtubeUrl, } from './url-preview'; import { isHttpsFullSableCssUrl } from '../theme/previewUrls'; +import { isSableCssAttachmentFileName } from '../theme/processThemeImport'; import { Image, MediaControl, PersistedVolumeVideo } from './media'; import { ImageViewer } from './image-viewer'; import { PdfViewer } from './Pdf-viewer'; @@ -278,13 +280,13 @@ function RenderMessageContentInternal({ renderCaptionedAttachment( & { msgtype: MsgType.File }} - renderFileContent={({ body, mimeType, info, encInfo, url }) => ( + renderFileContent={({ fileName, mimeType, info, encInfo, url }) => ( ( ( )} > - + {themeChatSableWidgets && isSableCssAttachmentFileName(fileName) && ( + + )} + )} outlined={outlineAttachment} diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 135aa66cde..982548d6f6 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -652,7 +652,7 @@ export function MAudio({ } type RenderFileContentProps = { - body: string; + fileName: string; info: IFileInfo & IThumbnailContent; mimeType: string; url: string; @@ -682,7 +682,7 @@ export function MFile({ content, renderFileContent, outlined }: MFileProps) { {renderFileContent({ - body: filename, + fileName: filename, info: fileInfo ?? {}, mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE, url: mxcUrl, diff --git a/src/app/components/message/content/UploadedSableCssContent.tsx b/src/app/components/message/content/UploadedSableCssContent.tsx new file mode 100644 index 0000000000..c3486000e7 --- /dev/null +++ b/src/app/components/message/content/UploadedSableCssContent.tsx @@ -0,0 +1,380 @@ +import { useCallback, useMemo } from 'react'; +import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; +import { Box, Button, IconButton, Spinner, Switch, Text, config, toRem } from 'folds'; + +import { Star, sizedIcon } from '$components/icons/phosphor'; +import { ThemePreviewCard } from '$components/theme/ThemePreviewCard'; +import { CssViewerButton } from '$components/theme/CssViewerButton'; +import { usePatchSettings } from '$features/settings/cosmetics/themeSettingsPatch'; +import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; +import { useClientConfig } from '$hooks/useClientConfig'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useSetting } from '$state/hooks/settings'; +import { + settingsAtom, + type Settings, + type ThemeRemoteFavorite, + type ThemeRemoteTweakFavorite, +} from '$state/settings'; +import { + processUploadedSableCssAttachment, + type ProcessedUploadedSableCss, +} from '../../../theme/processThemeImport'; +import { isThirdPartyThemeUrl } from '../../../theme/themeApproval'; +import { + buildPreviewStyleBlock, + extractSafePreviewCustomProperties, +} from '../../../theme/previewCss'; +import { + pruneThemeFavorites, + pruneThemeTweakFavorites, + themeSourceLabel, +} from '../../../theme/themeLibrary'; +import { decryptFile, downloadEncryptedMedia, downloadMedia, mxcUrlToHttp } from '$utils/matrix'; + +export const MAX_SABLE_CSS_ATTACHMENT_BYTES = 1024 * 1024; + +type UploadedSableCssContentProps = { + body: string; + mimeType: string; + url: string; + size?: number; + encInfo?: EncryptedAttachmentInfo; +}; + +type SuccessfulUpload = Extract; + +function UploadedTweakCard({ data }: { data: Extract }) { + const patchSettings = usePatchSettings(); + const [favorites] = useSetting(settingsAtom, 'themeRemoteTweakFavorites'); + const [enabledUrls] = useSetting(settingsAtom, 'themeRemoteEnabledTweakFullUrls'); + const isFavorite = favorites.some((favorite) => favorite.fullUrl === data.fullUrl); + const isEnabled = enabledUrls.includes(data.fullUrl); + const scopeClass = useMemo( + () => `sable-uploaded-tweak--${data.basename.replace(/[^a-zA-Z0-9_-]/g, '-')}`, + [data.basename] + ); + const styleBlock = useMemo( + () => buildPreviewStyleBlock(extractSafePreviewCustomProperties(data.cssText), scopeClass), + [data.cssText, scopeClass] + ); + + const makeFavorite = useCallback( + (pinned: boolean): ThemeRemoteTweakFavorite[] => { + const existing = favorites.find((favorite) => favorite.fullUrl === data.fullUrl); + if (existing) { + return favorites.map((favorite) => + favorite.fullUrl === data.fullUrl && pinned ? { ...favorite, pinned: true } : favorite + ); + } + return [ + ...favorites, + { + fullUrl: data.fullUrl, + displayName: data.displayName, + basename: data.basename, + pinned, + importedLocal: true, + }, + ]; + }, + [data, favorites] + ); + + const toggleFavorite = useCallback(() => { + if (isFavorite) { + const nextEnabled = enabledUrls.filter((url) => url !== data.fullUrl); + patchSettings({ + themeRemoteEnabledTweakFullUrls: nextEnabled, + themeRemoteTweakFavorites: pruneThemeTweakFavorites( + favorites.filter((favorite) => favorite.fullUrl !== data.fullUrl), + nextEnabled + ), + }); + return; + } + patchSettings({ themeRemoteTweakFavorites: makeFavorite(true) }); + }, [data.fullUrl, enabledUrls, favorites, isFavorite, makeFavorite, patchSettings]); + + const setEnabled = useCallback( + (enabled: boolean) => { + const nextEnabled = enabled + ? Array.from(new Set([...enabledUrls, data.fullUrl])) + : enabledUrls.filter((url) => url !== data.fullUrl); + patchSettings({ + themeRemoteEnabledTweakFullUrls: nextEnabled, + themeRemoteTweakFavorites: pruneThemeTweakFavorites(makeFavorite(false), nextEnabled), + }); + }, + [data.fullUrl, enabledUrls, makeFavorite, patchSettings] + ); + + const description = [ + data.description, + data.author ? `by ${data.author}` : undefined, + data.tags.length > 0 ? data.tags.join(', ') : undefined, + ] + .filter(Boolean) + .join(' · '); + + return ( + + + + {data.displayName} + + {description || 'Uploaded tweak'} + + + Source: Uploaded file + + + + + + {sizedIcon(Star, '200', { filled: isFavorite })} + + + + + {styleBlock && ( + <> + + + + Sample text + + + + )} + + ); +} + +function UploadedThemeCard({ data }: { data: Extract }) { + const clientConfig = useClientConfig(); + const patchSettings = usePatchSettings(); + const [favorites] = useSetting(settingsAtom, 'themeRemoteFavorites'); + const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme'); + const [manualUrl] = useSetting(settingsAtom, 'themeRemoteManualFullUrl'); + const [lightUrl] = useSetting(settingsAtom, 'themeRemoteLightFullUrl'); + const [darkUrl] = useSetting(settingsAtom, 'themeRemoteDarkFullUrl'); + const fullUrl = data.fullUrl; + const isFavorite = fullUrl ? favorites.some((favorite) => favorite.fullUrl === fullUrl) : false; + + const ensureFavorite = useCallback( + (pinned: boolean): ThemeRemoteFavorite[] => { + if (!fullUrl) return favorites; + const existing = favorites.find((favorite) => favorite.fullUrl === fullUrl); + if (existing) { + return favorites.map((favorite) => + favorite.fullUrl === fullUrl && pinned ? { ...favorite, pinned: true } : favorite + ); + } + return [ + ...favorites, + { + fullUrl, + displayName: data.displayName, + basename: data.basename, + kind: data.kind, + pinned, + importedLocal: data.importedLocal, + }, + ]; + }, + [data, favorites, fullUrl] + ); + + const toggleFavorite = useCallback(() => { + if (!fullUrl) return; + if (!isFavorite) { + patchSettings({ themeRemoteFavorites: ensureFavorite(true) }); + return; + } + const partial: Partial = {}; + if (manualUrl === fullUrl) { + partial.themeRemoteManualFullUrl = undefined; + partial.themeRemoteManualKind = undefined; + } + if (lightUrl === fullUrl) { + partial.themeRemoteLightFullUrl = undefined; + partial.themeRemoteLightKind = undefined; + } + if (darkUrl === fullUrl) { + partial.themeRemoteDarkFullUrl = undefined; + partial.themeRemoteDarkKind = undefined; + } + patchSettings({ + ...partial, + themeRemoteFavorites: pruneThemeFavorites( + favorites.filter((favorite) => favorite.fullUrl !== fullUrl), + [ + manualUrl === fullUrl ? undefined : manualUrl, + lightUrl === fullUrl ? undefined : lightUrl, + darkUrl === fullUrl ? undefined : darkUrl, + ] + ), + }); + }, [darkUrl, ensureFavorite, favorites, fullUrl, isFavorite, lightUrl, manualUrl, patchSettings]); + + const apply = useCallback( + (slot: 'manual' | 'light' | 'dark') => { + if (!fullUrl) return; + const partial: Partial = { + themeRemoteFavorites: pruneThemeFavorites(ensureFavorite(false), [ + slot === 'manual' ? fullUrl : manualUrl, + slot === 'light' ? fullUrl : lightUrl, + slot === 'dark' ? fullUrl : darkUrl, + ]), + }; + if (slot === 'manual') { + partial.themeRemoteManualFullUrl = fullUrl; + partial.themeRemoteManualKind = data.kind; + } else if (slot === 'light') { + partial.themeRemoteLightFullUrl = fullUrl; + partial.themeRemoteLightKind = data.kind; + } else { + partial.themeRemoteDarkFullUrl = fullUrl; + partial.themeRemoteDarkKind = data.kind; + } + patchSettings(partial); + }, + [darkUrl, data.kind, ensureFavorite, fullUrl, lightUrl, manualUrl, patchSettings] + ); + + const kindLabel = data.kind === 'dark' ? 'Dark' : 'Light'; + const subtitle = data.previewOnly + ? `${kindLabel} · Uploaded preview only — no reachable full theme was provided.` + : `${kindLabel} · ${data.importedLocal ? 'Uploaded theme' : 'Uploaded preview'}`; + + return ( + apply('light') : undefined} + onApplyDark={systemTheme && fullUrl ? () => apply('dark') : undefined} + onApplyManual={!systemTheme && fullUrl ? () => apply('manual') : undefined} + isAppliedLight={Boolean(fullUrl && lightUrl === fullUrl)} + isAppliedDark={Boolean(fullUrl && darkUrl === fullUrl)} + isAppliedManual={Boolean(fullUrl && manualUrl === fullUrl)} + /> + ); +} + +export function UploadedSableCssContent({ + body, + mimeType, + url, + size, + encInfo, +}: UploadedSableCssContentProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + + const [loadState, load] = useAsyncCallback( + useCallback(async (): Promise => { + if (typeof size === 'number' && size > MAX_SABLE_CSS_ATTACHMENT_BYTES) { + throw new Error('Theme CSS attachments must be 1 MiB or smaller.'); + } + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL.'); + const blob = encInfo + ? await downloadEncryptedMedia(mediaUrl, (encrypted) => + decryptFile(encrypted, mimeType, encInfo) + ) + : await downloadMedia(mediaUrl); + if (blob.size > MAX_SABLE_CSS_ATTACHMENT_BYTES) { + throw new Error('Theme CSS attachments must be 1 MiB or smaller.'); + } + const result = await processUploadedSableCssAttachment( + await blob.text(), + body, + `${url}\n${JSON.stringify(encInfo ?? null)}` + ); + if (!result.ok) throw new Error(result.error); + return result; + }, [body, encInfo, mimeType, mx, size, url, useAuthentication]) + ); + + if (loadState.status === AsyncStatus.Success) { + return loadState.data.role === 'theme' ? ( + + ) : ( + + ); + } + + return ( + + + {loadState.status === AsyncStatus.Error && ( + + {loadState.error instanceof Error ? loadState.error.message : 'Failed to load theme CSS.'} + + )} + + ); +} diff --git a/src/app/components/message/content/index.ts b/src/app/components/message/content/index.ts index 6a31ed7ee0..ca28c20052 100644 --- a/src/app/components/message/content/index.ts +++ b/src/app/components/message/content/index.ts @@ -3,5 +3,6 @@ export * from './ImageContent'; export * from './VideoContent'; export * from './AudioContent'; export * from './FileContent'; +export * from './UploadedSableCssContent'; export * from './FallbackContent'; export * from './EventContent'; diff --git a/src/app/components/theme/CssViewerButton.tsx b/src/app/components/theme/CssViewerButton.tsx new file mode 100644 index 0000000000..fff20ddaec --- /dev/null +++ b/src/app/components/theme/CssViewerButton.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { IconButton, Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds'; + +import { Code, sizedIcon } from '$components/icons/phosphor'; +import { TextViewer } from '$components/text-viewer'; +import { ModalWide } from '$styles/Modal.css'; +import { stopPropagation } from '$utils/keyboard'; + +type CssViewerButtonProps = { + title: string; + cssText?: string; + loadCssText?: () => Promise; + ariaLabel: string; +}; + +export function CssViewerButton({ title, cssText, loadCssText, ariaLabel }: CssViewerButtonProps) { + const [open, setOpen] = useState(false); + const [loadedCssText, setLoadedCssText] = useState(''); + const [loading, setLoading] = useState(false); + const viewerText = cssText?.trim() ? cssText : loadedCssText; + + if (!cssText?.trim() && !loadCssText) return null; + + const handleOpen = async () => { + if (viewerText) { + setOpen(true); + return; + } + if (!loadCssText || loading) return; + setLoading(true); + try { + const loaded = await loadCssText(); + if (!loaded.trim()) return; + setLoadedCssText(loaded); + setOpen(true); + } finally { + setLoading(false); + } + }; + + return ( + <> + { + handleOpen().catch(() => undefined); + }} + > + {sizedIcon(Code, '200')} + + {open && ( + }> + + setOpen(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + setOpen(false)} + /> + + + + + )} + + ); +} diff --git a/src/app/components/theme/ThemePreviewCard.tsx b/src/app/components/theme/ThemePreviewCard.tsx index 049ef33932..eb47b273bf 100644 --- a/src/app/components/theme/ThemePreviewCard.tsx +++ b/src/app/components/theme/ThemePreviewCard.tsx @@ -5,13 +5,17 @@ import { Check, Download, Link, Star, Warning, sizedIcon } from '$components/ico import { useTimeoutToggle } from '$hooks/useTimeoutToggle'; import { copyToClipboard } from '$utils/dom'; import { buildPreviewStyleBlock, extractSafePreviewCustomProperties } from '../../theme/previewCss'; +import { CssViewerButton } from './CssViewerButton'; export type ThemePreviewCardProps = { title: string; subtitle?: ReactNode; beforePreview?: ReactNode; previewCssText: string; + fullCssText?: string; + loadFullCssText?: () => Promise; scopeSlug: string; + sourceLabel?: string; copyText?: string; isFavorited?: boolean; onToggleFavorite?: () => void | Promise; @@ -40,7 +44,10 @@ export function ThemePreviewCard({ subtitle, beforePreview, previewCssText, + fullCssText, + loadFullCssText, scopeSlug, + sourceLabel, copyText, isFavorited, onToggleFavorite, @@ -119,9 +126,20 @@ export function ThemePreviewCard({ {subtitle} )} + {sourceLabel && ( + + Source: {sourceLabel} + + )} + {copyText && ( { - const active = new Set(nextActive); - return nextFavorites.filter((f) => f.pinned === true || active.has(f.fullUrl)); - }, - [] - ); - const toggleFavorite = useCallback(async () => { const fullUrl = previewQuery.data?.fullUrl; if (!fullUrl) return; @@ -173,7 +157,10 @@ export function ThemePreviewUrlCard({ url }: { url: string }) { const nextActive = [manualRemoteFullUrl, lightRemoteFullUrl, darkRemoteFullUrl] .filter((u): u is string => Boolean(u && u.trim().length > 0)) .filter((u) => u !== fullUrl); - patchSettings({ ...cleared, themeRemoteFavorites: pruneFavorites(nextFavs, nextActive) }); + patchSettings({ + ...cleared, + themeRemoteFavorites: pruneThemeFavorites(nextFavs, nextActive), + }); return; } @@ -205,7 +192,6 @@ export function ThemePreviewUrlCard({ url }: { url: string }) { manualRemoteFullUrl, patchSettings, previewQuery.data, - pruneFavorites, ]); const applyManual = useCallback(() => { @@ -304,7 +290,7 @@ export function ThemePreviewUrlCard({ url }: { url: string }) { ].filter((u): u is string => Boolean(u && u.trim().length > 0)); patchSettings({ - themeRemoteFavorites: pruneFavorites( + themeRemoteFavorites: pruneThemeFavorites( store.get(settingsAtom).themeRemoteFavorites, nextActive ), @@ -318,7 +304,6 @@ export function ThemePreviewUrlCard({ url }: { url: string }) { manualRemoteFullUrl, patchSettings, previewQuery.data?.fullUrl, - pruneFavorites, store, ] ); @@ -336,7 +321,7 @@ export function ThemePreviewUrlCard({ url }: { url: string }) { after.themeRemoteDarkFullUrl, ].filter((u): u is string => Boolean(u && u.trim().length > 0)); patchSettings({ - themeRemoteFavorites: pruneFavorites(favs, nextActive), + themeRemoteFavorites: pruneThemeFavorites(favs, nextActive), }); }; @@ -377,7 +362,6 @@ export function ThemePreviewUrlCard({ url }: { url: string }) { patchSettings, previewQuery.data?.fullUrl, previewQuery.data?.kind, - pruneFavorites, store, ]); @@ -388,7 +372,7 @@ export function ThemePreviewUrlCard({ url }: { url: string }) { { setUserTriggeredLoad(true); @@ -407,10 +391,9 @@ export function ThemePreviewUrlCard({ url }: { url: string }) { previewQuery.data?.tags && previewQuery.data.tags.length > 0 ? previewQuery.data.tags.join(', ') : ''; - const sourceLabel = isOfficial ? 'Official theme' : baseLabel(url); + const sourceLabel = isOfficial ? 'Official catalog' : themeUrlHostLabel(url, 'Unofficial theme'); const subtitleLine1 = [kindLabel, contrastLabel].filter(Boolean).join(' · '); const subtitleLine2 = [authorLabel, tagsLabel].filter(Boolean).join(' · '); - const subtitleLine3 = sourceLabel; const subtitle = ( <> {subtitleLine1} @@ -420,11 +403,19 @@ export function ThemePreviewUrlCard({ url }: { url: string }) { {subtitleLine2} ) : null} -
- {subtitleLine3} ); const fullUrl = previewQuery.data?.fullUrl; + const loadFullCssText = async (): Promise => { + if (!fullUrl) throw new Error('Full theme URL is unavailable.'); + const cached = await getCachedThemeCss(fullUrl); + if (cached) return cached; + const response = await fetch(fullUrl, { mode: 'cors' }); + if (!response.ok) throw new Error(`Theme CSS fetch failed: ${response.status}`); + const text = await response.text(); + await putCachedThemeCss(fullUrl, text); + return text; + }; return ( : undefined + showThirdPartyBanner ? ( + + ) : undefined } previewCssText={previewQuery.data?.previewText ?? ''} + loadFullCssText={fullUrl ? loadFullCssText : undefined} scopeSlug={`chat-${basenameFromUrl(url)}`} + sourceLabel={sourceLabel} copyText={url} isFavorited={fullUrl ? isFav : false} onToggleFavorite={fullUrl ? () => toggleFavorite() : undefined} diff --git a/src/app/components/url-preview/TweakPreviewUrlCard.tsx b/src/app/components/url-preview/TweakPreviewUrlCard.tsx index 43cd9a14a2..c20058341d 100644 --- a/src/app/components/url-preview/TweakPreviewUrlCard.tsx +++ b/src/app/components/url-preview/TweakPreviewUrlCard.tsx @@ -26,28 +26,14 @@ import { buildPreviewStyleBlock, extractSafePreviewCustomProperties } from '../. import { isApprovedCatalogHostUrl, isThirdPartyThemeUrl } from '../../theme/themeApproval'; import { SableChatPreviewPlaceholder } from './SableChatPreviewPlaceholder'; import { ThemeThirdPartyBanner } from './ThemeThirdPartyBanner'; - -function baseLabel(url: string): string { - try { - return new URL(url).hostname; - } catch { - return 'Unofficial tweak'; - } -} +import { CssViewerButton } from '../theme/CssViewerButton'; +import { pruneThemeTweakFavorites, themeUrlHostLabel } from '../../theme/themeLibrary'; function basenameFromFullSableUrl(url: string): string { const tail = url.split('/').pop() ?? url; return tail.replace(/\.sable\.css(\?.*)?$/i, '') || 'tweak'; } -function pruneTweakFavorites( - nextFavorites: ThemeRemoteTweakFavorite[], - nextEnabledUrls: string[] -): ThemeRemoteTweakFavorite[] { - const enabled = new Set(nextEnabledUrls); - return nextFavorites.filter((f) => f.pinned === true || enabled.has(f.fullUrl)); -} - function safeSlug(input: string): string { return (input || 'tweak').replace(/[^a-zA-Z0-9_-]/g, '-') || 'tweak'; } @@ -150,7 +136,7 @@ export function TweakPreviewUrlCard({ url }: { url: string }) { const nextFavs = tweakFavorites.filter((f) => f.fullUrl !== url); const nextEnabled = enabledTweakFullUrls.filter((u) => u !== url); patchSettings({ - themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled), + themeRemoteTweakFavorites: pruneThemeTweakFavorites(nextFavs, nextEnabled), themeRemoteEnabledTweakFullUrls: nextEnabled, }); return; @@ -164,7 +150,7 @@ export function TweakPreviewUrlCard({ url }: { url: string }) { pinned: true, }; patchSettings({ - themeRemoteTweakFavorites: pruneTweakFavorites( + themeRemoteTweakFavorites: pruneThemeTweakFavorites( [...tweakFavorites, next], enabledTweakFullUrls ), @@ -191,7 +177,7 @@ export function TweakPreviewUrlCard({ url }: { url: string }) { } patchSettings({ themeRemoteEnabledTweakFullUrls: nextEnabled, - themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled), + themeRemoteTweakFavorites: pruneThemeTweakFavorites(nextFavs, nextEnabled), }); } else { const nextEnabled = enabledTweakFullUrls.filter((u) => u !== url); @@ -210,7 +196,7 @@ export function TweakPreviewUrlCard({ url }: { url: string }) { { setUserTriggeredLoad(true); @@ -253,7 +239,7 @@ export function TweakPreviewUrlCard({ url }: { url: string }) { data.tags.length > 0 ? data.tags.join(', ') : '', ].filter(Boolean); const descLine = descParts.join(' · '); - const sourceLabel = isOfficial ? 'Official catalog' : baseLabel(url); + const sourceLabel = isOfficial ? 'Official catalog' : themeUrlHostLabel(url, 'Unofficial tweak'); return ( + {showThirdPartyBanner ? ( - + ) : undefined} {styleBlock ? ( diff --git a/src/app/features/settings/cosmetics/ThemeCatalogSettings.css.ts b/src/app/features/settings/cosmetics/ThemeCatalogSettings.css.ts new file mode 100644 index 0000000000..70e92daac2 --- /dev/null +++ b/src/app/features/settings/cosmetics/ThemeCatalogSettings.css.ts @@ -0,0 +1,15 @@ +import { style } from '@vanilla-extract/css'; +import { toRem } from 'folds'; + +export const themeCardGrid = style({ + display: 'grid', + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + gap: toRem(16), + width: '100%', + maxWidth: toRem(760), + '@media': { + [`screen and (max-width: ${toRem(700)})`]: { + gridTemplateColumns: 'minmax(0, 1fr)', + }, + }, +}); diff --git a/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx index 5694129f49..b410dc04e0 100644 --- a/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx +++ b/src/app/features/settings/cosmetics/ThemeCatalogSettings.tsx @@ -47,6 +47,15 @@ import { type SableThemeContrast, } from '../../../theme/metadata'; import { previewUrlFromFullThemeUrl } from '../../../theme/previewUrls'; +import { updateInstalledCatalogPackages } from '../../../theme/catalogUpdater'; +import { + pruneThemeFavorites, + pruneThemeTweakFavorites, + themeSourceLabel, + themeUrlHostLabel, +} from '../../../theme/themeLibrary'; +import { CssViewerButton } from '$components/theme/CssViewerButton'; +import * as css from './ThemeCatalogSettings.css'; export type CatalogPreviewRow = ThemePair & { previewText: string; @@ -103,6 +112,8 @@ type CatalogTweakCardProps = { isOn: boolean; onSetApplied: (v: boolean) => void; onExport?: () => void; + cssText: string; + sourceLabel: string; }; function CatalogTweakCard({ @@ -115,6 +126,8 @@ function CatalogTweakCard({ isOn, onSetApplied, onExport, + cssText, + sourceLabel, }: CatalogTweakCardProps) { const [copied, setCopied] = useTimeoutToggle(); const handleCopy = useCallback(async () => { @@ -139,6 +152,9 @@ function CatalogTweakCard({ {description} + + Source: {sourceLabel} + {thirdPartyChip && ( @@ -147,6 +163,11 @@ function CatalogTweakCard({ )} + {copyUrl && ( ('themes'); + const [catalogSection, setCatalogSection] = useState<'themes' | 'tweaks'>('themes'); const [kindFilter, setKindFilter] = useState<'all' | 'light' | 'dark'>('all'); const [contrastFilter, setContrastFilter] = useState<'all' | SableThemeContrast>('all'); const [tweakApplyFilter, setTweakApplyFilter] = useState<'all' | 'enabled' | 'disabled'>('all'); - + const [catalogUpdateStatus, setCatalogUpdateStatus] = useState< + 'idle' | 'checking' | 'updated' | 'unchanged' | 'failed' + >('idle'); + const [catalogUpdateMessage, setCatalogUpdateMessage] = useState( + 'Automatically checks installed catalog themes and tweaks for CSS changes.' + ); useEffect(() => setFavorites(getFavorites ? getFavorites : []), [getFavorites]); const onThemeSearchChange: ChangeEventHandler = (e) => @@ -284,21 +312,58 @@ export function ThemeCatalogSettings({ [darkRemoteFullUrl, lightRemoteFullUrl, manualRemoteFullUrl] ); - const pruneFavorites = useCallback( - (nextFavorites: ThemeRemoteFavorite[], nextActiveUrls: string[]) => { - const active = new Set(nextActiveUrls); - return nextFavorites.filter((f) => f.pinned === true || active.has(f.fullUrl)); - }, - [] - ); - - const pruneTweakFavorites = useCallback( - (nextFavorites: ThemeRemoteTweakFavorite[], nextEnabledUrls: string[]) => { - const enabled = new Set(nextEnabledUrls); - return nextFavorites.filter((f) => f.pinned === true || enabled.has(f.fullUrl)); - }, - [] - ); + const checkInstalledCatalogUpdates = useCallback(async () => { + if (catalogUpdateStatus === 'checking') return; + setCatalogUpdateStatus('checking'); + setCatalogUpdateMessage('Checking installed catalog themes and tweaks…'); + try { + const summary = await updateInstalledCatalogPackages({ + catalogBase, + manifestUrl: catalogManifestUrl, + installedThemeUrls: Array.from( + new Set([...favorites.map((favorite) => favorite.fullUrl), ...activeUrls]) + ), + installedTweakUrls: Array.from( + new Set([...tweakFavorites.map((favorite) => favorite.fullUrl), ...enabledTweakFullUrls]) + ), + maxAgeMs: 0, + }); + if (summary.updated > 0) { + setCatalogUpdateStatus('updated'); + setCatalogUpdateMessage( + `Updated ${summary.updated} catalog ${summary.updated === 1 ? 'package' : 'packages'}.` + ); + } else if (summary.failed > 0) { + setCatalogUpdateStatus('failed'); + setCatalogUpdateMessage( + `Could not check ${summary.failed} ${summary.failed === 1 ? 'package' : 'packages'}. Cached CSS is still active.` + ); + } else { + setCatalogUpdateStatus('unchanged'); + setCatalogUpdateMessage( + summary.checked === 0 + ? 'No installed themes or tweaks belong to the current catalog.' + : 'Installed catalog themes and tweaks are up to date.' + ); + } + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['theme-local-previews'] }), + queryClient.invalidateQueries({ queryKey: ['theme-local-tweaks'] }), + ]); + } catch { + setCatalogUpdateStatus('failed'); + setCatalogUpdateMessage('Could not check the catalog. Cached CSS is still active.'); + } + }, [ + activeUrls, + catalogBase, + catalogManifestUrl, + catalogUpdateStatus, + enabledTweakFullUrls, + favorites, + queryClient, + tweakFavorites, + ]); const clearAssignmentsIfMatch = useCallback( (fullUrl: string) => { @@ -338,7 +403,15 @@ export function ThemeCatalogSettings({ const rows = await Promise.all( pairs.map(async (pair) => { const res = await fetch(pair.previewUrl, { mode: 'cors' }); - const previewText = res.ok ? await res.text() : ''; + let previewText = res.ok ? await res.text() : ''; + if (!previewText) { + try { + const fullResponse = await fetch(pair.fullUrl, { mode: 'cors' }); + if (fullResponse.ok) previewText = await fullResponse.text(); + } catch { + // Keep the catalog row visible even when neither CSS file is currently reachable. + } + } const meta = parseSableThemeMetadata(previewText); const fullFromMeta = extractFullThemeUrlFromPreview(previewText); const fullInstallUrl = @@ -449,28 +522,35 @@ export function ThemeCatalogSettings({ queryFn: async (): Promise => { const rows = await Promise.all( favorites.map(async (fav) => { - const previewUrl = previewUrlFromFullThemeUrl(fav.fullUrl); - if (!previewUrl) return undefined; + const previewUrl = previewUrlFromFullThemeUrl(fav.fullUrl) ?? fav.fullUrl; try { - let previewText: string; - let fullCssText = ''; - if (isLocalImportThemeUrl(previewUrl)) { - previewText = (await getCachedThemeCss(previewUrl)) ?? ''; - } else { - const res = await fetch(previewUrl, { mode: 'cors' }); - if (!res.ok) return undefined; - previewText = await res.text(); + let fullCssText = (await getCachedThemeCss(fav.fullUrl)) ?? ''; + if (!isLocalImportBundledUrl(fav.fullUrl)) { + try { + const fullRes = await fetch(fav.fullUrl, { mode: 'cors' }); + if (fullRes.ok) fullCssText = await fullRes.text(); + } catch { + // Cached full CSS keeps saved themes usable while offline. + } } - if (isLocalImportBundledUrl(fav.fullUrl)) { - fullCssText = (await getCachedThemeCss(fav.fullUrl)) ?? ''; + + let previewText = ''; + if (previewUrl === fav.fullUrl) { + previewText = fullCssText; + } else if (isLocalImportThemeUrl(previewUrl)) { + previewText = (await getCachedThemeCss(previewUrl)) ?? fullCssText; } else { - const fullRes = await fetch(fav.fullUrl, { mode: 'cors' }); - if (fullRes.ok) { - fullCssText = await fullRes.text(); + try { + const previewRes = await fetch(previewUrl, { mode: 'cors' }); + previewText = previewRes.ok ? await previewRes.text() : fullCssText; + } catch { + previewText = fullCssText; } } - const meta = parseSableThemeMetadata(previewText); + if (!previewText && !fullCssText) return undefined; + const metadataCss = previewText || fullCssText; + const meta = parseSableThemeMetadata(metadataCss); const displayName = meta.name?.trim() || fav.displayName || fav.basename; const contrast: SableThemeContrast = meta.contrast === 'high' ? 'high' : 'low'; const authorTrim = meta.author?.trim(); @@ -504,14 +584,16 @@ export function ThemeCatalogSettings({ const rows = await Promise.all( tweakFavorites.map(async (fav) => { try { - let text: string; - if (isLocalImportBundledUrl(fav.fullUrl)) { - text = (await getCachedThemeCss(fav.fullUrl)) ?? ''; - } else { - const res = await fetch(fav.fullUrl, { mode: 'cors' }); - if (!res.ok) return undefined; - text = await res.text(); + let text = (await getCachedThemeCss(fav.fullUrl)) ?? ''; + if (!isLocalImportBundledUrl(fav.fullUrl)) { + try { + const res = await fetch(fav.fullUrl, { mode: 'cors' }); + if (res.ok) text = await res.text(); + } catch { + // Cached tweak CSS keeps the saved library available while offline. + } } + if (!text) return undefined; const meta = parseSableTweakMetadata(text); const authorTrim = meta.author?.trim(); const row: LocalTweakRow = { @@ -542,7 +624,7 @@ export function ThemeCatalogSettings({ .filter((u) => u !== fullUrl); patchSettings({ ...cleared, - themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive), + themeRemoteFavorites: pruneThemeFavorites(nextFavorites, nextActive), }); }, [ @@ -552,7 +634,6 @@ export function ThemeCatalogSettings({ lightRemoteFullUrl, manualRemoteFullUrl, patchSettings, - pruneFavorites, ] ); @@ -640,6 +721,17 @@ export function ThemeCatalogSettings({ } }, []); + const loadFullCssText = useCallback(async (url: string): Promise => { + const cached = await getCachedThemeCss(url); + if (cached) return cached; + if (isLocalImportBundledUrl(url)) throw new Error('Theme CSS is not cached.'); + const response = await fetch(url, { mode: 'cors' }); + if (!response.ok) throw new Error(`Theme CSS fetch failed: ${response.status}`); + const text = await response.text(); + await putCachedThemeCss(url, text); + return text; + }, []); + const toggleFavorite = useCallback( async (row: CatalogPreviewRow) => { const existing = favorites.find((f: ThemeRemoteFavorite) => f.fullUrl === row.fullInstallUrl); @@ -649,7 +741,7 @@ export function ThemeCatalogSettings({ const nextActive = activeUrls.filter((u) => u !== row.fullInstallUrl); patchSettings({ ...cleared, - themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive), + themeRemoteFavorites: pruneThemeFavorites(nextFavorites, nextActive), }); return; } @@ -667,7 +759,7 @@ export function ThemeCatalogSettings({ themeRemoteFavorites: [...favorites, next], }); }, - [activeUrls, clearAssignmentsIfMatch, favorites, patchSettings, prefetchFull, pruneFavorites] + [activeUrls, clearAssignmentsIfMatch, favorites, patchSettings, prefetchFull] ); const installFromCatalogLight = useCallback( @@ -699,10 +791,10 @@ export function ThemeCatalogSettings({ patchSettings({ themeRemoteLightFullUrl: row.fullInstallUrl, themeRemoteLightKind: kind, - themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive), + themeRemoteFavorites: pruneThemeFavorites(nextFavorites, nextActive), }); }, - [darkRemoteFullUrl, favorites, manualRemoteFullUrl, patchSettings, prefetchFull, pruneFavorites] + [darkRemoteFullUrl, favorites, manualRemoteFullUrl, patchSettings, prefetchFull] ); const installFromCatalogDark = useCallback( @@ -734,17 +826,10 @@ export function ThemeCatalogSettings({ patchSettings({ themeRemoteDarkFullUrl: row.fullInstallUrl, themeRemoteDarkKind: kind, - themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive), + themeRemoteFavorites: pruneThemeFavorites(nextFavorites, nextActive), }); }, - [ - favorites, - lightRemoteFullUrl, - manualRemoteFullUrl, - patchSettings, - prefetchFull, - pruneFavorites, - ] + [favorites, lightRemoteFullUrl, manualRemoteFullUrl, patchSettings, prefetchFull] ); const installFromCatalogManual = useCallback( @@ -776,10 +861,10 @@ export function ThemeCatalogSettings({ patchSettings({ themeRemoteManualFullUrl: row.fullInstallUrl, themeRemoteManualKind: kind, - themeRemoteFavorites: pruneFavorites(nextFavorites, nextActive), + themeRemoteFavorites: pruneThemeFavorites(nextFavorites, nextActive), }); }, - [darkRemoteFullUrl, favorites, lightRemoteFullUrl, patchSettings, prefetchFull, pruneFavorites] + [darkRemoteFullUrl, favorites, lightRemoteFullUrl, patchSettings, prefetchFull] ); const clearRemote = useCallback(() => { @@ -830,7 +915,7 @@ export function ThemeCatalogSettings({ } patchSettings({ themeRemoteEnabledTweakFullUrls: nextEnabled, - themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled), + themeRemoteTweakFavorites: pruneThemeTweakFavorites(nextFavs, nextEnabled), }); } else { const nextEnabled = enabledTweakFullUrls.filter((u) => u !== trimmed); @@ -855,7 +940,7 @@ export function ThemeCatalogSettings({ } } }, - [enabledTweakFullUrls, patchSettings, prefetchFull, pruneTweakFavorites, tweakFavorites] + [enabledTweakFullUrls, patchSettings, prefetchFull, tweakFavorites] ); const toggleCatalogTweakFavorite = useCallback( @@ -865,7 +950,7 @@ export function ThemeCatalogSettings({ const nextFavs = tweakFavorites.filter((f) => f.fullUrl !== row.fullUrl); const nextEnabled = enabledTweakFullUrls.filter((u) => u !== row.fullUrl); patchSettings({ - themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled), + themeRemoteTweakFavorites: pruneThemeTweakFavorites(nextFavs, nextEnabled), themeRemoteEnabledTweakFullUrls: nextEnabled, }); return; @@ -879,13 +964,13 @@ export function ThemeCatalogSettings({ pinned: true, }; patchSettings({ - themeRemoteTweakFavorites: pruneTweakFavorites( + themeRemoteTweakFavorites: pruneThemeTweakFavorites( [...tweakFavorites, next], enabledTweakFullUrls ), }); }, - [enabledTweakFullUrls, patchSettings, prefetchFull, pruneTweakFavorites, tweakFavorites] + [enabledTweakFullUrls, patchSettings, prefetchFull, tweakFavorites] ); const removeTweakFavorite = useCallback( @@ -893,11 +978,11 @@ export function ThemeCatalogSettings({ const nextFavs = tweakFavorites.filter((f) => f.fullUrl !== fullUrl); const nextEnabled = enabledTweakFullUrls.filter((u) => u !== fullUrl); patchSettings({ - themeRemoteTweakFavorites: pruneTweakFavorites(nextFavs, nextEnabled), + themeRemoteTweakFavorites: pruneThemeTweakFavorites(nextFavs, nextEnabled), themeRemoteEnabledTweakFullUrls: nextEnabled, }); }, - [enabledTweakFullUrls, patchSettings, pruneTweakFavorites, tweakFavorites] + [enabledTweakFullUrls, patchSettings, tweakFavorites] ); const downloadThemeFile = useCallback((row: LocalPreviewRow) => { @@ -980,26 +1065,6 @@ export function ThemeCatalogSettings({ )} - - - - Clear - - } - /> - )} @@ -1011,153 +1076,245 @@ export function ThemeCatalogSettings({ direction="Column" gap="400" > - Saved themes - {localPreviewsQuery.isPending && favorites.length > 0 && ( - - - Loading local previews… + + + Saved + setSavedSection('themes')} + > + Themes ({favorites.length}) + + setSavedSection('tweaks')} + > + Tweaks ({tweakFavorites.length}) + - )} + + { + checkInstalledCatalogUpdates().catch(() => undefined); + }} + > + + {catalogUpdateStatus === 'checking' ? 'Checking…' : 'Check updates'} + + + + Reset + + + - {favorites.length === 0 && ( - - No saved themes yet. Star themes in the catalog to download them locally. + {catalogUpdateStatus !== 'idle' && catalogUpdateStatus !== 'checking' && ( + + {catalogUpdateMessage} )} - {localPreviewsQuery.isSuccess && favorites.length > 0 && ( - <> - {localPreviewsQuery.data.length === 0 ? ( + {savedSection === 'themes' && ( + + {localPreviewsQuery.isPending && favorites.length > 0 && ( + + + Loading local previews… + + )} + + {favorites.length === 0 && ( - Could not load local previews. If this happens, the theme preview file may be - missing or not paired as `*.preview.sable.css`. + No saved themes yet. Star themes in the catalog to download them locally. - ) : ( - - {localPreviewsQuery.data.map((row) => { - const slug = row.basename.replace(/[^a-zA-Z0-9_-]/g, '-') || 'theme'; - const kindLabel = row.kind === 'dark' ? 'Dark' : 'Light'; - const line1 = `${kindLabel} · ${row.contrast} contrast`; - const line2 = `${row.author ? `by ${row.author}` : ''}${ - row.tags.length > 0 - ? `${row.author ? ' · ' : ''}${row.tags.join(', ')}` - : '' - }`.trim(); - const subtitle = ( - <> - {line1} - {line2 ? ( + )} + + {localPreviewsQuery.isSuccess && favorites.length > 0 && ( + <> + {localPreviewsQuery.data.length === 0 ? ( + + Could not load local previews. If this happens, the theme preview file may + be missing or not paired as `*.preview.sable.css`. + + ) : ( + + {localPreviewsQuery.data.map((row) => { + const slug = row.basename.replace(/[^a-zA-Z0-9_-]/g, '-') || 'theme'; + const kindLabel = row.kind === 'dark' ? 'Dark' : 'Light'; + const line1 = `${kindLabel} · ${row.contrast} contrast`; + const line2 = `${row.author ? `by ${row.author}` : ''}${ + row.tags.length > 0 + ? `${row.author ? ' · ' : ''}${row.tags.join(', ')}` + : '' + }`.trim(); + const subtitle = ( <> -
- {line2} + {line1} + {line2 ? ( + <> +
+ {line2} + + ) : null} - ) : null} - - ); - return ( - removeFavorite(row.fullUrl)} - onExport={() => downloadThemeFile(row)} - systemTheme={systemTheme} - onApplyLight={systemTheme ? () => applyFavoriteToLight(row) : undefined} - onApplyDark={systemTheme ? () => applyFavoriteToDark(row) : undefined} - onApplyManual={ - !systemTheme ? () => applyFavoriteToManual(row) : undefined - } - isAppliedLight={lightRemoteFullUrl === row.fullUrl} - isAppliedDark={darkRemoteFullUrl === row.fullUrl} - isAppliedManual={manualRemoteFullUrl === row.fullUrl} - /> - ); - })} -
+ ); + return ( + removeFavorite(row.fullUrl)} + onExport={() => downloadThemeFile(row)} + systemTheme={systemTheme} + onApplyLight={ + systemTheme ? () => applyFavoriteToLight(row) : undefined + } + onApplyDark={systemTheme ? () => applyFavoriteToDark(row) : undefined} + onApplyManual={ + !systemTheme ? () => applyFavoriteToManual(row) : undefined + } + isAppliedLight={lightRemoteFullUrl === row.fullUrl} + isAppliedDark={darkRemoteFullUrl === row.fullUrl} + isAppliedManual={manualRemoteFullUrl === row.fullUrl} + /> + ); + })} +
+ )} + )} - - )} - - - - Saved tweaks - {localTweaksQuery.isPending && tweakFavorites.length > 0 && ( - - - Loading tweaks… )} - {tweakFavorites.length === 0 && ( - - No saved tweaks. Favorite tweaks in the catalog to keep them here, or enable a tweak - to cache it automatically. - - )} - {localTweaksQuery.isSuccess && tweakFavorites.length > 0 && ( - - {localTweaksQuery.data.length === 0 ? ( + + {savedSection === 'tweaks' && ( + + {localTweaksQuery.isPending && tweakFavorites.length > 0 && ( + + + Loading tweaks… + + )} + {tweakFavorites.length === 0 && ( - Could not load tweak CSS. Check the URL or your connection. + No saved tweaks. Favorite tweaks in the catalog to keep them here, or enable a + tweak to cache it automatically. - ) : ( - localTweaksQuery.data.map((row) => { - const isOn = enabledTweakFullUrls.includes(row.fullUrl); - const descParts = [ - row.description, - row.author ? `by ${row.author}` : '', - row.tags.length > 0 ? row.tags.join(', ') : '', - ].filter(Boolean); - const desc = - descParts.join(' · ') || - 'Applies on top of your current theme after it loads.'; - return ( - removeTweakFavorite(row.fullUrl)} - onExport={() => downloadTweakFile(row)} - isOn={isOn} - onSetApplied={(v) => - setTweakApplied(row.fullUrl, v, { - displayName: row.displayName, - basename: row.basename, - }) - } - /> - ); - }) + )} + {localTweaksQuery.isSuccess && tweakFavorites.length > 0 && ( + + {localTweaksQuery.data.length === 0 ? ( + + Could not load tweak CSS. Check the URL or your connection. + + ) : ( + localTweaksQuery.data.map((row) => { + const isOn = enabledTweakFullUrls.includes(row.fullUrl); + const descParts = [ + row.description, + row.author ? `by ${row.author}` : '', + row.tags.length > 0 ? row.tags.join(', ') : '', + ].filter(Boolean); + const desc = + descParts.join(' · ') || + 'Applies on top of your current theme after it loads.'; + return ( + removeTweakFavorite(row.fullUrl)} + onExport={() => downloadTweakFile(row)} + cssText={row.fullCssText} + sourceLabel={themeSourceLabel({ + importedLocal: row.importedLocal, + official: + !row.importedLocal && + !isThirdPartyThemeUrl( + row.fullUrl, + clientConfig.themeCatalogApprovedHostPrefixes + ), + url: row.fullUrl, + })} + isOn={isOn} + onSetApplied={(v) => + setTweakApplied(row.fullUrl, v, { + displayName: row.displayName, + basename: row.basename, + }) + } + /> + ); + }) + )} + )} )} @@ -1186,7 +1343,6 @@ export function ThemeCatalogSettings({ } /> - {isAppearanceMode && browseOpen && ( - - - Browse catalog - - Download themes and tweaks from the catalog. - + + + + Browse catalog + + Find, preview, and save catalog CSS. + + + + + + - - - + Tweaks ({catalogTweakCount}) + )} @@ -1298,6 +1476,7 @@ export function ThemeCatalogSettings({ {catalogQuery.isSuccess && catalogHasEntries && catalogThemeCount > 0 && + (!isAppearanceMode || catalogSection === 'themes') && previewsQuery.isSuccess && ( - + {!isAppearanceMode && } - + {filteredRows.map((row) => { const slug = row.basename.replace(/[^a-zA-Z0-9_-]/g, '-') || 'theme'; const kindLabel = row.kind === ThemeKind.Dark ? 'Dark' : 'Light'; @@ -1388,7 +1561,16 @@ export function ThemeCatalogSettings({ title={row.displayName} subtitle={subtitle} previewCssText={row.previewText} + loadFullCssText={() => loadFullCssText(row.fullInstallUrl)} scopeSlug={`catalog-${slug}`} + sourceLabel={ + isThirdPartyThemeUrl( + row.previewUrl, + clientConfig.themeCatalogApprovedHostPrefixes + ) + ? themeUrlHostLabel(row.previewUrl) + : 'Official catalog' + } copyText={row.previewUrl} thirdParty={isThirdPartyThemeUrl( row.previewUrl, @@ -1427,6 +1609,7 @@ export function ThemeCatalogSettings({ {catalogQuery.isSuccess && catalogHasEntries && catalogTweakCount > 0 && + (!isAppearanceMode || catalogSection === 'tweaks') && tweakDetailsQuery.isSuccess && ( - + {!isAppearanceMode && } ); })} @@ -1533,9 +1725,9 @@ export function ThemeCatalogSettings({ <> } @@ -1543,9 +1735,9 @@ export function ThemeCatalogSettings({ - + { - const active = new Set(nextActiveUrls); - return nextFavorites.filter((f) => f.pinned === true || active.has(f.fullUrl)); - }, - [] - ); - - const pruneTweakFavorites = useCallback( - (nextFavorites: ThemeRemoteTweakFavorite[], nextEnabledUrls: string[]) => { - const enabled = new Set(nextEnabledUrls); - return nextFavorites.filter((f) => f.pinned === true || enabled.has(f.fullUrl)); - }, - [] - ); - useEffect(() => { if (!open) { setImportUrl(''); @@ -116,7 +101,10 @@ export function ThemeImportModal({ open, onClose }: ThemeImportModalProps) { ? [...enabledTweakFullUrls] : [...enabledTweakFullUrls, r.fullUrl]; patchSettings({ - themeRemoteTweakFavorites: pruneTweakFavorites([...tweakFavorites, next], nextEnabled), + themeRemoteTweakFavorites: pruneThemeTweakFavorites( + [...tweakFavorites, next], + nextEnabled + ), themeRemoteEnabledTweakFullUrls: nextEnabled, }); onClose(); @@ -142,20 +130,11 @@ export function ThemeImportModal({ open, onClose }: ThemeImportModalProps) { ) ); patchSettings({ - themeRemoteFavorites: pruneFavorites([...favorites, next], nextActive), + themeRemoteFavorites: pruneThemeFavorites([...favorites, next], nextActive), }); onClose(); }, - [ - activeUrls, - enabledTweakFullUrls, - favorites, - onClose, - patchSettings, - pruneFavorites, - pruneTweakFavorites, - tweakFavorites, - ] + [activeUrls, enabledTweakFullUrls, favorites, onClose, patchSettings, tweakFavorites] ); const onImportFileChange: ChangeEventHandler = (e) => { diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index e39fc9b33b..6acc1b34cb 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -128,7 +128,6 @@ const settingsLinkFocusIdsBySection: Record void) | undefined; type ThemeContextProviderProps = { value: Theme; @@ -59,6 +61,21 @@ vi.mock('$plugins/arborium', () => ({ kind === ThemeKind.Dark ? <>{children} : <>{children}, })); +vi.mock('../theme/cache', () => ({ + getCachedThemeCss: vi.fn<(url: string) => Promise>(async () => + cachedCss ? cachedCss : undefined + ), + putCachedThemeCss: vi.fn<(url: string, cssText: string) => Promise>(async () => undefined), + subscribeThemeCacheUpdates: vi.fn< + (listener: (update: { url: string; contentHash: string }) => void) => () => void + >((listener: (update: { url: string; contentHash: string }) => void) => { + cacheUpdateListener = listener; + return () => { + cacheUpdateListener = undefined; + }; + }), +})); + beforeEach(() => { systemThemeKind = ThemeKind.Light; activeTheme = { @@ -70,6 +87,8 @@ beforeEach(() => { settings.underlineLinks = false; settings.reducedMotion = false; settings.themeRemoteEnabledTweakFullUrls = []; + cachedCss = ''; + cacheUpdateListener = undefined; document.body.className = ''; document.body.style.filter = ''; }); @@ -105,4 +124,30 @@ describe('ThemeManager', () => { expect(document.body).toHaveClass('test-dark-theme'); expect(document.body).not.toHaveClass('test-light-theme'); }); + + it('reloads active CSS when the cached content changes without a URL change', async () => { + const themeUrl = 'https://catalog.example/theme.sable.css'; + cachedCss = 'body { --sable-primary-main: red; }'; + activeTheme = { + id: 'test-remote', + kind: ThemeKind.Dark, + classNames: ['test-dark-theme'], + remoteFullUrl: themeUrl, + }; + + render( + +
child
+
+ ); + + await waitFor(() => + expect(document.getElementById('sable-remote-theme-style')).toHaveTextContent('red') + ); + cachedCss = 'body { --sable-primary-main: blue; }'; + cacheUpdateListener?.({ url: themeUrl, contentHash: 'new-hash' }); + await waitFor(() => + expect(document.getElementById('sable-remote-theme-style')).toHaveTextContent('blue') + ); + }); }); diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index 9dae919490..69e34282f2 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { useEffect } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { configClass, varsClass } from 'folds'; import { DarkTheme, @@ -12,8 +12,14 @@ import { import { ArboriumThemeBridge } from '$plugins/arborium'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; -import { getCachedThemeCss, putCachedThemeCss } from '../theme/cache'; +import { useOptionalClientConfig } from '$hooks/useClientConfig'; +import { getCachedThemeCss, putCachedThemeCss, subscribeThemeCacheUpdates } from '../theme/cache'; import { isLocalImportBundledUrl } from '../theme/localImportUrls'; +import { themeCatalogListingBaseUrl } from '../theme/catalogDefaults'; +import { + THEME_CATALOG_AUTO_UPDATE_INTERVAL_MS, + updateInstalledCatalogPackages, +} from '../theme/catalogUpdater'; const REMOTE_STYLE_ID = 'sable-remote-theme-style'; const REMOTE_TWEAKS_STYLE_ID = 'sable-remote-tweaks-style'; @@ -39,6 +45,74 @@ async function loadRemoteThemeCssText(url: string): Promise return text; } +function CatalogThemeAutoUpdater() { + const clientConfig = useOptionalClientConfig(); + const [catalogEnabled] = useSetting(settingsAtom, 'themeRemoteCatalogEnabled'); + const [favorites] = useSetting(settingsAtom, 'themeRemoteFavorites'); + const [tweakFavorites] = useSetting(settingsAtom, 'themeRemoteTweakFavorites'); + const [manualUrl] = useSetting(settingsAtom, 'themeRemoteManualFullUrl'); + const [lightUrl] = useSetting(settingsAtom, 'themeRemoteLightFullUrl'); + const [darkUrl] = useSetting(settingsAtom, 'themeRemoteDarkFullUrl'); + const [enabledTweakUrls] = useSetting(settingsAtom, 'themeRemoteEnabledTweakFullUrls'); + const inFlightRef = useRef | null>(null); + + const installedThemeUrls = useMemo( + () => + Array.from( + new Set( + [ + ...(favorites ?? []).map((favorite) => favorite.fullUrl), + manualUrl, + lightUrl, + darkUrl, + ].filter((url): url is string => Boolean(url)) + ) + ), + [darkUrl, favorites, lightUrl, manualUrl] + ); + const installedTweakUrls = useMemo( + () => + Array.from( + new Set([ + ...(tweakFavorites ?? []).map((favorite) => favorite.fullUrl), + ...(enabledTweakUrls ?? []), + ]) + ), + [enabledTweakUrls, tweakFavorites] + ); + + const checkForUpdates = useCallback(() => { + if (!catalogEnabled || !clientConfig || inFlightRef.current) return; + if (installedThemeUrls.length === 0 && installedTweakUrls.length === 0) return; + const task = updateInstalledCatalogPackages({ + catalogBase: themeCatalogListingBaseUrl(clientConfig.themeCatalogBaseUrl?.trim()), + manifestUrl: clientConfig.themeCatalogManifestUrl?.trim() || undefined, + installedThemeUrls, + installedTweakUrls, + maxAgeMs: THEME_CATALOG_AUTO_UPDATE_INTERVAL_MS, + }) + .catch(() => undefined) + .finally(() => { + if (inFlightRef.current === task) inFlightRef.current = null; + }); + inFlightRef.current = task; + }, [catalogEnabled, clientConfig, installedThemeUrls, installedTweakUrls]); + + useEffect(() => { + checkForUpdates(); + }, [checkForUpdates]); + + useEffect(() => { + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') checkForUpdates(); + }; + document.addEventListener('visibilitychange', onVisibilityChange); + return () => document.removeEventListener('visibilitychange', onVisibilityChange); + }, [checkForUpdates]); + + return null; +} + export function UnAuthRouteThemeManager() { const systemThemeKind = useSystemThemeKind(); @@ -94,7 +168,7 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { let cancelled = false; if (url) { - (async () => { + const applyThemeCss = async () => { const text = await loadRemoteThemeCssText(url); if (cancelled) return; if (!text) { @@ -108,7 +182,15 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { document.head.appendChild(node); } node.textContent = text; - })(); + }; + applyThemeCss().catch(() => undefined); + const unsubscribe = subscribeThemeCacheUpdates((update) => { + if (update.url === url) applyThemeCss().catch(() => undefined); + }); + return () => { + cancelled = true; + unsubscribe(); + }; } else { document.getElementById(REMOTE_STYLE_ID)?.remove(); } @@ -127,7 +209,7 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { return undefined; } - (async () => { + const applyTweakCss = async () => { const texts = await Promise.all(urls.map((url) => loadRemoteThemeCssText(url.trim()))); if (cancelled) return; const chunks = texts.filter((text): text is string => Boolean(text)); @@ -138,16 +220,25 @@ export function AuthRouteThemeManager({ children }: { children: ReactNode }) { document.head.appendChild(node); } node.textContent = chunks.join('\n\n'); - })(); + }; + applyTweakCss().catch(() => undefined); + const activeUrls = new Set(urls.map((url) => url.trim())); + const unsubscribe = subscribeThemeCacheUpdates((update) => { + if (activeUrls.has(update.url)) applyTweakCss().catch(() => undefined); + }); return () => { cancelled = true; + unsubscribe(); }; }, [enabledTweakUrls]); return ( - - {children} - + <> + + + {children} + + ); } diff --git a/src/app/theme/cache.ts b/src/app/theme/cache.ts index b3659b1685..b10b68c1e4 100644 --- a/src/app/theme/cache.ts +++ b/src/app/theme/cache.ts @@ -1,40 +1,115 @@ const DB_NAME = 'sable-theme-cache'; const DB_VERSION = 1; const STORE = 'themes'; +const UPDATE_CHANNEL = 'sable-theme-cache-updates'; export type CachedThemeEntry = { url: string; cssText: string; cachedAt: number; + checkedAt?: number; + contentHash?: string; + etag?: string; + lastModified?: string; }; +export type ThemeCacheRevalidationResult = + | { status: 'updated'; url: string; previousHash?: string; contentHash: string } + | { status: 'unchanged'; url: string; contentHash: string } + | { status: 'skipped'; url: string; contentHash?: string } + | { status: 'failed'; url: string; error: string }; + +export type ThemeCacheUpdate = { + url: string; + contentHash: string; +}; + +export type ThemeCacheStats = { + entries: number; + localEntries: number; + remoteEntries: number; + totalBytes: number; + localBytes: number; + remoteBytes: number; + lastCheckedAt?: number; +}; + +const updateListeners = new Set<(update: ThemeCacheUpdate) => void>(); +let updateChannel: BroadcastChannel | undefined; + +function isThemeCacheUpdate(value: unknown): value is ThemeCacheUpdate { + if (!value || typeof value !== 'object') return false; + const update = value as Record; + return typeof update.url === 'string' && typeof update.contentHash === 'string'; +} + +function getUpdateChannel(): BroadcastChannel | undefined { + if (typeof BroadcastChannel === 'undefined') return undefined; + if (!updateChannel) { + updateChannel = new BroadcastChannel(UPDATE_CHANNEL); + updateChannel.addEventListener('message', (event: MessageEvent) => { + const update = event.data; + if (!isThemeCacheUpdate(update)) return; + updateListeners.forEach((listener) => listener(update)); + }); + } + return updateChannel; +} + +function notifyThemeCacheUpdated(update: ThemeCacheUpdate): void { + updateListeners.forEach((listener) => listener(update)); + getUpdateChannel()?.postMessage(update); +} + +export function subscribeThemeCacheUpdates( + listener: (update: ThemeCacheUpdate) => void +): () => void { + updateListeners.add(listener); + getUpdateChannel(); + return () => updateListeners.delete(listener); +} + function openDb(): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION); req.addEventListener('error', () => reject(req.error)); req.addEventListener('success', () => resolve(req.result)); req.addEventListener('upgradeneeded', () => { - req.result.createObjectStore(STORE, { keyPath: 'url' }); + if (!req.result.objectStoreNames.contains(STORE)) { + req.result.createObjectStore(STORE, { keyPath: 'url' }); + } }); }); } -export async function getCachedThemeCss(url: string): Promise { +export async function hashThemeCss(cssText: string): Promise { + try { + const bytes = new TextEncoder().encode(cssText); + const digest = await crypto.subtle.digest('SHA-256', bytes); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join( + '' + ); + } catch { + let hash = 0x811c9dc5; + for (let i = 0; i < cssText.length; i += 1) { + hash = Math.imul(hash ^ cssText.charCodeAt(i), 0x01000193); + } + return `fnv1a-${(hash >>> 0).toString(16).padStart(8, '0')}`; + } +} + +export async function getCachedThemeEntry(url: string): Promise { const db = await openDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readonly'); const req = tx.objectStore(STORE).get(url); req.addEventListener('error', () => reject(req.error)); - req.addEventListener('success', () => { - const row = req.result as CachedThemeEntry | undefined; - resolve(row?.cssText); - }); + req.addEventListener('success', () => resolve(req.result as CachedThemeEntry | undefined)); }); } -export async function putCachedThemeCss(url: string, cssText: string): Promise { +async function writeCachedThemeEntry(entry: CachedThemeEntry): Promise { const db = await openDb(); - const entry: CachedThemeEntry = { url, cssText, cachedAt: Date.now() }; return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite'); tx.objectStore(STORE).put(entry); @@ -43,6 +118,83 @@ export async function putCachedThemeCss(url: string, cssText: string): Promise { + return (await getCachedThemeEntry(url))?.cssText; +} + +export async function putCachedThemeCss(url: string, cssText: string): Promise { + const [existing, contentHash] = await Promise.all([ + getCachedThemeEntry(url), + hashThemeCss(cssText), + ]); + const now = Date.now(); + await writeCachedThemeEntry({ + ...existing, + url, + cssText, + cachedAt: now, + checkedAt: now, + contentHash, + etag: undefined, + lastModified: undefined, + }); + if (existing && existing.contentHash !== contentHash) { + notifyThemeCacheUpdated({ url, contentHash }); + } +} + +export async function revalidateCachedThemeCss( + url: string, + maxAgeMs = 0 +): Promise { + try { + const existing = await getCachedThemeEntry(url); + const lastCheck = existing?.checkedAt ?? existing?.cachedAt ?? 0; + if (maxAgeMs > 0 && Date.now() - lastCheck < maxAgeMs) { + return { status: 'skipped', url, contentHash: existing?.contentHash }; + } + + const response = await fetch(url, { mode: 'cors', cache: 'no-cache' }); + if (!response.ok) { + return { status: 'failed', url, error: `Theme update check failed (${response.status}).` }; + } + const cssText = await response.text(); + const contentHash = await hashThemeCss(cssText); + const previousHash = + existing?.contentHash ?? (existing ? await hashThemeCss(existing.cssText) : undefined); + const now = Date.now(); + + if (existing && previousHash === contentHash) { + await writeCachedThemeEntry({ + ...existing, + checkedAt: now, + contentHash, + etag: response.headers.get('etag') ?? existing.etag, + lastModified: response.headers.get('last-modified') ?? existing.lastModified, + }); + return { status: 'unchanged', url, contentHash }; + } + + await writeCachedThemeEntry({ + url, + cssText, + cachedAt: now, + checkedAt: now, + contentHash, + etag: response.headers.get('etag') ?? undefined, + lastModified: response.headers.get('last-modified') ?? undefined, + }); + notifyThemeCacheUpdated({ url, contentHash }); + return { status: 'updated', url, previousHash, contentHash }; + } catch (error) { + return { + status: 'failed', + url, + error: error instanceof Error ? error.message : 'Theme update check failed.', + }; + } +} + export async function clearThemeCache(): Promise { const db = await openDb(); return new Promise((resolve, reject) => { @@ -52,3 +204,60 @@ export async function clearThemeCache(): Promise { tx.addEventListener('error', () => reject(tx.error)); }); } + +export async function getThemeCacheStats(): Promise { + const db = await openDb(); + const entries = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readonly'); + const req = tx.objectStore(STORE).getAll(); + req.addEventListener('error', () => reject(req.error)); + req.addEventListener('success', () => resolve(req.result as CachedThemeEntry[])); + }); + const stats: ThemeCacheStats = { + entries: entries.length, + localEntries: 0, + remoteEntries: 0, + totalBytes: 0, + localBytes: 0, + remoteBytes: 0, + }; + entries.forEach((entry) => { + const bytes = new TextEncoder().encode(entry.cssText).byteLength; + const local = entry.url.startsWith('sable-import://'); + stats.totalBytes += bytes; + if (local) { + stats.localEntries += 1; + stats.localBytes += bytes; + } else { + stats.remoteEntries += 1; + stats.remoteBytes += bytes; + const checkedAt = entry.checkedAt ?? entry.cachedAt; + if (!stats.lastCheckedAt || checkedAt > stats.lastCheckedAt) stats.lastCheckedAt = checkedAt; + } + }); + return stats; +} + +/** Clears only refetchable network CSS. Uploaded local theme packages are preserved. */ +export async function clearRemoteThemeCache(): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + const store = tx.objectStore(STORE); + const req = store.openCursor(); + let removed = 0; + req.addEventListener('error', () => reject(req.error)); + req.addEventListener('success', () => { + const cursor = req.result; + if (!cursor) return; + const entry = cursor.value as CachedThemeEntry; + if (!entry.url.startsWith('sable-import://')) { + cursor.delete(); + removed += 1; + } + cursor.continue(); + }); + tx.addEventListener('complete', () => resolve(removed)); + tx.addEventListener('error', () => reject(tx.error)); + }); +} diff --git a/src/app/theme/catalogUpdater.ts b/src/app/theme/catalogUpdater.ts new file mode 100644 index 0000000000..bae5c7f002 --- /dev/null +++ b/src/app/theme/catalogUpdater.ts @@ -0,0 +1,57 @@ +import { revalidateCachedThemeCss, type ThemeCacheRevalidationResult } from './cache'; +import { fetchThemeCatalogBundle } from './catalog'; + +export const THEME_CATALOG_AUTO_UPDATE_INTERVAL_MS = 6 * 60 * 60_000; +const lastCatalogChecks = new Map(); + +export type CatalogUpdateSummary = { + checked: number; + updated: number; + unchanged: number; + skipped: number; + failed: number; + results: ThemeCacheRevalidationResult[]; +}; + +export type UpdateInstalledCatalogPackagesOptions = { + catalogBase: string; + manifestUrl?: string; + installedThemeUrls: string[]; + installedTweakUrls: string[]; + maxAgeMs?: number; +}; + +export async function updateInstalledCatalogPackages({ + catalogBase, + manifestUrl, + installedThemeUrls, + installedTweakUrls, + maxAgeMs = THEME_CATALOG_AUTO_UPDATE_INTERVAL_MS, +}: UpdateInstalledCatalogPackagesOptions): Promise { + const catalogCheckKey = `${catalogBase}\n${manifestUrl ?? ''}`; + const previousCatalogCheck = lastCatalogChecks.get(catalogCheckKey) ?? 0; + if (maxAgeMs > 0 && Date.now() - previousCatalogCheck < maxAgeMs) { + return { checked: 0, updated: 0, unchanged: 0, skipped: 0, failed: 0, results: [] }; + } + const bundle = await fetchThemeCatalogBundle(catalogBase, { manifestUrl }); + const catalogThemeUrls = new Set(bundle.themes.map((theme) => theme.fullUrl)); + lastCatalogChecks.set(catalogCheckKey, Date.now()); + const catalogTweakUrls = new Set(bundle.tweaks.map((tweak) => tweak.fullUrl)); + + const installed = new Set([ + ...installedThemeUrls.filter((url) => catalogThemeUrls.has(url)), + ...installedTweakUrls.filter((url) => catalogTweakUrls.has(url)), + ]); + const results = await Promise.all( + Array.from(installed, (url) => revalidateCachedThemeCss(url, maxAgeMs)) + ); + + return { + checked: results.filter((result) => result.status !== 'skipped').length, + updated: results.filter((result) => result.status === 'updated').length, + unchanged: results.filter((result) => result.status === 'unchanged').length, + skipped: results.filter((result) => result.status === 'skipped').length, + failed: results.filter((result) => result.status === 'failed').length, + results, + }; +} diff --git a/src/app/theme/processThemeImport.ts b/src/app/theme/processThemeImport.ts index eabcf3ad77..effe96770e 100644 --- a/src/app/theme/processThemeImport.ts +++ b/src/app/theme/processThemeImport.ts @@ -37,6 +37,62 @@ export type ProcessedThemeImport = } | { ok: false; error: string }; +export type ProcessedUploadedSableCss = + | { + ok: true; + role: 'theme'; + fullUrl?: string; + previewCssForCard: string; + fullCssForCard?: string; + displayName: string; + basename: string; + kind: 'light' | 'dark'; + importedLocal: boolean; + previewOnly: boolean; + } + | { + ok: true; + role: 'tweak'; + fullUrl: string; + cssText: string; + displayName: string; + basename: string; + description?: string; + author?: string; + tags: string[]; + importedLocal: true; + } + | { ok: false; error: string }; + +export function isSableCssAttachmentFileName(fileName: string): boolean { + return /\.sable\.css$/i.test(fileName); +} + +function fallbackStableId(sourceKey: string): string { + let first = 0x811c9dc5; + let second = 0x9e3779b9; + for (let i = 0; i < sourceKey.length; i += 1) { + const code = sourceKey.charCodeAt(i); + first = Math.imul(first ^ code, 0x01000193); + second = Math.imul(second ^ code, 0x85ebca6b); + } + return `${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0) + .toString(16) + .padStart(8, '0')}`; +} + +async function stableImportId(sourceKey: string): Promise { + try { + const bytes = new TextEncoder().encode(sourceKey); + const digest = await crypto.subtle.digest('SHA-256', bytes); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')) + .join('') + .slice(0, 32); + } catch { + return fallbackStableId(sourceKey); + } +} + function basenameFromHttpsUrl(url: string): string { try { const u = new URL(url); @@ -274,3 +330,130 @@ export async function processPastedOrUploadedCss( importedLocal: true, }; } + +/** Process a Matrix file attachment without treating a preview-only file as a full theme. */ +export async function processUploadedSableCssAttachment( + cssText: string, + fileName: string, + sourceKey: string +): Promise { + const trimmed = cssText.trim(); + if (!trimmed) return { ok: false, error: 'The uploaded CSS file is empty.' }; + + const packageKind = getSableCssPackageKind(trimmed); + if (packageKind === 'unknown') { + return { + ok: false, + error: 'This file does not contain @sable-theme or @sable-tweak metadata.', + }; + } + + const stableId = await stableImportId(sourceKey); + + if (packageKind === 'tweak') { + const meta = parseSableTweakMetadata(trimmed); + const basename = tweakBasename(meta, fileName); + const fullUrl = localImportTweakFullUrl(stableId); + await putCachedThemeCss(fullUrl, trimmed); + return { + ok: true, + role: 'tweak', + fullUrl, + cssText: trimmed, + displayName: meta.name?.trim() || basename, + basename, + description: meta.description?.trim() || undefined, + author: meta.author?.trim() || undefined, + tags: meta.tags ?? [], + importedLocal: true, + }; + } + + const meta = parseSableThemeMetadata(trimmed); + const fileBasename = fileName + .replace(/\.preview\.sable\.css$/i, '') + .replace(/\.sable\.css$/i, ''); + const displayName = meta.name?.trim() || fileBasename || 'Uploaded theme'; + const basename = + meta.id?.trim() || + displayName.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-|-$/g, '') || + 'uploaded'; + const isPreviewFile = /\.preview\.sable\.css$/i.test(fileName); + + if (isPreviewFile) { + const fullUrl = extractFullThemeUrlFromPreview(trimmed); + if (!fullUrl) { + return { + ok: true, + role: 'theme', + previewCssForCard: trimmed, + displayName, + basename, + kind: metaKindToLd(meta), + importedLocal: false, + previewOnly: true, + }; + } + + try { + const response = await fetch(fullUrl, { mode: 'cors' }); + if (!response.ok) { + return { + ok: true, + role: 'theme', + previewCssForCard: trimmed, + displayName, + basename, + kind: metaKindToLd(meta), + importedLocal: false, + previewOnly: true, + }; + } + const fullCss = await response.text(); + if (getSableCssPackageKind(fullCss) === 'tweak') { + return { ok: false, error: 'The preview file points to a tweak instead of a full theme.' }; + } + await putCachedThemeCss(fullUrl, fullCss); + return { + ok: true, + role: 'theme', + fullUrl, + previewCssForCard: trimmed, + fullCssForCard: fullCss, + displayName, + basename, + kind: metaKindToLd(meta), + importedLocal: false, + previewOnly: false, + }; + } catch { + return { + ok: true, + role: 'theme', + previewCssForCard: trimmed, + displayName, + basename, + kind: metaKindToLd(meta), + importedLocal: false, + previewOnly: true, + }; + } + } + + const fullUrl = localImportFullUrl(stableId); + const previewUrl = localImportPreviewUrl(stableId); + await putCachedThemeCss(fullUrl, trimmed); + await putCachedThemeCss(previewUrl, trimmed); + return { + ok: true, + role: 'theme', + fullUrl, + previewCssForCard: trimmed, + fullCssForCard: trimmed, + displayName, + basename, + kind: metaKindToLd(meta), + importedLocal: true, + previewOnly: false, + }; +} diff --git a/src/app/theme/themeLibrary.test.ts b/src/app/theme/themeLibrary.test.ts new file mode 100644 index 0000000000..d61161c3f0 --- /dev/null +++ b/src/app/theme/themeLibrary.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { + pruneThemeFavorites, + pruneThemeTweakFavorites, + themeSourceLabel, + themeUrlHostLabel, +} from './themeLibrary'; + +describe('themeLibrary', () => { + it('keeps pinned and active theme records', () => { + const favorites = [ + { fullUrl: 'active', displayName: 'Active', basename: 'active', kind: 'dark' as const }, + { + fullUrl: 'pinned', + displayName: 'Pinned', + basename: 'pinned', + kind: 'light' as const, + pinned: true, + }, + { fullUrl: 'unused', displayName: 'Unused', basename: 'unused', kind: 'dark' as const }, + ]; + + expect(pruneThemeFavorites(favorites, ['active']).map((item) => item.fullUrl)).toEqual([ + 'active', + 'pinned', + ]); + }); + + it('keeps pinned and enabled tweak records', () => { + const favorites = [ + { fullUrl: 'enabled', displayName: 'Enabled', basename: 'enabled' }, + { fullUrl: 'pinned', displayName: 'Pinned', basename: 'pinned', pinned: true }, + { fullUrl: 'unused', displayName: 'Unused', basename: 'unused' }, + ]; + + expect(pruneThemeTweakFavorites(favorites, ['enabled']).map((item) => item.fullUrl)).toEqual([ + 'enabled', + 'pinned', + ]); + }); + + it('provides consistent source labels', () => { + expect(themeSourceLabel({ importedLocal: true })).toBe('Uploaded file'); + expect(themeSourceLabel({ official: true })).toBe('Official catalog'); + expect(themeSourceLabel({ url: 'https://themes.example/night.sable.css' })).toBe( + 'themes.example' + ); + expect(themeUrlHostLabel('not a URL', 'Unknown')).toBe('Unknown'); + }); +}); diff --git a/src/app/theme/themeLibrary.ts b/src/app/theme/themeLibrary.ts new file mode 100644 index 0000000000..7c752ea778 --- /dev/null +++ b/src/app/theme/themeLibrary.ts @@ -0,0 +1,40 @@ +import type { ThemeRemoteFavorite, ThemeRemoteTweakFavorite } from '$state/settings'; + +export function pruneThemeFavorites( + favorites: ThemeRemoteFavorite[], + activeUrls: Array +): ThemeRemoteFavorite[] { + const active = new Set(activeUrls.filter((url): url is string => Boolean(url))); + return favorites.filter((favorite) => favorite.pinned === true || active.has(favorite.fullUrl)); +} + +export function pruneThemeTweakFavorites( + favorites: ThemeRemoteTweakFavorite[], + enabledUrls: string[] +): ThemeRemoteTweakFavorite[] { + const enabled = new Set(enabledUrls); + return favorites.filter((favorite) => favorite.pinned === true || enabled.has(favorite.fullUrl)); +} + +export function themeUrlHostLabel(url: string, fallback = 'Third-party source'): string { + try { + return new URL(url).hostname; + } catch { + return fallback; + } +} + +export function themeSourceLabel({ + importedLocal, + official, + url, +}: { + importedLocal?: boolean; + official?: boolean; + url?: string; +}): string { + if (importedLocal) return 'Uploaded file'; + if (official) return 'Official catalog'; + if (url) return themeUrlHostLabel(url); + return 'Local theme'; +}