Files
buzz/desktop/src/shared/theme/communityThemePreference.ts
2026-08-20 08:09:44 +01:00

293 lines
8.4 KiB
TypeScript

import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
import {
ACCENT_COLORS,
DEFAULT_GLASS_BACKGROUND,
DEFAULT_GLASS_OPACITY,
DEFAULT_PROMINENT_ACTIVE_TAB,
GLASS_OPACITY_MAX,
GLASS_OPACITY_MIN,
} from "./ThemeProvider";
import { SYNTAX_THEMES, type SyntaxThemeName } from "./theme-loader";
const STORAGE_KEY_PREFIX = "buzz-community-theme.v1";
const OUTBOX_KEY_PREFIX = "buzz-community-theme-outbox.v1";
const MIGRATION_KEY_PREFIX = "buzz-community-theme-migrated.v1";
const APPEARANCE_MIGRATION_KEY_PREFIX =
"buzz-community-theme-appearance-migrated.v1";
export type CommunityThemePreference = {
version: 1;
theme: SyntaxThemeName;
accent: string;
followSystem: boolean;
glassBackground: boolean;
glassOpacity: number;
prominentActiveTab: boolean;
};
export const DEFAULT_COMMUNITY_THEME: CommunityThemePreference = Object.freeze({
version: 1,
theme: "buzz",
accent: "#3b82f6",
followSystem: true,
glassBackground: DEFAULT_GLASS_BACKGROUND,
glassOpacity: DEFAULT_GLASS_OPACITY,
prominentActiveTab: DEFAULT_PROMINENT_ACTIVE_TAB,
});
const THEME_NAMES = new Set<string>(SYNTAX_THEMES);
const ACCENTS = new Set<string>(ACCENT_COLORS.map(({ value }) => value));
export function communityThemeStorageKey(
pubkey: string,
relayUrl: string,
): string {
return `${STORAGE_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}
export function communityThemeOutboxKey(
pubkey: string,
relayUrl: string,
): string {
return `${OUTBOX_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`;
}
export function parseCommunityThemePreference(
value: unknown,
legacyFallback: CommunityThemePreference = DEFAULT_COMMUNITY_THEME,
): CommunityThemePreference | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return null;
}
const candidate = value as Record<string, unknown>;
// These fields were added to the existing v1 payload. Fill older records
// from the pre-migration appearance so a user's former global glass and tab
// choices become the initial values for each existing community.
const glassBackground = Object.hasOwn(candidate, "glassBackground")
? candidate.glassBackground
: legacyFallback.glassBackground;
const glassOpacity = Object.hasOwn(candidate, "glassOpacity")
? candidate.glassOpacity
: legacyFallback.glassOpacity;
const prominentActiveTab = Object.hasOwn(candidate, "prominentActiveTab")
? candidate.prominentActiveTab
: legacyFallback.prominentActiveTab;
if (
candidate.version !== 1 ||
typeof candidate.theme !== "string" ||
!THEME_NAMES.has(candidate.theme) ||
typeof candidate.accent !== "string" ||
!ACCENTS.has(candidate.accent) ||
typeof candidate.followSystem !== "boolean" ||
typeof glassBackground !== "boolean" ||
typeof glassOpacity !== "number" ||
!Number.isInteger(glassOpacity) ||
glassOpacity < GLASS_OPACITY_MIN ||
glassOpacity > GLASS_OPACITY_MAX ||
typeof prominentActiveTab !== "boolean"
) {
return null;
}
return {
version: 1,
theme: candidate.theme as SyntaxThemeName,
accent: candidate.accent,
followSystem: candidate.followSystem,
glassBackground,
glassOpacity,
prominentActiveTab,
};
}
export function readCommunityThemePreference(
pubkey: string,
relayUrl: string,
legacyFallback: CommunityThemePreference = DEFAULT_COMMUNITY_THEME,
): CommunityThemePreference | null {
try {
const raw = window.localStorage.getItem(
communityThemeStorageKey(pubkey, relayUrl),
);
return raw
? parseCommunityThemePreference(JSON.parse(raw), legacyFallback)
: null;
} catch {
return null;
}
}
export function readCommunityThemeOutbox(
pubkey: string,
relayUrl: string,
legacyFallback: CommunityThemePreference = DEFAULT_COMMUNITY_THEME,
): CommunityThemePreference | null {
try {
const raw = window.localStorage.getItem(
communityThemeOutboxKey(pubkey, relayUrl),
);
return raw
? parseCommunityThemePreference(JSON.parse(raw), legacyFallback)
: null;
} catch {
return null;
}
}
export function writeCommunityThemeOutbox(
pubkey: string,
relayUrl: string,
preference: CommunityThemePreference,
): boolean {
try {
window.localStorage.setItem(
communityThemeOutboxKey(pubkey, relayUrl),
JSON.stringify(preference),
);
return true;
} catch {
return false;
}
}
export function clearCommunityThemeOutbox(
pubkey: string,
relayUrl: string,
acknowledged: CommunityThemePreference,
legacyFallback: CommunityThemePreference = DEFAULT_COMMUNITY_THEME,
): void {
const pending = readCommunityThemeOutbox(pubkey, relayUrl, legacyFallback);
if (!pending || !sameCommunityThemePreference(pending, acknowledged)) return;
try {
window.localStorage.removeItem(communityThemeOutboxKey(pubkey, relayUrl));
} catch {
// A later retry can safely publish the same replaceable event again.
}
}
export function hasMigratedCommunityTheme(pubkey: string): boolean {
try {
return (
window.localStorage.getItem(`${MIGRATION_KEY_PREFIX}:${pubkey}`) ===
"true"
);
} catch {
return false;
}
}
export function markCommunityThemeMigrated(pubkey: string): void {
try {
window.localStorage.setItem(`${MIGRATION_KEY_PREFIX}:${pubkey}`, "true");
} catch {
// The preference itself remains usable in memory when storage is full.
}
}
export function hasMigratedCommunityThemeAppearance(pubkey: string): boolean {
try {
return (
window.localStorage.getItem(
`${APPEARANCE_MIGRATION_KEY_PREFIX}:${pubkey}`,
) === "true"
);
} catch {
return false;
}
}
export function markCommunityThemeAppearanceMigrated(pubkey: string): void {
try {
window.localStorage.setItem(
`${APPEARANCE_MIGRATION_KEY_PREFIX}:${pubkey}`,
"true",
);
} catch {
// The expanded preference remains usable in memory when storage is full.
}
}
export function writeCommunityThemePreference(
pubkey: string,
relayUrl: string,
preference: CommunityThemePreference,
): boolean {
try {
window.localStorage.setItem(
communityThemeStorageKey(pubkey, relayUrl),
JSON.stringify(preference),
);
return true;
} catch {
return false;
}
}
export function cacheAndApplyCommunityTheme(
pubkey: string,
relayUrl: string,
preference: CommunityThemePreference,
apply: (preference: CommunityThemePreference) => void,
): void {
writeCommunityThemePreference(pubkey, relayUrl, preference);
apply(preference);
}
export function communityThemeScopeFallback(
migrated: boolean,
inherited: CommunityThemePreference,
): CommunityThemePreference {
return migrated ? DEFAULT_COMMUNITY_THEME : inherited;
}
/**
* Choose values for appearance fields missing from the original v1 payload.
* This migration is independent from the older per-community scope migration:
* profiles that already completed scoping still need one chance to retain
* their former global glass and prominent-tab preferences.
*/
export function communityThemeAppearanceFallback(
migrated: boolean,
inherited: CommunityThemePreference,
): CommunityThemePreference {
return migrated ? DEFAULT_COMMUNITY_THEME : inherited;
}
export function sameCommunityThemePreference(
left: CommunityThemePreference,
right: CommunityThemePreference,
): boolean {
return (
left.theme === right.theme &&
left.accent === right.accent &&
left.followSystem === right.followSystem &&
left.glassBackground === right.glassBackground &&
left.glassOpacity === right.glassOpacity &&
left.prominentActiveTab === right.prominentActiveTab
);
}
export function communityThemeApplyExpectation(
preference: CommunityThemePreference,
current: CommunityThemePreference,
preserveNoop = false,
): CommunityThemePreference | null {
return preserveNoop || !sameCommunityThemePreference(preference, current)
? preference
: null;
}
/**
* Decide whether the current context value is safe to persist for this scope.
* Applying a scoped preference updates the outer ThemeProvider asynchronously,
* so renders that still expose the previous scope must be deferred.
*/
export function communityThemePersistenceAction(
expectedApplied: CommunityThemePreference | null,
current: CommunityThemePreference,
): "persist" | "defer" | "acknowledge" {
if (!expectedApplied) return "persist";
return sameCommunityThemePreference(expectedApplied, current)
? "acknowledge"
: "defer";
}