diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..8577dd55 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "web", + "runtimeExecutable": "bash", + "runtimeArgs": [ + "-c", + "cd js/app && npx expo start --web --port 38081 -c" + ], + "port": 38081 + } + ] +} \ No newline at end of file diff --git a/js/app/components/emoji-picker/emoji-picker.web.tsx b/js/app/components/emoji-picker/emoji-picker.web.tsx index 2c6bf014..90f7e319 100644 --- a/js/app/components/emoji-picker/emoji-picker.web.tsx +++ b/js/app/components/emoji-picker/emoji-picker.web.tsx @@ -1,4 +1,5 @@ import { hexToRgba, Text, useAQState, useTheme } from "@streamplace/components"; +import { motion, scrims } from "@streamplace/components/src/lib/theme/tokens"; import { EmojiPicker as FrimousseEmojiPicker, SkinTone, @@ -62,6 +63,7 @@ interface SkinTonePickerOpen { } export function SkinToneTray({ open, setOpen }: SkinTonePickerOpen) { + const { theme } = useTheme(); const [aqSkinTone, setAQSkinTone] = useAQState( "emoji-picker-tone", "none" as SkinTone, @@ -83,11 +85,11 @@ export function SkinToneTray({ open, setOpen }: SkinTonePickerOpen) { overflow: "hidden", maxHeight: open ? 48 : 0, opacity: open ? 1 : 0, - transition: "max-height 0.2s ease, opacity 0.15s ease", + transition: `max-height ${motion.base}ms ease, opacity ${motion.fast}ms ease`, display: "flex", gap: 4, padding: open ? "6px 8px" : "0 8px", - borderTop: open ? "1px solid rgba(255,255,255,0.07)" : "none", + borderTop: open ? `1px solid ${theme.colors.borderSubtle}` : "none", }} > {skinToneVariations.map((variation) => ( @@ -100,11 +102,11 @@ export function SkinToneTray({ open, setOpen }: SkinTonePickerOpen) { borderRadius: 6, border: skinTone === variation.skinTone - ? "1px solid rgba(255,255,255,0.35)" - : "1px solid rgba(255,255,255,0.08)", + ? `1px solid ${theme.colors.ring}` + : `1px solid ${theme.colors.border}`, background: skinTone === variation.skinTone - ? "rgba(255,255,255,0.15)" + ? theme.colors.surfaceHover : "transparent", cursor: "pointer", fontSize: 18, @@ -121,6 +123,7 @@ export function SkinToneTray({ open, setOpen }: SkinTonePickerOpen) { } export function SkinToneTrigger({ open, setOpen }: SkinTonePickerOpen) { + const { theme } = useTheme(); const [aqSkinTone] = useAQState("emoji-picker-tone", "none" as SkinTone); const [, , skinToneVariations] = useSkinTone("๐Ÿ‘‹"); const current = @@ -136,9 +139,9 @@ export function SkinToneTrigger({ open, setOpen }: SkinTonePickerOpen) { alignItems: "center", gap: 4, padding: "2px 4px", - border: "1px solid rgba(255,255,255,0.08)", + border: `1px solid ${theme.colors.border}`, borderRadius: 6, - background: open ? "rgba(255,255,255,0.1)" : "transparent", + background: open ? theme.colors.surface2 : "transparent", cursor: "pointer", fontSize: 18, flexShrink: 0, @@ -150,8 +153,8 @@ export function SkinToneTrigger({ open, setOpen }: SkinTonePickerOpen) { fontSize: 9, transform: open ? "rotate(180deg)" : "rotate(0deg)", display: "inline-block", - transition: "transform 0.2s ease", - color: "grey", + transition: `transform ${motion.base}ms ease`, + color: theme.colors.text3, }} > @@ -274,7 +277,7 @@ export function EmojiPicker({ borderRadius: 12, overflow: "hidden", backgroundColor: theme.colors.background, - boxShadow: "0 4px 24px rgba(0,0,0,0.4)", + boxShadow: `0 4px 24px ${scrims.light}`, }} > {customEmoji.length > 0 && ( @@ -290,7 +293,7 @@ export function EmojiPicker({ display: "block", fontSize: 11, fontWeight: 600, - color: "rgba(255,255,255,0.4)", + color: theme.colors.text3, textTransform: "uppercase", letterSpacing: "0.05em", padding: "4px 4px 8px", @@ -324,7 +327,7 @@ export function EmojiPicker({ }} onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = - "rgba(255,255,255,0.1)"; + theme.colors.surfaceHover; }} onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = @@ -361,9 +364,9 @@ export function EmojiPicker({ margin: "10px 10px 4px 10px", padding: "6px 10px", borderRadius: 8, - border: "1px solid rgba(255,255,255,0.1)", - background: "rgba(255,255,255,0.07)", - color: "white", + border: `1px solid ${theme.colors.borderStrong}`, + background: theme.colors.input, + color: theme.colors.text1, fontSize: 13, outline: "none", width: "calc(100% - 16px - 10px)", @@ -392,7 +395,7 @@ export function EmojiPicker({ border: "none", background: activeCategory === i - ? "rgba(255,255,255,0.12)" + ? theme.colors.surfaceHover : "transparent", cursor: "pointer", display: "flex", @@ -400,9 +403,9 @@ export function EmojiPicker({ justifyContent: "center", color: activeCategory === i - ? "rgba(255,255,255,0.9)" - : "rgba(255,255,255,0.4)", - transition: "color 0.15s ease, background 0.15s ease", + ? theme.colors.text1 + : theme.colors.text3, + transition: `color ${motion.fast}ms ease, background ${motion.fast}ms ease`, }} > @@ -420,7 +423,7 @@ export function EmojiPicker({ display: "flex", alignItems: "center", justifyContent: "center", - color: "rgba(255,255,255,0.4)", + color: theme.colors.text3, fontSize: 13, }} > @@ -433,7 +436,7 @@ export function EmojiPicker({ display: "flex", alignItems: "center", justifyContent: "center", - color: "rgba(255,255,255,0.4)", + color: theme.colors.text3, fontSize: 13, }} > diff --git a/js/app/components/empty-state.tsx b/js/app/components/empty-state.tsx new file mode 100644 index 00000000..242bf7a9 --- /dev/null +++ b/js/app/components/empty-state.tsx @@ -0,0 +1,89 @@ +import { Text, useTheme } from "@streamplace/components"; +import { ReactNode } from "react"; +import { View } from "react-native"; + +/** + * A recessed 64px tile holding a lucide icon โ€” the standard illustration for + * icon-based empty states (pass as `illustration` to ). + */ +export function EmptyStateTile({ + icon: Icon, + tone, +}: { + icon: React.ComponentType; + tone?: string; +}) { + const { theme } = useTheme(); + const c = theme.colors; + return ( + + + + ); +} + +/** + * Shared empty-state layout: a ~64px illustration, a semibold headline, muted + * supporting copy, and an optional call to action โ€” centered in the available + * space. Used across Home, Videos, and the content hub so they read as one + * system. Give it a parent with a flex context (e.g. a flex:1 column or a + * flexGrow scroll container) for it to center vertically. + */ +export function EmptyState({ + illustration, + title, + subtitle, + action, +}: { + illustration: ReactNode; + title: string; + subtitle?: string; + action?: ReactNode; +}) { + return ( + + {illustration} + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + {action ? {action} : null} + + ); +} diff --git a/js/app/components/error/error.tsx b/js/app/components/error/error.tsx index 91f2815f..6b3062d7 100644 --- a/js/app/components/error/error.tsx +++ b/js/app/components/error/error.tsx @@ -1,5 +1,5 @@ -import { Text, zero } from "@streamplace/components"; -import { Pressable, View } from "react-native"; +import { Button, Text, zero } from "@streamplace/components"; +import { View } from "react-native"; export default function (props: { onRetry: () => void }) { return ( @@ -10,18 +10,9 @@ export default function (props: { onRetry: () => void }) { ]} > Unable to contact server. - - Retry? - + ); } diff --git a/js/app/components/follow-button.tsx b/js/app/components/follow-button.tsx index 7b3bd9ca..485abe64 100644 --- a/js/app/components/follow-button.tsx +++ b/js/app/components/follow-button.tsx @@ -1,4 +1,4 @@ -import { Button, Icon, Text, zero } from "@streamplace/components"; +import { Button, Icon, Text, useTheme, zero } from "@streamplace/components"; import { Plus } from "lucide-react-native"; import React, { useEffect, useState } from "react"; import { View } from "react-native"; @@ -27,6 +27,7 @@ const FollowButton: React.FC = ({ const [isFollowing, setIsFollowing] = useState(null); const [error, setError] = useState(null); const [followUri, setFollowUri] = useState(null); + const { theme } = useTheme(); const streamplaceUrl = useStreamplaceUrl(); const followUser = useStore((state) => state.followUser); const unfollowUser = useStore((state) => state.unfollowUser); @@ -123,7 +124,9 @@ const FollowButton: React.FC = ({ disabled={isFollowing === null} loading={isFollowing === null} leftIcon={!isFollowing && } - hoverStyle={isFollowing ? { backgroundColor: "#dc2626" } : undefined} + hoverStyle={ + isFollowing ? { backgroundColor: theme.colors.danger } : undefined + } > {isFollowing === null ? "Loading..." @@ -131,7 +134,11 @@ const FollowButton: React.FC = ({ ? "Following" : "Follow"} - {error && {error}} + {error && ( + + {error} + + )} ); }; diff --git a/js/app/components/home/avatar.tsx b/js/app/components/home/avatar.tsx deleted file mode 100644 index cdfcdb20..00000000 --- a/js/app/components/home/avatar.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Text } from "@streamplace/components"; -import { Image } from "expo-image"; -import { View } from "react-native"; - -interface UserAvatarProps { - src?: string | null; - size?: number; - style?: any; -} - -export function UserAvatar({ - src, - size = 32, - style, - ...props -}: UserAvatarProps) { - return ( - - {src ? ( - - ) : ( - ๐ŸŸ - )} - - ); -} - -export default UserAvatar; diff --git a/js/app/components/home/cards.tsx b/js/app/components/home/cards.tsx index 630c34e1..79279929 100644 --- a/js/app/components/home/cards.tsx +++ b/js/app/components/home/cards.tsx @@ -1,31 +1,22 @@ -import { LiquidGlassView } from "@callstack/liquid-glass"; import { - hexToRgba, - PlayerUI, + Avatar, + LiveBadge, Text, useTheme, zero, } from "@streamplace/components"; +import { + borderRadius, + motion, + spacing, +} from "@streamplace/components/src/lib/theme/tokens"; import { Image } from "expo-image"; import useStreamplaceNode from "hooks/useStreamplaceNode"; -import { useEffect, useMemo, useState } from "react"; +import { useState } from "react"; import { Platform, View } from "react-native"; export type StreamCardSize = "xs" | "sm" | "md" | "lg" | "xl"; -const displayTag = (tag: string): string => { - // could be top level but we want to make sure RN polyfill runs first - const langNames = new Intl.DisplayNames(["en"], { type: "language" }); - if (tag.startsWith("lang:")) { - try { - return langNames.of(tag.slice(5)) ?? tag; - } catch { - return tag; - } - } - return tag; -}; - interface StreamCardProps { size?: StreamCardSize; horizontal?: boolean; @@ -41,6 +32,20 @@ interface StreamCardProps { showAvatar?: boolean; } +// 11400 -> "11K", 1350 -> "1.3K", 942 -> "942" +function formatViewers(n: number): string { + if (n >= 1_000_000) + return `${(n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0)}M`; + if (n >= 10_000) return `${Math.round(n / 1000)}K`; + if (n >= 1000) return `${(n / 1000).toFixed(1)}K`; + return `${n}`; +} + +/** + * YouTube-grammar stream card: a rounded 16:9 thumbnail with a LIVE chip + * bottom-right, then a clean three-line meta stack โ€” title (16px medium, + * 2 lines), handle, activity ยท viewers. No chips, no noise. + */ const StreamCard = ({ size = "sm", horizontal = false, @@ -60,66 +65,37 @@ const StreamCard = ({ const { url } = useStreamplaceNode(); const { theme } = useTheme(); const isWeb = Platform.OS === "web"; + const [hovered, setHovered] = useState(false); - const tagItems = tags.length > 0 ? tags : category; - const tagsKey = tagItems.join(","); - - const [rowWidth, setRowWidth] = useState(0); - const [itemWidths, setItemWidths] = useState>({}); - - useEffect(() => { - setItemWidths({}); - }, [activity, tagsKey]); - - const visibleTagCount = useMemo(() => { - if (rowWidth === 0) return tagItems.length; - const activityW = activity ? (itemWidths["activity"] ?? 0) : 0; - let used = activityW; - let count = 0; - for (let i = 0; i < tagItems.length; i++) { - const w = itemWidths[`tag-${i}`]; - if (w === undefined) { - count++; - continue; - } - const gap = used > 0 ? 6 : 0; - if (used + gap + w <= rowWidth) { - used += gap + w; - count++; - } else { - break; - } - } - return count; - }, [rowWidth, itemWidths, tagItems, activity]); - - // Define dynamic styles - const borderRadius = 12; - const contentPaddingHoriz = 12; - const contentPaddingVertical = 2.65; - const avatarSize = 40; - const livePillHeight = inMobileMode ? 20 : 30; - const livePillPaddingHorizontal = inMobileMode ? 2 : 4; + const webTransition = isWeb + ? ({ + transitionDuration: `${motion.fast}ms`, + transitionTimingFunction: motion.easingCss, + transitionProperty: "transform, border-color", + } as any) + : null; - const contentSectionHeight = avatarSize + 2 * contentPaddingVertical; - const contentSectionWidth = avatarSize * 2 + contentPaddingHoriz; + const watchingLine = [ + activity, + isLive && viewers > 0 ? `${formatViewers(viewers)} watching` : null, + ] + .filter(Boolean) + .join(" ยท "); return ( - setHovered(true), + onPointerLeave: () => setHovered(false), + })} > {/* Thumbnail */} {isLive && ( - + )} - {/* Content */} + {/* Meta: avatar + three-line stack */} - {/* Avatar */} {showAvatar && ( - - {/* dynamically switching between these src crashes android */} - {avatarUrl && ( - - - - )} - {!avatarUrl && ( - - - - )} - + )} - {/* Text content */} {title && ( {title} @@ -265,100 +188,27 @@ const StreamCard = ({ {streamerName && ( @{streamerName} )} - {((activity && category.length > 0) || tags.length > 0) && ( - { - const width = e.nativeEvent?.layout?.width; - if (!width) { - return; - } - setRowWidth(width); - }} + {watchingLine.length > 0 && ( + - {activity && ( - { - const width = e.nativeEvent?.layout?.width; - if (!width) { - return; - } - setItemWidths((prev) => ({ - ...prev, - activity: width, - })); - }} - > - {activity} - - )} - {(inMobileMode - ? tagItems - : tagItems.slice(0, visibleTagCount) - ).map((cat, index) => ( - { - const width = e.nativeEvent?.layout?.width; - if (!width) { - return; - } - setItemWidths((prev) => ({ - ...prev, - [`tag-${index}`]: width, - })); - }} - > - - {displayTag(cat)} - - - ))} - + {watchingLine} + )} - + ); }; diff --git a/js/app/components/home/live-dot.tsx b/js/app/components/home/live-dot.tsx index 09ea36ea..9eae89fe 100644 --- a/js/app/components/home/live-dot.tsx +++ b/js/app/components/home/live-dot.tsx @@ -1,5 +1,6 @@ import { useEffect } from "react"; import { StyleSheet, View } from "react-native"; +import { statusColors } from "@streamplace/components/src/lib/theme/tokens"; import Animated, { useAnimatedStyle, useSharedValue, @@ -49,14 +50,14 @@ const styles = StyleSheet.create({ width: 12, height: 12, borderRadius: 999, - backgroundColor: "red", + backgroundColor: statusColors.live, position: "absolute", }, pulseDot: { width: 10, height: 10, borderRadius: 5, - backgroundColor: "red", + backgroundColor: statusColors.live, position: "absolute", }, }); diff --git a/js/app/components/loading/loading.tsx b/js/app/components/loading/loading.tsx index 244a2759..7fc66f23 100644 --- a/js/app/components/loading/loading.tsx +++ b/js/app/components/loading/loading.tsx @@ -1,4 +1,4 @@ -import { zero } from "@streamplace/components"; +import { useTheme, zero } from "@streamplace/components"; import { ActivityIndicator, View } from "react-native"; export default function () { @@ -15,5 +15,6 @@ export default function () { } export function Spinner() { - return ; + const { theme } = useTheme(); + return ; } diff --git a/js/app/components/login/login-form.tsx b/js/app/components/login/login-form.tsx index 4ebcb630..b1e929f8 100644 --- a/js/app/components/login/login-form.tsx +++ b/js/app/components/login/login-form.tsx @@ -146,7 +146,9 @@ export default function LoginForm({ - Handle + + Handle + {completionText && suggestion?.handle !== handle ? ( - + )} @@ -291,7 +293,7 @@ export default function LoginForm({ ]} > diff --git a/js/app/components/login/login-modal.tsx b/js/app/components/login/login-modal.tsx index 8fbafe7a..d66c9421 100644 --- a/js/app/components/login/login-modal.tsx +++ b/js/app/components/login/login-modal.tsx @@ -21,7 +21,7 @@ export default function LoginModal({ onClose, onOpenPdsModal, }: LoginModalProps) { - const { theme, zero: z } = useTheme(); + const { theme } = useTheme(); if (!visible) { return null; @@ -42,7 +42,7 @@ export default function LoginModal({ zero.layout.flex.alignCenter, zero.layout.flex.justifyCenter, { - backgroundColor: "rgba(0, 0, 0, 0.8)", + backgroundColor: theme.colors.overlay, position: "absolute", top: 0, left: 0, @@ -55,10 +55,17 @@ export default function LoginModal({ > e.stopPropagation()} > @@ -77,7 +84,7 @@ export default function LoginModal({ onPress={onClose} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} > - + diff --git a/js/app/components/login/pds-host-selector-modal.tsx b/js/app/components/login/pds-host-selector-modal.tsx index 35f2d319..eae45663 100644 --- a/js/app/components/login/pds-host-selector-modal.tsx +++ b/js/app/components/login/pds-host-selector-modal.tsx @@ -141,12 +141,12 @@ export const PdsHostSelectorModal: React.FC = ({ onOpenChange={onOpenChange} showCloseButton={false} variant="default" - size="sm" + size="md" dismissible={false} position="center" > - - + + {t("pds-selector-title")} @@ -155,26 +155,76 @@ export const PdsHostSelectorModal: React.FC = ({ - {SHUFFLED_PDS_HOSTS.map((host, index) => ( + + {SHUFFLED_PDS_HOSTS.map((host) => ( + handleSelectHost(host.value)} + style={[ + zero.py[2], + zero.px[3], + zero.r.lg, + { flexGrow: 1, flexBasis: "46%", minWidth: 200 }, + { + borderWidth: 1, + borderColor: + !useCustom && selectedHost === host.value + ? theme.colors.primary + : theme.colors.border, + backgroundColor: + !useCustom && selectedHost === host.value + ? theme.colors.surface2 + : "transparent", + }, + ]} + > + + + {host.label} + + {host.description} + + + {!useCustom && selectedHost === host.value && ( + + )} + + + ))} + handleSelectHost(host.value)} + onPress={handleSelectCustom} style={[ zero.py[2], zero.px[3], zero.r.lg, + { flexGrow: 1, flexBasis: "46%", minWidth: 200 }, { borderWidth: 1, - borderColor: - !useCustom && selectedHost === host.value - ? theme.colors.primary - : theme.colors.border, - backgroundColor: - !useCustom && selectedHost === host.value - ? "rgba(0, 122, 255, 0.05)" - : "transparent", + borderColor: useCustom + ? theme.colors.primary + : theme.colors.border, + backgroundColor: useCustom + ? theme.colors.surface2 + : "transparent", }, - index > 0 && zero.mt[2], ]} > = ({ ]} > - {host.label} + {t("pds-selector-custom-label")} - {host.description} + {t("pds-selector-custom-description")} - {!useCustom && selectedHost === host.value && ( - - )} + {useCustom && } - ))} - - - - - {t("pds-selector-custom-label")} - - {t("pds-selector-custom-description")} - - - {useCustom && } - - + - + = ({ /> )} - + {t("pds-selector-info")} {!useCustom && ( diff --git a/js/app/components/mobile-app-banner.tsx b/js/app/components/mobile-app-banner.tsx index e1e53e3f..b9111fec 100644 --- a/js/app/components/mobile-app-banner.tsx +++ b/js/app/components/mobile-app-banner.tsx @@ -1,17 +1,16 @@ -import { Text, View } from "@streamplace/components"; +import { Button, Text, View } from "@streamplace/components"; import { bg, borders, - colors, flex, gap, layout, px, py, - r, } from "@streamplace/components/src/lib/theme/atoms"; +import { textAlphas } from "@streamplace/components/src/lib/theme/tokens"; +import { LogoTile } from "components/brand/logo"; import { STORE_LABELS, STORE_URLS } from "constants/store-urls"; -import { Image } from "expo-image"; import usePlatform from "hooks/usePlatform.native"; import { X } from "lucide-react-native"; import { useState } from "react"; @@ -60,10 +59,7 @@ export function MobileAppBanner() { { paddingTop: insets.top + 8, paddingBottom: 8 }, ]} > - + Get the Streamplace app @@ -72,16 +68,15 @@ export function MobileAppBanner() { Better experience on mobile - Linking.openURL(STORE_URLS[mobilePlatform])} - style={[bg.primary[500], r.md, px[3], py[1]]} > - - {STORE_LABELS[mobilePlatform]} - - + {STORE_LABELS[mobilePlatform]} + - + ); diff --git a/js/app/components/provider/provider.shared.tsx b/js/app/components/provider/provider.shared.tsx index 889e8f2c..d2630d58 100644 --- a/js/app/components/provider/provider.shared.tsx +++ b/js/app/components/provider/provider.shared.tsx @@ -15,6 +15,7 @@ import BlueskyProvider from "features/bluesky/blueskyProvider"; import StreamplaceProvider from "features/streamplace/streamplaceProvider"; import useStreamplaceNode from "hooks/useStreamplaceNode"; import React from "react"; +import { useStore } from "store"; import { useOAuthSession } from "store/hooks"; import { i18n } from "@streamplace/components"; @@ -115,8 +116,13 @@ export const NewStreamplaceProvider = ({ }) => { const { url } = useStreamplaceNode(); const oauthSession = useOAuthSession(); + const openLoginModal = useStore((s) => s.openLoginModal); return ( - + openLoginModal() : undefined} + > {children} ); @@ -124,20 +130,15 @@ export const NewStreamplaceProvider = ({ export const FontProvider = ({ children }: { children: React.ReactNode }) => { const [fontLoaded, fontError] = useFonts({ - // Atkinson Hyperlegible Next (Sans Serif) fonts - "AtkinsonHyperlegibleNext-Regular": require("../../assets/fonts/AtkinsonHyperlegibleNext-Regular.ttf"), - "AtkinsonHyperlegibleNext-Light": require("../../assets/fonts/AtkinsonHyperlegibleNext-Light.ttf"), - "AtkinsonHyperlegibleNext-ExtraLight": require("../../assets/fonts/AtkinsonHyperlegibleNext-ExtraLight.ttf"), - "AtkinsonHyperlegibleNext-Medium": require("../../assets/fonts/AtkinsonHyperlegibleNext-Medium.ttf"), - "AtkinsonHyperlegibleNext-SemiBold": require("../../assets/fonts/AtkinsonHyperlegibleNext-SemiBold.ttf"), - "AtkinsonHyperlegibleNext-Bold": require("../../assets/fonts/AtkinsonHyperlegibleNext-Bold.ttf"), - "AtkinsonHyperlegibleNext-ExtraBold": require("../../assets/fonts/AtkinsonHyperlegibleNext-ExtraBold.ttf"), - - // Atkinson Hyperlegible Mono fonts - "AtkinsonHyperlegibleMono-Regular": require("../../assets/fonts/AtkinsonHyperlegibleMono-Regular.ttf"), - "AtkinsonHyperlegibleMono-Medium": require("../../assets/fonts/AtkinsonHyperlegibleMono-Medium.ttf"), - "AtkinsonHyperlegibleMono-SemiBold": require("../../assets/fonts/AtkinsonHyperlegibleMono-SemiBold.ttf"), - "AtkinsonHyperlegibleMono-Bold": require("../../assets/fonts/AtkinsonHyperlegibleMono-Bold.ttf"), + // Geist (Sans Serif) โ€” the design system uses exactly three weights + "Geist-Regular": require("../../assets/fonts/Geist-Regular.ttf"), + "Geist-Medium": require("../../assets/fonts/Geist-Medium.ttf"), + "Geist-SemiBold": require("../../assets/fonts/Geist-SemiBold.ttf"), + + // Geist Mono โ€” stream keys, ingest URLs, timers + "GeistMono-Regular": require("../../assets/fonts/GeistMono-Regular.ttf"), + "GeistMono-Medium": require("../../assets/fonts/GeistMono-Medium.ttf"), + "GeistMono-SemiBold": require("../../assets/fonts/GeistMono-SemiBold.ttf"), }); if (!fontLoaded && !fontError) { diff --git a/js/app/package.json b/js/app/package.json index d39525fb..7d6bb9be 100644 --- a/js/app/package.json +++ b/js/app/package.json @@ -3,6 +3,9 @@ "main": "./src/entrypoint.tsx", "version": "0.11.12", "runtimeVersion": "0.10.0", + "engines": { + "node": "22.x" + }, "scripts": { "start": "npx expo start -c --port 38081", "android": "expo run:android --no-bundler", diff --git a/js/app/src/screens/about.tsx b/js/app/src/screens/about.tsx index 7917ce76..182b93dd 100644 --- a/js/app/src/screens/about.tsx +++ b/js/app/src/screens/about.tsx @@ -1,84 +1,182 @@ -import { Text, View, zero } from "@streamplace/components"; -import { Linking, Pressable, ScrollView } from "react-native"; +import { Button, Text, useTheme, View } from "@streamplace/components"; +import { LogoMark } from "components/brand/logo"; +import { + MessagesSquare, + MonitorPlay, + Network, + ShieldCheck, + Terminal, + Zap, +} from "lucide-react-native"; +import { Linking, ScrollView } from "react-native"; -const Anchor = ({ - href, - children, - ...props -}: { - href: string; - children: React.ReactNode; -}) => ( - Linking.openURL(href)} {...props}> - - {children} - - -); +const FEATURES = [ + { + icon: Terminal, + title: "Open-source, single-binary node", + body: "Get up and running with one command. No complex configuration or deep video expertise required โ€” built for hackers and builders.", + }, + { + icon: ShieldCheck, + title: "User sovereignty by design", + body: "Every video is cryptographically signed by its creator and respects their consent preferences โ€” on the same public-key infrastructure as the social web.", + }, + { + icon: MonitorPlay, + title: "A familiar streaming experience", + body: "Native apps for iOS, Android, and web with the rich video features people expect: livestreaming, clips, uploads, and more.", + }, + { + icon: Network, + title: "Built for federation", + body: "Integrates seamlessly with the AT Protocol. Streamplace nodes connect to any compatible network to index and serve video content.", + }, + { + icon: Zap, + title: "Powered by Livepeer", + body: "Leverages battle-tested decentralized video infrastructure for transcoding, distribution, and delivery at scale.", + }, +]; -export default function AboutScreen() { +function FeatureRow({ + icon: Icon, + title, + body, +}: { + icon: typeof Terminal; + title: string; + body: string; +}) { + const { theme } = useTheme(); + const c = theme.colors; return ( - - - - What is Streamplace? - - - Streamplace is the video layer for decentralized social networks. - We're building open-source infrastructure around bringing high-quality - video experiences to the AT Protocol, designed around user sovereignty - and content authenticity. - - - - Open source single-binary node software - - - Get up and running with one command. No complex configuration or deep - video expertise required. Perfect for hackers and builders. - - - - User sovereignty by design - - - All video content is cryptographically signed by creators and respects - their consent preferences. Built on the same public key infrastructure - as decentralized social networks. - - - - Familiar streaming experience + + + + + + + {title} - - Native apps for iOS, Android, and web that provide the rich video - features users expect: livestreaming, clips, uploads, and more. + + {body} + + + ); +} - - Built for federation - - - Seamlessly integrates with the AT Protocol. Streamplace nodes can - connect to any compatible social network to index and serve video - content. - +export default function AboutScreen() { + const { theme } = useTheme(); + const c = theme.colors; + return ( + + + {/* Hero */} + + + + + What is Streamplace? + + + The video layer for the open social web. + + + Open-source infrastructure bringing high-quality, creator-owned + video to the AT Protocol โ€” designed around user sovereignty and + content authenticity. + + + - - Powered by Livepeer - - - Leverages battle-tested decentralized video infrastructure for - transcoding, distribution, and delivery at scale. - + {/* Feature list */} + + {FEATURES.map((f) => ( + + ))} + - - Want to get involved? - - - Join our Discord{" "} - to learn more about Streamplace and how you can get involved. - + {/* Get-involved CTA */} + + + Want to get involved? + + + Join our Discord to learn more about Streamplace and how you can help + build it. + + + + + ); diff --git a/js/app/src/screens/app-return.tsx b/js/app/src/screens/app-return.tsx index 85d5ae04..55667fff 100644 --- a/js/app/src/screens/app-return.tsx +++ b/js/app/src/screens/app-return.tsx @@ -1,8 +1,7 @@ -import { Text, useTheme } from "@streamplace/components"; -import { Pressable, View } from "react-native"; +import { Button } from "@streamplace/components"; +import { View } from "react-native"; export default function AppReturnScreen({ route }) { - const theme = useTheme(); const scheme = route.params?.scheme; // This should work. It does work on iOS. On Android, it causes the token to be // authorized twice? I don't know why. I spent two hours trying to figure out why @@ -19,22 +18,15 @@ export default function AppReturnScreen({ route }) { { justifyContent: "center" }, ]} > - { document.location.href = `${scheme}:/${document.location.search}`; }} > - - Complete login - - + Complete login + ); } diff --git a/js/app/src/screens/download.tsx b/js/app/src/screens/download.tsx index e7d42de1..41212bcc 100644 --- a/js/app/src/screens/download.tsx +++ b/js/app/src/screens/download.tsx @@ -1,60 +1,126 @@ -import { Text, zero } from "@streamplace/components"; -import { Linking, Pressable, View } from "react-native"; +import { Button, Text, useTheme, View } from "@streamplace/components"; +import { LogoMark } from "components/brand/logo"; +import { MonitorDown } from "lucide-react-native"; +import { Linking, ScrollView } from "react-native"; import GetApps from "../../components/get-apps"; -const H4 = ({ style, ...props }: any) => ( - -); -const P = (props: any) => ( - -); +const RELEASES_URL = + "https://git.stream.place/streamplace/streamplace/-/releases"; -const Anchor = ({ - href, - children, - style, - ...props -}: { - href: string; - children: React.ReactNode; - style?: any; -}) => { +function SectionLabel({ children }: { children: string }) { + const { theme } = useTheme(); return ( - Linking.openURL(href)} {...props}> - - {children} - - + + {children} + ); -}; +} export default function DownloadScreen() { + const { theme } = useTheme(); + const c = theme.colors; + + const card = { + width: "100%" as const, + padding: 28, + borderRadius: 16, + backgroundColor: c.surface1, + borderWidth: 1, + borderColor: c.borderSubtle, + alignItems: "center" as const, + gap: 18, + }; + return ( - - - Or: - - Get the latest releases for Windows, Mac, and Linux from - git.stream.place - - + + {/* Hero */} + + + + + Get Streamplace + + + Watch, stream, and manage your channel from anywhere โ€” on mobile, + desktop, or the web. + + + + + {/* Mobile */} + + Mobile + + + + {/* Desktop */} + + Desktop + + + + + Windows ยท macOS ยท Linux + + + + + ); } diff --git a/js/app/src/screens/home.tsx b/js/app/src/screens/home.tsx index c8f82cfc..ba3bed87 100644 --- a/js/app/src/screens/home.tsx +++ b/js/app/src/screens/home.tsx @@ -1,17 +1,19 @@ import { ACTIVITY_LABEL_DISPLAY, + Skeleton, Text, useStreamplaceStore, + useTheme, zero, } from "@streamplace/components"; +import { spacing } from "@streamplace/components/src/lib/theme/tokens"; import AQLink from "components/aqlink"; import Container from "components/container"; +import { EmptyState } from "components/empty-state"; import ErrorBox from "components/error/error"; import StreamCardHorizontal, { StreamCardSize } from "components/home/cards"; import LiveDot from "components/home/live-dot"; -import Loading from "components/loading/loading"; import PullToRefreshScrollView from "components/pull-to-refresh"; -import Title from "components/title"; import { Image } from "expo-image"; import useAvatars from "hooks/useAvatars"; import { useEffect, useState } from "react"; @@ -34,11 +36,6 @@ function getStreamActivity( return undefined; } -// as we're not using a specific grid library these are necessary -// to constrain the cards -const FIRST_ROW_MAGIC_RATIO = 0.95; -const LAST_ROW_MAGIC_RATIO = 1.16; - type StreamRecord = { createdAt: Date; title?: string; @@ -88,17 +85,12 @@ function getHomeScreenItemSize(width: number): StreamCardSize { return "xs"; // sm and below } +// Uniform responsive grid, YouTube-style. Cards stay large โ€” YouTube +// shows 3 columns on a laptop, 4 on a big display. function getHomeScreenCols(width: number): number { - if (width >= 1550) return 4; - if (width >= 1280) return 3; - if (width >= 1024) return 2; - if (width >= 768) return 2; - return 1; -} - -// Get the ratio for the first card based on column count -function getPadPercentage(cols: number): number { - if (cols >= 3) return 2.07; + if (width >= 1900) return 4; + if (width >= 1400) return 3; + if (width >= 900) return 2; return 1; } @@ -150,28 +142,66 @@ function HomeScreenItem({ ); } -function PlaceholderItem() { +function StreamCardSkeleton() { return ( - - + + + + + + + + ); } +// Full-page skeleton grid matching the real column layout so nothing +// shifts when data arrives. +function HomeSkeletonGrid() { + const { width } = useWindowDimensions(); + const cols = getHomeScreenCols(width); + const rows = 3; + return ( + + + + + + {Array(rows) + .fill(null) + .map((_, r) => ( + + {Array(cols) + .fill(null) + .map((_, c) => ( + + ))} + + ))} + + ); +} + export default function HomeScreen({ contentContainerStyle = {}, }: { @@ -186,6 +216,7 @@ export default function HomeScreen({ ); const liveUsersError = useStreamplaceStore((state) => state.liveUsersError); const [manualRefresh, setManualRefresh] = useState(false); + const { theme } = useTheme(); const { width } = useWindowDimensions(); // Use mock data for development/testing if needed @@ -203,7 +234,7 @@ export default function HomeScreen({ if (liveUsersError) { if (liveUsersLoading) { - return ; + return ; } if (!segments) { return ; @@ -212,7 +243,7 @@ export default function HomeScreen({ if (segments === null) { // Only show loading if not using mock data and no segments yet - return ; + return ; } let cols = getHomeScreenCols(width); @@ -220,21 +251,14 @@ export default function HomeScreen({ // Use horizontal (SBS) layout for all items on single-column breakpoint const useHorizontalAll = cols === 1; - // Only use horizontal layout for first card when we have enough columns (3+) - const useHorizontalFirst = cols >= 3; - const firstRowCols = useHorizontalFirst ? cols - 1 : cols; - - const firstRowItems = segments.slice(0, firstRowCols); - let cutSegs = segments.slice(firstRowCols); let rows: (PlaceStreamLivestream.LivestreamView | null)[][] = []; if (!useHorizontalAll) { - for (let i = 0; i < cutSegs.length; i += cols) { - let row = cutSegs.slice(i, i + cols); - if (i + cols >= cutSegs.length && row.length < cols) { - const paddingNeeded = cols - row.length; - row = [...row, ...Array(paddingNeeded).fill(null)]; + for (let i = 0; i < segments.length; i += cols) { + let row = segments.slice(i, i + cols); + if (row.length < cols) { + row = [...row, ...Array(cols - row.length).fill(null)]; } rows.push(row); } @@ -248,23 +272,23 @@ export default function HomeScreen({ - + โš ๏ธ - + There was an error fetching the latest streams. You might be offline? code: {liveUsersError || "nocode"} @@ -279,7 +303,13 @@ export default function HomeScreen({ }, Platform.OS === "ios" ? zero.pt[24] : zero.pt[0], ]} - contentContainerStyle={contentContainerStyle} + contentContainerStyle={[ + // When empty, fill the viewport so the Container centers the empty + // state vertically (matching Videos / My Videos). Populated feeds + // keep their natural top-aligned scroll. + segments.length === 0 && { flexGrow: 1 }, + contentContainerStyle, + ]} refreshing={manualRefresh} onRefresh={() => { refreshLiveUsers(); @@ -293,45 +323,38 @@ export default function HomeScreen({ style={[ { flexDirection: "row" }, { alignItems: "center" }, - { gap: 12 }, - zero.my[8], - zero.px[0], + { gap: spacing[3] }, + { marginTop: spacing[8], marginBottom: spacing[6] }, ]} > - - {segments.length} {segments.length === 1 ? "person" : "people"}{" "} - live now - + + Live now + + + {segments.length}{" "} + {segments.length === 1 ? "streamer" : "streamers"} + )} {segments.length === 0 && ( - - - - No one is streaming right now - - Check back later? - + + } + title="No one is streaming right now" + subtitle="Check back later?" + /> )} {useHorizontalAll ? segments.map((item) => ( 0 && ( - - {firstRowItems.map((item, itemIndex) => ( - - - - ))} - {Array( - useHorizontalFirst - ? cols - firstRowItems.length - 1 - : cols - firstRowItems.length, - ) - .fill(null) - .map((_, i) => ( - - - - ))} - - )} - {!useHorizontalAll && segments.length > 0 && ( {rows.map((row, rowIndex) => ( @@ -394,7 +374,7 @@ export default function HomeScreen({ key={`row-${rowIndex}`} style={[ { flexDirection: "row" }, - { gap: 24, marginBottom: 24 }, + { gap: spacing[4], marginBottom: spacing[8] }, ]} > {row.map((item, itemIndex) => @@ -413,11 +393,9 @@ export default function HomeScreen({ ) : ( - - + /> ), )} diff --git a/js/app/vercel.json b/js/app/vercel.json new file mode 100644 index 00000000..ea99b00a --- /dev/null +++ b/js/app/vercel.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "installCommand": "pnpm install --filter @streamplace/app... --filter @streamplace/config-react-native-webrtc...", + "buildCommand": "pnpm run build:web", + "outputDirectory": "dist", + "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] +} diff --git a/js/components/src/streamplace-provider/index.tsx b/js/components/src/streamplace-provider/index.tsx index 1554d057..dbd194b5 100644 --- a/js/components/src/streamplace-provider/index.tsx +++ b/js/components/src/streamplace-provider/index.tsx @@ -16,10 +16,12 @@ export function StreamplaceProvider({ children, url, oauthSession, + onNeedsLogin, }: { children: React.ReactNode; url: string; oauthSession?: SessionManager | null; + onNeedsLogin?: () => void; }) { // todo: handle url changes? const store = useRef(makeStreamplaceStore({ url })).current; @@ -32,6 +34,10 @@ export function StreamplaceProvider({ store.setState({ oauthSession }); }, [oauthSession]); + useEffect(() => { + store.setState({ onNeedsLogin: onNeedsLogin ?? null }); + }, [onNeedsLogin]); + return ( diff --git a/js/components/src/streamplace-provider/poller.tsx b/js/components/src/streamplace-provider/poller.tsx index 7e472159..2d532c31 100644 --- a/js/components/src/streamplace-provider/poller.tsx +++ b/js/components/src/streamplace-provider/poller.tsx @@ -36,7 +36,7 @@ export default function Poller({ children }: { children: React.ReactNode }) { liveUsersLoading: true, }); try { - const res = await agent.place.stream.live.getLiveUsers(); + const res = await agent.place.stream.live.getLiveUsers({ limit: 50 }); setLiveUsers({ liveUsers: res.data.streams || [], liveUsersLoading: false, diff --git a/js/components/src/streamplace-store/streamplace-store.tsx b/js/components/src/streamplace-store/streamplace-store.tsx index 8a3c4c1d..d767fec8 100644 --- a/js/components/src/streamplace-store/streamplace-store.tsx +++ b/js/components/src/streamplace-store/streamplace-store.tsx @@ -42,6 +42,9 @@ export interface StreamplaceState { liveUsersLoading: boolean; liveUsersError: string | null; oauthSession: SessionManager | null | undefined; + // Injected by the app: prompt the user to log in (opens the login modal). + // Lets components (comments, likes, chat) gate actions behind auth. + onNeedsLogin: (() => void) | null; handle: string | null; chatProfile: PlaceStreamChatProfile.Record | null; @@ -124,6 +127,7 @@ export const makeStreamplaceStore = ({ liveUsersLoading: true, liveUsersError: null, oauthSession: null, + onNeedsLogin: null, handle: null, chatProfile: null, ingests: null, @@ -347,6 +351,8 @@ export function useStreamplaceStore( export const useUrl = () => useStreamplaceStore((x) => x.url); export const useDID = () => useStreamplaceStore((x) => x.oauthSession?.did); +export const useOnNeedsLogin = () => + useStreamplaceStore((x) => x.onNeedsLogin); export const useHandle = () => useStreamplaceStore((x) => x.handle); export const useSetHandle = (): ((handle: string) => void) => {