From c734e33cf2ab19c2ee134bb22de90fee25ca795d Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Wed, 2 Sep 2026 22:19:13 -0700 Subject: [PATCH] branding: apply the uploaded logo everywhere, no unbranded flash branding: apply the uploaded logo everywhere and stop the unbranded flash The nav lockup, sign-in wall and brand menu always drew the generated default mark; LogoMark now renders the node's mainLogo branding asset (inline for SVG uploads, an Image otherwise) and the menu copies that SVG. Server side, unparameterised branding reads keyed on the bare broadcaster host while every write used did:web:, so getBranding without a broadcaster parameter (and the linker before it) missed the uploaded assets. Reads now normalise to the DID and fall back to the legacy key. A private node served strangers the raw index.html, skipping the internal-brand meta the client paints from before its fetch lands; they now get the default card, which carries the branding but no stream or profile data. The client also hydrates from its branding cache before refetching, and the page paints the branded background before any script runs. Four chrome color keys (background/foreground per scheme) are registered for the theme work that follows. Claude-Session: https://claude.ai/code/session_014aPQ5yqG9QFxKnbwQfCYKa (cherry picked from commit 204002da18f2b931a6282fa1e62228ab832e8e38) --- .../components/brand/logo-brand-menu.web.tsx | 27 ++++++--- js/app/components/brand/logo.tsx | 55 ++++++++++++++++++- .../src/streamplace-store/branding.tsx | 12 +++- pkg/linking/linking.go | 34 ++++++++++++ pkg/spxrpc/place_stream_branding.go | 51 +++++++++++++++-- 5 files changed, 161 insertions(+), 18 deletions(-) diff --git a/js/app/components/brand/logo-brand-menu.web.tsx b/js/app/components/brand/logo-brand-menu.web.tsx index edec79cbb..195213eb8 100644 --- a/js/app/components/brand/logo-brand-menu.web.tsx +++ b/js/app/components/brand/logo-brand-menu.web.tsx @@ -4,7 +4,12 @@ import { Type } from "lucide-react-native"; import { type ReactNode, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Pressable, View } from "react-native"; -import { LogoMark, markSvgString, wordmarkSvgString } from "./logo"; +import { + LogoMark, + markSvgString, + useCustomMark, + wordmarkSvgString, +} from "./logo"; /** * Vercel-style right-click menu on the logo: copy the mark or wordmark as SVG, @@ -55,13 +60,21 @@ export function LogoBrandMenu({ children }: { children: ReactNode }) { } }; + // A node's uploaded SVG logo is what gets copied; a raster upload has no + // SVG to offer, so the item is dropped rather than copying the default. + const custom = useCustomMark(); + const logoSvg = custom.svg ?? (custom.uri ? null : markSvgString()); const items = [ - { - key: "logo", - label: "Copy Logo as SVG", - icon: , - onPress: () => copy(markSvgString(), "Logo"), - }, + ...(logoSvg + ? [ + { + key: "logo", + label: "Copy Logo as SVG", + icon: , + onPress: () => copy(logoSvg, "Logo"), + }, + ] + : []), { key: "wordmark", label: "Copy Wordmark as SVG", diff --git a/js/app/components/brand/logo.tsx b/js/app/components/brand/logo.tsx index e5612cb82..e6486e67d 100644 --- a/js/app/components/brand/logo.tsx +++ b/js/app/components/brand/logo.tsx @@ -1,7 +1,7 @@ import { Text, useBrandingAsset, useTheme } from "@streamplace/components"; import { fontFamilies } from "@streamplace/components/src/lib/theme/tokens"; -import { Fragment } from "react"; -import { View, type ViewProps } from "react-native"; +import { Fragment, useMemo } from "react"; +import { Image, View, type ViewProps } from "react-native"; import { SvgXml } from "react-native-svg"; import { BRAND } from "../../assets/generated/brand"; @@ -30,6 +30,41 @@ export function wordmarkSvgString(color = BRAND.colors.ink) { return tinted(BRAND.wordmarkSvg, color); } +// Decode the payload of a base64 data: URL as UTF-8 text. +function decodeDataUrlText(dataUrl: string): string | null { + const comma = dataUrl.indexOf(","); + if (comma < 0) return null; + const header = dataUrl.slice(0, comma); + const payload = dataUrl.slice(comma + 1); + try { + if (!/;base64$/i.test(header)) return decodeURIComponent(payload); + const bin = atob(payload); + const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } catch { + return null; + } +} + +/** + * The node's uploaded mainLogo branding asset, if any: an inline SVG when + * the upload was SVG (so it scales and can be copied as SVG), else the data + * URL for an . Empty when the node has no custom logo. + */ +export function useCustomMark(): { svg?: string; uri?: string } { + const asset = useBrandingAsset("mainLogo"); + const data = asset?.data; + const mime = asset?.mimeType; + return useMemo(() => { + if (!data || !data.startsWith("data:")) return {}; + if ((mime ?? "").includes("svg") || data.startsWith("data:image/svg")) { + const svg = decodeDataUrlText(data); + if (svg && svg.includes("; + } + if (custom.uri) { + return ( + + ); + } // Monochrome brands default the mark to the ink/paper text color so it // matches the wordmark exactly; pass `color` explicitly for the rare // colored variant. diff --git a/js/components/src/streamplace-store/branding.tsx b/js/components/src/streamplace-store/branding.tsx index b50b9bd9d..fe4a4a606 100644 --- a/js/components/src/streamplace-store/branding.tsx +++ b/js/components/src/streamplace-store/branding.tsx @@ -147,11 +147,11 @@ export function useFetchBranding() { // check localStorage first const cacheKey = `branding:${broadcasterDID}`; const cached = await storage.getItem(cacheKey); - if (!force && cached) { + if (cached) { try { const parsed = JSON.parse(cached); - // check if cache is less than 1 hour old - if (Date.now() - parsed.timestamp < 60 * 60 * 1000) { + const fresh = Date.now() - parsed.timestamp < 60 * 60 * 1000; + if (!force && fresh) { store.setState({ branding: parsed.data, brandingLoading: false, @@ -159,6 +159,12 @@ export function useFetchBranding() { }); return; } + // Paint what we had last time right away, then refresh: the + // alternative is a flash of default branding on every cold start + // (no server-injected meta on native or behind the dev proxy). + if (parsed.data && !store.getState().branding) { + store.setState({ branding: parsed.data }); + } } catch (e) { // invalid cache, continue to fetch console.warn("Invalid branding cache, refetching", e); diff --git a/pkg/linking/linking.go b/pkg/linking/linking.go index 7b67ee9a6..204b8c998 100644 --- a/pkg/linking/linking.go +++ b/pkg/linking/linking.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/url" + "regexp" "strings" "stream.place/streamplace/pkg/log" @@ -56,6 +57,32 @@ var BrandingAssetList = [...]string{ "favicon", "sidebarBg", "legalLinks", + "backgroundColor", + "foregroundColor", + "backgroundColorLight", + "foregroundColorLight", +} + +// hexColor accepts #rgb / #rrggbb / #rrggbbaa, the only forms the app's +// theme accepts, so a stored value can be dropped straight into a style. +var hexColor = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`) + +// bodyBackground returns the node's branded dark background color, if any, +// so the page can paint it before the bundle loads instead of the default +// then re-painting (the "flash of unbranded content"). +func (l *Linker) bodyBackground() string { + if l.sdb == nil || l.cli == nil { + return "" + } + blob, err := l.sdb.GetBrandingBlob("did:web:"+l.cli.BroadcasterHost, "backgroundColor") + if err != nil || blob == nil { + return "" + } + v := strings.TrimSpace(string(blob.Data)) + if !hexColor.MatchString(v) { + return "" + } + return v } // atTags returns meta tags implementing the at-tags proposal @@ -453,6 +480,13 @@ func (l *Linker) GenerateHTML(ctx context.Context, pc *PageConfig) ([]byte, erro }) } + // Paint the branded background before any script runs. + if bg := l.bodyBackground(); bg != "" { + style := &html.Node{Type: html.ElementNode, Data: "style"} + head.AppendChild(style) + style.AppendChild(&html.Node{Type: html.TextNode, Data: "body{background-color:" + bg + "}"}) + } + // Add Sentry DSN script if configured if pc.SentryDSN != "" { script := &html.Node{ diff --git a/pkg/spxrpc/place_stream_branding.go b/pkg/spxrpc/place_stream_branding.go index 14fa51c45..405d0d47c 100644 --- a/pkg/spxrpc/place_stream_branding.go +++ b/pkg/spxrpc/place_stream_branding.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "strings" "github.com/labstack/echo/v4" "github.com/streamplace/oatproxy/pkg/oatproxy" @@ -28,20 +29,51 @@ var defaultBrandingAssets = map[string]struct { "primaryColor": {data: []byte("#6366f1"), mime: "text/plain"}, "accentColor": {data: []byte("#8b5cf6"), mime: "text/plain"}, "defaultStreamer": {data: []byte(""), mime: "text/plain"}, + // Chrome colors: the app derives its surface, text and border ramps from + // one background + one foreground per color scheme (see + // js/components/src/lib/theme/chrome.ts). Empty means the app's defaults. + "backgroundColor": {data: []byte(""), mime: "text/plain"}, + "foregroundColor": {data: []byte(""), mime: "text/plain"}, + "backgroundColorLight": {data: []byte(""), mime: "text/plain"}, + "foregroundColorLight": {data: []byte(""), mime: "text/plain"}, } -func (s *Server) getBroadcasterID(ctx context.Context, broadcasterDID string) string { - // if broadcaster param provided, use it; otherwise use server's default - if broadcasterDID != "" { - return broadcasterDID +// brandingTextKeys are the small text-valued assets (1KB cap). +var brandingTextKeys = map[string]bool{ + "siteTitle": true, "siteDescription": true, "primaryColor": true, "accentColor": true, + "defaultStreamer": true, "backgroundColor": true, "foregroundColor": true, + "backgroundColorLight": true, "foregroundColorLight": true, +} + +// NormalizeBroadcasterID turns the optional `broadcaster` parameter into the +// key branding is stored under. Assets are written under the broadcaster's +// DID (did:web:), which is what clients send; an empty parameter means +// this node's own broadcaster, and a bare host is upgraded to its did:web. +func NormalizeBroadcasterID(param, defaultHost string) string { + param = strings.TrimSpace(param) + if param == "" { + return "did:web:" + defaultHost } - return s.cli.BroadcasterHost + if strings.HasPrefix(param, "did:") { + return param + } + return "did:web:" + param +} + +func (s *Server) getBroadcasterID(ctx context.Context, broadcasterDID string) string { + return NormalizeBroadcasterID(broadcasterDID, s.cli.BroadcasterHost) } func (s *Server) GetBrandingBlob(ctx context.Context, broadcasterID, key string) ([]byte, string, *int, *int, error) { // cache miss - fetch from db blob, err := s.statefulDB.GetBrandingBlob(broadcasterID, key) if err == gorm.ErrRecordNotFound { + // Older nodes stored unparameterised writes under the bare host. + if host, ok := strings.CutPrefix(broadcasterID, "did:web:"); ok { + if legacy, lerr := s.statefulDB.GetBrandingBlob(host, key); lerr == nil { + return legacy.Data, legacy.MimeType, legacy.Width, legacy.Height, nil + } + } // not in db, use default if def, ok := defaultBrandingAssets[key]; ok { return def.data, def.mime, nil, nil, nil @@ -81,6 +113,13 @@ func (s *Server) HandlePlaceStreamBrandingGetBrandingDirect(ctx context.Context, if err != nil { return nil, fmt.Errorf("error listing branding keys: %w", err) } + if host, ok := strings.CutPrefix(broadcasterID, "did:web:"); ok { + legacyKeys, err := s.statefulDB.ListBrandingKeys(host) + if err != nil { + return nil, fmt.Errorf("error listing legacy branding keys: %w", err) + } + dbKeys = append(dbKeys, legacyKeys...) + } // build key set including defaults allKeys := make(map[string]bool) @@ -169,7 +208,7 @@ func (s *Server) handlePlaceStreamBrandingUpdateBlob(ctx context.Context, input maxSize := 500 * 1024 // 500KB default for logos if input.Key == "favicon" { maxSize = 100 * 1024 // 100KB for favicons - } else if input.Key == "siteTitle" || input.Key == "siteDescription" || input.Key == "primaryColor" || input.Key == "accentColor" || input.Key == "defaultStreamer" { + } else if brandingTextKeys[input.Key] { maxSize = 1024 // 1KB for text values } // sidebarBackgroundImage uses default 500KB limit -- 2.51.2