From ca6c1a6625e6a9c4b7bc9e5952bd2f11d1abc6cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Fri, 3 Apr 2026 12:04:31 +0200 Subject: [PATCH] feat: color moments --- src/components/nostr/ColorPaletteDisplay.tsx | 216 ++++++++++++++++++ .../nostr/kinds/ColorMomentDetailRenderer.tsx | 66 ++++++ .../nostr/kinds/ColorMomentRenderer.tsx | 63 +++++ src/components/nostr/kinds/index.tsx | 4 + src/constants/kinds.ts | 9 + src/index.css | 4 +- src/lib/color-moment-helpers.ts | 62 +++++ 7 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 src/components/nostr/ColorPaletteDisplay.tsx create mode 100644 src/components/nostr/kinds/ColorMomentDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/ColorMomentRenderer.tsx create mode 100644 src/lib/color-moment-helpers.ts diff --git a/src/components/nostr/ColorPaletteDisplay.tsx b/src/components/nostr/ColorPaletteDisplay.tsx new file mode 100644 index 0000000..240469f --- /dev/null +++ b/src/components/nostr/ColorPaletteDisplay.tsx @@ -0,0 +1,216 @@ +import { memo, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { safeHex, type LayoutMode } from "@/lib/color-moment-helpers"; + +interface ColorPaletteDisplayProps { + colors: string[]; + layout?: LayoutMode; + emoji?: string; + emojiSize?: "md" | "lg"; + className?: string; +} + +/** Shared color palette renderer supporting all 6 layout modes */ +export const ColorPaletteDisplay = memo(function ColorPaletteDisplay({ + colors, + layout = "horizontal", + emoji, + emojiSize = "md", + className, +}: ColorPaletteDisplayProps) { + if (colors.length === 0) return null; + + return ( +
+ + {emoji && ( +
+ + {emoji} + +
+ )} +
+ ); +}); + +function LayoutRenderer({ + colors, + layout, +}: { + colors: string[]; + layout: LayoutMode; +}) { + switch (layout) { + case "horizontal": + return ( +
+ {colors.map((color, i) => ( +
+ ))} +
+ ); + + case "vertical": + return ( +
+ {colors.map((color, i) => ( +
+ ))} +
+ ); + + case "grid": + return ( +
+ {colors.map((color, i) => ( +
+ ))} +
+ ); + + case "star": + return ; + + case "checkerboard": + return ; + + case "diagonalStripes": + return ; + + default: + return ( +
+ {colors.map((color, i) => ( +
+ ))} +
+ ); + } +} + +/** Radial pie slices from center using clip-path */ +function StarLayout({ colors }: { colors: string[] }) { + const total = colors.length; + + return ( +
+ {/* Background fill to cover center gap */} +
+ {colors.map((color, index) => { + const angle = 360 / total; + const startAngle = index * angle - 90; + const scale = 1.5; + const overlap = 0.5; + const adjustedStartAngle = startAngle - overlap; + const adjustedAngle = angle + overlap * 2; + + const points: string[] = ["50% 50%"]; + const steps = 12; + for (let i = 0; i <= steps; i++) { + const currentAngle = adjustedStartAngle + (adjustedAngle * i) / steps; + const rad = (currentAngle * Math.PI) / 180; + const x = 50 + 50 * scale * Math.cos(rad); + const y = 50 + 50 * scale * Math.sin(rad); + points.push(`${x}% ${y}%`); + } + + return ( +
+ ); + })} +
+ ); +} + +/** SVG data URI tiled checkerboard pattern */ +function CheckerboardLayout({ colors }: { colors: string[] }) { + const backgroundImage = useMemo(() => { + const n = colors.length; + const cellSize = 1; + const svgSize = n * cellSize; + + let rects = ""; + for (let row = 0; row < n; row++) { + for (let col = 0; col < n; col++) { + const color = safeHex(colors[(row + col) % n]); + rects += ``; + } + } + + const svg = `${rects}`; + return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; + }, [colors]); + + return ( +
+ ); +} + +/** Diagonal stripes via CSS linear-gradient */ +function DiagonalStripesLayout({ colors }: { colors: string[] }) { + const background = useMemo(() => { + const n = colors.length; + const stripePercent = 100 / n; + + const stops = colors + .map((color, i) => { + const start = i * stripePercent; + const end = (i + 1) * stripePercent; + const hex = safeHex(color); + return `${hex} ${start}%, ${hex} ${end}%`; + }) + .join(", "); + + return `linear-gradient(135deg, ${stops})`; + }, [colors]); + + return
; +} diff --git a/src/components/nostr/kinds/ColorMomentDetailRenderer.tsx b/src/components/nostr/kinds/ColorMomentDetailRenderer.tsx new file mode 100644 index 0000000..5b0b797 --- /dev/null +++ b/src/components/nostr/kinds/ColorMomentDetailRenderer.tsx @@ -0,0 +1,66 @@ +import type { NostrEvent } from "@/types/nostr"; +import { + getColorMomentColors, + getColorMomentLayout, + getColorMomentName, + getColorMomentEmoji, + safeHex, +} from "@/lib/color-moment-helpers"; +import { ColorPaletteDisplay } from "@/components/nostr/ColorPaletteDisplay"; +import { useCopy } from "@/hooks/useCopy"; + +/** + * Kind 3367 Detail Renderer - Color Moment (Detail View) + * Full-size palette with copyable color swatches below + */ +export function ColorMomentDetailRenderer({ event }: { event: NostrEvent }) { + const colors = getColorMomentColors(event); + const layout = getColorMomentLayout(event) || "horizontal"; + const name = getColorMomentName(event); + const emoji = getColorMomentEmoji(event); + const { copy } = useCopy(); + + if (colors.length < 3) { + return ( +
+ Invalid color moment (fewer than 3 colors) +
+ ); + } + + return ( +
+ {name &&

{name}

} + + + +
+ {colors.map((color, i) => { + const hex = safeHex(color); + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/nostr/kinds/ColorMomentRenderer.tsx b/src/components/nostr/kinds/ColorMomentRenderer.tsx new file mode 100644 index 0000000..2ea90b2 --- /dev/null +++ b/src/components/nostr/kinds/ColorMomentRenderer.tsx @@ -0,0 +1,63 @@ +import { + getColorMomentColors, + getColorMomentLayout, + getColorMomentName, + getColorMomentEmoji, +} from "@/lib/color-moment-helpers"; +import { ColorPaletteDisplay } from "@/components/nostr/ColorPaletteDisplay"; +import { + BaseEventProps, + BaseEventContainer, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { useAddWindow } from "@/core/state"; + +/** + * Kind 3367 Renderer - Color Moment (Feed View) + * Shows a color palette with optional emoji overlay and name + */ +export function ColorMomentRenderer({ event }: BaseEventProps) { + const colors = getColorMomentColors(event); + const layout = getColorMomentLayout(event); + const name = getColorMomentName(event); + const emoji = getColorMomentEmoji(event); + const addWindow = useAddWindow(); + + if (colors.length < 3) { + return ( + +
+ Invalid color moment (fewer than 3 colors) +
+
+ ); + } + + const openDetail = () => { + addWindow("open", { pointer: { id: event.id } }); + }; + + return ( + +
+ {name && ( + + {name} + + )} + +
+ +
+
+
+ ); +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index 6b3668c..72465cd 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -195,6 +195,8 @@ import { NsiteNamedDetailRenderer, NsiteLegacyDetailRenderer, } from "./NsiteDetailRenderer"; +import { ColorMomentRenderer } from "./ColorMomentRenderer"; +import { ColorMomentDetailRenderer } from "./ColorMomentDetailRenderer"; /** * Registry of kind-specific renderers @@ -222,6 +224,7 @@ const kindRenderers: Record> = { 1311: LiveChatMessageRenderer, // Live Chat Message (NIP-53) 1244: VoiceMessageRenderer, // Voice Message Reply (NIP-A0) 1337: Kind1337Renderer, // Code Snippet (NIP-C0) + 3367: ColorMomentRenderer, // Color Moment 1617: PatchRenderer, // Patch (NIP-34) 1618: PullRequestRenderer, // Pull Request (NIP-34) 1621: IssueRenderer, // Issue (NIP-34) @@ -354,6 +357,7 @@ const detailRenderers: Record< 777: SpellDetailRenderer, // Spell Detail 1068: PollDetailRenderer, // Poll Detail (NIP-88) 1337: Kind1337DetailRenderer, // Code Snippet Detail (NIP-C0) + 3367: ColorMomentDetailRenderer, // Color Moment Detail 1617: PatchDetailRenderer, // Patch Detail (NIP-34) 1618: PullRequestDetailRenderer, // Pull Request Detail (NIP-34) 1621: IssueDetailRenderer, // Issue Detail (NIP-34) diff --git a/src/constants/kinds.ts b/src/constants/kinds.ts index 4740b10..ed90554 100644 --- a/src/constants/kinds.ts +++ b/src/constants/kinds.ts @@ -71,6 +71,7 @@ import { UserX, Video, Wallet, + Palette, WandSparkles, XCircle, Zap, @@ -614,6 +615,14 @@ export const EVENT_KINDS: Record = { // icon: Coins, // }, + 3367: { + kind: 3367, + name: "Color Moment", + description: "Color Moment", + nip: "", + icon: Palette, + }, + // Community 4550: { kind: 4550, diff --git a/src/index.css b/src/index.css index ed0e975..03f2765 100644 --- a/src/index.css +++ b/src/index.css @@ -399,7 +399,9 @@ body.animating-layout border-radius: 0 !important; } -.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme .mosaic-window * { +.mosaic.mosaic-blueprint-theme.mosaic-blueprint-theme + .mosaic-window + *:not(.color-palette-display) { border-radius: 0 !important; } diff --git a/src/lib/color-moment-helpers.ts b/src/lib/color-moment-helpers.ts new file mode 100644 index 0000000..8adc984 --- /dev/null +++ b/src/lib/color-moment-helpers.ts @@ -0,0 +1,62 @@ +import { getOrComputeCachedValue, getTagValue } from "applesauce-core/helpers"; +import type { NostrEvent } from "nostr-tools"; + +export type LayoutMode = + | "horizontal" + | "vertical" + | "grid" + | "star" + | "checkerboard" + | "diagonalStripes"; + +const HEX_RE = /^#[0-9a-fA-F]{6}$/; + +/** Validate hex color, fallback to black */ +export function safeHex(color: string): string { + return HEX_RE.test(color) ? color : "#000000"; +} + +const ColorMomentColorsSymbol = Symbol("colorMomentColors"); + +/** Extract validated hex colors from `c` tags */ +export function getColorMomentColors(event: NostrEvent): string[] { + return getOrComputeCachedValue(event, ColorMomentColorsSymbol, () => + event.tags + .filter((t) => t[0] === "c" && t[1] && HEX_RE.test(t[1])) + .map((t) => t[1]), + ); +} + +/** Get layout mode from `layout` tag */ +export function getColorMomentLayout( + event: NostrEvent, +): LayoutMode | undefined { + const value = getTagValue(event, "layout"); + if (!value) return undefined; + const valid: LayoutMode[] = [ + "horizontal", + "vertical", + "grid", + "star", + "checkerboard", + "diagonalStripes", + ]; + return valid.includes(value as LayoutMode) + ? (value as LayoutMode) + : undefined; +} + +/** Get optional name from `name` tag */ +export function getColorMomentName(event: NostrEvent): string | undefined { + return getTagValue(event, "name") || undefined; +} + +/** Get single emoji from content (if present) */ +export function getColorMomentEmoji(event: NostrEvent): string | undefined { + const content = event.content?.trim(); + if (!content) return undefined; + // Match a single emoji (including compound emoji with ZWJ, skin tones, etc.) + const emojiRegex = + /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)(\u200D(\p{Emoji_Presentation}|\p{Emoji}\uFE0F))*$/u; + return emojiRegex.test(content) ? content : undefined; +}