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