diff --git a/js/app/components/brand/logo.tsx b/js/app/components/brand/logo.tsx
index e6486e67d..0d8a2059e 100644
--- a/js/app/components/brand/logo.tsx
+++ b/js/app/components/brand/logo.tsx
@@ -31,7 +31,7 @@ export function wordmarkSvgString(color = BRAND.colors.ink) {
}
// Decode the payload of a base64 data: URL as UTF-8 text.
-function decodeDataUrlText(dataUrl: string): string | null {
+export function decodeDataUrlText(dataUrl: string): string | null {
const comma = dataUrl.indexOf(",");
if (comma < 0) return null;
const header = dataUrl.slice(0, comma);
diff --git a/js/app/components/settings/branding-admin.tsx b/js/app/components/settings/branding-admin.tsx
index 383b98742..857357e95 100644
--- a/js/app/components/settings/branding-admin.tsx
+++ b/js/app/components/settings/branding-admin.tsx
@@ -39,6 +39,14 @@ const NAV_LINKS_EXAMPLE =
'[{"label": "Home", "url": "https://example.com", "icon": "home"}, {"label": "Live", "url": "/", "icon": "play"}]';
const NAV_CTA_EXAMPLE =
'{"label": "New post", "url": "https://example.com/compose"}';
+const SOCIAL_LINKS_EXAMPLE =
+ '[{"label": "Bluesky", "url": "https://bsky.app/profile/example.com", "icon": "bluesky"}, {"label": "Forum", "url": "https://example.com/forum", "icon": "socialIcon1"}]';
+const SOCIAL_ICON_SLOTS = [
+ "socialIcon1",
+ "socialIcon2",
+ "socialIcon3",
+ "socialIcon4",
+];
export function BrandingAdmin() {
const { t } = useTranslation("settings");
@@ -266,6 +274,8 @@ export function BrandingAdmin() {
case "liveColor":
case "navLinks":
case "navCta":
+ case "socialHeading":
+ case "socialLinks":
setChromeInputs((prev) => ({ ...prev, [key]: "" }));
break;
case "defaultStreamer":
@@ -1215,6 +1225,186 @@ export function BrandingAdmin() {
+
+
{t("branding-theme")}
diff --git a/js/app/components/sidebar/sidebar-overlay.tsx b/js/app/components/sidebar/sidebar-overlay.tsx
index 3d2e9f363..8ebca6a47 100644
--- a/js/app/components/sidebar/sidebar-overlay.tsx
+++ b/js/app/components/sidebar/sidebar-overlay.tsx
@@ -16,7 +16,7 @@ import {
import { BlueskyIcon } from "@streamplace/components/src/components/icons/bluesky-icon";
import { DiscordIcon } from "@streamplace/components/src/components/icons/discord-icon";
import { colors, spacing } from "@streamplace/components/src/lib/theme/tokens";
-import { SiteTitleLockup } from "components/brand/logo";
+import { decodeDataUrlText, SiteTitleLockup } from "components/brand/logo";
import { LogoBrandMenu } from "components/brand/logo-brand-menu";
import { Image } from "expo-image";
import usePlatform from "hooks/usePlatform";
@@ -30,6 +30,7 @@ import {
Hash,
Home,
Library,
+ Link as LinkIcon,
List,
LogIn,
Menu,
@@ -41,9 +42,10 @@ import {
UserCircle,
Video,
} from "lucide-react-native";
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
import { Linking, Platform, Pressable, View } from "react-native";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
+import { SvgXml } from "react-native-svg";
import {
getStreamplaceStateFromPath,
streamplaceLinkingOptions,
@@ -116,6 +118,52 @@ export function parseNavCta(
return null;
}
+/**
+ * Social links (branding keys socialHeading / socialLinks / socialIcon1-4):
+ * the "Say Hello?" row at the bottom of the sidebar. socialLinks is a JSON
+ * array of { label, url, icon } where icon is one of SOCIAL_ICONS, one of
+ * NAV_ICONS, or socialIcon1..socialIcon4 for an uploaded image. Unset shows
+ * the Streamplace defaults; an empty array hides the row.
+ */
+export const SOCIAL_ICONS: Record> = {
+ bluesky: BlueskyIcon,
+ discord: DiscordIcon,
+};
+
+export const SOCIAL_ICON_SLOTS = [
+ "socialIcon1",
+ "socialIcon2",
+ "socialIcon3",
+ "socialIcon4",
+];
+
+export const DEFAULT_SOCIAL_LINKS: BrandedNavLink[] = [
+ {
+ label: "Bluesky",
+ url: "https://bsky.app/profile/stream.place",
+ icon: "bluesky",
+ },
+ { label: "Discord", url: "https://discord.stream.place", icon: "discord" },
+];
+
+export function parseSocialLinks(
+ raw: string | undefined,
+): BrandedNavLink[] | null {
+ if (!raw) return null;
+ try {
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return null;
+ return parsed
+ .filter(
+ (l: any) =>
+ l && typeof l.label === "string" && typeof l.url === "string",
+ )
+ .map((l: any) => ({ label: l.label, url: l.url, icon: l.icon }));
+ } catch {
+ return null;
+ }
+}
+
const isExternal = (url: string) => /^[a-z]+:/i.test(url);
/**
@@ -161,18 +209,64 @@ export function SidebarToggle({
);
}
+// An uploaded SVG icon is tinted like the built-in ones when it is authored
+// with currentColor; anything else (multi-color SVG, raster) renders as
+// drawn.
+function UploadedSocialIcon({
+ slot,
+ size,
+ color,
+}: {
+ slot: string;
+ size: number;
+ color: string;
+}) {
+ const asset = useBrandingAsset(slot);
+ const data = asset?.data;
+ const mime = asset?.mimeType ?? "";
+ const svg = useMemo(() => {
+ if (!data || !data.startsWith("data:")) return null;
+ if (!mime.includes("svg") && !data.startsWith("data:image/svg")) {
+ return null;
+ }
+ const text = decodeDataUrlText(data);
+ return text && text.includes("
+ );
+ }
+ if (data) {
+ return (
+
+ );
+ }
+ return ;
+}
+
function SocialIconButton({
icon,
label,
href,
}: {
- icon: React.ComponentType;
+ icon?: string;
label: string;
href: string;
}) {
const { theme } = useTheme();
const [hover, setHover] = useState(false);
- const Icon = icon;
+ const color = hover ? theme.colors.text1 : theme.colors.text2;
+ const slot = icon && SOCIAL_ICON_SLOTS.includes(icon) ? icon : null;
+ const Icon = SOCIAL_ICONS[icon ?? ""] ?? NAV_ICONS[icon ?? ""] ?? LinkIcon;
return (
{
@@ -197,10 +291,11 @@ function SocialIconButton({
},
]}
>
-
+ {slot ? (
+
+ ) : (
+
+ )}
);
@@ -305,15 +400,24 @@ export function SidebarOverlay() {
};
});
+ // Branded navigation replaces the browse/creator sections when present;
+ // the social row is branded the same way. Hooks stay above the early
+ // return below so the count is stable when the sidebar deactivates on
+ // resize.
+ const brandedLinks = parseNavLinks(useBrandingAsset("navLinks")?.data);
+ const brandedCta = parseNavCta(useBrandingAsset("navCta")?.data);
+ const socialLinks =
+ parseSocialLinks(useBrandingAsset("socialLinks")?.data) ??
+ DEFAULT_SOCIAL_LINKS;
+ const socialHeading =
+ useBrandingAsset("socialHeading")?.data?.trim() || "Say Hello?";
+
// Don't render if sidebar is not active (small screen) or hidden
if (!sidebar.isActive || sidebar.isHidden) {
return null;
}
// Browse destinations — public, content-first, YouTube-style
- // Branded navigation replaces the browse/creator sections when present.
- const brandedLinks = parseNavLinks(useBrandingAsset("navLinks")?.data);
- const brandedCta = parseNavCta(useBrandingAsset("navCta")?.data);
const brandedItems: SidebarNavItem[] = brandedLinks.map((l) => ({
icon: NAV_ICONS[l.icon ?? ""] ?? Hash,
label: l.label,
@@ -613,44 +717,37 @@ export function SidebarOverlay() {
Linking.openURL(u.toString());
}}
/>
- {renderSectionHeader("Say Hello?")}
- {collapsed ? (
-
-
-
-
- ) : (
-
-
-
-
+ {socialLinks.length > 0 && (
+ <>
+ {renderSectionHeader(socialHeading)}
+
+ {socialLinks.map((link) => (
+
+ ))}
+
+ >
)}
)}
diff --git a/js/docs/src/content/docs/guides/installing/branding-bundles.md b/js/docs/src/content/docs/guides/installing/branding-bundles.md
index f161929fb..20d67d022 100644
--- a/js/docs/src/content/docs/guides/installing/branding-bundles.md
+++ b/js/docs/src/content/docs/guides/installing/branding-bundles.md
@@ -25,7 +25,15 @@ branding: # only what is set on this node
primaryColor: "#11e8b2"
navLinks: # JSON-valued keys are plain YAML
- { label: Home, url: https://example.com, icon: home }
+ socialLinks:
+ - {
+ label: Bluesky,
+ url: https://bsky.app/profile/example.com,
+ icon: bluesky,
+ }
+ - { label: Forum, url: https://example.com/forum, icon: socialIcon1 }
mainLogo: mainLogo.svg # image keys name a file in the zip
+ socialIcon1: socialIcon1.svg
linkBanner: linkBanner.png
defaults: # informational: the app's default for each key
primaryColor: "#6366f1"
diff --git a/js/i18n/locales/en-US/settings.ftl b/js/i18n/locales/en-US/settings.ftl
index 44d9bf80d..1d2d9f739 100644
--- a/js/i18n/locales/en-US/settings.ftl
+++ b/js/i18n/locales/en-US/settings.ftl
@@ -235,6 +235,13 @@ branding-nav-links = Navigation links
branding-nav-links-description = Replace the sidebar's browse and creator sections with your own links: a JSON list of objects with label, url and an optional icon (home, search, play, live, bell, message, hash, list, bookmark, user, settings, video, book). Paths starting with / navigate inside the app; other URLs open as links.
branding-nav-cta = Navigation button
branding-nav-cta-description = A highlighted button under the navigation links: JSON with label and url.
+branding-social-links = Social links
+branding-social-links-description = The row of icon links at the bottom of the sidebar. The first field is its heading; the second is a JSON list of objects with label, url and icon, where icon is bluesky, discord, one of the navigation icon names, or socialIcon1 to socialIcon4 for an uploaded icon. Unset shows the Streamplace links; Hide removes the row.
+branding-social-heading-placeholder = Say Hello?
+branding-social-hide = Hide
+branding-upload = Upload
+branding-remove = Remove
+branding-social-icons-description = Uploaded icons, referenced from the list above by slot name. SVG preferred, 24 x 24, max 64KB; an SVG drawn with currentColor takes the sidebar's icon color.
branding-theme = Theme
branding-theme-description = Page background and text color, one pair per color scheme; the app derives its raised surfaces, muted text and hairline borders from them. Hex colors (#rrggbb). Accent and status colors are separate settings above. Leave a value unset to use the default, or set only the background and the text color is chosen to read on it.
branding-background-dark = Page background (dark mode)
diff --git a/js/i18n/public/locales/en-US/settings.json b/js/i18n/public/locales/en-US/settings.json
index 9b64e1542..43abbbe97 100644
--- a/js/i18n/public/locales/en-US/settings.json
+++ b/js/i18n/public/locales/en-US/settings.json
@@ -177,6 +177,13 @@
"branding-nav-links-description": "Replace the sidebar's browse and creator sections with your own links: a JSON list of objects with label, url and an optional icon (home, search, play, live, bell, message, hash, list, bookmark, user, settings, video, book). Paths starting with / navigate inside the app; other URLs open as links.",
"branding-nav-cta": "Navigation button",
"branding-nav-cta-description": "A highlighted button under the navigation links: JSON with label and url.",
+ "branding-social-links": "Social links",
+ "branding-social-links-description": "The row of icon links at the bottom of the sidebar. The first field is its heading; the second is a JSON list of objects with label, url and icon, where icon is bluesky, discord, one of the navigation icon names, or socialIcon1 to socialIcon4 for an uploaded icon. Unset shows the Streamplace links; Hide removes the row.",
+ "branding-social-heading-placeholder": "Say Hello?",
+ "branding-social-hide": "Hide",
+ "branding-upload": "Upload",
+ "branding-remove": "Remove",
+ "branding-social-icons-description": "Uploaded icons, referenced from the list above by slot name. SVG preferred, 24 x 24, max 64KB; an SVG drawn with currentColor takes the sidebar's icon color.",
"branding-theme": "Theme",
"branding-theme-description": "Page background and text color, one pair per color scheme; the app derives its raised surfaces, muted text and hairline borders from them. Hex colors (#rrggbb). Accent and status colors are separate settings above. Leave a value unset to use the default, or set only the background and the text color is chosen to read on it.",
"branding-background-dark": "Page background (dark mode)",
diff --git a/js/web/public/locales/en-US/settings.json b/js/web/public/locales/en-US/settings.json
index 9b64e1542..43abbbe97 100644
--- a/js/web/public/locales/en-US/settings.json
+++ b/js/web/public/locales/en-US/settings.json
@@ -177,6 +177,13 @@
"branding-nav-links-description": "Replace the sidebar's browse and creator sections with your own links: a JSON list of objects with label, url and an optional icon (home, search, play, live, bell, message, hash, list, bookmark, user, settings, video, book). Paths starting with / navigate inside the app; other URLs open as links.",
"branding-nav-cta": "Navigation button",
"branding-nav-cta-description": "A highlighted button under the navigation links: JSON with label and url.",
+ "branding-social-links": "Social links",
+ "branding-social-links-description": "The row of icon links at the bottom of the sidebar. The first field is its heading; the second is a JSON list of objects with label, url and icon, where icon is bluesky, discord, one of the navigation icon names, or socialIcon1 to socialIcon4 for an uploaded icon. Unset shows the Streamplace links; Hide removes the row.",
+ "branding-social-heading-placeholder": "Say Hello?",
+ "branding-social-hide": "Hide",
+ "branding-upload": "Upload",
+ "branding-remove": "Remove",
+ "branding-social-icons-description": "Uploaded icons, referenced from the list above by slot name. SVG preferred, 24 x 24, max 64KB; an SVG drawn with currentColor takes the sidebar's icon color.",
"branding-theme": "Theme",
"branding-theme-description": "Page background and text color, one pair per color scheme; the app derives its raised surfaces, muted text and hairline borders from them. Hex colors (#rrggbb). Accent and status colors are separate settings above. Leave a value unset to use the default, or set only the background and the text color is chosen to read on it.",
"branding-background-dark": "Page background (dark mode)",
diff --git a/pkg/branding/vocab.go b/pkg/branding/vocab.go
index 6b04750fa..07015a26e 100644
--- a/pkg/branding/vocab.go
+++ b/pkg/branding/vocab.go
@@ -46,6 +46,7 @@ const (
textMax = 1024
jsonMax = 16 * 1024
imageMax = 500 * 1024
+ iconMax = 64 * 1024
)
// Specs lists every known key in export order.
@@ -72,10 +73,16 @@ var Specs = []Spec{
{Key: "navLinks", Kind: KindJSON, MaxSize: jsonMax, Doc: "Navigation links replacing the sidebar sections: [{label, url, icon}]."},
{Key: "navCta", Kind: KindJSON, MaxSize: jsonMax, Doc: "Highlighted button under the navigation links: {label, url}."},
{Key: "legalLinks", Kind: KindJSON, MaxSize: jsonMax, Doc: "Footer legal links: [{text, url}]."},
+ {Key: "socialHeading", Kind: KindText, Default: "Say Hello?", MaxSize: textMax, Doc: "Heading above the social links at the bottom of the sidebar."},
+ {Key: "socialLinks", Kind: KindJSON, MaxSize: jsonMax, Doc: "Social links at the bottom of the sidebar: [{label, url, icon}], icon a built-in name (bluesky, discord, ...) or socialIcon1..4; [] hides them."},
{Key: "mainLogo", Kind: KindImage, MaxSize: imageMax, Doc: "The logo mark (SVG preferred)."},
{Key: "favicon", Kind: KindImage, MaxSize: 100 * 1024, Doc: "Browser tab icon."},
{Key: "sidebarBackgroundImage", Kind: KindImage, MaxSize: imageMax, Doc: "Decorative image at the bottom of the sidebar."},
{Key: "linkBanner", Kind: KindImage, MaxSize: 2 * 1024 * 1024, Doc: "OpenGraph image for the front page's link card (1200x630)."},
+ {Key: "socialIcon1", Kind: KindImage, MaxSize: iconMax, Doc: "Custom icon for socialLinks (SVG preferred; use currentColor to take the sidebar's icon color)."},
+ {Key: "socialIcon2", Kind: KindImage, MaxSize: iconMax, Doc: "Custom icon for socialLinks (SVG preferred; use currentColor to take the sidebar's icon color)."},
+ {Key: "socialIcon3", Kind: KindImage, MaxSize: iconMax, Doc: "Custom icon for socialLinks (SVG preferred; use currentColor to take the sidebar's icon color)."},
+ {Key: "socialIcon4", Kind: KindImage, MaxSize: iconMax, Doc: "Custom icon for socialLinks (SVG preferred; use currentColor to take the sidebar's icon color)."},
}
var specByKey = func() map[string]Spec {