mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
293 lines
8.4 KiB
TypeScript
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";
|
|
}
|