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