Files
eranos/src/lib/themeEvent.ts
T
Mary Kate Fain b59eeeca81 Show theme description on 'updated their theme' (kind 16767) posts
Kind 16767 events previously hardcoded description to undefined, so the
theme description never appeared on 'updated their theme' posts in the
feed or detail view.

Three changes:
- buildActiveThemeTags now accepts and includes a description tag, so
  future kind 16767 events carry the description directly
- setActiveTheme accepts description to thread it through publishing
- ThemeContent extracts the description tag from kind 16767 events, and
  for older events without one, falls back to querying the source theme
  definition via the a-tag reference
2026-03-27 17:09:08 -05:00

317 lines
10 KiB
TypeScript

import type { NostrEvent } from '@nostrify/nostrify';
import type { CoreThemeColors, ThemeConfig, ThemeFont, ThemeBackground } from '@/themes';
import { hslStringToHex, hexToHslString } from '@/lib/colorUtils';
// ─── Kind Constants ───────────────────────────────────────────────────
/** Addressable event: a shareable, named theme definition. Multiple per user. */
export const THEME_DEFINITION_KIND = 36767;
/** Replaceable event: the user's currently active profile theme. One per user. */
export const ACTIVE_THEME_KIND = 16767;
// ─── Color Tag Helpers ────────────────────────────────────────────────
/** Color role markers used in `c` tags. */
type ColorRole = 'primary' | 'text' | 'background';
/** Build `c` tags from CoreThemeColors (HSL → hex conversion). */
function buildColorTags(colors: CoreThemeColors): string[][] {
const roles: ColorRole[] = ['background', 'text', 'primary'];
return roles.map((role) => ['c', hslStringToHex(colors[role]), role]);
}
/**
* Parse `c` tags into CoreThemeColors.
* Returns null if any of the 3 required roles are missing.
*/
function parseColorTags(tags: string[][]): CoreThemeColors | null {
const colorMap = new Map<string, string>();
for (const tag of tags) {
if (tag[0] === 'c' && tag[1] && tag[2]) {
colorMap.set(tag[2], tag[1]);
}
}
const bgHex = colorMap.get('background');
const textHex = colorMap.get('text');
const primaryHex = colorMap.get('primary');
if (!bgHex || !textHex || !primaryHex) return null;
return {
background: hexToHslString(bgHex),
text: hexToHslString(textHex),
primary: hexToHslString(primaryHex),
};
}
// ─── Font Tag Helpers ─────────────────────────────────────────────────
/** Build `f` tags from body and title fonts. Body tag is always ordered before title tag. */
function buildFontTags(font: ThemeFont | undefined, titleFont: ThemeFont | undefined): string[][] {
const tags: string[][] = [];
if (font?.family) {
const tag = ['f', font.family];
if (font.url) tag.push(font.url); else tag.push('');
tag.push('body');
tags.push(tag);
}
if (titleFont?.family) {
const tag = ['f', titleFont.family];
if (titleFont.url) tag.push(titleFont.url); else tag.push('');
tag.push('title');
tags.push(tag);
}
return tags;
}
/** Parse `f` tags into body and title ThemeFonts. Legacy tags without a role are treated as body. */
function parseFontTags(tags: string[][]): { font?: ThemeFont; titleFont?: ThemeFont } {
let font: ThemeFont | undefined;
let titleFont: ThemeFont | undefined;
for (const tag of tags) {
if (tag[0] !== 'f' || !tag[1]) continue;
const role = tag[3]; // 4th element: "body", "title", or absent (legacy)
const parsed: ThemeFont = { family: tag[1] };
if (tag[2]) parsed.url = tag[2];
if (role === 'title') {
if (!titleFont) titleFont = parsed;
} else {
// "body" or absent (legacy) — treat as body font
if (!font) font = parsed;
}
}
return { font, titleFont };
}
// ─── Background Tag Helpers ───────────────────────────────────────────
/** Build a `bg` tag from ThemeBackground (imeta-style variadic). */
function buildBackgroundTag(bg: ThemeBackground | undefined): string[][] {
if (!bg?.url) return [];
const entries: string[] = ['bg', `url ${bg.url}`];
if (bg.mode) entries.push(`mode ${bg.mode}`);
if (bg.mimeType) entries.push(`m ${bg.mimeType}`);
if (bg.dimensions) entries.push(`dim ${bg.dimensions}`);
if (bg.blurhash) entries.push(`blurhash ${bg.blurhash}`);
return [entries];
}
/** Parse a `bg` tag into ThemeBackground. Returns undefined if no bg tag. */
function parseBackgroundTag(tags: string[][]): ThemeBackground | undefined {
const bgTag = tags.find(([n]) => n === 'bg');
if (!bgTag) return undefined;
const kv = new Map<string, string>();
for (let i = 1; i < bgTag.length; i++) {
const entry = bgTag[i];
const spaceIdx = entry.indexOf(' ');
if (spaceIdx === -1) continue;
kv.set(entry.slice(0, spaceIdx), entry.slice(spaceIdx + 1));
}
const url = kv.get('url');
if (!url) return undefined;
const bg: ThemeBackground = { url };
const mode = kv.get('mode');
if (mode === 'cover' || mode === 'tile') bg.mode = mode;
bg.mimeType = kv.get('m');
bg.dimensions = kv.get('dim');
bg.blurhash = kv.get('blurhash');
return bg;
}
// ─── Theme Definition (Kind 36767) ────────────────────────────────────
export interface ThemeDefinition {
/** The d-tag identifier (slug) */
identifier: string;
/** Theme title */
title: string;
/** Optional description */
description?: string;
/** The 3 core theme colors */
colors: CoreThemeColors;
/** Optional custom body font */
font?: ThemeFont;
/** Optional title/header font (profile display name) */
titleFont?: ThemeFont;
/** Optional background */
background?: ThemeBackground;
/** The original Nostr event */
event: NostrEvent;
}
/** Parse and validate a kind 36767 theme definition event. Returns null if invalid. */
export function parseThemeDefinition(event: NostrEvent): ThemeDefinition | null {
if (event.kind !== THEME_DEFINITION_KIND) return null;
const identifier = event.tags.find(([n]) => n === 'd')?.[1];
if (!identifier) return null;
const title = event.tags.find(([n]) => n === 'title')?.[1];
if (!title) return null;
const description = event.tags.find(([n]) => n === 'description')?.[1];
// Try new format: colors in `c` tags, content is empty
let colors = parseColorTags(event.tags);
// Fall back to legacy format: colors as JSON in content
if (!colors && event.content) {
try {
const parsed = JSON.parse(event.content);
colors = normalizeLegacyColors(parsed);
} catch {
// Invalid JSON
}
}
if (!colors) return null;
const { font, titleFont } = parseFontTags(event.tags);
const background = parseBackgroundTag(event.tags);
return { identifier, title, description, colors, font, titleFont, background, event };
}
/** Create tags for a kind 36767 theme definition event. */
export function buildThemeDefinitionTags(
identifier: string,
title: string,
themeConfig: ThemeConfig,
description?: string,
): string[][] {
const tags: string[][] = [
['d', identifier],
...buildColorTags(themeConfig.colors),
...buildFontTags(themeConfig.font, themeConfig.titleFont),
...buildBackgroundTag(themeConfig.background),
['title', title],
['alt', `Custom theme: ${title}`],
['t', 'theme'],
];
if (description) {
tags.push(['description', description]);
}
return tags;
}
/** Generate a URL-safe slug from a title. */
export function titleToSlug(title: string): string {
return title
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.slice(0, 64);
}
// ─── Active Profile Theme (Kind 16767) ────────────────────────────────
export interface ActiveProfileTheme {
/** The 3 core theme colors */
colors: CoreThemeColors;
/** Optional custom body font */
font?: ThemeFont;
/** Optional title/header font (profile display name) */
titleFont?: ThemeFont;
/** Optional background */
background?: ThemeBackground;
/** naddr-style reference to the source theme definition, if any */
sourceRef?: string;
/** The original Nostr event */
event: NostrEvent;
}
/** Parse and validate a kind 16767 active profile theme event. Returns null if invalid. */
export function parseActiveProfileTheme(event: NostrEvent): ActiveProfileTheme | null {
if (event.kind !== ACTIVE_THEME_KIND) return null;
// Try new format: colors in `c` tags
let colors = parseColorTags(event.tags);
// Fall back to legacy format: colors as JSON in content
if (!colors && event.content) {
try {
const parsed = JSON.parse(event.content);
colors = normalizeLegacyColors(parsed);
} catch {
// Invalid JSON
}
}
if (!colors) return null;
const { font, titleFont } = parseFontTags(event.tags);
const background = parseBackgroundTag(event.tags);
const sourceRef = event.tags.find(([n]) => n === 'a')?.[1];
return { colors, font, titleFont, background, sourceRef, event };
}
/** Create tags for a kind 16767 active profile theme event. */
export function buildActiveThemeTags(
themeConfig: ThemeConfig,
sourceAuthor?: string,
sourceIdentifier?: string,
description?: string,
): string[][] {
const tags: string[][] = [
...buildColorTags(themeConfig.colors),
...buildFontTags(themeConfig.font, themeConfig.titleFont),
...buildBackgroundTag(themeConfig.background),
['alt', 'Active profile theme'],
];
if (themeConfig.title) {
tags.push(['title', themeConfig.title]);
}
if (description) {
tags.push(['description', description]);
}
if (sourceAuthor && sourceIdentifier) {
tags.push(['a', `${THEME_DEFINITION_KIND}:${sourceAuthor}:${sourceIdentifier}`]);
}
return tags;
}
// ─── Backward Compatibility ───────────────────────────────────────────
/**
* Normalize a parsed JSON object to CoreThemeColors (legacy format).
* Handles:
* - Current format: { background, text, primary }
* - Old 4-color format: { background, text, primary, secondary } (secondary dropped)
* - Legacy 19-token format: { background, foreground, primary, ... }
*/
function normalizeLegacyColors(parsed: Record<string, unknown>): CoreThemeColors | null {
// Current or old 4-color format (both have background + text + primary)
if (parsed.background && parsed.text && parsed.primary) {
return {
background: String(parsed.background),
text: String(parsed.text),
primary: String(parsed.primary),
};
}
// Legacy 19-token format (background, foreground, primary, ...)
if (parsed.background && parsed.foreground && parsed.primary) {
return {
background: String(parsed.background),
text: String(parsed.foreground),
primary: String(parsed.primary),
};
}
return null;
}