Live video on the AT Protocol

Merge branch 'eli/localhost-oauth-fixes' into eli/iroh-streamplace-merged

+2588 -896
+1 -1
go.mod
··· 55 55 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e 56 56 github.com/slok/go-http-metrics v0.13.0 57 57 github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac 58 - github.com/streamplace/oatproxy v0.0.0-20250903220936-3131b72481a6 58 + github.com/streamplace/oatproxy v0.0.0-20251013221731-72a05c87a22c 59 59 github.com/stretchr/testify v1.10.0 60 60 github.com/tdewolff/canvas v0.0.0-20250728095813-50d4cb1eee71 61 61 github.com/whyrusleeping/cbor-gen v0.3.1
+2 -2
go.sum
··· 1277 1277 github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0= 1278 1278 github.com/streamplace/indigo v0.0.0-20250813192504-b19ccd82854b h1:vh0Z1bDgcVXQjgmcXOBkC3B4uWi+HaxeyTvETzcA/Vg= 1279 1279 github.com/streamplace/indigo v0.0.0-20250813192504-b19ccd82854b/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 1280 - github.com/streamplace/oatproxy v0.0.0-20250903220936-3131b72481a6 h1:s83q+spLKCE/UXZEDnU7FEt6PJxyH+utQCyvQILATZk= 1281 - github.com/streamplace/oatproxy v0.0.0-20250903220936-3131b72481a6/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= 1280 + github.com/streamplace/oatproxy v0.0.0-20251013221731-72a05c87a22c h1:4QNHrs5oE8n2unAFE/XqvwDCMmrq9dPitbMA25Dtu5Q= 1281 + github.com/streamplace/oatproxy v0.0.0-20251013221731-72a05c87a22c/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= 1282 1282 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 1283 1283 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 1284 1284 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+19
js/app/components/aqlink.tsx
··· 2 2 Link, 3 3 NavigationProp, 4 4 ParamListBase, 5 + useLinkBuilder, 5 6 useNavigation, 6 7 } from "@react-navigation/native"; 7 8 import usePlatform from "hooks/usePlatform"; ··· 54 55 }, []); 55 56 return <Loading />; 56 57 } 58 + 59 + // generates the proper href for a given LinkParams object, for better web support 60 + export function useAQLinkHref(to: LinkParams): { href?: string } { 61 + const { isWeb } = usePlatform(); 62 + const buildLink = useLinkBuilder(); 63 + 64 + if (!isWeb) { 65 + return { href: undefined }; 66 + } 67 + 68 + try { 69 + const href = buildLink(to.screen, to.params); 70 + return { href }; 71 + } catch (e) { 72 + console.warn("Failed to build link for", to, e); 73 + return { href: undefined }; 74 + } 75 + }
+17 -51
js/app/components/follow-button.tsx
··· 1 - import { Text, zero } from "@streamplace/components"; 1 + import { Button, Icon, Text, zero } from "@streamplace/components"; 2 + import { Plus } from "lucide-react-native"; 2 3 import React, { useEffect, useState } from "react"; 3 - import { Pressable, View } from "react-native"; 4 + import { View } from "react-native"; 4 5 import { followUser, unfollowUser } from "../features/bluesky/blueskySlice"; 5 6 import { selectStreamplace } from "../features/streamplace/streamplaceSlice"; 6 7 import { useAppDispatch, useAppSelector } from "../store/hooks"; ··· 111 112 } 112 113 }; 113 114 114 - const buttonStyle = [ 115 - zero.bg.transparent, 116 - zero.px[3], 117 - zero.py[2], 118 - zero.r.md, 119 - zero.borders.width.thin, 120 - zero.borders.color.gray[300], 121 - ]; 122 - 123 115 return ( 124 116 <View 125 117 style={[ ··· 128 120 zero.gap.all[2], 129 121 ]} 130 122 > 131 - {isFollowing === null ? ( 132 - // Skeleton loader to prevent layout shift 133 - <Pressable style={[...buttonStyle, { opacity: 0.5 }]} disabled> 134 - <Text>&nbsp;</Text> 135 - </Pressable> 136 - ) : isFollowing ? ( 137 - <Pressable 138 - style={buttonStyle} 139 - onPress={handleUnfollow} 140 - accessibilityLabel="Following" 141 - > 142 - <View 143 - style={[ 144 - { flexDirection: "row" }, 145 - { alignItems: "center" }, 146 - zero.gap.all[1], 147 - ]} 148 - > 149 - <Text>✓</Text> 150 - <Text>Following</Text> 151 - </View> 152 - </Pressable> 153 - ) : ( 154 - <Pressable 155 - style={buttonStyle} 156 - onPress={handleFollow} 157 - accessibilityLabel="Follow" 158 - > 159 - <View 160 - style={[ 161 - { flexDirection: "row" }, 162 - { alignItems: "center" }, 163 - zero.gap.all[1], 164 - ]} 165 - > 166 - <Text>+</Text> 167 - <Text>Follow</Text> 168 - </View> 169 - </Pressable> 170 - )} 123 + <Button 124 + onPress={isFollowing ? handleUnfollow : handleFollow} 125 + variant={isFollowing ? "secondary" : "primary"} 126 + size="pill" 127 + disabled={isFollowing === null} 128 + loading={isFollowing === null} 129 + leftIcon={!isFollowing && <Icon icon={Plus} size="sm" />} 130 + > 131 + {isFollowing === null 132 + ? "Loading..." 133 + : isFollowing 134 + ? "Unfollow" 135 + : "Follow"} 136 + </Button> 171 137 {error && <Text style={[{ color: "#c00" }, zero.ml[2]]}>{error}</Text>} 172 138 </View> 173 139 );
+119 -104
js/app/components/live-dashboard/bento-grid.tsx
··· 9 9 useSegmentTiming, 10 10 zero, 11 11 } from "@streamplace/components"; 12 - import { useCallback, useEffect, useMemo, useState } from "react"; 12 + import { 13 + ProblemsWrapper, 14 + ProblemsWrapperRef, 15 + } from "@streamplace/components/src/components/dashboard/problems"; 16 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 13 17 import { Dimensions, Platform, ScrollView, View } from "react-native"; 14 18 import LivestreamPanel from "./livestream-panel"; 15 19 import StreamMonitor from "./stream-monitor"; ··· 17 21 const { flex, p, gap, layout, bg } = zero; 18 22 19 23 interface BentoGridProps { 20 - userProfile: any; 21 24 isLive: boolean; 22 25 videoRef: any; 23 26 } 24 27 25 - export default function BentoGrid({ 26 - userProfile, 27 - isLive, 28 - videoRef, 29 - }: BentoGridProps) { 28 + export default function BentoGrid({ isLive, videoRef }: BentoGridProps) { 30 29 const navigation = useNavigation(); 31 30 const isWeb = Platform.OS === "web"; 31 + const problemsRef = useRef<ProblemsWrapperRef>(null); 32 + 33 + const handleProblemsPress = useCallback(() => { 34 + problemsRef.current?.setDismiss(false); 35 + }, []); 32 36 33 37 // Screen width state for responsive design 34 38 const [screenWidth, setScreenWidth] = useState( ··· 62 66 const profile = useProfile(); 63 67 const viewers = useLivestreamStore((x) => x.viewers); 64 68 const chat = useLivestreamStore((x) => x.chat); 69 + const problems = useLivestreamStore((x) => x.problems); 65 70 const segmentTiming = useSegmentTiming(); 66 71 const seg = useSegment(); 67 72 const ingestConnectionState = usePlayerStore((x) => x.ingestConnectionState); ··· 69 74 70 75 // Calculate derived values 71 76 const isConnected = ingestConnectionState === "connected"; 72 - const canModerate = isLive && isConnected; 73 77 74 78 // Calculate uptime 75 79 const getUptime = useCallback((): string => { ··· 131 135 if (isDesktop) { 132 136 // Desktop layout (>= 1200px) - Original bento grid 133 137 return ( 134 - <View style={[flex.values[1], gap.all[4], p[4], bg.black]}> 135 - <View style={[layout.flex.column, { minWidth: isWeb ? 400 : "100%" }]}> 136 - <Dashboard.Header 137 - isLive={isLive} 138 - streamTitle={ 139 - profile?.displayName || profile?.handle || "Live Stream" 140 - } 141 - viewers={viewers || 0} 142 - uptime={getUptime()} 143 - bitrate={getBitrate()} 144 - timeBetweenSegments={segmentTiming.timeBetweenSegments || 0} 145 - connectionStatus={getConnectionStatus} 146 - /> 147 - </View> 148 - <View style={[flex.values[1], layout.flex.row, gap.all[4]]}> 149 - <View style={[flex.values[4], gap.all[4]]}> 138 + <ProblemsWrapper ref={problemsRef}> 139 + <View style={[flex.values[1], gap.all[4], p[4], bg.black]}> 140 + <View 141 + style={[layout.flex.column, { minWidth: isWeb ? 400 : "100%" }]} 142 + > 143 + <Dashboard.Header 144 + isLive={isLive} 145 + streamTitle={ 146 + profile?.displayName || profile?.handle || "Live Stream" 147 + } 148 + viewers={viewers || 0} 149 + uptime={getUptime()} 150 + bitrate={getBitrate()} 151 + timeBetweenSegments={segmentTiming.timeBetweenSegments || 0} 152 + connectionStatus={getConnectionStatus} 153 + problemsCount={problems.length} 154 + onProblemsPress={handleProblemsPress} 155 + /> 156 + </View> 157 + <View style={[flex.values[1], layout.flex.row, gap.all[4]]}> 158 + <View style={[flex.values[4], gap.all[4]]}> 159 + <View 160 + style={[ 161 + flex.values[2], 162 + layout.flex.row, 163 + gap.all[4], 164 + { height: isWeb ? 300 : 200 }, 165 + ]} 166 + > 167 + <StreamMonitor 168 + isLive={isLive} 169 + userProfile={profile} 170 + videoRef={videoRef} 171 + /> 172 + </View> 173 + 174 + <View style={[layout.flex.row, gap.all[4], flex.values[1]]}> 175 + <Dashboard.InformationWidget /> 176 + </View> 177 + </View> 178 + 150 179 <View 151 180 style={[ 152 181 flex.values[2], 153 - layout.flex.row, 182 + layout.flex.column, 154 183 gap.all[4], 155 - { height: isWeb ? 300 : 200 }, 184 + { maxWidth: isWeb ? 600 : "100%" }, 156 185 ]} 157 186 > 158 - <StreamMonitor 187 + <Dashboard.ChatPanel 159 188 isLive={isLive} 160 - userProfile={profile} 161 - videoRef={videoRef} 189 + isConnected={isConnected} 190 + messagesPerMinute={messagesPerMinute} 162 191 /> 163 192 </View> 164 - 165 - <View style={[layout.flex.row, gap.all[4], flex.values[1]]}> 166 - <Dashboard.InformationWidget /> 193 + <View 194 + style={[ 195 + flex.values[2], 196 + layout.flex.column, 197 + gap.all[4], 198 + { maxWidth: isWeb ? 600 : "100%" }, 199 + ]} 200 + > 201 + <LivestreamPanel /> 167 202 </View> 168 203 </View> 169 - 170 - <View 171 - style={[ 172 - flex.values[2], 173 - layout.flex.column, 174 - gap.all[4], 175 - { maxWidth: isWeb ? 600 : "100%" }, 176 - ]} 177 - > 178 - <Dashboard.ChatPanel 179 - isLive={isLive} 180 - isConnected={isConnected} 181 - messagesPerMinute={messagesPerMinute} 182 - canModerate={canModerate} 183 - /> 184 - </View> 185 - <View 186 - style={[ 187 - flex.values[2], 188 - layout.flex.column, 189 - gap.all[4], 190 - { maxWidth: isWeb ? 600 : "100%" }, 191 - ]} 192 - > 193 - <LivestreamPanel /> 194 - </View> 195 204 </View> 196 - </View> 205 + </ProblemsWrapper> 197 206 ); 198 207 } 199 208 200 209 return ( 201 - <ScrollView style={[flex.values[1], bg.black]}> 202 - {/* Header always at top */} 203 - <View style={[p[4]]}> 204 - <Dashboard.Header 205 - isLive={isLive} 206 - streamTitle={profile?.displayName || profile?.handle || "Live Stream"} 207 - viewers={viewers || 0} 208 - uptime={getUptime()} 209 - bitrate={getBitrate()} 210 - timeBetweenSegments={segmentTiming.timeBetweenSegments || 0} 211 - connectionStatus={getConnectionStatus} 212 - /> 213 - </View> 214 - 215 - {/* Fixed layout with flex */} 216 - <View 217 - style={[ 218 - flex.values[1], 219 - layout.flex.column, 220 - gap.all[4], 221 - p[4], 222 - { paddingTop: 0 }, 223 - ]} 224 - > 225 - {/* Stream Monitor Panel */} 226 - <View style={[{ maxHeight: screenHeight * 0.35, height: "100%" }]}> 227 - <StreamMonitor 210 + <ProblemsWrapper ref={problemsRef}> 211 + <ScrollView style={[flex.values[1], bg.black]}> 212 + {/* Header always at top */} 213 + <View style={[p[4]]}> 214 + <Dashboard.Header 228 215 isLive={isLive} 229 - userProfile={profile} 230 - videoRef={videoRef} 216 + streamTitle={ 217 + profile?.displayName || profile?.handle || "Live Stream" 218 + } 219 + viewers={viewers || 0} 220 + uptime={getUptime()} 221 + bitrate={getBitrate()} 222 + timeBetweenSegments={segmentTiming.timeBetweenSegments || 0} 223 + connectionStatus={getConnectionStatus} 224 + problemsCount={problems.length} 225 + onProblemsPress={handleProblemsPress} 231 226 /> 232 227 </View> 233 228 234 - {/* Chat Panel - takes remaining space */} 235 - <View style={[flex.values[1], { maxHeight: screenHeight * 0.65 }]}> 236 - <Button 237 - disabled={!profile} 238 - onPress={() => 239 - navigation.navigate("PopoutChat", { user: profile!.did }) 240 - } 241 - > 242 - Go to chat 243 - </Button> 244 - </View> 229 + {/* Fixed layout with flex */} 230 + <View 231 + style={[ 232 + flex.values[1], 233 + layout.flex.column, 234 + gap.all[4], 235 + p[4], 236 + { paddingTop: 0 }, 237 + ]} 238 + > 239 + {/* Stream Monitor Panel */} 240 + <View style={[{ maxHeight: screenHeight * 0.35, height: "100%" }]}> 241 + <StreamMonitor 242 + isLive={isLive} 243 + userProfile={profile} 244 + videoRef={videoRef} 245 + /> 246 + </View> 247 + 248 + {/* Chat Panel - takes remaining space */} 249 + <View style={[flex.values[1], { maxHeight: screenHeight * 0.65 }]}> 250 + <Button 251 + disabled={!profile} 252 + onPress={() => 253 + navigation.navigate("PopoutChat", { user: profile!.did }) 254 + } 255 + > 256 + Go to chat 257 + </Button> 258 + </View> 245 259 246 - {/* Livestream Panel */} 247 - <View style={[{ height: "auto" }]}> 248 - <LivestreamPanel /> 260 + {/* Livestream Panel */} 261 + <View style={[{ height: "auto" }]}> 262 + <LivestreamPanel /> 263 + </View> 249 264 </View> 250 - </View> 251 - </ScrollView> 265 + </ScrollView> 266 + </ProblemsWrapper> 252 267 ); 253 268 }
+24 -4
js/app/components/mobile/bottom-metadata.tsx
··· 5 5 ShareSheet, 6 6 Text, 7 7 useAvatars, 8 + useDID, 8 9 useLivestreamInfo, 9 10 useLivestreamStore, 10 11 zero, 11 12 } from "@streamplace/components"; 13 + import FollowButton from "components/follow-button"; 12 14 import { ChevronLeft, ChevronRight } from "lucide-react-native"; 13 - import { Image, View } from "react-native"; 15 + import { Image, Linking, Pressable, View } from "react-native"; 14 16 const { gap, px, py, colors } = zero; 15 17 16 18 export function BottomMetadata({ ··· 23 25 const { profile } = useLivestreamInfo(); 24 26 const avatars = useAvatars(profile?.did ? [profile?.did] : []); 25 27 const ls = useLivestreamStore((x) => x.livestream); 28 + 29 + const did = useDID(); 26 30 27 31 return ( 28 32 <View ··· 68 72 /> 69 73 )} 70 74 <View style={{ flex: 1, minWidth: 0 }}> 71 - <Text style={{ color: "white", fontWeight: "600" }}> 72 - @{profile?.handle || "user"} 73 - </Text> 75 + <View 76 + style={[layout.flex.row, layout.flex.alignCenter, gap.all[2]]} 77 + > 78 + <Pressable 79 + onPress={() => { 80 + if (profile?.handle) { 81 + const url = `https://bsky.app/profile/${profile.handle}`; 82 + Linking.openURL(url); 83 + } 84 + }} 85 + > 86 + <Text style={{ color: "white", fontWeight: "600" }}> 87 + @{profile?.handle || "user"} 88 + </Text> 89 + </Pressable> 90 + {did && profile && ( 91 + <FollowButton streamerDID={profile?.did} currentUserDID={did} /> 92 + )} 93 + </View> 74 94 <Text 75 95 style={{ color: colors.gray[400] }} 76 96 numberOfLines={3}
+1 -2
js/app/components/mobile/chat.tsx
··· 105 105 let agent = usePDSAgent(); 106 106 107 107 const navigation = useNavigation(); 108 - let canModerate = profile?.handle === handle; 109 108 110 109 return ( 111 110 <View ··· 117 116 ]} 118 117 > 119 118 <View style={[flex.values[1]]}> 120 - <Chat canModerate={canModerate} /> 119 + <Chat /> 121 120 </View> 122 121 <View style={[layout.flex.column, gap.all[2]]}> 123 122 {agent?.did ? (
+2
js/app/components/mobile/desktop-ui.tsx
··· 63 63 const showMetrics = usePlayerStore((state) => state.showDebugInfo); 64 64 const pipAction = usePlayerStore((state) => state.pipAction); 65 65 const videoRef = usePlayerStore((state) => state.videoRef); 66 + const embedded = usePlayerStore((state) => state.embedded); 66 67 67 68 const segment = useSegment(); 68 69 ··· 213 214 isChatOpen={isChatOpen || false} 214 215 onToggleChat={toggleChat} 215 216 safeAreaInsets={safeAreaInsets} 217 + embedded={embedded} 216 218 /> 217 219 </Animated.View> 218 220
+42 -2
js/app/components/mobile/desktop-ui/top-controls.tsx
··· 9 9 zero, 10 10 } from "@streamplace/components"; 11 11 import { ChevronLeft, MessageSquare, SwitchCamera } from "lucide-react-native"; 12 - import { Image, Platform, Pressable } from "react-native"; 12 + import { 13 + Image, 14 + Linking, 15 + Platform, 16 + Pressable, 17 + useWindowDimensions, 18 + } from "react-native"; 13 19 import { LiveBubble } from "./live-bubble"; 14 20 15 - const { borders, colors, gap, layout, p, r, text } = zero; 21 + const { borders, colors, gap, layout, p, px, py, r, text } = zero; 16 22 17 23 interface TopControlBarProps { 18 24 offline: boolean; ··· 21 27 isChatOpen: boolean; 22 28 onToggleChat: () => void; 23 29 safeAreaInsets: { top: number }; 30 + embedded?: boolean; 24 31 } 25 32 26 33 export function TopControlBar({ ··· 30 37 isChatOpen, 31 38 onToggleChat, 32 39 safeAreaInsets, 40 + embedded = false, 33 41 }: TopControlBarProps) { 34 42 const navigation = useNavigation(); 35 43 const { profile } = useLivestreamInfo(); 36 44 const { doSetIngestCamera } = useCameraToggle(); 37 45 const avatars = useAvatars(profile?.did ? [profile?.did] : []); 46 + const { width } = useWindowDimensions(); 47 + const isTinyScreen = width < 450; 48 + const isSmallScreen = width < 600; 38 49 39 50 return ( 40 51 <View ··· 84 95 </View> 85 96 86 97 <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[3]]}> 98 + {embedded && Platform.OS === "web" && ( 99 + <Pressable 100 + onPress={() => { 101 + const url = window.location.href.replace("/embed/", "/"); 102 + Linking.openURL(url); 103 + }} 104 + style={[ 105 + layout.flex.row, 106 + layout.flex.alignCenter, 107 + gap.all[2], 108 + py[2], 109 + px[3], 110 + r.xl, 111 + { 112 + backgroundColor: "rgba(75,75,75, 0.65)", 113 + }, 114 + ]} 115 + > 116 + {!isSmallScreen && <Text size="lg">Powered by</Text>} 117 + <Image 118 + source={require("assets/images/cube_small.png")} 119 + style={{ 120 + width: 24, 121 + height: 24, 122 + }} 123 + /> 124 + {!isTinyScreen && <Text size="lg">Streamplace</Text>} 125 + </Pressable> 126 + )} 87 127 {isActivelyLive && ( 88 128 <> 89 129 <PlayerUI.Viewers />
+9 -1
js/app/components/provider/provider.shared.tsx
··· 36 36 Sentry.setTag("expoGoVersion", Constants.expoVersion); 37 37 Sentry.setTag("expoRuntimeVersion", Constants.expoRuntimeVersion); 38 38 39 + const SPDarkTheme = { 40 + ...DarkTheme, 41 + colors: { 42 + ...DarkTheme.colors, 43 + background: "transparent", 44 + }, 45 + }; 46 + 39 47 function ProviderInner({ 40 48 children, 41 49 linking, ··· 74 82 return ( 75 83 <SafeAreaProvider> 76 84 <ThemeProvider forcedTheme="dark"> 77 - <NavigationContainer theme={DarkTheme} linking={linking}> 85 + <NavigationContainer theme={SPDarkTheme} linking={linking}> 78 86 <ReduxProvider store={store}> 79 87 <StreamplaceProvider> 80 88 <BlueskyProvider>
+9 -1
js/app/components/settings/settings.tsx
··· 1 1 import { useNavigation } from "@react-navigation/native"; 2 - import { Button, Input, Text, View, zero } from "@streamplace/components"; 2 + import { 3 + Button, 4 + Input, 5 + Text, 6 + useToast, 7 + View, 8 + zero, 9 + } from "@streamplace/components"; 3 10 import AQLink from "components/aqlink"; 4 11 import { 5 12 createServerSettingsRecord, ··· 21 28 const defaultUrl = DEFAULT_URL; 22 29 const [newUrl, setNewUrl] = useState(""); 23 30 const [overrideEnabled, setOverrideEnabled] = useState(false); 31 + const t = useToast(); 24 32 25 33 // are we logged in? 26 34 const loggedIn = useAppSelector(
+37 -2
js/app/components/settings/webhook-manager.tsx
··· 3 3 Dialog, 4 4 DialogFooter, 5 5 Input, 6 + ResponsiveDialog, 6 7 Text, 7 8 zero, 8 9 } from "@streamplace/components"; ··· 50 51 prefix?: string; 51 52 suffix?: string; 52 53 rewrite?: Array<{ from: string; to: string }>; 54 + muteWords?: string[]; 53 55 description?: string; 54 56 createdAt: string; 55 57 updatedAt?: string; ··· 65 67 prefix: string; 66 68 suffix: string; 67 69 rewrite: Array<{ from: string; to: string }>; 70 + muteWords: string[]; 68 71 description: string; 69 72 } 70 73 ··· 247 250 prefix: webhook?.prefix || "", 248 251 suffix: webhook?.suffix || "", 249 252 rewrite: webhook?.rewrite || [{ from: "", to: "" }], 253 + muteWords: webhook?.muteWords || [], 250 254 description: webhook?.description || "", 251 255 }); 252 256 ··· 263 267 prefix: webhook.prefix || "", 264 268 suffix: webhook.suffix || "", 265 269 rewrite: webhook.rewrite || [{ from: "", to: "" }], 270 + muteWords: webhook.muteWords || [], 266 271 description: webhook.description || "", 267 272 }); 268 273 } else { ··· 275 280 prefix: "", 276 281 suffix: "", 277 282 rewrite: [{ from: "", to: "" }], 283 + muteWords: [], 278 284 description: "", 279 285 }); 280 286 } ··· 340 346 }; 341 347 342 348 return ( 343 - <Dialog 349 + <ResponsiveDialog 344 350 open={isVisible} 345 351 onOpenChange={(open) => !open && onClose()} 346 352 title={webhook ? "Edit Webhook" : "Create Webhook"} ··· 546 552 ))} 547 553 </View> 548 554 555 + {/* Mute Words */} 556 + <View style={[mb[4]]}> 557 + <Text 558 + style={[text.gray[400], mb[2], { fontSize: 14, fontWeight: "500" }]} 559 + > 560 + Mute Words (Chat Only) 561 + </Text> 562 + <Text style={[text.gray[400], mb[3], { fontSize: 12 }]}> 563 + Chat messages containing any of these words will not be forwarded. 564 + Useful for avoiding reforwarding of forwarded messages. 565 + </Text> 566 + <Input 567 + value={formData.muteWords.join(", ")} 568 + onChangeText={(text) => 569 + setFormData((prev) => ({ 570 + ...prev, 571 + muteWords: text 572 + .split(",") 573 + .map((w) => w.trim()) 574 + .filter((w) => w), 575 + })) 576 + } 577 + placeholder="word1, word2, word3" 578 + multiline 579 + /> 580 + </View> 581 + 549 582 {/* Example message text */} 550 583 <View style={[mb[4]]}> 551 584 <Text ··· 598 631 <Text>{isLoading ? "Saving..." : webhook ? "Update" : "Create"}</Text> 599 632 </Button> 600 633 </DialogFooter> 601 - </Dialog> 634 + </ResponsiveDialog> 602 635 ); 603 636 } 604 637 ··· 663 696 prefix: data.prefix || undefined, 664 697 suffix: data.suffix || undefined, 665 698 rewrite: rewriteRules.length > 0 ? rewriteRules : undefined, 699 + muteWords: data.muteWords.length > 0 ? data.muteWords : undefined, 666 700 description: data.description || undefined, 667 701 }); 668 702 setShowForm(false); ··· 699 733 prefix: data.prefix || undefined, 700 734 suffix: data.suffix || undefined, 701 735 rewrite: rewriteRules.length > 0 ? rewriteRules : undefined, 736 + muteWords: data.muteWords.length > 0 ? data.muteWords : undefined, 702 737 description: data.description || undefined, 703 738 }); 704 739 setShowForm(false);
+29 -11
js/app/components/sidebar/sidebar-item.tsx
··· 1 - import { Text, useTheme } from "@streamplace/components"; 1 + import { DrawerNavigationState, ParamListBase } from "@react-navigation/native"; 2 + import { Text, useTheme, zero } from "@streamplace/components"; 3 + import { useAQLinkHref } from "components/aqlink"; 2 4 import React, { ReactNode, useState } from "react"; 3 5 import { 6 + GestureResponderEvent, 7 + Pressable, 4 8 PressableStateCallbackType, 5 9 StyleProp, 6 10 View, 7 11 ViewStyle, 8 12 } from "react-native"; 9 - import { Pressable } from "react-native-gesture-handler"; 10 13 11 14 export default function SidebarItem({ 12 15 icon, ··· 14 17 collapsed, 15 18 active, 16 19 onPress, 20 + route, 17 21 style = null, 18 22 tint = "rgba(189, 110, 134)", 19 23 }: { ··· 24 28 label: string | ReactNode; 25 29 collapsed: boolean; 26 30 active: boolean; 27 - onPress: () => void; 31 + onPress: (event: GestureResponderEvent) => void; 32 + route?: DrawerNavigationState<ParamListBase>["routes"][number]; 28 33 style?: 29 34 | StyleProp<ViewStyle> 30 35 | ((state: PressableStateCallbackType) => StyleProp<ViewStyle>); ··· 32 37 }) { 33 38 const [hover, setHover] = useState<boolean>(false); 34 39 const theme = useTheme(); 40 + const { href } = useAQLinkHref({ 41 + screen: route?.name || "Home", 42 + params: route?.params as any, 43 + }); 35 44 36 45 // Handle different icon types - component, JSX element, or function returning JSX 37 46 const renderIcon = () => { ··· 62 71 } 63 72 64 73 // Fallback 65 - console.log("tried to render item, but couldn't", (icon as any).$$typeof); 74 + console.log( 75 + "tried to render item for route", 76 + label, 77 + href, 78 + ", but couldn't", 79 + (icon as any).$$typeof, 80 + ); 66 81 67 82 return <Text>📄</Text>; 68 83 }; ··· 73 88 style={style} 74 89 onHoverIn={() => setHover(true)} 75 90 onHoverOut={() => setHover(false)} 91 + role="link" 92 + accessibilityLabel={typeof label === "string" ? label : "Link to " + href} 93 + // @ts-ignore This makes it render as <a> on web! 94 + href={route ? href : undefined} 76 95 > 77 96 <View 78 97 style={[ 98 + zero.r.md, 99 + zero.layout.flex.row, 100 + zero.layout.flex.alignCenter, 101 + zero.px[3], 102 + zero.gap.all[2], 79 103 { 80 104 backgroundColor: 81 105 hover || active ··· 84 108 ", " + (active && !hover ? "0.1" : "0.25") + ")", 85 109 ) 86 110 : undefined, 87 - borderRadius: 12, 88 - flexDirection: "row", 89 - justifyContent: "flex-start", 90 - alignItems: "center", 91 - paddingHorizontal: 12, 92 - gap: 8, 93 111 overflow: "hidden", 94 112 }, 95 113 ]} 96 114 > 97 - <View style={[{ width: 32, paddingVertical: 12 }]}>{renderIcon()}</View> 115 + <View style={[zero.w[8], zero.py[3]]}>{renderIcon()}</View> 98 116 {!collapsed && ( 99 117 <View 100 118 style={[
+33 -15
js/app/components/sidebar/sidebar.tsx
··· 6 6 ParamListBase, 7 7 useNavigation, 8 8 } from "@react-navigation/native"; 9 - import { Text, useTheme } from "@streamplace/components"; 9 + import { Text, zero } from "@streamplace/components"; 10 10 import React from "react"; 11 11 import { Image, Platform, View } from "react-native"; 12 12 import Animated, { ··· 44 44 widthAnim, 45 45 externalItems = [], 46 46 }: SidebarProps) { 47 - const theme = useTheme(); 48 47 const navigation = useNavigation(); 49 - // Apply the defined type to the component props 50 48 const animatedSidebarStyle = useAnimatedStyle(() => { 51 49 return { 52 50 minWidth: widthAnim.value, ··· 61 59 return ( 62 60 <Animated.View 63 61 style={[ 64 - animatedSidebarStyle, // Apply the animated style 65 - { padding: 8, gap: 8, flexDirection: "column" }, 62 + animatedSidebarStyle, 63 + zero.p[2], 64 + zero.gap.all[2], 65 + zero.layout.flex.column, 66 66 ]} 67 67 > 68 68 <View 69 69 style={[ 70 + zero.layout.flex.row, 71 + zero.layout.flex.alignCenter, 72 + zero.gap.all[3], 70 73 { 71 - marginTop: Platform.OS === "ios" ? 29 : 12, 74 + marginTop: Platform.OS === "ios" ? 29 : 8, 72 75 marginBottom: 20, 73 - paddingLeft: 10, 74 - gap: 12, 75 - flexDirection: "row", 76 - justifyContent: "flex-start", 77 - alignItems: "center", 76 + paddingLeft: 11, 78 77 }, 79 78 ]} 80 79 > 81 80 <Image 82 81 source={require("../../assets/images/cube.png")} 83 - style={[{ height: 30, width: 28 }]} 82 + height={30} 83 + width={28} 84 + style={{ width: 28, height: 30, resizeMode: "contain" }} 84 85 /> 85 86 {!collapsed && <Text size="2xl">Streamplace</Text>} 86 87 </View> ··· 98 99 | React.ComponentType<any> 99 100 | undefined; 100 101 102 + // if we have style display: none on the drawer item, completely skip rendering it 103 + const drawerItemStyle = options.drawerItemStyle; 104 + let isHidden = false; 105 + if ( 106 + drawerItemStyle && 107 + typeof drawerItemStyle === "object" && 108 + "display" in drawerItemStyle && 109 + (drawerItemStyle as any).display === "none" 110 + ) { 111 + isHidden = true; 112 + } 113 + if (isHidden) { 114 + return null; 115 + } 116 + 101 117 return ( 102 118 <SidebarItem 103 119 key={route.key} ··· 105 121 label={label} 106 122 active={descriptor.navigation.isFocused()} 107 123 collapsed={collapsed} 108 - onPress={() => { 124 + route={route} 125 + onPress={(ev) => { 126 + ev.preventDefault(); 109 127 if (route.name === "Home") { 110 - // copy logic for 'Home' to reset the stack 128 + // reset the stack (b/c streamlist is in the same stack as home) 111 129 navigation.dispatch( 112 130 CommonActions.reset({ 113 131 index: 0, ··· 126 144 } 127 145 }} 128 146 style={options.drawerItemStyle} 129 - tint={options.drawerActiveTintColor as string} // Assuming tint is a string color or undefined 147 + tint={options.drawerActiveTintColor as string} 130 148 /> 131 149 ); 132 150 })}
+5
js/app/features/bluesky/blueskySlice.tsx
··· 253 253 throw new Error("No client"); 254 254 } 255 255 const u = await bluesky.client.authorize(handle, {}); 256 + if (document.location.href.startsWith("http://127.0.0.1")) { 257 + const hostUrl = new URL(document.location.href); 258 + u.host = hostUrl.host; 259 + u.protocol = hostUrl.protocol; 260 + } 256 261 thunkAPI.dispatch(openLoginLink(u.toString())); 257 262 // cheeky 500ms delay so you don't see the text flash back 258 263 await new Promise((resolve) => setTimeout(resolve, 5000));
+60 -21
js/app/features/bluesky/oauthClient.tsx
··· 69 69 ); 70 70 meta = await res.json(); 71 71 } 72 - clientMetadataSchema.parse(meta); 72 + try { 73 + clientMetadataSchema.parse(meta); 74 + } catch (e) { 75 + console.error("error parsing client metadata", e, meta); 76 + throw e; 77 + } 73 78 return new ReactNativeOAuthClient({ 74 79 fetch: async (input, init) => { 80 + console.log("fetch", input, init); 75 81 // Normalize input to a Request object 76 82 let request: Request; 77 83 if (typeof input === "string" || input instanceof URL) { ··· 79 85 } else { 80 86 request = input; 81 87 } 82 - 83 - // Lie to the oauth client and use our upstream server instead 84 - if ( 85 - request.url.includes("plc.directory") || 86 - request.url.endsWith("did.json") 87 - ) { 88 - const res = await fetch(request, init); 89 - if (!res.ok) { 90 - return res; 88 + if (streamplaceUrl.startsWith("http://127.0.0.1")) { 89 + // everything other than PDS resolution gets rewritten to the host 90 + if ( 91 + request.url.includes("plc.directory") || 92 + request.url.endsWith("did.json") || 93 + request.url.endsWith("/.well-known/oauth-protected-resource") || 94 + request.url.endsWith("/.well-known/oauth-authorization-server") 95 + ) { 96 + return fetch(request, init) as any; 97 + } 98 + const newUrl = new URL(request.url.toString()); 99 + newUrl.protocol = "http:"; 100 + newUrl.host = "127.0.0.1:38080"; 101 + let newRequest: Request; 102 + if (request.method === "POST") { 103 + const data = await request.blob(); 104 + newRequest = new Request(newUrl.toString(), { 105 + body: data, 106 + method: "POST", 107 + headers: request.headers, 108 + }); 109 + } else if (request.method === "GET") { 110 + newRequest = new Request(newUrl.toString(), { 111 + method: "GET", 112 + headers: request.headers, 113 + }); 114 + } else { 115 + throw new Error("Unsupported method: " + request.method); 91 116 } 92 - const data = await res.json(); 93 - const service = data.service.find((s: any) => s.id === "#atproto_pds"); 94 - if (!service) { 95 - return res; 117 + return fetch(newRequest) as any; 118 + } else { 119 + // Lie to the oauth client and use our upstream server instead 120 + if ( 121 + request.url.includes("plc.directory") || 122 + request.url.endsWith("did.json") 123 + ) { 124 + const res = await fetch(request, init); 125 + if (!res.ok) { 126 + return res; 127 + } 128 + const data = await res.json(); 129 + const service = data.service.find( 130 + (s: any) => s.id === "#atproto_pds", 131 + ); 132 + if (!service) { 133 + return res; 134 + } 135 + service.serviceEndpoint = streamplaceUrl; 136 + return new Response(JSON.stringify(data), { 137 + status: res.status, 138 + headers: res.headers, 139 + }); 140 + } else { 141 + return fetch(request, init); 96 142 } 97 - service.serviceEndpoint = streamplaceUrl; 98 - return new Response(JSON.stringify(data), { 99 - status: res.status, 100 - headers: res.headers, 101 - }); 102 143 } 103 - 104 - return fetch(request, init); 105 144 }, 106 145 handleResolver: streamplaceUrl, 107 146 responseMode: "query", // or "fragment" (frontend only) or "form_post" (backend only)
+1 -1
js/app/package.json
··· 1 1 { 2 2 "name": "@streamplace/app", 3 3 "main": "./src/entrypoint.tsx", 4 - "version": "0.7.33", 4 + "version": "0.7.35", 5 5 "runtimeVersion": "0.7.2", 6 6 "scripts": { 7 7 "start": "npx expo start -c --port 38081",
+1 -1
js/app/public/index.html
··· 23 23 } 24 24 </style> 25 25 <style> 26 - html { 26 + body { 27 27 background-color: black; 28 28 } 29 29 </style>
+18 -37
js/app/src/router.tsx
··· 15 15 useRoute, 16 16 } from "@react-navigation/native"; 17 17 import { createNativeStackNavigator } from "@react-navigation/native-stack"; 18 - import { Text, useTheme } from "@streamplace/components"; 18 + import { Text, useTheme, useToast } from "@streamplace/components"; 19 19 import { Provider, Settings } from "components"; 20 20 import AQLink from "components/aqlink"; 21 21 import Login from "components/login/login"; 22 - import Popup from "components/popup"; 23 22 import Sidebar, { ExternalDrawerItem } from "components/sidebar/sidebar"; 24 23 import * as ExpoLinking from "expo-linking"; 25 24 import { hydrate, selectHydrated } from "features/base/baseSlice"; ··· 332 331 const { isWeb, isElectron, isNative, isBrowser } = usePlatform(); 333 332 const navigation = useNavigation(); 334 333 const dispatch = useAppDispatch(); 335 - const [poppedUp, setPoppedUp] = useState(false); 336 334 const [livePopup, setLivePopup] = useState(false); 337 335 338 336 const sidebar = useSidebarControl(); 337 + 338 + const toast = useToast(); 339 339 340 340 SystemBars.setStyle("dark"); 341 341 ··· 385 385 386 386 let foregroundColor = theme.theme.colors.text || "#fff"; 387 387 388 - const [isLiveDashboard, setIsLiveDashboard] = useState(true); 388 + // are we in the live dashboard? 389 + const [isLiveDashboard, setIsLiveDashboard] = useState(false); 389 390 useEffect(() => { 390 - if (!isLiveDashboard && userIsLive && !poppedUp) { 391 - setPoppedUp(true); 392 - setLivePopup(true); 391 + if (!isLiveDashboard && userIsLive) { 392 + toast.show("You are live!", "Do you want to go to your Live Dashboard?", { 393 + actionLabel: "Go", 394 + onAction: () => { 395 + navigation.navigate("LiveDashboard"); 396 + setLivePopup(false); 397 + }, 398 + onClose: () => setLivePopup(false), 399 + variant: "error", 400 + duration: 8, 401 + }); 393 402 } 394 - }, [userIsLive, poppedUp]); 403 + }, [userIsLive]); 395 404 const externalItems = useExternalItems(); 396 405 397 406 if (!hydrated) { 398 407 return <View />; 399 408 } 409 + 400 410 return ( 401 411 <> 402 412 <StatusBar barStyle="light-content" /> ··· 589 599 }} 590 600 /> 591 601 </Drawer.Navigator> 592 - {isWeb && livePopup && ( 593 - <Popup 594 - onPress={() => { 595 - navigation.navigate("LiveDashboard"); 596 - setLivePopup(false); 597 - }} 598 - onClose={() => { 599 - setLivePopup(false); 600 - }} 601 - containerProps={{ 602 - style: { bottom: 32 }, 603 - }} 604 - bubbleProps={{ 605 - style: { backgroundColor: "#cc0000" }, 606 - }} 607 - > 608 - <Text 609 - style={[ 610 - { textAlign: "center" }, 611 - { fontSize: 24, fontWeight: "bold" }, 612 - ]} 613 - > 614 - ✨YOU ARE LIVE!!!✨ 615 - </Text> 616 - <Text> 617 - {isNative ? "Tap" : "Click"} here to go to the live dashboard 618 - </Text> 619 - </Popup> 620 - )} 621 602 </> 622 603 ); 623 604 }
+1 -1
js/app/src/screens/chat-popout.native.tsx
··· 171 171 </View> 172 172 </View> 173 173 <View style={[zero.flex.values[1], zero.p[4]]}> 174 - <Chat canModerate={profile?.handle === user} /> 174 + <Chat /> 175 175 {profile && <ChatBox emojiData={emojiData} isPopout={true} />} 176 176 </View> 177 177 </View>
+2 -1
js/app/src/screens/chat-popout.tsx
··· 33 33 useEffect(() => { 34 34 setSrc(user); 35 35 }, [user]); 36 + 36 37 return ( 37 38 <View style={[{ position: "relative" }, zero.flex.values[1], zero.m[2]]}> 38 39 <View ··· 41 42 { position: "absolute", width: "100%", minHeight: "100%", bottom: 0 }, 42 43 ]} 43 44 > 44 - <Chat canModerate={profile?.handle === user} /> 45 + <Chat /> 45 46 {profile && <ChatBox emojiData={emojiData} isPopout={true} />} 46 47 </View> 47 48 </View>
+1 -1
js/app/src/screens/embed.tsx
··· 37 37 return ( 38 38 <LivestreamProvider src={src}> 39 39 <PlayerProvider {...extraProps}> 40 - <Player src={src} {...extraProps}> 40 + <Player src={src} embedded={true} {...extraProps}> 41 41 <DesktopUi /> 42 42 </Player> 43 43 </PlayerProvider>
+1 -5
js/app/src/screens/live-dashboard.tsx
··· 45 45 <VideoElementProvider videoElement={videoElement}> 46 46 <PlayerProvider> 47 47 <View style={[flex.values[1], bg.gray[900]]}> 48 - <BentoGrid 49 - userProfile={userProfile} 50 - isLive={isLive} 51 - videoRef={videoRef} 52 - /> 48 + <BentoGrid isLive={isLive} videoRef={videoRef} /> 53 49 </View> 54 50 </PlayerProvider> 55 51 </VideoElementProvider>
+1 -1
js/components/package.json
··· 1 1 { 2 2 "name": "@streamplace/components", 3 - "version": "0.7.32", 3 + "version": "0.7.35", 4 4 "description": "Streamplace React (Native) Components", 5 5 "main": "dist/index.js", 6 6 "types": "src/index.tsx",
+90 -104
js/components/src/components/chat/chat.tsx
··· 136 136 }, 137 137 ); 138 138 139 - const ChatLine = memo( 140 - ({ 141 - item, 142 - canModerate, 143 - }: { 144 - item: ChatMessageViewHydrated; 145 - canModerate: boolean; 146 - }) => { 147 - const setReply = useSetReplyToMessage(); 148 - const setModMsg = usePlayerStore((state) => state.setModMessage); 149 - const swipeableRef = useRef<SwipeableMethods | null>(null); 150 - const [isHovered, setIsHovered] = useState(false); 151 - const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null); 139 + const ChatLine = memo(({ item }: { item: ChatMessageViewHydrated }) => { 140 + const setReply = useSetReplyToMessage(); 141 + const setModMsg = usePlayerStore((state) => state.setModMessage); 142 + const swipeableRef = useRef<SwipeableMethods | null>(null); 143 + const [isHovered, setIsHovered] = useState(false); 144 + const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null); 145 + 146 + const handleHoverIn = () => { 147 + if (hoverTimeoutRef.current) { 148 + clearTimeout(hoverTimeoutRef.current); 149 + hoverTimeoutRef.current = null; 150 + } 151 + setIsHovered(true); 152 + }; 153 + 154 + const handleHoverOut = () => { 155 + hoverTimeoutRef.current = setTimeout(() => { 156 + setIsHovered(false); 157 + }, 50); 158 + }; 152 159 153 - const handleHoverIn = () => { 160 + useEffect(() => { 161 + return () => { 154 162 if (hoverTimeoutRef.current) { 155 163 clearTimeout(hoverTimeoutRef.current); 156 - hoverTimeoutRef.current = null; 157 164 } 158 - setIsHovered(true); 159 165 }; 166 + }, []); 160 167 161 - const handleHoverOut = () => { 162 - hoverTimeoutRef.current = setTimeout(() => { 163 - setIsHovered(false); 164 - }, 50); 165 - }; 166 - 167 - useEffect(() => { 168 - return () => { 169 - if (hoverTimeoutRef.current) { 170 - clearTimeout(hoverTimeoutRef.current); 171 - } 172 - }; 173 - }, []); 174 - 175 - if (item.author.did === "did:sys:system") { 176 - return ( 177 - <SystemMessage 178 - timestamp={new Date(item.record.createdAt)} 179 - title={item.record.text} 180 - /> 181 - ); 182 - } 183 - 184 - if (Platform.OS === "web") { 185 - return ( 186 - <View 187 - style={[ 188 - py[1], 189 - px[2], 190 - { 191 - position: "relative", 192 - borderRadius: 8, 193 - minWidth: 0, 194 - maxWidth: "100%", 195 - }, 196 - isHovered && bg.gray[950], 197 - ]} 198 - onPointerEnter={handleHoverIn} 199 - onPointerLeave={handleHoverOut} 200 - > 201 - <Pressable style={[{ minWidth: 0, maxWidth: "100%" }]}> 202 - <RenderChatMessage item={item} /> 203 - </Pressable> 204 - <ActionsBar 205 - item={item} 206 - visible={isHovered} 207 - hoverTimeoutRef={hoverTimeoutRef} 208 - /> 209 - </View> 210 - ); 211 - } 168 + if (item.author.did === "did:sys:system") { 169 + return ( 170 + <SystemMessage 171 + timestamp={new Date(item.record.createdAt)} 172 + title={item.record.text} 173 + /> 174 + ); 175 + } 212 176 177 + if (Platform.OS === "web") { 213 178 return ( 214 - <> 215 - <Swipeable 216 - containerStyle={[py[1]]} 217 - friction={2} 218 - enableTrackpadTwoFingerGesture 219 - rightThreshold={40} 220 - leftThreshold={40} 221 - renderRightActions={ 222 - Platform.OS === "android" ? undefined : RightAction 223 - } 224 - renderLeftActions={Platform.OS === "android" ? undefined : LeftAction} 225 - overshootFriction={9} 226 - ref={swipeableRef} 227 - onSwipeableOpen={(r) => { 228 - if (r === (Platform.OS === "android" ? "right" : "left")) { 229 - setReply(item); 230 - } 231 - if (r === (Platform.OS === "android" ? "left" : "right")) { 232 - setModMsg(item); 233 - } 234 - // close this swipeable 235 - const swipeable = swipeableRef.current; 236 - if (swipeable) { 237 - swipeable.close(); 238 - } 239 - }} 240 - > 179 + <View 180 + style={[ 181 + py[1], 182 + px[2], 183 + { 184 + position: "relative", 185 + borderRadius: 8, 186 + minWidth: 0, 187 + maxWidth: "100%", 188 + }, 189 + isHovered && bg.gray[950], 190 + ]} 191 + onPointerEnter={handleHoverIn} 192 + onPointerLeave={handleHoverOut} 193 + > 194 + <Pressable style={[{ minWidth: 0, maxWidth: "100%" }]}> 241 195 <RenderChatMessage item={item} /> 242 - </Swipeable> 243 - </> 196 + </Pressable> 197 + <ActionsBar 198 + item={item} 199 + visible={isHovered} 200 + hoverTimeoutRef={hoverTimeoutRef} 201 + /> 202 + </View> 244 203 ); 245 - }, 246 - ); 204 + } 205 + 206 + return ( 207 + <> 208 + <Swipeable 209 + containerStyle={[py[1]]} 210 + friction={2} 211 + enableTrackpadTwoFingerGesture 212 + rightThreshold={40} 213 + leftThreshold={40} 214 + renderRightActions={Platform.OS === "android" ? undefined : RightAction} 215 + renderLeftActions={Platform.OS === "android" ? undefined : LeftAction} 216 + overshootFriction={9} 217 + ref={swipeableRef} 218 + onSwipeableOpen={(r) => { 219 + if (r === (Platform.OS === "android" ? "right" : "left")) { 220 + setReply(item); 221 + } 222 + if (r === (Platform.OS === "android" ? "left" : "right")) { 223 + setModMsg(item); 224 + } 225 + // close this swipeable 226 + const swipeable = swipeableRef.current; 227 + if (swipeable) { 228 + swipeable.close(); 229 + } 230 + }} 231 + > 232 + <RenderChatMessage item={item} /> 233 + </Swipeable> 234 + </> 235 + ); 236 + }); 247 237 248 238 export function Chat({ 249 239 shownMessages = SHOWN_MSGS, 250 240 style: propsStyle, 251 - canModerate = false, 252 241 ...props 253 242 }: ComponentProps<typeof View> & { 254 243 shownMessages?: number; 255 244 style?: ComponentProps<typeof View>["style"]; 256 - canModerate?: boolean; 257 245 }) { 258 246 const chat = useChat(); 259 247 const [isScrolledUp, setIsScrolledUp] = useState(false); ··· 276 264 if (!chat) 277 265 return ( 278 266 <View style={[flex.shrink[1], { minWidth: 0, maxWidth: "100%" }]}> 279 - <Text>Loading chaat...</Text> 267 + <Text>Loading chat...</Text> 280 268 </View> 281 269 ); 282 270 ··· 295 283 data={chat.slice(0, shownMessages)} 296 284 inverted={true} 297 285 keyExtractor={keyExtractor} 298 - renderItem={({ item, index }) => ( 299 - <ChatLine item={item} canModerate={canModerate} /> 300 - )} 286 + renderItem={({ item, index }) => <ChatLine item={item} />} 301 287 removeClippedSubviews={true} 302 288 maxToRenderPerBatch={10} 303 289 initialNumToRender={10}
+3 -2
js/components/src/components/chat/mod-view.tsx
··· 1 1 import { TriggerRef, useRootContext } from "@rn-primitives/dropdown-menu"; 2 2 import { forwardRef, useEffect, useRef, useState } from "react"; 3 3 import { gap, mr, w } from "../../lib/theme/atoms"; 4 - import { usePlayerStore } from "../../player-store"; 4 + import { useIsMyStream, usePlayerStore } from "../../player-store"; 5 5 import { 6 6 useCreateBlockRecord, 7 7 useCreateHideChatRecord, ··· 51 51 const setReportSubject = usePlayerStore((x) => x.setReportSubject); 52 52 const setModMessage = usePlayerStore((x) => x.setModMessage); 53 53 const deleteChatMessage = useDeleteChatMessage(); 54 + const isMyStream = useIsMyStream(); 54 55 55 56 // get the channel did 56 57 const channelId = usePlayerStore((state) => state.src); ··· 119 120 </DropdownMenuGroup> 120 121 121 122 {/* TODO: Checking for non-owner moderators */} 122 - {channelId === handle && ( 123 + {isMyStream() && ( 123 124 <DropdownMenuGroup title={`Moderation actions`}> 124 125 <DropdownMenuItem 125 126 disabled={isHideLoading || messageRemoved}
+1 -3
js/components/src/components/dashboard/chat-panel.tsx
··· 10 10 isLive: boolean; 11 11 isConnected: boolean; 12 12 messagesPerMinute?: number; 13 - canModerate?: boolean; 14 13 shownMessages?: number; 15 14 } 16 15 ··· 18 17 isLive, 19 18 isConnected, 20 19 messagesPerMinute = 0, 21 - canModerate = false, 22 20 shownMessages = 50, 23 21 }: ChatPanelProps) { 24 22 return ( ··· 59 57 </View> 60 58 <View style={[flex.values[1], px[2], { minHeight: 0 }]}> 61 59 <View style={[flex.values[1], { minHeight: 0 }]}> 62 - <Chat canModerate={canModerate} shownMessages={shownMessages} /> 60 + <Chat shownMessages={shownMessages} /> 63 61 </View> 64 62 <View style={[{ flexShrink: 0 }]}> 65 63 <ChatBox
+37 -3
js/components/src/components/dashboard/header.tsx
··· 1 - import { Car, Radio, Users } from "lucide-react-native"; 2 - import { Text, View } from "react-native"; 1 + import { AlertCircle, Car, Radio, Users } from "lucide-react-native"; 2 + import { Pressable, Text, View } from "react-native"; 3 3 import * as zero from "../../ui"; 4 4 5 5 const { bg, r, borders, px, py, text, layout, gap } = zero; ··· 103 103 bitrate?: string; 104 104 timeBetweenSegments?: number; 105 105 connectionStatus?: "excellent" | "good" | "poor" | "offline"; 106 + problemsCount?: number; 107 + onProblemsPress?: () => void; 106 108 } 107 109 108 110 export default function Header({ ··· 113 115 bitrate = "0 mbps", 114 116 timeBetweenSegments = 0, 115 117 connectionStatus = "offline", 118 + problemsCount = 0, 119 + onProblemsPress, 116 120 }: HeaderProps) { 117 121 const getConnectionQuality = (): "good" | "warning" | "error" => { 118 122 if (timeBetweenSegments <= 1500) return "good"; ··· 139 143 <Text style={[text.white, { fontSize: 18, fontWeight: "600" }]}> 140 144 {streamTitle} 141 145 </Text> 142 - <StatusIndicator status={connectionStatus} isLive={isLive} /> 146 + <View style={[layout.flex.row, layout.flex.alignCenter, gap.all[3]]}> 147 + <StatusIndicator status={connectionStatus} isLive={isLive} /> 148 + {problemsCount > 0 && ( 149 + <Pressable onPress={onProblemsPress}> 150 + <View 151 + style={[ 152 + layout.flex.row, 153 + layout.flex.alignCenter, 154 + gap.all[1], 155 + px[2], 156 + py[1], 157 + r.md, 158 + bg.orange[900], 159 + borders.width.thin, 160 + borders.color.orange[700], 161 + { marginVertical: -8 }, 162 + ]} 163 + > 164 + <AlertCircle size={14} color="#fb923c" /> 165 + <Text 166 + style={[ 167 + text.orange[400], 168 + { fontSize: 11, fontWeight: "600" }, 169 + ]} 170 + > 171 + {problemsCount} {problemsCount === 1 ? "Issue" : "Issues"} 172 + </Text> 173 + </View> 174 + </Pressable> 175 + )} 176 + </View> 143 177 </View> 144 178 </View> 145 179
+1 -1
js/components/src/components/dashboard/index.tsx
··· 2 2 export { default as Header } from "./header"; 3 3 export { default as InformationWidget } from "./information-widget"; 4 4 export { default as ModActions } from "./mod-actions"; 5 - export { default as Problems } from "./problems"; 5 + export { default as Problems, ProblemsWrapperRef } from "./problems";
+57 -46
js/components/src/components/dashboard/problems.tsx
··· 1 - import { ExternalLink } from "lucide-react-native"; 2 - import { useState } from "react"; 3 - import { Linking, Pressable, Text, View } from "react-native"; 1 + import { 2 + CircleAlert, 3 + CircleX, 4 + ExternalLink, 5 + Info, 6 + Sparkle, 7 + } from "lucide-react-native"; 8 + import { forwardRef, useImperativeHandle, useState } from "react"; 9 + import { Linking, Pressable, View } from "react-native"; 4 10 import { useLivestreamStore } from "../../livestream-store"; 5 11 import { LivestreamProblem } from "../../livestream-store/livestream-state"; 6 12 import * as zero from "../../ui"; 13 + import { Button, Text } from "../ui"; 7 14 8 15 const { bg, r, borders, p, text, layout, gap } = zero; 16 + 17 + const getIcon = (severity: string) => { 18 + switch (severity) { 19 + case "error": 20 + return <CircleX size={24} color="white" />; 21 + case "warning": 22 + return <CircleAlert size={24} color="white" />; 23 + case "info": 24 + return <Info size={24} color="white" />; 25 + default: 26 + return <Sparkle size={24} color="white" />; 27 + } 28 + }; 9 29 10 30 const Problems = ({ 11 31 probs, ··· 15 35 onIgnore: () => void; 16 36 }) => { 17 37 return ( 18 - <View style={[gap.all[3]]}> 19 - <View> 20 - <Text style={[text.white, { fontSize: 24, fontWeight: "bold" }]}> 38 + <View style={[gap.all[4]]}> 39 + <View style={[gap.all[2]]}> 40 + <Text size="2xl" style={[text.white, { fontWeight: "600" }]}> 21 41 Optimize Your Stream 22 42 </Text> 23 43 <Text style={[text.gray[300]]}> ··· 34 54 { gap: 8, alignItems: "flex-start" }, 35 55 ]} 36 56 > 37 - <Text 57 + <View 38 58 style={[ 39 - r.sm, 40 - p[2], 59 + zero.r.full, 60 + zero.p[1], 41 61 { 42 - width: 82, 43 - textAlign: "center", 44 62 backgroundColor: 45 63 p.severity === "error" 46 64 ? "#7f1d1d" 47 65 : p.severity === "warning" 48 66 ? "#7c2d12" 49 67 : "#1e3a8a", 50 - color: "white", 51 - fontSize: 12, 52 68 }, 53 69 ]} 54 70 > 55 - {p.severity} 56 - </Text> 71 + {getIcon(p.severity)} 72 + </View> 57 73 <View style={[{ flex: 1 }, gap.all[1]]}> 58 74 <Text style={[text.white, { fontWeight: "600" }]}>{p.code}</Text> 59 75 <Text style={[text.gray[400], { fontSize: 14 }]}> ··· 79 95 </View> 80 96 </View> 81 97 ))} 82 - 83 - <Pressable 84 - onPress={onIgnore} 85 - style={[ 86 - bg.blue[600], 87 - r.md, 88 - p[3], 89 - layout.flex.center, 90 - { marginTop: 16 }, 91 - ]} 92 - > 93 - <Text style={[text.white, { fontWeight: "600" }]}>Ignore</Text> 94 - </Pressable> 98 + <View style={[layout.flex.row, layout.flex.justify.end]}> 99 + <Button onPress={onIgnore} variant="secondary"> 100 + <Text style={[text.white, { fontWeight: "600" }]}>Acknowledge</Text> 101 + </Button> 102 + </View> 95 103 </View> 96 104 ); 97 105 }; 98 106 99 - export const ProblemsWrapper = ({ 100 - children, 101 - }: { 102 - children: React.ReactElement; 103 - }) => { 107 + export interface ProblemsWrapperRef { 108 + setDismiss: (value: boolean) => void; 109 + } 110 + 111 + export const ProblemsWrapper = forwardRef< 112 + ProblemsWrapperRef, 113 + { 114 + children: React.ReactElement; 115 + } 116 + >(({ children }, ref) => { 104 117 const problems = useLivestreamStore((x) => x.problems); 105 118 const [dismiss, setDismiss] = useState(false); 106 119 120 + useImperativeHandle(ref, () => ({ 121 + setDismiss, 122 + })); 123 + 107 124 return ( 108 - <View 109 - style={[ 110 - { position: "relative", flex: 1 }, 111 - layout.flex.center, 112 - { flexBasis: 0 }, 113 - ]} 114 - > 125 + <> 115 126 {children} 116 127 {problems.length > 0 && !dismiss && ( 117 128 <View ··· 127 138 }, 128 139 layout.flex.center, 129 140 { justifyContent: "flex-start" }, 130 - p[8], 141 + p[12], 131 142 ]} 132 143 > 133 144 <View 134 145 style={[ 135 - bg.gray[900], 136 - borders.color.gray[700], 146 + bg.neutral[900], 147 + borders.color.neutral[700], 137 148 borders.width.thin, 138 149 r.lg, 139 - p[4], 150 + p[8], 140 151 { maxWidth: 700, width: "100%" }, 141 152 ]} 142 153 > ··· 144 155 </View> 145 156 </View> 146 157 )} 147 - </View> 158 + </> 148 159 ); 149 - }; 160 + }); 150 161 151 162 export default Problems;
+5
js/components/src/components/mobile-player/player.tsx
··· 23 23 const clearControlsTimeout = usePlayerStore((x) => x.clearControlsTimeout); 24 24 25 25 const setReportingURL = usePlayerStore((x) => x.setReportingURL); 26 + const setEmbedded = usePlayerStore((x) => x.setEmbedded); 26 27 27 28 const reportModalOpen = usePlayerStore((x) => x.reportModalOpen); 28 29 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); ··· 31 32 useEffect(() => { 32 33 setReportingURL(props.reportingURL ?? null); 33 34 }, [props.reportingURL]); 35 + 36 + useEffect(() => { 37 + setEmbedded(props.embedded ?? false); 38 + }, [props.embedded]); 34 39 35 40 // Will call back every few seconds to send health updates 36 41 usePlayerStatus();
+13 -7
js/components/src/components/mobile-player/ui/report-modal.tsx
··· 9 9 import { useSubmitReport } from "../../../livestream-store"; 10 10 import { 11 11 Button, 12 - Dialog, 13 12 DialogFooter, 14 - ModalContent, 13 + ResponsiveDialog, 15 14 Text, 16 15 Textarea, 16 + useTheme, 17 17 } from "../../ui"; 18 18 19 19 // AT Protocol moderation reason types with proper labels ··· 72 72 const [isSubmitting, setIsSubmitting] = useState(false); 73 73 const [submitError, setSubmitError] = useState<string | null>(null); 74 74 75 + const { theme } = useTheme(); 76 + 75 77 const submitReport = useSubmitReport(); 76 78 77 79 const handleCancel = () => { ··· 107 109 }; 108 110 109 111 return ( 110 - <Dialog 112 + <ResponsiveDialog 111 113 open={open} 112 114 onOpenChange={onOpenChange} 113 115 title={title} ··· 118 120 dismissible={false} 119 121 position="center" 120 122 > 121 - <ModalContent style={[zero.pb[2]]}> 123 + <View style={[zero.pb[2]]}> 122 124 {REPORT_REASONS.map((reason) => ( 123 125 <TouchableOpacity 124 126 key={reason.value} ··· 136 138 ]} 137 139 > 138 140 <View> 139 - {selectedReason === reason.value ? <CheckCircle /> : <Circle />} 141 + {selectedReason === reason.value ? ( 142 + <CheckCircle color={theme.colors.foreground} /> 143 + ) : ( 144 + <Circle color={theme.colors.foreground} /> 145 + )} 140 146 </View> 141 147 <View 142 148 style={[zero.layout.flex.column, zero.gap.all[1], zero.flex[1]]} ··· 164 170 </Text> 165 171 )} 166 172 </View> 167 - </ModalContent> 173 + </View> 168 174 <DialogFooter> 169 175 <Button 170 176 variant="secondary" ··· 188 194 )} 189 195 </Button> 190 196 </DialogFooter> 191 - </Dialog> 197 + </ResponsiveDialog> 192 198 ); 193 199 }; 194 200
+100 -1
js/components/src/components/mobile-player/ui/viewer-context-menu.tsx
··· 1 1 import { useRootContext } from "@rn-primitives/dropdown-menu"; 2 2 import { Menu } from "lucide-react-native"; 3 - import { Platform, View } from "react-native"; 3 + import { Image, Linking, Platform, Pressable, View } from "react-native"; 4 + import { useAvatars, useLivestreamInfo, zero } from "../../.."; 4 5 import { colors } from "../../../lib/theme"; 5 6 import { useLivestreamStore } from "../../../livestream-store"; 6 7 import { PlayerProtocol, usePlayerStore } from "../../../player-store/"; 8 + import { useGraphManager } from "../../../streamplace-store/graph"; 7 9 import { 8 10 DropdownMenu, 9 11 DropdownMenuCheckboxItem, ··· 14 16 DropdownMenuPortal, 15 17 DropdownMenuRadioGroup, 16 18 DropdownMenuRadioItem, 19 + DropdownMenuSeparator, 17 20 DropdownMenuTrigger, 18 21 ResponsiveDropdownMenuContent, 19 22 Text, ··· 38 41 const setReportModalOpen = usePlayerStore((x) => x.setReportModalOpen); 39 42 const setReportSubject = usePlayerStore((x) => x.setReportSubject); 40 43 44 + const { profile } = useLivestreamInfo(); 45 + const avatars = useAvatars(profile?.did ? [profile?.did] : []); 46 + const ls = useLivestreamStore((x) => x.livestream); 47 + 48 + let graphManager = useGraphManager(profile?.did); 49 + 41 50 const lowLatency = protocol === "webrtc"; 42 51 const setLowLatency = (value: boolean) => { 43 52 setProtocol(value ? PlayerProtocol.WEBRTC : PlayerProtocol.HLS); ··· 61 70 </DropdownMenuTrigger> 62 71 <Portal container={dropdownPortalContainer}> 63 72 <DropdownMenuContent side="top" align="end"> 73 + {Platform.OS !== "web" && ( 74 + <DropdownMenuGroup title="Streamer"> 75 + <View 76 + style={[ 77 + zero.layout.flex.row, 78 + zero.layout.flex.center, 79 + zero.gap.all[3], 80 + { flex: 1, minWidth: 0 }, 81 + ]} 82 + > 83 + {profile?.did && avatars[profile?.did]?.avatar && ( 84 + <Image 85 + key="avatar" 86 + source={{ 87 + uri: avatars[profile?.did]?.avatar, 88 + }} 89 + style={{ width: 42, height: 42, borderRadius: 999 }} 90 + resizeMode="cover" 91 + /> 92 + )} 93 + <View style={{ flex: 1, minWidth: 0 }}> 94 + <View 95 + style={[ 96 + zero.layout.flex.row, 97 + zero.layout.flex.alignCenter, 98 + zero.gap.all[2], 99 + ]} 100 + > 101 + <Pressable 102 + onPress={() => { 103 + if (profile?.handle) { 104 + const url = `https://bsky.app/profile/${profile.handle}`; 105 + Linking.openURL(url); 106 + } 107 + }} 108 + > 109 + <Text>@{profile?.handle || "user"}</Text> 110 + </Pressable> 111 + {/*{did && profile && ( 112 + <FollowButton streamerDID={profile?.did} currentUserDID={did} /> 113 + )}*/} 114 + </View> 115 + <Text 116 + color="muted" 117 + size="sm" 118 + numberOfLines={2} 119 + ellipsizeMode="tail" 120 + > 121 + {ls?.record.title || "Stream Title"} 122 + </Text> 123 + </View> 124 + </View> 125 + <DropdownMenuSeparator /> 126 + <DropdownMenuItem 127 + disabled={graphManager.isLoading || !profile?.did} 128 + onPress={async () => { 129 + try { 130 + if (graphManager.isFollowing) { 131 + await graphManager.unfollow(); 132 + } else { 133 + await graphManager.follow(); 134 + } 135 + } catch (err) { 136 + console.error("Follow/unfollow error:", err); 137 + } 138 + }} 139 + > 140 + <Text 141 + color={graphManager.isFollowing ? "destructive" : "default"} 142 + > 143 + {graphManager.isLoading 144 + ? "Loading..." 145 + : graphManager.isFollowing 146 + ? "Unfollow" 147 + : "Follow"} 148 + </Text> 149 + </DropdownMenuItem> 150 + <DropdownMenuSeparator /> 151 + <DropdownMenuItem 152 + onPress={() => { 153 + if (profile?.handle) { 154 + const url = `https://bsky.app/profile/${profile.handle}`; 155 + Linking.openURL(url); 156 + } 157 + }} 158 + > 159 + <Text>View Profile on Bluesky</Text> 160 + </DropdownMenuItem> 161 + </DropdownMenuGroup> 162 + )} 64 163 <DropdownMenuGroup title="Resolution"> 65 164 <DropdownMenuRadioGroup value={quality} onValueChange={setQuality}> 66 165 <DropdownMenuRadioItem value="source">
+10 -13
js/components/src/components/ui/button.tsx
··· 109 109 { borderRadius: zero.borderRadius.md }, 110 110 ], 111 111 inner: { gap: 4 }, 112 - text: zt.text.sm, 112 + text: zero.typography.universal.sm, 113 113 }; 114 114 case "lg": 115 115 return { ··· 118 118 zero.py[3], 119 119 { borderRadius: zero.borderRadius.md }, 120 120 ], 121 - inner: { gap: 8 }, 122 - text: zt.text.lg, 121 + inner: { gap: 12 }, 122 + text: zero.typography.universal.lg, 123 123 }; 124 124 case "xl": 125 125 return { ··· 129 129 { borderRadius: zero.borderRadius.lg }, 130 130 ], 131 131 inner: { gap: 12 }, 132 - text: zt.text.xl, 132 + text: zero.typography.universal.xl, 133 133 }; 134 134 case "pill": 135 135 return { 136 136 button: [ 137 - zero.px[4], 138 - zero.py[2], 137 + zero.px[2], 138 + zero.py[1], 139 139 { borderRadius: zero.borderRadius.full }, 140 140 ], 141 141 inner: { gap: 4 }, 142 - text: zt.text.sm, 142 + text: zero.typography.universal.xs, 143 143 }; 144 144 case "md": 145 145 default: ··· 150 150 { borderRadius: zero.borderRadius.md }, 151 151 ], 152 152 inner: { gap: 6 }, 153 - text: zt.text.md, 153 + text: zero.typography.universal.sm, 154 154 }; 155 155 } 156 156 }, [size, zt]); ··· 211 211 <ActivityIndicator size={spinnerSize} color={spinnerColor} /> 212 212 </ButtonPrimitive.Icon> 213 213 ) : leftIcon ? ( 214 - <ButtonPrimitive.Icon 215 - position="left" 216 - style={{ width: iconSize, height: iconSize }} 217 - > 214 + <ButtonPrimitive.Icon position="left"> 218 215 {leftIcon} 219 216 </ButtonPrimitive.Icon> 220 217 ) : null} 221 218 222 - <TextPrimitive.Root style={[textStyle, sizeStyles.text]}> 219 + <TextPrimitive.Root style={[textStyle as any, sizeStyles.text]}> 223 220 {loading && loadingText ? loadingText : children} 224 221 </TextPrimitive.Root> 225 222
+318 -98
js/components/src/components/ui/dialog.tsx
··· 1 + import BottomSheet, { BottomSheetScrollView } from "@gorhom/bottom-sheet"; 2 + // to get the portal 3 + import * as Portal from "@rn-primitives/portal"; 1 4 import { cva, type VariantProps } from "class-variance-authority"; 2 5 import { X } from "lucide-react-native"; 3 - import React, { forwardRef } from "react"; 4 - import { Platform, Text } from "react-native"; 6 + import React, { forwardRef, useRef } from "react"; 7 + import { 8 + Platform, 9 + Pressable, 10 + StyleSheet, 11 + useWindowDimensions, 12 + View, 13 + } from "react-native"; 14 + import { useSafeAreaInsets } from "react-native-safe-area-context"; 5 15 import { useTheme } from "../../lib/theme/theme"; 6 - import * as zero from "../../ui"; 7 16 import { createThemedIcon } from "./icons"; 8 17 import { ModalPrimitive, ModalPrimitiveProps } from "./primitives/modal"; 18 + import { Text } from "./text"; 9 19 10 20 const ThemedX = createThemedIcon(X); 11 21 ··· 50 60 onClose?: () => void; 51 61 } 52 62 63 + // Bottom Sheet Dialog Component 64 + const DialogBottomSheet = forwardRef< 65 + any, 66 + DialogProps & { 67 + overlayStyle?: any; 68 + portalHost?: string; 69 + } 70 + >(function DialogBottomSheet( 71 + { 72 + overlayStyle, 73 + portalHost, 74 + children, 75 + title, 76 + description, 77 + showCloseButton = true, 78 + onClose, 79 + open = false, 80 + onOpenChange, 81 + ...props 82 + }, 83 + _ref, 84 + ) { 85 + const { theme } = useTheme(); 86 + const sheetRef = useRef<BottomSheet>(null); 87 + const { top } = useSafeAreaInsets(); 88 + const dims = useWindowDimensions(); 89 + 90 + const handleClose = React.useCallback(() => { 91 + if (onClose) { 92 + onClose(); 93 + } 94 + if (onOpenChange) { 95 + onOpenChange(false); 96 + } 97 + }, [onClose, onOpenChange]); 98 + 99 + if (!open) { 100 + return null; 101 + } 102 + 103 + return ( 104 + <Portal.Portal name="dialog"> 105 + <BottomSheet 106 + ref={sheetRef} 107 + index={open ? 0 : -1} 108 + enablePanDownToClose 109 + enableDynamicSizing={true} 110 + maxDynamicContentSize={dims.height - top} 111 + keyboardBehavior="interactive" 112 + keyboardBlurBehavior="restore" 113 + enableContentPanningGesture={false} 114 + backdropComponent={({ style }) => ( 115 + <Pressable 116 + style={[style, StyleSheet.absoluteFill]} 117 + onPress={handleClose} 118 + /> 119 + )} 120 + onClose={handleClose} 121 + style={[overlayStyle]} 122 + backgroundStyle={{ 123 + backgroundColor: theme.colors.card, 124 + borderRadius: theme.borderRadius.lg, 125 + ...theme.shadows.lg, 126 + }} 127 + handleIndicatorStyle={{ 128 + width: 48, 129 + height: 4, 130 + backgroundColor: theme.colors.textMuted, 131 + }} 132 + > 133 + <BottomSheetScrollView 134 + style={{ 135 + flex: 1, 136 + width: "100%", 137 + }} 138 + contentContainerStyle={{ flexGrow: 1 }} 139 + > 140 + {/* Header */} 141 + {(title || showCloseButton) && ( 142 + <View 143 + style={{ 144 + paddingHorizontal: theme.spacing[4], 145 + paddingVertical: theme.spacing[4], 146 + flexDirection: "row", 147 + alignItems: "center", 148 + justifyContent: "space-between", 149 + width: "100%", 150 + }} 151 + > 152 + {title && <DialogTitle>{title}</DialogTitle>} 153 + {showCloseButton && ( 154 + <Pressable 155 + onPress={handleClose} 156 + style={{ 157 + width: theme.touchTargets.minimum, 158 + height: theme.touchTargets.minimum, 159 + alignItems: "center", 160 + justifyContent: "center", 161 + borderRadius: theme.borderRadius.sm, 162 + marginLeft: theme.spacing[2], 163 + }} 164 + > 165 + <DialogCloseIcon /> 166 + </Pressable> 167 + )} 168 + </View> 169 + )} 170 + 171 + {/* Scrollable Content */} 172 + <View 173 + style={{ 174 + paddingHorizontal: theme.spacing[4], 175 + paddingBottom: theme.spacing[6], 176 + flex: 1, 177 + width: "100%", 178 + }} 179 + > 180 + {description && ( 181 + <DialogDescription>{description}</DialogDescription> 182 + )} 183 + {children} 184 + </View> 185 + </BottomSheetScrollView> 186 + </BottomSheet> 187 + </Portal.Portal> 188 + ); 189 + }); 190 + 53 191 export const Dialog = forwardRef<any, DialogProps>( 54 192 ( 55 193 { 56 - variant = "left", 194 + variant = "default", 57 195 size = "md", 58 196 position = "center", 59 197 children, ··· 68 206 }, 69 207 ref, 70 208 ) => { 71 - const { zero: zt, theme } = useTheme(); 72 - 73 - // Content styles using theme.zero 74 - const contentStyles = React.useMemo(() => { 75 - const baseStyle = [ 76 - zt.bg.card, 77 - zero.r.lg, 78 - zero.shadows.lg, 79 - { maxHeight: "90%", maxWidth: "90%" }, 80 - ]; 81 - 82 - const variantStyle = (() => { 83 - switch (variant) { 84 - case "sheet": 85 - return [ 86 - { borderRadius: zero.borderRadius.xl }, 87 - { 88 - borderBottomLeftRadius: 0, 89 - borderBottomRightRadius: 0, 90 - marginTop: "auto", 91 - marginBottom: 0, 92 - maxHeight: "80%", 93 - width: "100%", 94 - maxWidth: "100%", 95 - }, 96 - ]; 97 - case "fullscreen": 98 - return [ 99 - { 100 - width: "100%", 101 - height: "100%", 102 - maxWidth: "100%", 103 - maxHeight: "100%", 104 - borderRadius: 0, 105 - margin: 0, 106 - }, 107 - ]; 108 - default: 109 - return []; 110 - } 111 - })(); 112 - 113 - const sizeStyle = (() => { 114 - switch (size) { 115 - case "sm": 116 - return { minWidth: 300, minHeight: 200 }; 117 - case "lg": 118 - return { minWidth: 500, minHeight: 400 }; 119 - case "xl": 120 - return { minWidth: 600, minHeight: 500 }; 121 - case "full": 122 - return { 123 - width: "95%", 124 - height: "95%", 125 - maxWidth: "95%", 126 - maxHeight: "95%", 127 - }; 128 - default: 129 - return { minWidth: 400, minHeight: 300 }; 130 - } 131 - })(); 209 + const { theme } = useTheme(); 132 210 133 - return [baseStyle, variantStyle, sizeStyle].flat(); 134 - }, [variant, size, zero]); 211 + // Create dynamic styles based on theme 212 + const styles = React.useMemo(() => createStyles(theme), [theme]); 135 213 136 214 const handleClose = React.useCallback(() => { 137 215 if (onClose) { ··· 173 251 <ModalPrimitive.Overlay 174 252 dismissible={dismissible} 175 253 onDismiss={handleClose} 176 - style={zt.bg.overlay} 254 + style={styles.overlay} 177 255 > 178 256 <ModalPrimitive.Content 179 - position={position || "left"} 257 + position={position || "center"} 180 258 size={size || "md"} 181 - style={contentStyles} 259 + style={[ 260 + styles.content, 261 + variant === "sheet" && styles.sheetContent, 262 + variant === "fullscreen" && styles.fullscreenContent, 263 + size === "sm" && styles.smContent, 264 + size === "md" && styles.mdContent, 265 + size === "lg" && styles.lgContent, 266 + size === "xl" && styles.xlContent, 267 + size === "full" && styles.fullContent, 268 + ]} 182 269 > 183 270 {(title || showCloseButton) && ( 184 271 <ModalPrimitive.Header 185 272 withBorder={variant !== "sheet"} 186 - style={[ 187 - zero.p[4], 188 - { 189 - flexDirection: "row", 190 - alignItems: "center", 191 - justifyContent: "space-between", 192 - }, 193 - ]} 273 + style={styles.header} 194 274 > 195 275 <DialogTitle>{title}</DialogTitle> 196 276 {showCloseButton && ( 197 277 <ModalPrimitive.Close 198 278 onClose={handleClose} 199 - style={[ 200 - zero.p[2], 201 - { 202 - width: 44, 203 - height: 44, 204 - alignItems: "center", 205 - justifyContent: "center", 206 - }, 207 - ]} 279 + style={styles.closeButton} 208 280 > 209 281 <DialogCloseIcon /> 210 282 </ModalPrimitive.Close> ··· 214 286 215 287 <ModalPrimitive.Body 216 288 scrollable={variant !== "fullscreen"} 217 - style={[zero.p[6], { paddingTop: 0, flex: 1 }]} 289 + style={styles.body} 218 290 > 219 291 {description && ( 220 292 <DialogDescription>{description}</DialogDescription> ··· 230 302 231 303 Dialog.displayName = "Dialog"; 232 304 305 + /// Responsive Dialog Component. On mobile this will render a *bottom sheet*. 306 + /// Prefer this over the regular Dialog component for better mobile UX. 307 + export const ResponsiveDialog = forwardRef<any, DialogProps>( 308 + ({ children, size, ...props }, ref) => { 309 + const { width } = useWindowDimensions(); 310 + 311 + // On web, you might want to always use the normal dialog 312 + // On mobile (width < 800), use the bottom sheet 313 + const isBottomSheet = Platform.OS !== "web" && width < 800; 314 + 315 + if (isBottomSheet) { 316 + return ( 317 + <DialogBottomSheet 318 + ref={ref} 319 + {...props} 320 + size={"full"} 321 + showCloseButton={false} 322 + variant="fullscreen" 323 + > 324 + {children} 325 + </DialogBottomSheet> 326 + ); 327 + } 328 + 329 + // Use larger default size for regular dialogs to give more room 330 + const dialogSize = size || "lg"; 331 + 332 + return ( 333 + <Dialog ref={ref} size={dialogSize} {...props}> 334 + {children} 335 + </Dialog> 336 + ); 337 + }, 338 + ); 339 + 340 + ResponsiveDialog.displayName = "ResponsiveDialog"; 341 + 233 342 // Dialog Title component 234 343 export interface DialogTitleProps { 235 344 children?: React.ReactNode; ··· 238 347 239 348 export const DialogTitle = forwardRef<any, DialogTitleProps>( 240 349 ({ children, style, ...props }, ref) => { 241 - const { zero: zt } = useTheme(); 350 + const { theme } = useTheme(); 351 + const styles = React.useMemo(() => createStyles(theme), [theme]); 242 352 243 353 if (!children) return null; 244 354 245 355 return ( 246 - <Text 247 - ref={ref} 248 - style={[zt.text.xl, { fontWeight: "600", flex: 1 }, style]} 249 - {...props} 250 - > 356 + <Text ref={ref} style={[styles.title, style]} {...props}> 251 357 {children} 252 358 </Text> 253 359 ); ··· 264 370 265 371 export const DialogDescription = forwardRef<any, DialogDescriptionProps>( 266 372 ({ children, style, ...props }, ref) => { 267 - const { zero: zt } = useTheme(); 373 + const { theme } = useTheme(); 374 + const styles = React.useMemo(() => createStyles(theme), [theme]); 268 375 269 376 if (!children) return null; 270 377 271 378 return ( 272 - <Text ref={ref} style={[zt.text.muted, zero.mb[4], style]} {...props}> 379 + <Text ref={ref} style={[styles.description, style]} {...props}> 273 380 {children} 274 381 </Text> 275 382 ); ··· 304 411 }, 305 412 ref, 306 413 ) => { 307 - const { zero: zt } = useTheme(); 414 + const { theme } = useTheme(); 415 + const styles = React.useMemo(() => createStyles(theme), [theme]); 308 416 309 417 if (!children) return null; 310 418 ··· 314 422 withBorder={withBorder} 315 423 direction={direction} 316 424 justify={justify} 317 - style={[zero.p[6], { gap: 8 }, style]} 425 + style={[styles.footer, style]} 318 426 {...props} 319 427 > 320 428 {children} ··· 329 437 const DialogCloseIcon = () => { 330 438 return <ThemedX size="md" variant="default" />; 331 439 }; 440 + 441 + // Create theme-aware styles 442 + function createStyles(theme: any) { 443 + return StyleSheet.create({ 444 + overlay: { 445 + backgroundColor: "rgba(0, 0, 0, 0.5)", 446 + }, 447 + 448 + content: { 449 + backgroundColor: theme.colors.card, 450 + borderRadius: theme.borderRadius.lg, 451 + ...theme.shadows.lg, 452 + maxHeight: "90%", 453 + maxWidth: "90%", 454 + }, 455 + 456 + // Variant styles 457 + sheetContent: { 458 + borderTopLeftRadius: theme.borderRadius.xl, 459 + borderTopRightRadius: theme.borderRadius.xl, 460 + borderBottomLeftRadius: 0, 461 + borderBottomRightRadius: 0, 462 + marginTop: "auto", 463 + marginBottom: 0, 464 + maxHeight: "80%", 465 + width: "100%", 466 + maxWidth: "100%", 467 + }, 468 + 469 + fullscreenContent: { 470 + width: "100%", 471 + height: "100%", 472 + maxWidth: "100%", 473 + maxHeight: "100%", 474 + borderRadius: 0, 475 + margin: 0, 476 + }, 477 + 478 + // Size styles 479 + smContent: { 480 + minWidth: 300, 481 + minHeight: 200, 482 + }, 483 + 484 + mdContent: { 485 + minWidth: 400, 486 + minHeight: 300, 487 + }, 488 + 489 + lgContent: { 490 + minWidth: 500, 491 + minHeight: 400, 492 + }, 493 + 494 + xlContent: { 495 + minWidth: 600, 496 + minHeight: 500, 497 + }, 498 + 499 + fullContent: { 500 + width: "95%", 501 + height: "95%", 502 + maxWidth: "95%", 503 + maxHeight: "95%", 504 + }, 505 + 506 + header: { 507 + paddingHorizontal: theme.spacing[6], 508 + paddingVertical: theme.spacing[4], 509 + flexDirection: "row", 510 + alignItems: "center", 511 + justifyContent: "space-between", 512 + }, 513 + 514 + body: { 515 + paddingHorizontal: theme.spacing[6], 516 + paddingBottom: theme.spacing[6], 517 + flex: 1, 518 + }, 519 + 520 + footer: { 521 + paddingHorizontal: theme.spacing[6], 522 + paddingVertical: theme.spacing[4], 523 + gap: theme.spacing[2], 524 + width: "100%", 525 + }, 526 + 527 + title: { 528 + fontSize: 20, 529 + fontWeight: "600", 530 + color: theme.colors.text, 531 + flex: 1, 532 + lineHeight: 24, 533 + }, 534 + 535 + description: { 536 + fontSize: 16, 537 + color: theme.colors.textMuted, 538 + lineHeight: 22, 539 + marginVertical: theme.spacing[4], 540 + }, 541 + 542 + closeButton: { 543 + width: theme.touchTargets.minimum, 544 + height: theme.touchTargets.minimum, 545 + alignItems: "center", 546 + justifyContent: "center", 547 + borderRadius: theme.borderRadius.sm, 548 + marginLeft: theme.spacing[2], 549 + }, 550 + }); 551 + } 332 552 333 553 // Export dialog variants for external use 334 554 export { dialogVariants };
+27 -13
js/components/src/components/ui/dropdown.tsx
··· 13 13 Platform, 14 14 Pressable, 15 15 StyleSheet, 16 - Text, 17 16 useWindowDimensions, 18 17 View, 19 18 } from "react-native"; ··· 39 38 objectFromObjects, 40 39 TextContext as TextClassContext, 41 40 } from "./primitives/text"; 41 + import { Text } from "./text"; 42 42 43 43 export const DropdownMenu = DropdownMenuPrimitive.Root; 44 44 export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; ··· 87 87 zt.bg.mutedForeground, 88 88 ]} 89 89 > 90 - <BottomSheetView style={[px[2]]}> 90 + <BottomSheetView style={[px[4]]}> 91 91 {typeof children === "function" 92 92 ? children({ pressed: true }) 93 93 : children} ··· 285 285 pr[2], 286 286 ]} 287 287 > 288 - {typeof children === "function" 289 - ? children({ pressed: true }) 290 - : children} 288 + {typeof children === "function" ? ( 289 + children({ pressed: true }) 290 + ) : typeof children === "string" ? ( 291 + <Text style={[inset && gap[2], disabled && { opacity: 0.5 }]}> 292 + {children} 293 + </Text> 294 + ) : ( 295 + children 296 + )} 291 297 </View> 292 298 </TextClassContext.Provider> 293 299 </Pressable> ··· 384 390 return ( 385 391 <Text 386 392 ref={ref} 387 - style={[ 388 - px[2], 389 - py[2], 390 - { color: theme.colors.textMuted }, 391 - a.fontSize.base, 392 - inset && gap[2], 393 - ]} 393 + style={ 394 + [ 395 + px[2], 396 + py[2], 397 + { color: theme.colors.textMuted }, 398 + a.fontSize.base, 399 + (inset && gap[2]) as any, 400 + ] as any 401 + } 394 402 {...props} 395 403 /> 396 404 ); ··· 404 412 return ( 405 413 <View 406 414 ref={ref} 407 - style={[{ height: 0.5 }, { backgroundColor: theme.colors.border }]} 415 + style={[ 416 + { 417 + borderBottomWidth: 1, 418 + borderBottomColor: theme.colors.border, 419 + marginVertical: -0.5, 420 + }, 421 + ]} 408 422 {...props} 409 423 /> 410 424 );
+14
js/components/src/components/ui/icons.tsx
··· 48 48 ); 49 49 }; 50 50 } 51 + 52 + // usage of createThemedIcon 53 + export function Icon({ 54 + icon, 55 + variant = "default", 56 + size = "md", 57 + color, 58 + ...restProps 59 + }: { icon: React.ComponentType<LucideProps> } & IconProps) { 60 + const ThemedIcon = createThemedIcon(icon); 61 + return ( 62 + <ThemedIcon variant={variant} size={size} color={color} {...restProps} /> 63 + ); 64 + }
-7
js/components/src/components/ui/primitives/button.tsx
··· 244 244 flexDirection: "row", 245 245 alignItems: "center", 246 246 justifyContent: "center", 247 - minHeight: 44, // iOS minimum touch target 248 247 }, 249 248 disabled: { 250 249 opacity: 0.5, ··· 263 262 icon: { 264 263 alignItems: "center", 265 264 justifyContent: "center", 266 - }, 267 - iconLeft: { 268 - marginRight: 8, 269 - }, 270 - iconRight: { 271 - marginLeft: 8, 272 265 }, 273 266 iconDisabled: { 274 267 opacity: 0.5,
+19 -2
js/components/src/components/ui/primitives/input.tsx
··· 1 + import { 2 + BottomSheetTextInput, 3 + useBottomSheetInternal, 4 + } from "@gorhom/bottom-sheet"; 1 5 import React, { forwardRef } from "react"; 2 6 import { 3 7 NativeSyntheticEvent, ··· 24 28 } 25 29 26 30 // Input root primitive - the main TextInput component 27 - export const InputRoot = forwardRef<TextInput, InputPrimitiveProps>( 31 + export const InputRoot = forwardRef<any, InputPrimitiveProps>( 28 32 ( 29 33 { 30 34 value, ··· 44 48 ) => { 45 49 const [isFocused, setIsFocused] = React.useState(false); 46 50 51 + let isInBottomSheet = false; 52 + try { 53 + useBottomSheetInternal(); 54 + isInBottomSheet = true; 55 + } catch { 56 + isInBottomSheet = false; 57 + } 58 + 59 + const InputComponent = 60 + isInBottomSheet && Platform.OS !== "web" 61 + ? BottomSheetTextInput 62 + : TextInput; 63 + 47 64 const handleChangeText = React.useCallback( 48 65 (text: string) => { 49 66 if (onChangeText) { ··· 77 94 ); 78 95 79 96 return ( 80 - <TextInput 97 + <InputComponent 81 98 ref={ref} 82 99 value={value} 83 100 onChangeText={handleChangeText}
+4 -2
js/components/src/components/ui/primitives/modal.tsx
··· 32 32 children, 33 33 onRequestClose, 34 34 animationType = "fade", 35 - presentationStyle = Platform.OS === "ios" ? "pageSheet" : "fullScreen", 36 - statusBarTranslucent = Platform.OS === "android", 35 + presentationStyle = Platform.OS === "ios" ? "pageSheet" : "formSheet", 36 + statusBarTranslucent = Platform.OS !== "ios", 37 + transparent = true, 37 38 ...props 38 39 }, 39 40 ref, ··· 57 58 animationType={animationType} 58 59 presentationStyle={presentationStyle} 59 60 statusBarTranslucent={statusBarTranslucent} 61 + transparent={transparent} 60 62 {...props} 61 63 > 62 64 <View ref={ref} style={primitiveStyles.container}>
+47 -29
js/components/src/components/ui/textarea.tsx
··· 1 + import { 2 + BottomSheetTextInput, 3 + useBottomSheetInternal, 4 + } from "@gorhom/bottom-sheet"; 1 5 import * as React from "react"; 2 - import { TextInput, type TextInputProps } from "react-native"; 6 + import { Platform, TextInput, type TextInputProps } from "react-native"; 3 7 import { bg, borders, flex, p, text } from "../../lib/theme/atoms"; 4 8 5 - function Textarea({ 6 - style, 7 - multiline = true, 8 - numberOfLines = 4, 9 - ...props 10 - }: TextInputProps & { 11 - ref?: React.RefObject<TextInput>; 12 - }) { 13 - return ( 14 - <TextInput 15 - style={[ 16 - flex.values[1], 17 - borders.width.thin, 18 - borders.color.gray[400], 19 - bg.gray[900], 20 - p[3], 21 - text.gray[200], 22 - props.editable === false && { opacity: 0.5 }, 23 - { borderRadius: 10 }, 24 - style, 25 - ]} 26 - multiline={multiline} 27 - numberOfLines={numberOfLines} 28 - textAlignVertical="top" 29 - {...props} 30 - /> 31 - ); 32 - } 9 + const Textarea = React.forwardRef<TextInput, TextInputProps>( 10 + ({ style, multiline = true, numberOfLines = 4, ...props }, ref) => { 11 + // Detect if we're inside a bottom sheet 12 + let isInBottomSheet = false; 13 + try { 14 + useBottomSheetInternal(); 15 + isInBottomSheet = true; 16 + } catch { 17 + // Not in a bottom sheet context 18 + isInBottomSheet = false; 19 + } 20 + 21 + // Use BottomSheetTextInput when inside a bottom sheet, regular TextInput otherwise 22 + const InputComponent = 23 + isInBottomSheet && Platform.OS !== "web" 24 + ? BottomSheetTextInput 25 + : TextInput; 26 + 27 + return ( 28 + <InputComponent 29 + ref={ref as any} 30 + style={[ 31 + flex.values[1], 32 + borders.width.thin, 33 + borders.color.gray[400], 34 + bg.gray[900], 35 + p[3], 36 + text.gray[200], 37 + props.editable === false && { opacity: 0.5 }, 38 + { borderRadius: 10 }, 39 + style, 40 + ]} 41 + multiline={multiline} 42 + numberOfLines={numberOfLines} 43 + textAlignVertical="top" 44 + {...props} 45 + /> 46 + ); 47 + }, 48 + ); 49 + 50 + Textarea.displayName = "Textarea"; 33 51 34 52 export { Textarea };
+784 -178
js/components/src/components/ui/toast.tsx
··· 1 1 import { Portal } from "@rn-primitives/portal"; 2 - import { useEffect, useState } from "react"; 2 + import { CheckCircle, Info, X, XCircle } from "lucide-react-native"; 3 + import { useEffect, useRef, useState } from "react"; 3 4 import { 4 - Animated, 5 5 Platform, 6 6 Pressable, 7 7 StyleSheet, 8 - Text, 9 8 useWindowDimensions, 10 9 View, 11 10 ViewStyle, 12 11 } from "react-native"; 12 + import { Gesture, GestureDetector } from "react-native-gesture-handler"; 13 + import Animated, { 14 + cancelAnimation, 15 + Easing, 16 + runOnJS, 17 + useAnimatedProps, 18 + useAnimatedStyle, 19 + useSharedValue, 20 + withTiming, 21 + } from "react-native-reanimated"; 13 22 import { useSafeAreaInsets } from "react-native-safe-area-context"; 23 + import { Circle, Svg } from "react-native-svg"; 24 + import { useTheme } from "../../ui"; 25 + import { Button } from "./button"; 26 + import { Text } from "./text"; 14 27 15 - import { useTheme } from "../../ui"; 28 + type Position = 29 + | "auto" 30 + | "top-left" 31 + | "top-center" 32 + | "top-right" 33 + | "bottom-left" 34 + | "bottom-center" 35 + | "bottom-right"; 16 36 17 37 type ToastConfig = { 18 - title: string; 38 + title?: string; 19 39 description?: string; 20 40 duration?: number; 21 41 actionLabel?: string; 22 42 onAction?: () => void; 43 + onClose?: () => void; 44 + variant?: "default" | "success" | "error" | "info"; 45 + render?: (props: { 46 + close: () => void; 47 + action?: () => void; 48 + }) => React.ReactNode; 49 + position?: Position; 50 + iconLeft?: React.ComponentType<any>; 51 + iconRight?: React.ComponentType<any>; 52 + onToastPress?: () => void; 23 53 }; 24 54 25 55 type ToastState = { 26 56 id: string; 27 57 open: boolean; 28 - title: string; 58 + title?: string; 29 59 description?: string; 30 60 duration: number; 31 61 actionLabel?: string; 32 62 onAction?: () => void; 63 + onClose?: () => void; 64 + variant?: "default" | "success" | "error" | "info"; 65 + render?: (props: { 66 + close: () => void; 67 + action?: () => void; 68 + }) => React.ReactNode; 69 + position: Position; 70 + iconLeft?: React.ComponentType<any>; 71 + iconRight?: React.ComponentType<any>; 72 + onToastPress?: () => void; 33 73 }; 34 74 35 75 class ToastManager { 36 - private listeners: Set<(state: ToastState | null) => void> = new Set(); 37 - private currentToast: ToastState | null = null; 38 - private timeoutId: ReturnType<typeof setTimeout> | null = null; 76 + private listeners: Set<(state: ToastState[]) => void> = new Set(); 77 + private toasts: ToastState[] = []; 78 + private toastHeights: Map<string, number> = new Map(); 79 + 80 + private hoverListeners: Set<(isHovered: boolean) => void> = new Set(); 81 + private isHovered: boolean = false; 39 82 40 83 show(config: ToastConfig) { 41 - if (this.timeoutId) { 42 - clearTimeout(this.timeoutId); 43 - } 44 - 45 84 const toast: ToastState = { 46 - id: Math.random().toString(36).substr(2, 9), 85 + id: Math.random().toString(36).slice(2, 12), 47 86 open: true, 48 87 title: config.title, 49 88 description: config.description, 50 89 duration: config.duration ?? 3, 51 90 actionLabel: config.actionLabel, 52 91 onAction: config.onAction, 92 + onClose: config.onClose, 93 + variant: config.variant ?? "default", 94 + render: config.render, 95 + position: config.position ?? "auto", 96 + iconLeft: config.iconLeft, 97 + iconRight: config.iconRight, 98 + onToastPress: config.onToastPress, 53 99 }; 54 100 55 - this.currentToast = toast; 101 + this.toasts = [...this.toasts, toast]; 56 102 this.notifyListeners(); 103 + } 57 104 58 - if (toast.duration > 0) { 59 - this.timeoutId = setTimeout(() => { 60 - this.hide(); 61 - }, toast.duration * 1000); 62 - } 105 + getToasts() { 106 + return this.toasts; 63 107 } 64 108 65 - hide() { 66 - if (this.timeoutId) { 67 - clearTimeout(this.timeoutId); 68 - this.timeoutId = null; 109 + hide(id: string) { 110 + const toast = this.toasts.find((t) => t.id === id); 111 + if (toast?.onClose) { 112 + toast.onClose(); 69 113 } 70 - this.currentToast = null; 114 + this.toasts = this.toasts.map((toast) => 115 + toast.id === id ? { ...toast, open: false } : toast, 116 + ); 71 117 this.notifyListeners(); 118 + 119 + setTimeout(() => { 120 + this.toasts = this.toasts.filter((toast) => toast.id !== id); 121 + this.notifyListeners(); 122 + }, 500); 72 123 } 73 124 74 - subscribe(listener: (state: ToastState | null) => void) { 125 + subscribe(listener: (state: ToastState[]) => void) { 75 126 this.listeners.add(listener); 76 127 return () => { 77 128 this.listeners.delete(listener); 78 129 }; 79 130 } 80 131 132 + subscribeHover(listener: (isHovered: boolean) => void) { 133 + this.hoverListeners.add(listener); 134 + return () => { 135 + this.hoverListeners.delete(listener); 136 + }; 137 + } 138 + 139 + setHovered(hovered: boolean) { 140 + this.isHovered = hovered; 141 + this.notifyHoverListeners(); 142 + } 143 + 144 + updateToastHeight(id: string, height: number) { 145 + this.toastHeights.set(id, height); 146 + } 147 + 148 + getToastHeight(id: string): number { 149 + return this.toastHeights.get(id) || 100; // Default fallback height 150 + } 151 + 81 152 private notifyListeners() { 82 - this.listeners.forEach((listener) => listener(this.currentToast)); 153 + this.listeners.forEach((listener) => listener(this.toasts)); 154 + } 155 + 156 + private notifyHoverListeners() { 157 + this.hoverListeners.forEach((listener) => listener(this.isHovered)); 83 158 } 84 159 } 85 160 ··· 93 168 duration?: number; 94 169 actionLabel?: string; 95 170 onAction?: () => void; 171 + onClose?: () => void; 172 + variant?: "default" | "success" | "error" | "info"; 173 + position?: Position; 174 + iconLeft?: React.ComponentType<any>; 175 + iconRight?: React.ComponentType<any>; 176 + onToastPress?: () => void; 96 177 }, 97 178 ) => { 98 179 toastManager.show({ ··· 101 182 ...options, 102 183 }); 103 184 }, 104 - hide: () => toastManager.hide(), 185 + hide: (id?: string) => { 186 + if (id) { 187 + toastManager.hide(id); 188 + } else { 189 + const toasts = toastManager.getToasts(); 190 + if (toasts.length > 0) { 191 + toastManager.hide(toasts[toasts.length - 1].id); 192 + } 193 + } 194 + }, 195 + showManual: ( 196 + render: (props: { 197 + close: () => void; 198 + action?: () => void; 199 + }) => React.ReactNode, 200 + options?: { 201 + duration?: number; 202 + actionLabel?: string; 203 + onAction?: () => void; 204 + onClose?: () => void; 205 + variant?: "default" | "success" | "error" | "info"; 206 + position?: Position; 207 + iconLeft?: React.ComponentType<any>; 208 + iconRight?: React.ComponentType<any>; 209 + onToastPress?: () => void; 210 + }, 211 + ) => { 212 + toastManager.show({ 213 + render, 214 + ...options, 215 + }); 216 + }, 105 217 }; 106 218 107 219 export function useToast() { 108 - const [toastState, setToastState] = useState<ToastState | null>(null); 220 + const [toasts, setToasts] = useState<ToastState[]>([]); 109 221 110 222 useEffect(() => { 111 - return toastManager.subscribe(setToastState); 223 + return toastManager.subscribe(setToasts); 112 224 }, []); 113 225 114 226 return { 115 - toast: toastState, 227 + toasts, 116 228 ...toast, 117 229 }; 118 230 } 119 231 120 232 export function ToastProvider() { 121 - const [toastState, setToastState] = useState<ToastState | null>(null); 233 + const [toasts, setToasts] = useState<ToastState[]>([]); 122 234 123 235 useEffect(() => { 124 - return toastManager.subscribe(setToastState); 236 + return toastManager.subscribe(setToasts); 125 237 }, []); 126 238 127 - if (!toastState?.open) return null; 239 + const toastsByPosition = toasts.reduce( 240 + (acc, toast) => { 241 + const { position } = toast; 242 + if (!acc[position]) { 243 + acc[position] = []; 244 + } 245 + acc[position].push(toast); 246 + return acc; 247 + }, 248 + {} as Record<Position, ToastState[]>, 249 + ); 250 + 251 + return ( 252 + <> 253 + {Object.entries(toastsByPosition).map(([position, toasts]) => ( 254 + <ToastContainer 255 + key={position} 256 + position={position as Position} 257 + toasts={toasts} 258 + /> 259 + ))} 260 + </> 261 + ); 262 + } 263 + 264 + function ToastContainer({ 265 + position = "auto", 266 + toasts, 267 + }: { 268 + position?: Position; 269 + toasts: ToastState[]; 270 + }) { 271 + const insets = useSafeAreaInsets(); 272 + const { theme } = useTheme(); 273 + const isWeb = Platform.OS === "web"; 274 + const { width } = useWindowDimensions(); 275 + const isDesktop = isWeb && width >= 768; 276 + const isTop = position.includes("top"); 277 + const visibleToasts = toasts.slice(-4); 278 + const prevToastIds = useRef<string[]>(visibleToasts.map((t) => t.id)); 279 + const allKnownToastIds = useRef<Set<string>>( 280 + new Set(toasts.map((t) => t.id)), 281 + ); 282 + const [isHovered, setIsHovered] = useState(false); 283 + 284 + useEffect(() => { 285 + return toastManager.subscribeHover(setIsHovered); 286 + }, []); 287 + 288 + useEffect(() => { 289 + const currentIds = visibleToasts.map((t) => t.id); 290 + const hasNewToast = currentIds.some( 291 + (id) => !allKnownToastIds.current.has(id), 292 + ); 293 + 294 + if (hasNewToast && isHovered) { 295 + // Brand new toast arrived while expanded - collapse momentarily 296 + toastManager.setHovered(false); 297 + setTimeout(() => { 298 + toastManager.setHovered(true); 299 + }, 700); 300 + } else if (isHovered) { 301 + toastManager.setHovered(true); 302 + setTimeout(() => { 303 + toastManager.setHovered(true); 304 + }, 700); 305 + } 306 + 307 + // Update known toast IDs 308 + toasts.forEach((t) => allKnownToastIds.current.add(t.id)); 309 + prevToastIds.current = currentIds; 310 + }, [visibleToasts, isHovered, toasts]); 311 + 312 + const setHovered = (value: boolean) => toastManager.setHovered(value); 313 + 314 + const pan = Gesture.Pan() 315 + .onUpdate((event) => { 316 + const velocity = isTop ? -event.velocityY : event.velocityY; 317 + if (velocity > 500) { 318 + runOnJS(setHovered)(true); 319 + } else if (velocity < -500) { 320 + runOnJS(setHovered)(false); 321 + } 322 + }) 323 + .onEnd((event) => { 324 + const translationY = isTop ? -event.translationY : event.translationY; 325 + if (translationY > 50) { 326 + runOnJS(setHovered)(true); 327 + } else if (translationY < -50) { 328 + runOnJS(setHovered)(false); 329 + } 330 + }); 331 + 332 + const gesture = 333 + Platform.OS === "web" 334 + ? Gesture.Hover() 335 + .onStart(() => { 336 + runOnJS(setHovered)(true); 337 + }) 338 + .onEnd(() => { 339 + runOnJS(setHovered)(false); 340 + }) 341 + : pan; 342 + 343 + const getPositionStyle = (): ViewStyle => { 344 + const resolvedPosition = 345 + position === "auto" 346 + ? isDesktop 347 + ? "bottom-right" 348 + : "top-center" 349 + : position; 350 + 351 + const styles: ViewStyle = { 352 + position: "absolute", 353 + zIndex: 1000, 354 + paddingHorizontal: isDesktop ? 0 : theme.spacing[4], 355 + }; 356 + 357 + // Set width/maxWidth 358 + if (isDesktop) { 359 + styles.width = 400; 360 + } else { 361 + styles.width = "100%"; 362 + styles.maxWidth = 400; 363 + } 364 + 365 + if (resolvedPosition.includes("top")) { 366 + styles.top = insets.top + theme.spacing[4]; 367 + } 368 + if (resolvedPosition.includes("bottom")) { 369 + styles.bottom = insets.bottom + theme.spacing[4]; 370 + } 371 + if (resolvedPosition.includes("left")) { 372 + styles.left = insets.left + theme.spacing[4]; 373 + styles.alignItems = "flex-start"; 374 + } 375 + if (resolvedPosition.includes("right")) { 376 + styles.right = insets.right + theme.spacing[4]; 377 + styles.alignItems = "flex-end"; 378 + } 379 + if (resolvedPosition.includes("center")) { 380 + styles.alignItems = "center"; 381 + // Center the container itself when it has maxWidth 382 + if (!isDesktop) { 383 + styles.left = "50%"; 384 + styles.transform = [{ translateX: "-50%" }]; 385 + } else { 386 + styles.left = 0; 387 + styles.right = 0; 388 + } 389 + } 390 + 391 + return styles; 392 + }; 128 393 129 394 return ( 130 - <Toast 131 - open={toastState.open} 132 - onOpenChange={(open) => { 133 - if (!open) toastManager.hide(); 395 + <Portal name="toasties"> 396 + <GestureDetector gesture={gesture}> 397 + <View style={getPositionStyle()}> 398 + {visibleToasts.reverse().map((toastState, index) => ( 399 + <Toast 400 + key={toastState.id} 401 + {...toastState} 402 + onOpenChange={(open) => { 403 + if (!open) toastManager.hide(toastState.id); 404 + }} 405 + index={index} 406 + isLatest={index === 0} 407 + position={ 408 + position === "auto" 409 + ? isDesktop 410 + ? "bottom-right" 411 + : "top-center" 412 + : position 413 + } 414 + /> 415 + ))} 416 + </View> 417 + </GestureDetector> 418 + </Portal> 419 + ); 420 + } 421 + 422 + export function AndMore({ more }: { more: number }) { 423 + const { theme } = useTheme(); 424 + return ( 425 + <View 426 + style={{ 427 + padding: theme.spacing[2], 428 + paddingHorizontal: theme.spacing[4], 429 + backgroundColor: theme.colors.muted, 430 + borderRadius: theme.borderRadius.xl, 431 + marginTop: theme.spacing[2], 432 + alignSelf: "center", 134 433 }} 135 - title={toastState.title} 136 - description={toastState.description} 137 - actionLabel={toastState.actionLabel} 138 - onAction={toastState.onAction} 139 - duration={toastState.duration} 140 - /> 434 + > 435 + <Text size="sm" style={{ color: theme.colors.mutedForeground }}> 436 + and {more} more notification 437 + {more === 1 ? "" : "s"} 438 + </Text> 439 + </View> 141 440 ); 142 441 } 143 442 144 - type ToastProps = { 145 - open: boolean; 146 - onOpenChange: (open: boolean) => void; 147 - title: string; 148 - description?: string; 149 - actionLabel?: string; 150 - onAction?: () => void; 151 - duration?: number; // seconds 443 + type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>; 444 + 445 + type ToastProps = PartialBy<Omit<ToastState, "id" | "open">, "position"> & { 446 + id?: string; 447 + open?: boolean; 448 + onOpenChange?: (open: boolean) => void; 449 + index?: number; 450 + isLatest?: boolean; 152 451 }; 153 452 453 + const AnimatedCircle = Animated.createAnimatedComponent(Circle); 454 + 455 + function CloseButton({ 456 + onPress, 457 + isLatest, 458 + duration, 459 + animatedCircleProps, 460 + theme, 461 + RADIUS, 462 + CIRCUMFERENCE, 463 + }: { 464 + onPress: () => void; 465 + isLatest: boolean; 466 + duration: number; 467 + animatedCircleProps: any; 468 + theme: any; 469 + RADIUS: number; 470 + CIRCUMFERENCE: number; 471 + }) { 472 + return ( 473 + <Pressable 474 + onPress={onPress} 475 + style={{ 476 + alignItems: "center", 477 + justifyContent: "center", 478 + }} 479 + > 480 + <Svg 481 + width={RADIUS * 2} 482 + height={RADIUS * 2} 483 + viewBox={`0 0 ${RADIUS * 2 + 2} ${RADIUS * 2 + 2}`} 484 + > 485 + <AnimatedCircle 486 + stroke={theme.colors.border} 487 + fill="transparent" 488 + strokeWidth="2" 489 + r={RADIUS} 490 + cx={RADIUS + 1} 491 + cy={RADIUS + 1} 492 + /> 493 + {isLatest && duration > 0 && ( 494 + <AnimatedCircle 495 + animatedProps={animatedCircleProps} 496 + stroke={theme.colors.primary} 497 + fill="transparent" 498 + strokeWidth="2" 499 + strokeDasharray={CIRCUMFERENCE} 500 + r={RADIUS} 501 + cx={RADIUS + 1} 502 + cy={RADIUS + 1} 503 + rotation="-90" 504 + originX={RADIUS + 1} 505 + originY={RADIUS + 1} 506 + strokeLinecap="round" 507 + /> 508 + )} 509 + </Svg> 510 + <View style={{ position: "absolute" }}> 511 + <X color={theme.colors.foreground} size={12} /> 512 + </View> 513 + </Pressable> 514 + ); 515 + } 516 + 154 517 export function Toast({ 155 - open, 156 - onOpenChange, 157 - title, 518 + open = false, 519 + onOpenChange = () => {}, 520 + title = "", 158 521 description, 159 522 actionLabel = "Action", 160 523 onAction, 161 - duration = 3, 524 + duration = 60, 525 + index = 0, 526 + isLatest = true, 527 + variant = "default", 528 + render, 529 + position = "auto", 530 + id, 531 + iconLeft: IconLeft, 532 + iconRight: IconRight, 533 + onToastPress, 162 534 }: ToastProps) { 163 - const [seconds, setSeconds] = useState(duration); 164 - const insets = useSafeAreaInsets(); 535 + const [isHovered, setIsHovered] = useState(false); 536 + const progress = useSharedValue(1); 537 + const remainingTime = useSharedValue(duration * 1000); 538 + const wasOpen = useRef(open); 539 + const [measuredHeight, setMeasuredHeight] = useState(100); 165 540 const { theme } = useTheme(); 166 - const [fadeAnim] = useState(new Animated.Value(0)); 167 - const { width } = useWindowDimensions(); 168 541 const isWeb = Platform.OS === "web"; 542 + const { width } = useWindowDimensions(); 169 543 const isDesktop = isWeb && width >= 768; 170 544 171 - const containerPosition: ViewStyle = isDesktop 172 - ? { 173 - top: undefined, 174 - bottom: theme.spacing[4], 175 - right: theme.spacing[4], // <-- use spacing, not 1 176 - alignItems: "flex-end", 177 - minWidth: 400, 178 - width: 400, 179 - // Do NOT set left at all 180 - } 181 - : { 182 - bottom: insets.bottom + theme.spacing[1], 183 - left: 0, 184 - right: 0, 185 - alignItems: "center", 186 - width: "100%", 187 - maxWidth: undefined, 188 - }; 545 + const { top, bottom } = useSafeAreaInsets(); 546 + 547 + const isTop = position.includes("top"); 548 + 549 + const RADIUS = 12; 550 + const CIRCUMFERENCE = 2 * Math.PI * RADIUS; 551 + 552 + const dismissTranslateY = useSharedValue(0); 553 + const dismissTranslateX = useSharedValue(0); 554 + 555 + // Auto-select iconLeft based on variant if not provided 556 + const defaultIconLeft = 557 + !IconLeft && !onAction 558 + ? variant === "success" 559 + ? CheckCircle 560 + : variant === "error" 561 + ? XCircle 562 + : variant === "info" 563 + ? Info 564 + : null 565 + : null; 566 + const FinalIconLeft = IconLeft || defaultIconLeft; 567 + const FinalIconRight = IconRight; 568 + 569 + const animatedCircleProps = useAnimatedProps(() => { 570 + return { 571 + strokeDashoffset: CIRCUMFERENCE * (1 - progress.value), 572 + }; 573 + }); 574 + 575 + const opacity = useSharedValue(0); 576 + const translateY = useSharedValue(isTop ? -100 : 100); 577 + const scale = useSharedValue(1 - index * 0.05); 578 + 579 + useEffect(() => { 580 + return toastManager.subscribeHover(setIsHovered); 581 + }, []); 189 582 190 583 useEffect(() => { 191 - let interval: ReturnType<typeof setInterval> | null = null; 584 + if (open && !wasOpen.current) { 585 + // Toast just opened 586 + progress.value = 1; 587 + remainingTime.value = duration * 1000; 588 + } 589 + wasOpen.current = open; 192 590 193 - if (open) { 194 - setSeconds(duration); 195 - Animated.timing(fadeAnim, { 196 - toValue: 1, 197 - duration: 200, 198 - useNativeDriver: true, 199 - }).start(); 591 + if (!open) { 592 + // Close animation 593 + opacity.value = withTiming(0, { duration: 250 }); 594 + translateY.value = withTiming(isTop ? -100 : 100, { duration: 250 }); 595 + return; 596 + } 597 + 598 + if (isHovered) { 599 + // Stack vertically with proper spacing when hovered 600 + // Calculate cumulative height of all toasts before this one 601 + let cumulativeHeight = 0; 602 + const allToasts = toastManager.getToasts().slice(-4).reverse(); // Get visible toasts in same order as rendered 603 + for (let i = 0; i < index; i++) { 604 + if (allToasts[i]) { 605 + cumulativeHeight += toastManager.getToastHeight(allToasts[i].id) + 8; // height + gap 606 + } 607 + } 608 + 609 + translateY.value = withTiming( 610 + isTop ? cumulativeHeight : -cumulativeHeight, 611 + { 612 + duration: 750, 613 + easing: Easing.out(Easing.exp), 614 + }, 615 + ); 616 + scale.value = withTiming(1, { 617 + duration: 750, 618 + easing: Easing.out(Easing.exp), 619 + }); 620 + opacity.value = withTiming(1, { 621 + duration: 750, 622 + easing: Easing.out(Easing.exp), 623 + }); 624 + } else { 625 + // Compact stacked view when not hovered 626 + translateY.value = withTiming((isTop ? index : -index) * 15, { 627 + duration: 750, 628 + easing: Easing.out(Easing.exp), 629 + }); 630 + scale.value = withTiming(1 - index * 0.1, { 631 + duration: 750, 632 + easing: Easing.out(Easing.exp), 633 + }); 634 + opacity.value = withTiming(isLatest ? 1 : 0.95, { 635 + duration: 750, 636 + easing: Easing.out(Easing.exp), 637 + }); 638 + } 639 + }, [open, isHovered, index, isLatest, measuredHeight, duration, id]); 200 640 201 - interval = setInterval(() => { 202 - setSeconds((prev) => { 203 - if (prev <= 1) { 204 - onOpenChange(false); 205 - if (interval) clearInterval(interval); 206 - return duration; 207 - } 208 - return prev - 1; 209 - }); 210 - }, 1000); 641 + useEffect(() => { 642 + if (open && isLatest && duration > 0) { 643 + if (isHovered) { 644 + cancelAnimation(progress); 645 + remainingTime.value = progress.value * duration * 1000; 646 + } else { 647 + progress.value = withTiming( 648 + 0, 649 + { 650 + duration: remainingTime.value, 651 + easing: Easing.linear, 652 + }, 653 + (finished) => { 654 + if (finished) { 655 + runOnJS(onOpenChange)(false); 656 + } 657 + }, 658 + ); 659 + } 211 660 } else { 212 - if (interval) clearInterval(interval); 213 - Animated.timing(fadeAnim, { 214 - toValue: 0, 215 - duration: 150, 216 - useNativeDriver: true, 217 - }).start(); 218 - setSeconds(duration); 661 + cancelAnimation(progress); 219 662 } 220 663 221 664 return () => { 222 - if (interval) clearInterval(interval); 665 + cancelAnimation(progress); 666 + }; 667 + }, [open, isLatest, isHovered, duration]); 668 + 669 + const dismissGestureVertical = Gesture.Pan() 670 + .enabled(isLatest && !isHovered) 671 + .activeOffsetY(isTop ? [-10, 999999] : [-999999, 10]) 672 + .onUpdate((event) => { 673 + const direction = isTop ? -1 : 1; 674 + const movement = event.translationY * direction; 675 + if (movement > 0) { 676 + dismissTranslateY.value = event.translationY; 677 + dismissTranslateX.value = 0; 678 + } 679 + }) 680 + .onEnd((event) => { 681 + const direction = isTop ? -1 : 1; 682 + const movement = event.translationY * direction; 683 + const velocity = event.velocityY * direction; 684 + 685 + if (movement > 50 || velocity > 500) { 686 + // Dismiss - slide out in expansion direction 687 + dismissTranslateY.value = withTiming( 688 + isTop ? -200 : 200, 689 + { duration: 250 }, 690 + (finished) => { 691 + if (finished) { 692 + runOnJS(onOpenChange)(false); 693 + } 694 + }, 695 + ); 696 + opacity.value = withTiming(0, { duration: 250 }); 697 + } else { 698 + // Spring back 699 + dismissTranslateY.value = withTiming(0, { duration: 200 }); 700 + } 701 + }); 702 + 703 + const dismissGestureHorizontal = Gesture.Pan() 704 + .enabled(isLatest || isHovered) 705 + .activeOffsetX([-10, 10]) 706 + .onUpdate((event) => { 707 + if (event.translationX < 0) { 708 + dismissTranslateX.value = event.translationX; 709 + dismissTranslateY.value = 0; 710 + } 711 + }) 712 + .onEnd((event) => { 713 + // Check for horizontal swipe left 714 + if (event.translationX < -100 || event.velocityX < -500) { 715 + dismissTranslateX.value = withTiming( 716 + -400, 717 + { duration: 250 }, 718 + (finished) => { 719 + if (finished) { 720 + runOnJS(onOpenChange)(false); 721 + } 722 + }, 723 + ); 724 + opacity.value = withTiming(0, { duration: 250 }); 725 + } else { 726 + // Spring back 727 + dismissTranslateX.value = withTiming(0, { duration: 200 }); 728 + } 729 + }); 730 + 731 + const dismissGesture = Gesture.Race( 732 + dismissGestureVertical, 733 + dismissGestureHorizontal, 734 + ); 735 + 736 + const animatedStyle = useAnimatedStyle(() => { 737 + return { 738 + opacity: opacity.value, 739 + transform: [ 740 + // +22 is to get it just below the header 741 + { 742 + translateY: 743 + translateY.value + 744 + dismissTranslateY.value + 745 + (isTop ? top / 2 : -bottom), 746 + }, 747 + { translateX: dismissTranslateX.value }, 748 + { scale: scale.value }, 749 + ], 750 + zIndex: 1000 - index, 223 751 }; 224 - // eslint-disable-next-line 225 - }, [open, duration]); 752 + }); 226 753 227 - if (!open) return null; 754 + const variantStyles = { 755 + default: { 756 + backgroundColor: theme.colors.secondary, 757 + borderColor: theme.colors.border, 758 + }, 759 + success: { 760 + backgroundColor: theme.colors.success, 761 + borderColor: theme.colors.success, 762 + }, 763 + error: { 764 + backgroundColor: theme.colors.destructive, 765 + borderColor: theme.colors.destructive, 766 + }, 767 + info: { 768 + backgroundColor: theme.colors.info, 769 + borderColor: theme.colors.info, 770 + }, 771 + }; 772 + 773 + const buttonTypeMap = { 774 + default: "primary", 775 + success: "success", 776 + error: "primary", 777 + info: "secondary", 778 + } as const; 228 779 229 780 return ( 230 - <Portal name="toast"> 231 - <Animated.View 232 - style={[styles.container, containerPosition, { opacity: fadeAnim }]} 233 - pointerEvents="box-none" 234 - > 235 - <View 781 + <Animated.View 782 + onLayout={(l) => { 783 + const height = l.nativeEvent.layout.height; 784 + setMeasuredHeight(height); 785 + if (id) { 786 + toastManager.updateToastHeight(id, height); 787 + } 788 + }} 789 + style={[ 790 + isTop ? styles.containerTop : styles.containerBottom, 791 + animatedStyle, 792 + ]} 793 + > 794 + <GestureDetector gesture={dismissGesture}> 795 + <Pressable 796 + onPress={onToastPress} 797 + disabled={!onToastPress} 236 798 style={[ 237 799 styles.toast, 238 800 { 239 - backgroundColor: theme.colors.secondary, 240 - borderColor: theme.colors.border, 241 801 borderRadius: theme.borderRadius.xl, 242 802 flexDirection: "column", 243 803 justifyContent: "space-between", 244 804 alignItems: "center", 245 805 padding: theme.spacing[4], 246 - width: isDesktop ? "100%" : "95%", 806 + width: "100%", 247 807 }, 808 + variantStyles[variant], 248 809 ]} 249 810 > 250 - <View style={{ gap: theme.spacing[1], width: "100%" }}> 251 - <Text 252 - style={[ 253 - { 254 - color: theme.colors.foreground, 255 - fontSize: 16, 256 - fontWeight: "500", 257 - }, 258 - ]} 259 - > 260 - {title} 261 - </Text> 262 - {description ? ( 263 - <Text style={[{ color: theme.colors.foreground, fontSize: 14 }]}> 264 - {description} 265 - </Text> 266 - ) : null} 267 - </View> 268 - <View 269 - style={{ 270 - gap: theme.spacing[1], 271 - flexDirection: "row", 272 - justifyContent: "flex-end", 273 - width: "100%", 274 - }} 275 - > 276 - {onAction && ( 277 - <Pressable 278 - style={[ 279 - styles.button, 280 - { 281 - borderColor: theme.colors.primary, 282 - paddingHorizontal: theme.spacing[4], 283 - paddingVertical: theme.spacing[2], 284 - }, 285 - ]} 286 - onPress={onAction} 811 + {render ? ( 812 + render({ close: () => onOpenChange(false), action: onAction }) 813 + ) : ( 814 + <> 815 + <View 816 + style={{ 817 + flexDirection: "row", 818 + alignItems: "flex-start", 819 + justifyContent: "space-between", 820 + width: "100%", 821 + gap: theme.spacing[4], 822 + }} 287 823 > 288 - <Text style={{ color: theme.colors.foreground }}> 289 - {actionLabel} 290 - </Text> 291 - </Pressable> 292 - )} 293 - <Pressable 294 - style={[ 295 - styles.button, 296 - { 297 - borderColor: theme.colors.primary, 298 - paddingHorizontal: theme.spacing[4], 299 - paddingVertical: theme.spacing[2], 300 - }, 301 - ]} 302 - onPress={() => onOpenChange(false)} 303 - > 304 - <Text style={{ color: theme.colors.foreground }}>Close</Text> 305 - </Pressable> 306 - </View> 307 - </View> 308 - </Animated.View> 309 - </Portal> 824 + <View 825 + style={{ 826 + flexDirection: "row", 827 + flex: 1, 828 + gap: theme.spacing[3], 829 + }} 830 + > 831 + {FinalIconLeft && ( 832 + <View style={{ paddingTop: 2 }}> 833 + <FinalIconLeft color={theme.colors.foreground} /> 834 + </View> 835 + )} 836 + <View style={{ flex: 1 }}> 837 + <Text size="lg">{title}</Text> 838 + {description ? <Text>{description}</Text> : null} 839 + </View> 840 + </View> 841 + {FinalIconRight && !onAction ? ( 842 + <View 843 + style={{ 844 + gap: theme.spacing[2], 845 + height: "100%", 846 + justifyContent: "space-between", 847 + }} 848 + > 849 + <Pressable onPress={onToastPress}> 850 + <FinalIconRight color={theme.colors.foreground} /> 851 + </Pressable> 852 + <CloseButton 853 + onPress={() => onOpenChange(false)} 854 + isLatest={isLatest} 855 + duration={duration} 856 + animatedCircleProps={animatedCircleProps} 857 + theme={theme} 858 + RADIUS={RADIUS} 859 + CIRCUMFERENCE={CIRCUMFERENCE} 860 + /> 861 + </View> 862 + ) : !onAction ? ( 863 + <CloseButton 864 + onPress={() => onOpenChange(false)} 865 + isLatest={isLatest} 866 + duration={duration} 867 + animatedCircleProps={animatedCircleProps} 868 + theme={theme} 869 + RADIUS={RADIUS} 870 + CIRCUMFERENCE={CIRCUMFERENCE} 871 + /> 872 + ) : null} 873 + </View> 874 + {onAction && ( 875 + <View 876 + style={{ 877 + gap: theme.spacing[1], 878 + flexDirection: "row", 879 + justifyContent: "flex-end", 880 + width: "100%", 881 + }} 882 + > 883 + <Button variant={buttonTypeMap[variant]} onPress={onAction}> 884 + <Text style={{ color: theme.colors.foreground }}> 885 + {actionLabel} 886 + </Text> 887 + </Button> 888 + <Button 889 + variant="secondary" 890 + onPress={() => onOpenChange(false)} 891 + > 892 + <Text style={{ color: theme.colors.foreground }}> 893 + Close 894 + </Text> 895 + </Button> 896 + </View> 897 + )} 898 + </> 899 + )} 900 + </Pressable> 901 + </GestureDetector> 902 + </Animated.View> 310 903 ); 311 904 } 312 905 313 906 const styles = StyleSheet.create({ 314 - container: { 907 + providerContainer: { 315 908 position: "absolute", 316 909 zIndex: 1000, 317 - paddingHorizontal: 16, 910 + }, 911 + containerBottom: { 912 + position: "absolute", 913 + bottom: 0, 914 + left: 0, 915 + right: 0, 916 + alignItems: "center", 917 + }, 918 + containerTop: { 919 + position: "absolute", 920 + top: 0, 921 + left: 0, 922 + right: 0, 923 + alignItems: "center", 318 924 }, 319 925 toast: { 320 - opacity: 0.95, 926 + opacity: 0.99, 321 927 borderWidth: 1, 322 928 gap: 8, 323 929 },
+10 -6
js/components/src/lib/theme/theme.tsx
··· 84 84 warning: string; 85 85 warningForeground: string; 86 86 87 + // Info colors 88 + info: string; 89 + infoForeground: string; 90 + 87 91 // Border and input colors 88 92 border: string; 89 93 input: string; ··· 344 348 accent: isDark ? palette[800] : palette[100], 345 349 accentForeground: isDark ? palette[50] : palette[900], 346 350 347 - destructive: 348 - Platform.OS === "ios" ? colors.ios.systemRed : colors.destructive[500], 351 + destructive: colors.destructive[700], 349 352 destructiveForeground: colors.white, 350 353 351 - success: 352 - Platform.OS === "ios" ? colors.ios.systemGreen : colors.success[500], 354 + success: colors.success[700], 353 355 successForeground: colors.white, 354 356 355 - warning: 356 - Platform.OS === "ios" ? colors.ios.systemOrange : colors.warning[500], 357 + warning: colors.warning[700], 357 358 warningForeground: colors.white, 359 + 360 + info: colors.blue[700], 361 + infoForeground: isDark ? palette[50] : palette[900], 358 362 359 363 border: isDark ? palette[500] + "30" : palette[200] + "30", 360 364 input: isDark ? palette[800] : palette[200],
+9
js/components/src/lib/theme/tokens.ts
··· 590 590 }, 591 591 592 592 // Universal typography scale 593 + // Atkinson's center is weird so the marginBottom is there to correct it? 593 594 universal: { 594 595 xs: { 595 596 fontSize: 12, 596 597 lineHeight: 16, 598 + marginBottom: -0.7, 597 599 fontWeight: "400" as const, 598 600 fontFamily: "AtkinsonHyperlegibleNext-Regular", 599 601 }, 600 602 sm: { 601 603 fontSize: 14, 602 604 lineHeight: 20, 605 + marginBottom: -1, 603 606 fontWeight: "400" as const, 604 607 fontFamily: "AtkinsonHyperlegibleNext-Regular", 605 608 }, 606 609 base: { 607 610 fontSize: 16, 608 611 lineHeight: 24, 612 + marginBottom: -1.2, 609 613 fontWeight: "400" as const, 610 614 fontFamily: "AtkinsonHyperlegibleNext-Regular", 611 615 }, 612 616 lg: { 613 617 fontSize: 18, 614 618 lineHeight: 28, 619 + marginBottom: -1.5, 615 620 fontWeight: "400" as const, 616 621 fontFamily: "AtkinsonHyperlegibleNext-Regular", 617 622 }, 618 623 xl: { 619 624 fontSize: 20, 620 625 lineHeight: 28, 626 + marginBottom: -1.75, 621 627 fontWeight: "500" as const, 622 628 fontFamily: "AtkinsonHyperlegibleNext-Medium", 623 629 }, 624 630 "2xl": { 625 631 fontSize: 24, 626 632 lineHeight: 32, 633 + marginBottom: -2, 627 634 fontWeight: "600" as const, 628 635 fontFamily: "AtkinsonHyperlegibleNext-SemiBold", 629 636 }, 630 637 "3xl": { 631 638 fontSize: 30, 632 639 lineHeight: 36, 640 + marginBottom: -2.5, 633 641 fontWeight: "700" as const, 634 642 fontFamily: "AtkinsonHyperlegibleNext-Bold", 635 643 }, 636 644 "4xl": { 637 645 fontSize: 36, 638 646 lineHeight: 40, 647 + marginBottom: -3, 639 648 fontWeight: "700" as const, 640 649 fontFamily: "AtkinsonHyperlegibleNext-ExtraBold", 641 650 },
+10
js/components/src/player-store/player-store.tsx
··· 3 3 import { ChatMessageViewHydrated } from "streamplace"; 4 4 import { createStore, StoreApi, useStore } from "zustand"; 5 5 import { useLivestreamStore } from "../livestream-store"; 6 + import { useStreamplaceStore } from "../streamplace-store"; 6 7 import { PlayerContext } from "./context"; 7 8 import { 8 9 IngestMediaSource, ··· 271 272 } 272 273 return now - Date.parse(segment.startTime) > 10000; 273 274 }; 275 + 276 + export const useIsMyStream = () => { 277 + const myHandle = useStreamplaceStore((state) => state.handle); 278 + const myDid = useStreamplaceStore((state) => state.oauthSession?.did); 279 + const channelId = usePlayerStore((state) => state.src); 280 + return () => { 281 + return myHandle === channelId || myDid === channelId; 282 + }; 283 + };
+232
js/components/src/streamplace-store/graph.tsx
··· 1 + import { AppBskyGraphFollow } from "@atproto/api"; 2 + import { useEffect, useState } from "react"; 3 + import { useStreamplaceStore } from "./streamplace-store"; 4 + import { usePDSAgent } from "./xrpc"; 5 + 6 + export function useCreateFollowRecord() { 7 + let agent = usePDSAgent(); 8 + const [isLoading, setIsLoading] = useState(false); 9 + 10 + const createFollow = async (subjectDID: string) => { 11 + if (!agent) { 12 + throw new Error("No PDS agent found"); 13 + } 14 + 15 + if (!agent.did) { 16 + throw new Error("No user DID found, assuming not logged in"); 17 + } 18 + 19 + setIsLoading(true); 20 + try { 21 + const record: AppBskyGraphFollow.Record = { 22 + $type: "app.bsky.graph.follow", 23 + subject: subjectDID, 24 + createdAt: new Date().toISOString(), 25 + }; 26 + const result = await agent.com.atproto.repo.createRecord({ 27 + repo: agent.did, 28 + collection: "app.bsky.graph.follow", 29 + record, 30 + }); 31 + return result; 32 + } finally { 33 + setIsLoading(false); 34 + } 35 + }; 36 + 37 + return { createFollow, isLoading }; 38 + } 39 + 40 + export function useDeleteFollowRecord() { 41 + let agent = usePDSAgent(); 42 + const [isLoading, setIsLoading] = useState(false); 43 + 44 + const deleteFollow = async (followRecordUri: string) => { 45 + if (!agent) { 46 + throw new Error("No PDS agent found"); 47 + } 48 + 49 + if (!agent.did) { 50 + throw new Error("No user DID found, assuming not logged in"); 51 + } 52 + 53 + setIsLoading(true); 54 + try { 55 + const result = await agent.com.atproto.repo.deleteRecord({ 56 + repo: agent.did, 57 + collection: "app.bsky.graph.follow", 58 + rkey: followRecordUri.split("/").pop()!, 59 + }); 60 + return result; 61 + } finally { 62 + setIsLoading(false); 63 + } 64 + }; 65 + 66 + return { deleteFollow, isLoading }; 67 + } 68 + 69 + interface GraphManagerState { 70 + isFollowing: boolean | null; 71 + followUri: string | null; 72 + isLoading: boolean; 73 + error: string | null; 74 + } 75 + 76 + interface GraphManagerActions { 77 + follow: () => Promise<void>; 78 + unfollow: () => Promise<void>; 79 + refresh: () => Promise<void>; 80 + } 81 + 82 + export function useGraphManager( 83 + subjectDID: string | null | undefined, 84 + ): GraphManagerState & GraphManagerActions { 85 + const agent = usePDSAgent(); 86 + const [isFollowing, setIsFollowing] = useState<boolean | null>(null); 87 + const [followUri, setFollowUri] = useState<string | null>(null); 88 + const [isLoading, setIsLoading] = useState(false); 89 + const [error, setError] = useState<string | null>(null); 90 + 91 + const userDID = agent?.did; 92 + 93 + const streamplaceUrl = useStreamplaceStore((state) => state.url); 94 + 95 + const fetchFollowStatus = async () => { 96 + if (!userDID || !subjectDID || !streamplaceUrl) { 97 + setIsFollowing(null); 98 + setFollowUri(null); 99 + return; 100 + } 101 + 102 + setIsLoading(true); 103 + setError(null); 104 + try { 105 + const res = await fetch( 106 + `${streamplaceUrl}/xrpc/place.stream.graph.getFollowingUser?subjectDID=${encodeURIComponent(subjectDID)}&userDID=${encodeURIComponent(userDID)}`, 107 + { 108 + credentials: "include", 109 + }, 110 + ); 111 + 112 + if (!res.ok) { 113 + const errorText = await res.text(); 114 + throw new Error(`Failed to fetch follow status: ${errorText}`); 115 + } 116 + 117 + const data = await res.json(); 118 + 119 + if (data.follow) { 120 + setIsFollowing(true); 121 + setFollowUri(data.follow.uri); 122 + } else { 123 + setIsFollowing(false); 124 + setFollowUri(null); 125 + } 126 + } catch (err) { 127 + setError( 128 + `Could not determine follow state: ${err instanceof Error ? err.message : `Unknown error: ${err}`}`, 129 + ); 130 + setIsFollowing(null); 131 + } finally { 132 + setIsLoading(false); 133 + } 134 + }; 135 + 136 + useEffect(() => { 137 + if (!userDID || !subjectDID) { 138 + setIsFollowing(null); 139 + setFollowUri(null); 140 + setError(null); 141 + return; 142 + } 143 + 144 + fetchFollowStatus(); 145 + }, [userDID, subjectDID, streamplaceUrl]); 146 + 147 + const follow = async () => { 148 + if (!agent || !subjectDID) { 149 + throw new Error("Cannot follow: not logged in or no subject DID"); 150 + } 151 + 152 + if (!agent.did) { 153 + throw new Error("No user DID found, assuming not logged in"); 154 + } 155 + 156 + setIsLoading(true); 157 + setError(null); 158 + const previousState = isFollowing; 159 + setIsFollowing(true); // Optimistic 160 + 161 + try { 162 + const record: AppBskyGraphFollow.Record = { 163 + $type: "app.bsky.graph.follow", 164 + subject: subjectDID, 165 + createdAt: new Date().toISOString(), 166 + }; 167 + const result = await agent.com.atproto.repo.createRecord({ 168 + repo: agent.did, 169 + collection: "app.bsky.graph.follow", 170 + record, 171 + }); 172 + setFollowUri(result.data.uri); 173 + setIsFollowing(true); 174 + } catch (err) { 175 + setIsFollowing(previousState); 176 + const errorMsg = `Failed to follow: ${err instanceof Error ? err.message : "Unknown error"}`; 177 + setError(errorMsg); 178 + throw new Error(errorMsg); 179 + } finally { 180 + setIsLoading(false); 181 + } 182 + }; 183 + 184 + const unfollow = async () => { 185 + if (!agent || !subjectDID) { 186 + throw new Error("Cannot unfollow: not logged in or no subject DID"); 187 + } 188 + 189 + if (!agent.did) { 190 + throw new Error("No user DID found, assuming not logged in"); 191 + } 192 + 193 + if (!followUri) { 194 + throw new Error("Cannot unfollow: no follow URI found"); 195 + } 196 + 197 + setIsLoading(true); 198 + setError(null); 199 + const previousState = isFollowing; 200 + const previousUri = followUri; 201 + setIsFollowing(false); // Optimistic 202 + setFollowUri(null); 203 + 204 + try { 205 + await agent.com.atproto.repo.deleteRecord({ 206 + repo: agent.did, 207 + collection: "app.bsky.graph.follow", 208 + rkey: followUri.split("/").pop()!, 209 + }); 210 + setIsFollowing(false); 211 + setFollowUri(null); 212 + } catch (err) { 213 + setIsFollowing(previousState); 214 + setFollowUri(previousUri); 215 + const errorMsg = `Failed to unfollow: ${err instanceof Error ? err.message : "Unknown error"}`; 216 + setError(errorMsg); 217 + throw new Error(errorMsg); 218 + } finally { 219 + setIsLoading(false); 220 + } 221 + }; 222 + 223 + return { 224 + isFollowing, 225 + followUri, 226 + isLoading, 227 + error, 228 + follow, 229 + unfollow, 230 + refresh: fetchFollowStatus, 231 + }; 232 + }
+8 -4
js/config-react-native-webrtc/src/config-react-native-webrtc.ts
··· 43 43 .build() 44 44 options.audioDeviceModule = JavaAudioDeviceModule.builder(this) 45 45 .setAudioAttributes(audioAttributes) 46 + .setUseStereoInput(true) 47 + .setUseStereoOutput(true) 46 48 .createAudioDeviceModule() 47 49 `, 48 50 }, ··· 115 117 WebRTCModuleOptions *options = [WebRTCModuleOptions sharedInstance]; 116 118 options.loggingSeverity = RTCLoggingSeverityWarning; 117 119 options.audioDevice = device; 120 + // Enable stereo audio 121 + options.enableStereoOutput = YES; 118 122 ////END RTC PATCH//// 119 123 `, 120 124 }, ··· 124 128 to: () => ` 125 129 // WebRTC Configuration 126 130 let config = RTCAudioSessionConfiguration.webRTC() 127 - 131 + 128 132 let session = AVAudioSession.sharedInstance() 129 133 do { 130 - try session.setCategory(.playAndRecord, 134 + try session.setCategory(.playAndRecord, 131 135 options: [.defaultToSpeaker, .allowBluetooth]) 132 136 try session.setActive(true) 133 137 } catch { 134 138 print("Failed to configure audio session: \(error)") 135 139 } 136 - 140 + 137 141 let device = AUAudioUnitRTCAudioDevice() 138 142 139 143 let options = WebRTCModuleOptions.sharedInstance() 140 144 options.loggingSeverity = .warning 141 145 options.audioDevice = device 142 146 // End WebRTC Configuration 143 - 147 + 144 148 let delegate = ReactNativeDelegate() 145 149 `, 146 150 },
+1 -1
js/docs/package.json
··· 1 1 { 2 2 "name": "streamplace-docs", 3 3 "type": "module", 4 - "version": "0.7.33", 4 + "version": "0.7.35", 5 5 "scripts": { 6 6 "dev": "astro dev --host 0.0.0.0 --port 38082", 7 7 "start": "astro dev --host 0.0.0.0 --port 38082",
+24
js/docs/src/content/docs/lex-reference/openapi.json
··· 110 110 "type": "string", 111 111 "description": "A description of what this webhook is used for.", 112 112 "maxLength": 500 113 + }, 114 + "muteWords": { 115 + "type": "array", 116 + "description": "Words to filter out from chat messages. Messages containing any of these words will not be forwarded.", 117 + "items": { 118 + "type": "string", 119 + "maxLength": 100 120 + } 113 121 } 114 122 }, 115 123 "required": ["url", "events"] ··· 465 473 "type": "string", 466 474 "description": "A description of what this webhook is used for.", 467 475 "maxLength": 500 476 + }, 477 + "muteWords": { 478 + "type": "array", 479 + "description": "Words to filter out from chat messages. Messages containing any of these words will not be forwarded.", 480 + "items": { 481 + "type": "string", 482 + "maxLength": 100 483 + } 468 484 } 469 485 }, 470 486 "required": ["id"] ··· 1655 1671 "errorCount": { 1656 1672 "type": "integer", 1657 1673 "description": "Number of consecutive errors for this webhook." 1674 + }, 1675 + "muteWords": { 1676 + "type": "array", 1677 + "description": "Words to filter out from chat messages. Messages containing any of these words will not be forwarded.", 1678 + "items": { 1679 + "type": "string", 1680 + "maxLength": 100 1681 + } 1658 1682 } 1659 1683 }, 1660 1684 "required": ["id", "url", "events", "active", "createdAt"]
+19 -10
js/docs/src/content/docs/lex-reference/server/place-stream-server-createwebhook.md
··· 24 24 25 25 **Schema Type:** `object` 26 26 27 - | Name | Type | Req'd | Description | Constraints | 28 - | ------------- | ------------------------------------------------------------------------------------------------------ | ----- | ---------------------------------------------------- | ---------------- | 29 - | `url` | `string` | ✅ | The webhook URL where events will be sent. | Format: `uri` | 30 - | `events` | Array of `string` | ✅ | The types of events this webhook should receive. | | 31 - | `active` | `boolean` | ❌ | Whether this webhook should be active upon creation. | Default: `false` | 32 - | `prefix` | `string` | ❌ | Text to prepend to webhook messages. | Max Length: 100 | 33 - | `suffix` | `string` | ❌ | Text to append to webhook messages. | Max Length: 100 | 34 - | `rewrite` | Array of [`place.stream.server.defs#rewriteRule`](/lex-reference/place-stream-server-defs#rewriterule) | ❌ | Text replacement rules for webhook messages. | | 35 - | `name` | `string` | ❌ | A user-friendly name for this webhook. | Max Length: 100 | 36 - | `description` | `string` | ❌ | A description of what this webhook is used for. | Max Length: 500 | 27 + | Name | Type | Req'd | Description | Constraints | 28 + | ------------- | ------------------------------------------------------------------------------------------------------ | ----- | ----------------------------------------------------------------------------------------------------- | ---------------- | 29 + | `url` | `string` | ✅ | The webhook URL where events will be sent. | Format: `uri` | 30 + | `events` | Array of `string` | ✅ | The types of events this webhook should receive. | | 31 + | `active` | `boolean` | ❌ | Whether this webhook should be active upon creation. | Default: `false` | 32 + | `prefix` | `string` | ❌ | Text to prepend to webhook messages. | Max Length: 100 | 33 + | `suffix` | `string` | ❌ | Text to append to webhook messages. | Max Length: 100 | 34 + | `rewrite` | Array of [`place.stream.server.defs#rewriteRule`](/lex-reference/place-stream-server-defs#rewriterule) | ❌ | Text replacement rules for webhook messages. | | 35 + | `name` | `string` | ❌ | A user-friendly name for this webhook. | Max Length: 100 | 36 + | `description` | `string` | ❌ | A description of what this webhook is used for. | Max Length: 500 | 37 + | `muteWords` | Array of `string` | ❌ | Words to filter out from chat messages. Messages containing any of these words will not be forwarded. | | 37 38 38 39 **Output:** 39 40 ··· 115 116 "type": "string", 116 117 "maxLength": 500, 117 118 "description": "A description of what this webhook is used for." 119 + }, 120 + "muteWords": { 121 + "type": "array", 122 + "items": { 123 + "type": "string", 124 + "maxLength": 100 125 + }, 126 + "description": "Words to filter out from chat messages. Messages containing any of these words will not be forwarded." 118 127 } 119 128 } 120 129 }
+24 -15
js/docs/src/content/docs/lex-reference/server/place-stream-server-defs.md
··· 17 17 18 18 **Properties:** 19 19 20 - | Name | Type | Req'd | Description | Constraints | 21 - | --------------- | --------------------------------------- | ----- | ------------------------------------------------ | ------------------ | 22 - | `id` | `string` | ✅ | Unique identifier for this webhook. | | 23 - | `url` | `string` | ✅ | The webhook URL where events will be sent. | Format: `uri` | 24 - | `events` | Array of `string` | ✅ | The types of events this webhook should receive. | | 25 - | `active` | `boolean` | ✅ | Whether this webhook is currently active. | | 26 - | `prefix` | `string` | ❌ | Text to prepend to webhook messages. | Max Length: 100 | 27 - | `suffix` | `string` | ❌ | Text to append to webhook messages. | Max Length: 100 | 28 - | `rewrite` | Array of [`#rewriteRule`](#rewriterule) | ❌ | Text replacement rules for webhook messages. | | 29 - | `createdAt` | `string` | ✅ | When this webhook was created. | Format: `datetime` | 30 - | `updatedAt` | `string` | ❌ | When this webhook was last updated. | Format: `datetime` | 31 - | `name` | `string` | ❌ | A user-friendly name for this webhook. | Max Length: 100 | 32 - | `description` | `string` | ❌ | A description of what this webhook is used for. | Max Length: 500 | 33 - | `lastTriggered` | `string` | ❌ | When this webhook was last triggered. | Format: `datetime` | 34 - | `errorCount` | `integer` | ❌ | Number of consecutive errors for this webhook. | | 20 + | Name | Type | Req'd | Description | Constraints | 21 + | --------------- | --------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------- | ------------------ | 22 + | `id` | `string` | ✅ | Unique identifier for this webhook. | | 23 + | `url` | `string` | ✅ | The webhook URL where events will be sent. | Format: `uri` | 24 + | `events` | Array of `string` | ✅ | The types of events this webhook should receive. | | 25 + | `active` | `boolean` | ✅ | Whether this webhook is currently active. | | 26 + | `prefix` | `string` | ❌ | Text to prepend to webhook messages. | Max Length: 100 | 27 + | `suffix` | `string` | ❌ | Text to append to webhook messages. | Max Length: 100 | 28 + | `rewrite` | Array of [`#rewriteRule`](#rewriterule) | ❌ | Text replacement rules for webhook messages. | | 29 + | `createdAt` | `string` | ✅ | When this webhook was created. | Format: `datetime` | 30 + | `updatedAt` | `string` | ❌ | When this webhook was last updated. | Format: `datetime` | 31 + | `name` | `string` | ❌ | A user-friendly name for this webhook. | Max Length: 100 | 32 + | `description` | `string` | ❌ | A description of what this webhook is used for. | Max Length: 500 | 33 + | `lastTriggered` | `string` | ❌ | When this webhook was last triggered. | Format: `datetime` | 34 + | `errorCount` | `integer` | ❌ | Number of consecutive errors for this webhook. | | 35 + | `muteWords` | Array of `string` | ❌ | Words to filter out from chat messages. Messages containing any of these words will not be forwarded. | | 35 36 36 37 --- 37 38 ··· 129 130 "errorCount": { 130 131 "type": "integer", 131 132 "description": "Number of consecutive errors for this webhook." 133 + }, 134 + "muteWords": { 135 + "type": "array", 136 + "items": { 137 + "type": "string", 138 + "maxLength": 100 139 + }, 140 + "description": "Words to filter out from chat messages. Messages containing any of these words will not be forwarded." 132 141 } 133 142 } 134 143 },
+20 -11
js/docs/src/content/docs/lex-reference/server/place-stream-server-updatewebhook.md
··· 24 24 25 25 **Schema Type:** `object` 26 26 27 - | Name | Type | Req'd | Description | Constraints | 28 - | ------------- | ------------------------------------------------------------------------------------------------------ | ----- | ------------------------------------------------ | --------------- | 29 - | `id` | `string` | ✅ | The ID of the webhook to update. | | 30 - | `url` | `string` | ❌ | The webhook URL where events will be sent. | Format: `uri` | 31 - | `events` | Array of `string` | ❌ | The types of events this webhook should receive. | | 32 - | `active` | `boolean` | ❌ | Whether this webhook should be active. | | 33 - | `prefix` | `string` | ❌ | Text to prepend to webhook messages. | Max Length: 100 | 34 - | `suffix` | `string` | ❌ | Text to append to webhook messages. | Max Length: 100 | 35 - | `rewrite` | Array of [`place.stream.server.defs#rewriteRule`](/lex-reference/place-stream-server-defs#rewriterule) | ❌ | Text replacement rules for webhook messages. | | 36 - | `name` | `string` | ❌ | A user-friendly name for this webhook. | Max Length: 100 | 37 - | `description` | `string` | ❌ | A description of what this webhook is used for. | Max Length: 500 | 27 + | Name | Type | Req'd | Description | Constraints | 28 + | ------------- | ------------------------------------------------------------------------------------------------------ | ----- | ----------------------------------------------------------------------------------------------------- | --------------- | 29 + | `id` | `string` | ✅ | The ID of the webhook to update. | | 30 + | `url` | `string` | ❌ | The webhook URL where events will be sent. | Format: `uri` | 31 + | `events` | Array of `string` | ❌ | The types of events this webhook should receive. | | 32 + | `active` | `boolean` | ❌ | Whether this webhook should be active. | | 33 + | `prefix` | `string` | ❌ | Text to prepend to webhook messages. | Max Length: 100 | 34 + | `suffix` | `string` | ❌ | Text to append to webhook messages. | Max Length: 100 | 35 + | `rewrite` | Array of [`place.stream.server.defs#rewriteRule`](/lex-reference/place-stream-server-defs#rewriterule) | ❌ | Text replacement rules for webhook messages. | | 36 + | `name` | `string` | ❌ | A user-friendly name for this webhook. | Max Length: 100 | 37 + | `description` | `string` | ❌ | A description of what this webhook is used for. | Max Length: 500 | 38 + | `muteWords` | Array of `string` | ❌ | Words to filter out from chat messages. Messages containing any of these words will not be forwarded. | | 38 39 39 40 **Output:** 40 41 ··· 120 121 "type": "string", 121 122 "maxLength": 500, 122 123 "description": "A description of what this webhook is used for." 124 + }, 125 + "muteWords": { 126 + "type": "array", 127 + "items": { 128 + "type": "string", 129 + "maxLength": 100 130 + }, 131 + "description": "Words to filter out from chat messages. Messages containing any of these words will not be forwarded." 123 132 } 124 133 } 125 134 }
+1 -1
lerna.json
··· 1 1 { 2 2 "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 - "version": "0.7.33", 3 + "version": "0.7.35", 4 4 "npmClient": "pnpm" 5 5 }
+8
lexicons/place/stream/server/createWebhook.json
··· 57 57 "type": "string", 58 58 "maxLength": 500, 59 59 "description": "A description of what this webhook is used for." 60 + }, 61 + "muteWords": { 62 + "type": "array", 63 + "items": { 64 + "type": "string", 65 + "maxLength": 100 66 + }, 67 + "description": "Words to filter out from chat messages. Messages containing any of these words will not be forwarded." 60 68 } 61 69 } 62 70 }
+8
lexicons/place/stream/server/defs.json
··· 71 71 "errorCount": { 72 72 "type": "integer", 73 73 "description": "Number of consecutive errors for this webhook." 74 + }, 75 + "muteWords": { 76 + "type": "array", 77 + "items": { 78 + "type": "string", 79 + "maxLength": 100 80 + }, 81 + "description": "Words to filter out from chat messages. Messages containing any of these words will not be forwarded." 74 82 } 75 83 } 76 84 },
+8
lexicons/place/stream/server/updateWebhook.json
··· 60 60 "type": "string", 61 61 "maxLength": 500, 62 62 "description": "A description of what this webhook is used for." 63 + }, 64 + "muteWords": { 65 + "type": "array", 66 + "items": { 67 + "type": "string", 68 + "maxLength": 100 69 + }, 70 + "description": "Words to filter out from chat messages. Messages containing any of these words will not be forwarded." 63 71 } 64 72 } 65 73 }
+29 -8
pkg/cmd/streamplace.go
··· 8 8 "errors" 9 9 "flag" 10 10 "fmt" 11 + "net/url" 11 12 "os" 12 13 "os/signal" 13 14 "path/filepath" ··· 370 371 return err 371 372 } 372 373 373 - clientMetadata := &oatproxy.OAuthClientMetadata{ 374 - Scope: "atproto transition:generic", 375 - ClientName: "Streamplace", 376 - RedirectURIs: []string{ 377 - fmt.Sprintf("https://%s/login", cli.BroadcasterHost), 378 - fmt.Sprintf("https://%s/api/app-return", cli.BroadcasterHost), 379 - }, 374 + var clientMetadata *oatproxy.OAuthClientMetadata 375 + var host string 376 + if cli.PublicOAuth { 377 + u, err := url.Parse(cli.OwnPublicURL()) 378 + if err != nil { 379 + return err 380 + } 381 + host = u.Host 382 + clientMetadata = &oatproxy.OAuthClientMetadata{ 383 + Scope: "atproto transition:generic", 384 + ClientName: "Streamplace", 385 + RedirectURIs: []string{ 386 + fmt.Sprintf("%s/login", cli.OwnPublicURL()), 387 + fmt.Sprintf("%s/api/app-return", cli.OwnPublicURL()), 388 + }, 389 + } 390 + } else { 391 + host = cli.BroadcasterHost 392 + clientMetadata = &oatproxy.OAuthClientMetadata{ 393 + Scope: "atproto transition:generic", 394 + ClientName: "Streamplace", 395 + RedirectURIs: []string{ 396 + fmt.Sprintf("https://%s/login", cli.BroadcasterHost), 397 + fmt.Sprintf("https://%s/api/app-return", cli.BroadcasterHost), 398 + }, 399 + } 380 400 } 381 401 382 402 exists, err := cli.DataFileExists([]string{"iroh-kv-secret"}) ··· 406 426 } 407 427 408 428 op := oatproxy.New(&oatproxy.Config{ 409 - Host: cli.BroadcasterHost, 429 + Host: host, 410 430 CreateOAuthSession: state.CreateOAuthSession, 411 431 UpdateOAuthSession: state.UpdateOAuthSession, 412 432 GetOAuthSession: state.LoadOAuthSession, ··· 415 435 UpstreamJWK: cli.JWK, 416 436 DownstreamJWK: cli.AccessJWK, 417 437 ClientMetadata: clientMetadata, 438 + Public: cli.PublicOAuth, 418 439 }) 419 440 d := director.NewDirector(mm, mod, &cli, b, op, state, swarm) 420 441 a, err := api.MakeStreamplaceAPI(&cli, mod, state, eip712signer, noter, mm, ms, b, atsync, d, op)
+18
pkg/config/config.go
··· 90 90 Redirects []string 91 91 TestStream bool 92 92 FrontendProxy string 93 + PublicOAuth bool 93 94 AppBundleID string 94 95 NoFirehose bool 95 96 PrintChat bool ··· 154 155 fs.StringVar(&cli.AppBundleID, "app-bundle-id", "", "bundle id of an app that we facilitate oauth login for") 155 156 fs.StringVar(&cli.StreamerName, "streamer-name", "", "name of the person streaming from this streamplace node") 156 157 fs.StringVar(&cli.FrontendProxy, "dev-frontend-proxy", "", "(FOR DEVELOPMENT ONLY) proxy frontend requests to this address instead of using the bundled frontend") 158 + fs.BoolVar(&cli.PublicOAuth, "dev-public-oauth", false, "(FOR DEVELOPMENT ONLY) enable public oauth login for http://127.0.0.1 development") 157 159 fs.StringVar(&cli.LivepeerGatewayURL, "livepeer-gateway-url", "", "URL of the Livepeer Gateway to use for transcoding") 158 160 fs.BoolVar(&cli.LivepeerGateway, "livepeer-gateway", false, "enable embedded Livepeer Gateway") 159 161 fs.BoolVar(&cli.WideOpen, "wide-open", false, "allow ALL streams to be uploaded to this node (not recommended for production)") ··· 211 213 212 214 var StreamplaceSchemePrefix = "streamplace://" 213 215 216 + func (cli *CLI) OwnPublicURL() string { 217 + // No errors because we know it's valid from AddrFlag 218 + host, port, _ := net.SplitHostPort(cli.HTTPAddr) 219 + 220 + ip := net.ParseIP(host) 221 + if host == "" || ip.IsUnspecified() { 222 + host = "127.0.0.1" 223 + } 224 + addr := net.JoinHostPort(host, port) 225 + return fmt.Sprintf("http://%s", addr) 226 + } 227 + 214 228 func (cli *CLI) OwnInternalURL() string { 215 229 // No errors because we know it's valid from AddrFlag 216 230 host, port, _ := net.SplitHostPort(cli.HTTPInternalAddr) ··· 280 294 return fmt.Errorf("defining both livepeer-gateway and livepeer-gateway-url doesn't make sense. do you want an embedded gateway or an external one?") 281 295 } 282 296 if cli.LivepeerGateway { 297 + log.MonkeypatchStderr() 283 298 gatewayPath := cli.DataFilePath([]string{"livepeer", "gateway"}) 284 299 err = fs.Set("livepeer.rtmp-addr", "127.0.0.1:0") 285 300 if err != nil { ··· 323 338 } 324 339 if cli.ServerHost == "" && cli.BroadcasterHost != "" { 325 340 cli.ServerHost = cli.BroadcasterHost 341 + } 342 + if cli.PublicOAuth { 343 + log.Warn(context.Background(), "--dev-public-oauth is set, this is not recommended for production") 326 344 } 327 345 return nil 328 346 }
+14
pkg/integrations/webhook/manager.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "strings" 6 7 7 8 "github.com/bluesky-social/indigo/api/bsky" 8 9 "stream.place/streamplace/pkg/integrations/discord" ··· 12 13 13 14 // SendChatWebhook sends chat message to a specific webhook 14 15 func SendChatWebhook(ctx context.Context, webhook *streamplace.ServerDefs_Webhook, authorDID string, scm *streamplace.ChatDefs_MessageView) error { 16 + // Check if message should be muted 17 + if msg, ok := scm.Record.Val.(*streamplace.ChatMessage); ok { 18 + if len(webhook.MuteWords) > 0 { 19 + messageText := strings.ToLower(msg.Text) 20 + for _, muteWord := range webhook.MuteWords { 21 + if strings.Contains(messageText, strings.ToLower(muteWord)) { 22 + // Message contains a mute word, skip forwarding 23 + return nil 24 + } 25 + } 26 + } 27 + } 28 + 15 29 discordWebhook, err := webhookToDiscordWebhook(webhook) 16 30 if err != nil { 17 31 return fmt.Errorf("failed to convert webhook: %w", err)
+1 -1
pkg/log/log.go
··· 72 72 )) 73 73 } 74 74 75 - func init() { 75 + func MonkeypatchStderr() { 76 76 r, w, err := os.Pipe() 77 77 if err != nil { 78 78 panic(err)
+2 -2
pkg/media/webrtc_playback.go
··· 303 303 if err != nil { 304 304 log.Log(ctx, "failed to set pipeline state to null", "error", err) 305 305 } 306 - spmetrics.ViewerInc(user) 307 - defer spmetrics.ViewerDec(user) 306 + spmetrics.ViewerInc(user, "webrtc") 307 + defer spmetrics.ViewerDec(user, "webrtc") 308 308 309 309 go func() { 310 310 rtcpBuf := make([]byte, 1500)
+2 -2
pkg/media/webrtc_playback2.go
··· 181 181 } 182 182 }() 183 183 184 - spmetrics.ViewerInc(user) 185 - defer spmetrics.ViewerDec(user) 184 + spmetrics.ViewerInc(user, "webrtc") 185 + defer spmetrics.ViewerDec(user, "webrtc") 186 186 187 187 go func() { 188 188 rtcpBuf := make([]byte, 1500)
+1 -1
pkg/model/chat_message.go
··· 62 62 message.ChatProfile = scp 63 63 } else { 64 64 // If no chat profile exists, create a default one with a color based on the user's DID 65 - defaultColor := defaultColors[hashString(m.RepoDID)%len(defaultColors)] 65 + defaultColor := DefaultColors[hashString(m.RepoDID)%len(DefaultColors)] 66 66 message.ChatProfile = &streamplace.ChatProfile{ 67 67 Color: defaultColor, 68 68 }
+1 -1
pkg/model/chat_message_default_colors.go
··· 2 2 3 3 import "stream.place/streamplace/pkg/streamplace" 4 4 5 - var defaultColors = []*streamplace.ChatProfile_Color{ 5 + var DefaultColors = []*streamplace.ChatProfile_Color{ 6 6 {Red: 244, Green: 67, Blue: 54}, 7 7 {Red: 233, Green: 30, Blue: 99}, 8 8 {Red: 156, Green: 39, Blue: 176},
+6 -1
pkg/model/livestream.go
··· 9 9 "github.com/bluesky-social/indigo/api/bsky" 10 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 11 "gorm.io/gorm" 12 + "gorm.io/gorm/clause" 12 13 "stream.place/streamplace/pkg/streamplace" 13 14 ) 14 15 ··· 44 45 } 45 46 46 47 func (m *DBModel) CreateLivestream(ctx context.Context, ls *Livestream) error { 47 - return m.DB.Create(ls).Error 48 + // upsert livestream record, actually 49 + return m.DB.Clauses(clause.OnConflict{ 50 + Columns: []clause.Column{{Name: "uri"}}, 51 + DoUpdates: clause.AssignmentColumns([]string{"cid", "created_at", "livestream", "repo_did", "post_cid", "post_uri"}), 52 + }).Create(ls).Error 48 53 } 49 54 50 55 // GetLatestLivestreamForRepo returns the most recent livestream for a given repo DID
+22 -15
pkg/spmetrics/spmetrics.go
··· 12 12 13 13 const SessionExpireTime = 30 * time.Second //nolint:all 14 14 15 - var viewers = map[string]int{} 15 + var viewersByStreamer = map[string]int{} 16 + var viewersByProtocol = map[string]int{} 16 17 var viewersLock sync.RWMutex 17 18 18 19 var sessions = map[string]map[string]time.Time{} ··· 22 23 Help: "number of current viewers per user", 23 24 }, []string{"streamer"}) 24 25 25 - var ViewersTotal = promauto.NewGauge(prometheus.GaugeOpts{ 26 + var ViewersTotal = promauto.NewGaugeVec(prometheus.GaugeOpts{ 26 27 Name: "streamplace_viewers_total", 27 28 Help: "total number of viewers", 28 - }) 29 + }, []string{"protocol"}) 29 30 30 31 var TranscodeAttemptsTotal = promauto.NewCounter(prometheus.CounterOpts{ 31 32 Name: "streamplace_transcode_attempts_total", ··· 74 75 Help: "number of open new segment subscriptions", 75 76 }, []string{"streamer", "rendition"}) 76 77 77 - func ViewerInc(user string) { 78 + func ViewerInc(user string, protocol string) { 78 79 go func() { 79 80 viewersLock.Lock() 80 81 defer viewersLock.Unlock() 81 - viewers[user]++ 82 - Viewers.WithLabelValues(user).Set(float64(viewers[user])) 83 - ViewersTotal.Inc() 82 + viewersByStreamer[user]++ 83 + viewersByProtocol[protocol]++ 84 + Viewers.WithLabelValues(user).Set(float64(viewersByStreamer[user])) 85 + ViewersTotal.WithLabelValues(protocol).Set(float64(viewersByProtocol[protocol])) 84 86 }() 85 87 } 86 88 87 - func ViewerDec(user string) { 89 + func ViewerDec(user string, protocol string) { 88 90 go func() { 89 91 viewersLock.Lock() 90 92 defer viewersLock.Unlock() 91 - viewers[user]-- 92 - if viewers[user] == 0 { 93 + viewersByStreamer[user]-- 94 + if viewersByStreamer[user] == 0 { 93 95 Viewers.DeleteLabelValues(user) 94 96 } else { 95 - Viewers.WithLabelValues(user).Set(float64(viewers[user])) 97 + Viewers.WithLabelValues(user).Set(float64(viewersByStreamer[user])) 96 98 } 97 - ViewersTotal.Dec() 99 + viewersByProtocol[protocol]-- 100 + if viewersByProtocol[protocol] == 0 { 101 + Viewers.DeleteLabelValues(protocol) 102 + } else { 103 + Viewers.WithLabelValues(protocol).Set(float64(viewersByProtocol[protocol])) 104 + } 98 105 }() 99 106 } 100 107 101 108 func GetViewCount(user string) int { 102 109 viewersLock.RLock() 103 110 defer viewersLock.RUnlock() 104 - return viewers[user] 111 + return viewersByStreamer[user] 105 112 } 106 113 107 114 func SessionSeen(user string, session string) { ··· 114 121 } 115 122 if _, ok := sessions[user][session]; !ok { 116 123 log.Warn(context.TODO(), "ViewerInc", "user", user, "session", session) 117 - ViewerInc(user) 124 + ViewerInc(user, "hls") 118 125 } 119 126 sessions[user][session] = now 120 127 }() ··· 131 138 for session, seen := range sessions { 132 139 if time.Since(seen) > SessionExpireTime { 133 140 delete(sessions, session) 134 - ViewerDec(user) 141 + ViewerDec(user, "hls") 135 142 } 136 143 } 137 144 }
+109 -43
pkg/spxrpc/og.go
··· 6 6 _ "embed" 7 7 "errors" 8 8 "fmt" 9 + "hash/fnv" 9 10 "image" 10 11 "image/color" 11 12 _ "image/jpeg" ··· 29 30 "stream.place/streamplace/js/app" 30 31 "stream.place/streamplace/pkg/aqhttp" 31 32 "stream.place/streamplace/pkg/log" 33 + "stream.place/streamplace/pkg/model" 32 34 ) 33 35 34 36 const ( 35 37 // Canvas dimensions 36 38 ogWidth = 400.0 37 - ogHeight = 200.0 39 + ogHeight = 210.0 38 40 39 41 // Card dimensions and positioning 40 42 cardPadding = 10.0 41 43 cardWidth = 380.0 42 - cardHeight = 180.0 44 + cardHeight = 190.0 43 45 cardRadius = 12.0 44 46 45 47 // Image dimensions and positioning 46 - imageX = 25.0 47 - imageY = 55.0 48 + imageX = 27.5 49 + imageY = 60.0 48 50 imageWidth = 400 49 51 imageHeight = 480 50 52 imageRadius = 180.0 ··· 52 54 53 55 // Text positioning 54 56 textStartX = 135.0 55 - joinY = 142.0 56 - subtitleY = 115.0 57 - descY = 90.0 57 + joinY = 147.0 58 + subtitleY = 120.0 59 + descY = 95.0 58 60 59 61 // Font sizes 60 62 joinFontSize = 56.0 61 - minJoinFontSize = 40.0 63 + minJoinFontSize = 48.0 62 64 subtitleFontSize = 48.0 63 65 descFontSize = 28.0 64 66 placeholderFontSize = 18.0 65 67 66 68 // Available text width 67 - textAvailableWidth = 255.0 69 + textAvailableWidth = 245.0 68 70 69 71 // Canvas DPI 70 - canvasDPMM = 2.0 72 + canvasDPMM = 3.0 71 73 ) 72 74 73 75 var ( ··· 77 79 cardBorderColor = color.RGBA{R: 64, G: 64, B: 64, A: 255} 78 80 placeholderColor = color.RGBA{R: 240, G: 240, B: 240, A: 255} 79 81 placeholderTextColor = color.RGBA{R: 100, G: 100, B: 100, A: 255} 80 - joinTextColor = color.RGBA{R: 255, G: 200, B: 50, A: 255} 82 + joinTextColor = color.RGBA{R: 248, G: 186, B: 202, A: 255} 81 83 subtitleColor = color.RGBA{R: 200, G: 200, B: 200, A: 255} 82 84 descColor = color.RGBA{R: 180, G: 180, B: 180, A: 255} 83 - imageBorderColor = color.RGBA{R: 200, G: 200, B: 200, A: 255} 85 + imageBorderColor = color.RGBA{R: 248, G: 186, B: 202, A: 255} 84 86 ) 85 87 86 88 const ( ··· 91 93 92 94 var ErrUserNotFound = errors.New("user not found") 93 95 94 - // createResponsiveJoinText creates a text box for "Join [username]" that fits within the available width 96 + // blendWithBackground creates a pseudo-transparent color by blending the given color with the background 97 + // alpha should be between 0.0 (fully background) and 1.0 (fully foreground color) 98 + func blendWithBackground(fg color.RGBA, bg color.RGBA, alpha float64) color.RGBA { 99 + return color.RGBA{ 100 + R: uint8(float64(bg.R)*(1-alpha) + float64(fg.R)*alpha), 101 + G: uint8(float64(bg.G)*(1-alpha) + float64(fg.G)*alpha), 102 + B: uint8(float64(bg.B)*(1-alpha) + float64(fg.B)*alpha), 103 + A: 255, 104 + } 105 + } 106 + 107 + // createResponsiveJoinText creates a text line for "Join [username]" that fits within the available width 95 108 // by reducing font size and truncating with ellipsis if necessary 96 - func createResponsiveJoinText(fontFamily *canvas.FontFamily, text string, availableWidth float64) (*canvas.Text, float64) { 109 + // Returns the text object and the font size used 110 + func createResponsiveJoinText(fontFamily *canvas.FontFamily, text string, availableWidth float64, textColor color.RGBA) *canvas.Text { 97 111 fontSize := joinFontSize 98 112 minFontSize := minJoinFontSize 99 113 100 114 for fontSize >= minFontSize { 101 115 // Try bold first, fall back to regular if bold fails 102 - face := fontFamily.Face(fontSize, joinTextColor, canvas.FontBold, canvas.FontNormal) 116 + face := fontFamily.Face(fontSize, textColor, canvas.FontBold, canvas.FontNormal) 103 117 if face == nil { 104 - face = fontFamily.Face(fontSize, joinTextColor, canvas.FontRegular, canvas.FontNormal) 118 + face = fontFamily.Face(fontSize, textColor, canvas.FontRegular, canvas.FontNormal) 105 119 } 106 120 107 121 if face != nil { 108 - textBox := canvas.NewTextBox(face, text, availableWidth, 40, canvas.Left, canvas.Center, &canvas.TextOptions{}) 122 + // Measure actual text width 123 + textWidth := face.TextWidth(text) 109 124 110 - // Check if text fits 111 - if textBox.Bounds().W() <= availableWidth { 112 - return textBox, fontSize 125 + // Check if text fits with some margin 126 + if textWidth <= availableWidth { 127 + textObj := canvas.NewTextLine(face, text, canvas.Bottom) 128 + return textObj 113 129 } 114 130 } 115 131 ··· 117 133 } 118 134 119 135 // If we get here, even minimum size doesn't fit, so we need to truncate 120 - face := fontFamily.Face(minFontSize, joinTextColor, canvas.FontBold, canvas.FontNormal) 136 + face := fontFamily.Face(minFontSize, textColor, canvas.FontBold, canvas.FontNormal) 121 137 if face == nil { 122 - face = fontFamily.Face(minFontSize, joinTextColor, canvas.FontRegular, canvas.FontNormal) 138 + face = fontFamily.Face(minFontSize, textColor, canvas.FontRegular, canvas.FontNormal) 139 + } 140 + 141 + // Ensure we have a valid face before truncating 142 + if face == nil { 143 + // Absolute fallback - just return "Join" 144 + fallbackFace := fontFamily.Face(minFontSize, textColor, canvas.FontRegular, canvas.FontNormal) 145 + if fallbackFace == nil { 146 + return canvas.NewTextLine(nil, "Join", canvas.Bottom) 147 + } 148 + face = fallbackFace 123 149 } 124 150 125 151 // Try progressively shorter versions with ellipsis 126 152 runes := []rune(text) 127 - for i := len(runes) - 1; i > 0; i-- { 153 + for i := len(runes) - 1; i >= 7; i-- { // Keep at least "Join @" + one char 128 154 truncatedText := string(runes[:i]) + "..." 129 - textBox := canvas.NewTextBox(face, truncatedText, availableWidth, 40, canvas.Left, canvas.Center, &canvas.TextOptions{}) 130 - if textBox.Bounds().W() <= availableWidth { 131 - return textBox, minFontSize 155 + textWidth := face.TextWidth(truncatedText) 156 + if textWidth <= availableWidth { 157 + return canvas.NewTextLine(face, truncatedText, canvas.Bottom) 132 158 } 133 159 } 134 160 135 - // Fallback - just ellipsis 136 - return canvas.NewTextBox(face, "...", availableWidth, 40, canvas.Left, canvas.Center, &canvas.TextOptions{}), minFontSize 161 + // Final fallback - just "Join ..." 162 + return canvas.NewTextLine(face, "Join ...", canvas.Bottom) 137 163 } 138 164 139 165 func (s *Server) handlePlaceStreamLiveGetProfileCard(ctx context.Context, id string) (io.Reader, error) { ··· 204 230 // Fetch user profile and avatar from Bluesky 205 231 var imageURL string 206 232 var handle, description string 233 + var userDID string 207 234 208 235 // Set default fallbacks 209 236 handle = username ··· 214 241 return nil, fmt.Errorf("failed to fetch profile, because %w", err) 215 242 } else if profileData != nil { 216 243 // Safely extract profile data with nil checks 244 + userDID = profileData.Did 245 + 217 246 if profileData.Avatar != nil && *profileData.Avatar != "" { 218 247 imageURL = *profileData.Avatar 219 248 } ··· 235 264 log.Warn(ctx, "received nil profile data, using fallbacks", "username", username) 236 265 } 237 266 267 + // Fetch user's chat profile color 268 + var userColor = joinTextColor // default 269 + var borderColor = imageBorderColor // default 270 + if userDID != "" { 271 + chatProfile, err := s.ATSync.Model.GetChatProfile(ctx, userDID) 272 + if err != nil { 273 + log.Warn(ctx, "failed to fetch chat profile", "did", userDID, "error", err) 274 + clr := model.DefaultColors[hashString(userDID)%len(model.DefaultColors)] 275 + userColor = color.RGBA{ 276 + R: uint8(clr.Red), 277 + G: uint8(clr.Green), 278 + B: uint8(clr.Blue), 279 + A: 255, 280 + } 281 + } else if chatProfile != nil { 282 + streamplaceChatProfile, err := chatProfile.ToStreamplaceChatProfile() 283 + if err != nil { 284 + log.Warn(ctx, "failed to decode chat profile", "did", userDID, "error", err) 285 + } else if streamplaceChatProfile != nil && streamplaceChatProfile.Color != nil { 286 + userColor = color.RGBA{ 287 + R: uint8(streamplaceChatProfile.Color.Red), 288 + G: uint8(streamplaceChatProfile.Color.Green), 289 + B: uint8(streamplaceChatProfile.Color.Blue), 290 + A: 255, 291 + } 292 + borderColor = userColor 293 + log.Debug(ctx, "using user's custom color", "did", userDID, "color", userColor) 294 + } 295 + } 296 + } 297 + 238 298 // Create new canvas of dimension ogWidth x ogHeight mm for profile card 239 299 c := canvas.New(ogWidth, ogHeight) 240 300 ··· 281 341 canvasCtx.Fill() 282 342 283 343 // Create neutral-800 rounded card 284 - canvasCtx.SetFillColor(cardColor) 344 + canvasCtx.SetFillColor(blendWithBackground(borderColor, cardColor, 0.04)) 285 345 canvasCtx.DrawPath(cardPadding, cardPadding, canvas.RoundedRectangle(cardWidth, cardHeight, cardRadius)) 286 346 canvasCtx.Fill() 287 347 288 - // Add subtle border to card 289 - canvasCtx.SetStrokeColor(cardBorderColor) 348 + // border 349 + cardBorderTransparent := blendWithBackground(blendWithBackground(borderColor, color.RGBA{R: 180, G: 180, B: 180}, 0.3), bgColor, 0.3) 350 + canvasCtx.SetStrokeColor(cardBorderTransparent) 290 351 canvasCtx.SetStrokeWidth(1) 291 352 canvasCtx.DrawPath(cardPadding, cardPadding, canvas.RoundedRectangle(cardWidth, cardHeight, cardRadius)) 292 353 canvasCtx.Stroke() ··· 312 373 if img == nil { 313 374 // Fallback to placeholder if download or loading fails - positioned within card 314 375 canvasCtx.SetFillColor(placeholderColor) 315 - canvasCtx.DrawPath(imageX, 50, canvas.RoundedRectangle(100, 120, 8)) 376 + canvasCtx.DrawPath(imageX, 55, canvas.RoundedRectangle(100, 120, 8)) 316 377 canvasCtx.Fill() 317 378 318 379 imageFace := fontAHN.Face(placeholderFontSize, placeholderTextColor, canvas.FontBold, canvas.FontNormal) 319 380 imageText := canvas.NewTextBox(imageFace, "Streamplace", 100, 30, canvas.Center, canvas.Center, &canvas.TextOptions{}) 320 - canvasCtx.DrawText(imageX, 100, imageText) 381 + canvasCtx.DrawText(imageX, 105, imageText) 321 382 } else { 322 383 // High-quality avatar processing with circular masking 323 384 avatarDisplaySize := imageRadius * 2 / imageDPMM ··· 370 431 maskedAvatar := image.NewRGBA(image.Rect(0, 0, avatarSize, avatarSize)) 371 432 imagedraw.DrawMask(maskedAvatar, maskedAvatar.Bounds(), scaledAvatar, image.Point{}, mask, image.Point{}, imagedraw.Over) 372 433 373 - // Add circular border 434 + // Add circular border with user's color (50% opacity for subtle effect) 374 435 avatarCenterX := imageX + avatarDisplaySize/2 375 436 avatarCenterY := imageY + avatarDisplaySize/2 376 - canvasCtx.SetStrokeColor(imageBorderColor) 377 - canvasCtx.SetStrokeWidth(3) 437 + avatarBorderTransparent := blendWithBackground(blendWithBackground(borderColor, color.RGBA{R: 180, G: 180, B: 180}, 0.5), bgColor, 0.5) 438 + canvasCtx.SetStrokeColor(avatarBorderTransparent) 439 + canvasCtx.SetStrokeWidth(1) 378 440 canvasCtx.DrawPath(avatarCenterX, avatarCenterY, canvas.Circle(avatarDisplaySize/2)) 379 441 canvasCtx.Stroke() 380 442 381 - // Draw the final circular avatar 382 443 canvasCtx.DrawImage(imageX, imageY, maskedAvatar, canvas.DPMM(canvasDPMM)) 383 444 } 384 445 385 - // Create unified responsive "Join @handle" text 386 446 joinUserContent := fmt.Sprintf("Join @%s", handle) 387 447 388 - availableWidth := textAvailableWidth // Full available width for the text 389 - joinText, _ := createResponsiveJoinText(fontAHN, joinUserContent, availableWidth) 390 - canvasCtx.DrawText(textStartX, joinY, joinText) 448 + availableWidth := textAvailableWidth 449 + joinText := createResponsiveJoinText(fontAHN, joinUserContent, availableWidth, userColor) 450 + canvasCtx.DrawText(textStartX, joinY-(joinFontSize*0.5), joinText) 391 451 392 452 // Add "streaming on Stream.place" subtitle 393 - onFace := fontAHN.Face(subtitleFontSize, subtitleColor, canvas.FontRegular, canvas.FontNormal) 453 + onFace := fontAHN.Face(subtitleFontSize, blendWithBackground(borderColor, subtitleColor, 0.2), canvas.FontRegular, canvas.FontNormal) 394 454 onText := canvas.NewTextBox(onFace, "streaming on Stream.place", 250, 30, canvas.Left, canvas.Center, &canvas.TextOptions{}) 395 455 canvasCtx.DrawText(textStartX, subtitleY, onText) 396 456 397 457 // Add user description or promotional text 398 - descFace := fontAHN.Face(descFontSize, descColor, canvas.FontRegular, canvas.FontNormal) 458 + descFace := fontAHN.Face(descFontSize, blendWithBackground(borderColor, descColor, 0.2), canvas.FontRegular, canvas.FontNormal) 399 459 descText := canvas.NewTextBox(descFace, description, 230, 30, canvas.Left, canvas.Center, &canvas.TextOptions{}) 400 460 canvasCtx.DrawText(textStartX, descY, descText) 401 461 ··· 405 465 } 406 466 407 467 return b.Bytes(), nil 468 + } 469 + 470 + func hashString(s string) int { 471 + h := fnv.New32a() 472 + h.Write([]byte(s)) 473 + return int(h.Sum32()) 408 474 } 409 475 410 476 // getAtkinsonRegular returns the regular Atkinson Hyperlegible Next font data from app filesystem
+7
pkg/spxrpc/webhook.go
··· 214 214 if input.Description != nil { 215 215 updates["description"] = *input.Description 216 216 } 217 + if input.MuteWords != nil { 218 + muteWordsJSON, err := json.Marshal(input.MuteWords) 219 + if err != nil { 220 + return nil, echo.NewHTTPError(http.StatusBadRequest, "Invalid mute words format") 221 + } 222 + updates["mute_words"] = json.RawMessage(muteWordsJSON) 223 + } 217 224 218 225 if len(updates) == 0 { 219 226 return nil, echo.NewHTTPError(http.StatusBadRequest, "No fields to update")
+22
pkg/statedb/webhook.go
··· 22 22 Prefix string `gorm:"column:prefix"` 23 23 Suffix string `gorm:"column:suffix"` 24 24 Rewrite json.RawMessage `gorm:"column:rewrite;type:json"` 25 + MuteWords json.RawMessage `gorm:"column:mute_words;type:json"` 25 26 Name string `gorm:"column:name"` 26 27 Description string `gorm:"column:description"` 27 28 CreatedAt time.Time `gorm:"column:created_at"` ··· 172 173 } 173 174 } 174 175 176 + var muteWords []string 177 + if len(w.MuteWords) > 0 { 178 + err := json.Unmarshal(w.MuteWords, &muteWords) 179 + if err != nil { 180 + return nil, fmt.Errorf("failed to unmarshal mute words: %w", err) 181 + } 182 + } 183 + 175 184 createdAt := w.CreatedAt.Format(time.RFC3339) 176 185 177 186 webhook := &streamplace.ServerDefs_Webhook{ ··· 194 203 } 195 204 if w.Description != "" { 196 205 webhook.Description = &w.Description 206 + } 207 + if len(muteWords) > 0 { 208 + webhook.MuteWords = muteWords 197 209 } 198 210 if !w.UpdatedAt.IsZero() { 199 211 updatedAt := w.UpdatedAt.Format(time.RFC3339) ··· 248 260 rewriteJSON = json.RawMessage(jsonBytes) 249 261 } 250 262 263 + var muteWordsJSON json.RawMessage 264 + if len(input.MuteWords) > 0 { 265 + jsonBytes, err := json.Marshal(input.MuteWords) 266 + if err != nil { 267 + return nil, fmt.Errorf("failed to marshal mute words: %w", err) 268 + } 269 + muteWordsJSON = json.RawMessage(jsonBytes) 270 + } 271 + 251 272 webhook := &Webhook{ 252 273 ID: id, 253 274 UserDID: userDID, ··· 257 278 CreatedAt: time.Now(), 258 279 UpdatedAt: time.Now(), 259 280 Rewrite: rewriteJSON, 281 + MuteWords: muteWordsJSON, 260 282 } 261 283 262 284 // if active is provided, use that value
+2
pkg/streamplace/servercreateWebhook.go
··· 18 18 Description *string `json:"description,omitempty" cborgen:"description,omitempty"` 19 19 // events: The types of events this webhook should receive. 20 20 Events []string `json:"events" cborgen:"events"` 21 + // muteWords: Words to filter out from chat messages. Messages containing any of these words will not be forwarded. 22 + MuteWords []string `json:"muteWords,omitempty" cborgen:"muteWords,omitempty"` 21 23 // name: A user-friendly name for this webhook. 22 24 Name *string `json:"name,omitempty" cborgen:"name,omitempty"` 23 25 // prefix: Text to prepend to webhook messages.
+2
pkg/streamplace/serverdefs.go
··· 30 30 Id string `json:"id" cborgen:"id"` 31 31 // lastTriggered: When this webhook was last triggered. 32 32 LastTriggered *string `json:"lastTriggered,omitempty" cborgen:"lastTriggered,omitempty"` 33 + // muteWords: Words to filter out from chat messages. Messages containing any of these words will not be forwarded. 34 + MuteWords []string `json:"muteWords,omitempty" cborgen:"muteWords,omitempty"` 33 35 // name: A user-friendly name for this webhook. 34 36 Name *string `json:"name,omitempty" cborgen:"name,omitempty"` 35 37 // prefix: Text to prepend to webhook messages.
+2
pkg/streamplace/serverupdateWebhook.go
··· 20 20 Events []string `json:"events,omitempty" cborgen:"events,omitempty"` 21 21 // id: The ID of the webhook to update. 22 22 Id string `json:"id" cborgen:"id"` 23 + // muteWords: Words to filter out from chat messages. Messages containing any of these words will not be forwarded. 24 + MuteWords []string `json:"muteWords,omitempty" cborgen:"muteWords,omitempty"` 23 25 // name: A user-friendly name for this webhook. 24 26 Name *string `json:"name,omitempty" cborgen:"name,omitempty"` 25 27 // prefix: Text to prepend to webhook messages.
+1
util/streamplace-dev.sh
··· 9 9 STREAMPLACE_DEV_VERSION=$(go run ./pkg/config/git/git.go -v) \ 10 10 LD_LIBRARY_PATH="$SCRIPT_DIR/lib" \ 11 11 SP_DEV_FRONTEND_PROXY="http://127.0.0.1:38081" \ 12 + SP_DEV_PUBLIC_OAUTH=true \ 12 13 exec "$SCRIPT_DIR/libstreamplace" "$@"