diff --git a/js/app/components/provider/provider.shared.tsx b/js/app/components/provider/provider.shared.tsx index e8ad876d8..2b75446f5 100644 --- a/js/app/components/provider/provider.shared.tsx +++ b/js/app/components/provider/provider.shared.tsx @@ -5,6 +5,7 @@ import { } from "@react-navigation/native"; import * as Sentry from "@sentry/react-native"; import { + BrandedThemeProvider, I18nProvider, ThemeProvider, StreamplaceProvider as ZustandStreamplaceProvider, @@ -85,19 +86,21 @@ function ProviderInner({ return ( - - - - - - - {children} - - - - - - + + + + + + + + {children} + + + + + + + ); } diff --git a/js/app/components/sidebar/sidebar.tsx b/js/app/components/sidebar/sidebar.tsx index 253c2150b..3e40d5a12 100644 --- a/js/app/components/sidebar/sidebar.tsx +++ b/js/app/components/sidebar/sidebar.tsx @@ -6,7 +6,7 @@ import { ParamListBase, useNavigation, } from "@react-navigation/native"; -import { Text, zero } from "@streamplace/components"; +import { Text, useMainLogo, useSiteTitle, zero } from "@streamplace/components"; import { useAQLinkHref } from "components/aqlink"; import React from "react"; import { Image, Platform, Pressable, View } from "react-native"; @@ -46,6 +46,9 @@ export default function Sidebar({ externalItems = [], }: SidebarProps) { const navigation = useNavigation(); + const siteTitle = useSiteTitle(); + const mainLogo = useMainLogo(); + const animatedSidebarStyle = useAnimatedStyle(() => { return { minWidth: widthAnim.value, @@ -87,13 +90,22 @@ export default function Sidebar({ }, ]} > - - {!collapsed && Streamplace} + {mainLogo ? ( + + ) : ( + + )} + {!collapsed && {siteTitle}} {state.routes.map((route) => { diff --git a/js/components/src/hooks/index.ts b/js/components/src/hooks/index.ts index 1f590b942..9d5fedc2f 100644 --- a/js/components/src/hooks/index.ts +++ b/js/components/src/hooks/index.ts @@ -1,6 +1,7 @@ // barrel file :) export * from "./useAvatars"; export * from "./useCameraToggle"; +export * from "./useDocumentTitle"; export * from "./useKeyboard"; export * from "./useKeyboardSlide"; export * from "./useLivestreamInfo"; diff --git a/js/components/src/hooks/useDocumentTitle.tsx b/js/components/src/hooks/useDocumentTitle.tsx new file mode 100644 index 000000000..9669517de --- /dev/null +++ b/js/components/src/hooks/useDocumentTitle.tsx @@ -0,0 +1,17 @@ +import { useEffect } from "react"; +import { Platform } from "react-native"; +import { useSiteTitle } from "../streamplace-store"; + +/** + * Hook to set the document title on web based on branding. + * No-op on native platforms. + */ +export function useDocumentTitle() { + const siteTitle = useSiteTitle(); + + useEffect(() => { + if (Platform.OS === "web" && typeof document !== "undefined") { + document.title = siteTitle; + } + }, [siteTitle]); +} diff --git a/js/components/src/lib/theme/branded-theme-provider.tsx b/js/components/src/lib/theme/branded-theme-provider.tsx new file mode 100644 index 000000000..20cc9d110 --- /dev/null +++ b/js/components/src/lib/theme/branded-theme-provider.tsx @@ -0,0 +1,58 @@ +import { useMemo, type ReactNode } from "react"; +import { + useAccentColor, + usePrimaryColor, + useStreamplaceStore, +} from "../../streamplace-store"; +import { ThemeProvider, type Theme } from "./theme"; + +interface BrandedThemeProviderProps { + children: ReactNode; + defaultTheme?: "light" | "dark" | "system"; + forcedTheme?: "light" | "dark"; +} + +/** + * ThemeProvider wrapper that automatically applies branding colors from the + * broadcaster's branding configuration. + */ +export function BrandedThemeProvider({ + children, + defaultTheme, + forcedTheme, +}: BrandedThemeProviderProps) { + const primaryColor = usePrimaryColor(); + const accentColor = useAccentColor(); + const brandingLoading = useStreamplaceStore((state) => state.brandingLoading); + + // Build color theme overrides from branding + const colorTheme = useMemo>(() => { + // don't override until branding is loaded + if (brandingLoading) { + return {}; + } + + const overrides: Partial = {}; + + if (primaryColor) { + overrides.primary = primaryColor; + overrides.ring = primaryColor; + } + + if (accentColor) { + overrides.accent = accentColor; + } + + return overrides; + }, [primaryColor, accentColor, brandingLoading]); + + return ( + + {children} + + ); +} diff --git a/js/components/src/lib/theme/index.ts b/js/components/src/lib/theme/index.ts index dd3c86ff9..c42abad4d 100644 --- a/js/components/src/lib/theme/index.ts +++ b/js/components/src/lib/theme/index.ts @@ -12,6 +12,9 @@ export { type ThemeIcons, } from "./theme"; +// Branded theme provider +export { BrandedThemeProvider } from "./branded-theme-provider"; + // Design tokens export { animations, diff --git a/js/components/src/streamplace-provider/index.tsx b/js/components/src/streamplace-provider/index.tsx index 40d9c34a2..671b425ad 100644 --- a/js/components/src/streamplace-provider/index.tsx +++ b/js/components/src/streamplace-provider/index.tsx @@ -1,5 +1,6 @@ import { SessionManager } from "@atproto/api/dist/session-manager"; import { useEffect, useRef } from "react"; +import { useDocumentTitle } from "../hooks"; import { useBrandingAutoFetch, useFetchBroadcasterDID, @@ -43,6 +44,7 @@ export function StreamplaceProvider({ export function BrandingFetcher({ children }: { children: React.ReactNode }) { const fetchBroadcasterDID = useFetchBroadcasterDID(); useBrandingAutoFetch(); + useDocumentTitle(); useEffect(() => { fetchBroadcasterDID();