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.
+
+
+ }
+ onPress={() => Linking.openURL("https://discord.stream.place")}
+ >
+ Join our Discord
+
+
+
);
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
+
+ }
+ onPress={() => Linking.openURL(RELEASES_URL)}
+ // width="min" โ alignSelf:flex-start; center it within this column.
+ style={{ alignSelf: "center" }}
+ >
+ Get the latest release
+
+
+
+
);
}
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) => {