Live video on the AT Protocol
79
fork

Configure Feed

Select the types of activity you want to include in your feed.

tost

+257 -96
+10 -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( ··· 164 172 </> 165 173 )} 166 174 </View> 175 + <Button onPress={() => t.show("toast")}>tost</Button> 167 176 </View> 168 177 </ScrollView> 169 178 );
+240 -95
js/components/src/components/ui/toast.tsx
··· 1 1 import { Portal } from "@rn-primitives/portal"; 2 - import { useEffect, useState } from "react"; 2 + import { useEffect, useRef, useState } from "react"; 3 3 import { 4 - Animated, 5 4 Platform, 6 5 Pressable, 7 6 StyleSheet, 8 - Text, 9 7 useWindowDimensions, 10 8 View, 11 9 ViewStyle, 12 10 } from "react-native"; 11 + import Animated, { 12 + Easing, 13 + runOnJS, 14 + useAnimatedStyle, 15 + useSharedValue, 16 + withTiming, 17 + } from "react-native-reanimated"; 13 18 import { useSafeAreaInsets } from "react-native-safe-area-context"; 14 - 15 19 import { useTheme } from "../../ui"; 20 + import { Text } from "./text"; 16 21 17 22 type ToastConfig = { 18 23 title: string; ··· 20 25 duration?: number; 21 26 actionLabel?: string; 22 27 onAction?: () => void; 28 + variant?: "default" | "success" | "error" | "info"; 23 29 }; 24 30 25 31 type ToastState = { ··· 30 36 duration: number; 31 37 actionLabel?: string; 32 38 onAction?: () => void; 39 + variant?: "default" | "success" | "error" | "info"; 33 40 }; 34 41 35 42 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; 43 + private listeners: Set<(state: ToastState[]) => void> = new Set(); 44 + private toasts: ToastState[] = []; 45 + private timeoutIds: Map<string, ReturnType<typeof setTimeout>> = new Map(); 46 + private hoverListeners: Set<(isHovered: boolean) => void> = new Set(); 47 + private isHovered: boolean = false; 39 48 40 49 show(config: ToastConfig) { 41 - if (this.timeoutId) { 42 - clearTimeout(this.timeoutId); 43 - } 44 - 45 50 const toast: ToastState = { 46 - id: Math.random().toString(36).substr(2, 9), 51 + id: Math.random().toString(36).slice(2, 12), 47 52 open: true, 48 53 title: config.title, 49 54 description: config.description, 50 55 duration: config.duration ?? 3, 51 56 actionLabel: config.actionLabel, 52 57 onAction: config.onAction, 58 + variant: config.variant ?? "default", 53 59 }; 54 60 55 - this.currentToast = toast; 61 + this.toasts = [...this.toasts, toast]; 56 62 this.notifyListeners(); 57 63 58 64 if (toast.duration > 0) { 59 - this.timeoutId = setTimeout(() => { 60 - this.hide(); 65 + const timeoutId = setTimeout(() => { 66 + this.hide(toast.id); 61 67 }, toast.duration * 1000); 68 + this.timeoutIds.set(toast.id, timeoutId); 62 69 } 63 70 } 64 71 65 - hide() { 66 - if (this.timeoutId) { 67 - clearTimeout(this.timeoutId); 68 - this.timeoutId = null; 69 - } 70 - this.currentToast = null; 72 + getToasts() { 73 + return this.toasts; 74 + } 75 + 76 + hide(id: string) { 77 + this.toasts = this.toasts.map((toast) => 78 + toast.id === id ? { ...toast, open: false } : toast, 79 + ); 71 80 this.notifyListeners(); 81 + 82 + const timeoutId = this.timeoutIds.get(id); 83 + if (timeoutId) { 84 + clearTimeout(timeoutId); 85 + this.timeoutIds.delete(id); 86 + } 87 + 88 + setTimeout(() => { 89 + this.toasts = this.toasts.filter((toast) => toast.id !== id); 90 + this.notifyListeners(); 91 + }, 500); 72 92 } 73 93 74 - subscribe(listener: (state: ToastState | null) => void) { 94 + subscribe(listener: (state: ToastState[]) => void) { 75 95 this.listeners.add(listener); 76 96 return () => { 77 97 this.listeners.delete(listener); 78 98 }; 79 99 } 80 100 101 + subscribeHover(listener: (isHovered: boolean) => void) { 102 + this.hoverListeners.add(listener); 103 + return () => { 104 + this.hoverListeners.delete(listener); 105 + }; 106 + } 107 + 108 + setHovered(hovered: boolean) { 109 + this.isHovered = hovered; 110 + this.notifyHoverListeners(); 111 + } 112 + 81 113 private notifyListeners() { 82 - this.listeners.forEach((listener) => listener(this.currentToast)); 114 + this.listeners.forEach((listener) => listener(this.toasts)); 115 + } 116 + 117 + private notifyHoverListeners() { 118 + this.hoverListeners.forEach((listener) => listener(this.isHovered)); 83 119 } 84 120 } 85 121 ··· 93 129 duration?: number; 94 130 actionLabel?: string; 95 131 onAction?: () => void; 132 + variant?: "default" | "success" | "error" | "info"; 96 133 }, 97 134 ) => { 98 135 toastManager.show({ ··· 101 138 ...options, 102 139 }); 103 140 }, 104 - hide: () => toastManager.hide(), 141 + hide: (id?: string) => { 142 + if (id) { 143 + toastManager.hide(id); 144 + } else { 145 + const toasts = toastManager.getToasts(); 146 + if (toasts.length > 0) { 147 + toastManager.hide(toasts[toasts.length - 1].id); 148 + } 149 + } 150 + }, 105 151 }; 106 152 107 153 export function useToast() { 108 - const [toastState, setToastState] = useState<ToastState | null>(null); 154 + const [toasts, setToasts] = useState<ToastState[]>([]); 109 155 110 156 useEffect(() => { 111 - return toastManager.subscribe(setToastState); 157 + return toastManager.subscribe(setToasts); 112 158 }, []); 113 159 114 160 return { 115 - toast: toastState, 161 + toasts, 116 162 ...toast, 117 163 }; 118 164 } 119 165 120 166 export function ToastProvider() { 121 - const [toastState, setToastState] = useState<ToastState | null>(null); 167 + const [toasts, setToasts] = useState<ToastState[]>([]); 122 168 123 169 useEffect(() => { 124 - return toastManager.subscribe(setToastState); 170 + return toastManager.subscribe(setToasts); 125 171 }, []); 126 172 127 - if (!toastState?.open) return null; 128 - 129 173 return ( 130 - <Toast 131 - open={toastState.open} 132 - onOpenChange={(open) => { 133 - if (!open) toastManager.hide(); 134 - }} 135 - title={toastState.title} 136 - description={toastState.description} 137 - actionLabel={toastState.actionLabel} 138 - onAction={toastState.onAction} 139 - duration={toastState.duration} 140 - /> 174 + <> 175 + {toasts 176 + .slice(-4) 177 + .reverse() 178 + .map((toastState, index) => ( 179 + <Toast 180 + key={toastState.id} 181 + open={toastState.open} 182 + onOpenChange={(open) => { 183 + if (!open) toastManager.hide(toastState.id); 184 + }} 185 + title={toastState.title} 186 + description={toastState.description} 187 + actionLabel={toastState.actionLabel} 188 + onAction={toastState.onAction} 189 + duration={toastState.duration} 190 + index={index} 191 + isLatest={index === 0} 192 + totalToasts={toasts.length} 193 + variant={toastState.variant} 194 + /> 195 + ))} 196 + </> 141 197 ); 142 198 } 143 199 144 200 type ToastProps = { 145 - open: boolean; 146 - onOpenChange: (open: boolean) => void; 147 - title: string; 201 + open?: boolean; 202 + onOpenChange?: (open: boolean) => void; 203 + title?: string; 148 204 description?: string; 149 205 actionLabel?: string; 150 206 onAction?: () => void; 151 - duration?: number; // seconds 207 + duration?: number; 208 + index?: number; 209 + isLatest?: boolean; 210 + totalToasts?: number; 211 + variant?: "default" | "success" | "error" | "info"; 152 212 }; 153 213 154 214 export function Toast({ 155 - open, 156 - onOpenChange, 157 - title, 215 + open = false, 216 + onOpenChange = () => {}, 217 + title = "", 158 218 description, 159 219 actionLabel = "Action", 160 220 onAction, 161 - duration = 3, 221 + duration = 60, 222 + index = 0, 223 + isLatest = true, 224 + totalToasts = 0, 225 + variant = "default", 162 226 }: ToastProps) { 163 227 const [seconds, setSeconds] = useState(duration); 228 + const [isHovered, setIsHovered] = useState(false); 229 + const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); 230 + const [height, setHeight] = useState(100); 164 231 const insets = useSafeAreaInsets(); 165 232 const { theme } = useTheme(); 166 - const [fadeAnim] = useState(new Animated.Value(0)); 233 + const isWeb = Platform.OS === "web"; 167 234 const { width } = useWindowDimensions(); 168 - const isWeb = Platform.OS === "web"; 169 235 const isDesktop = isWeb && width >= 768; 170 236 237 + const opacity = useSharedValue(0); 238 + const translateY = useSharedValue(100); 239 + const scale = useSharedValue(1 - index * 0.05); 240 + 241 + useEffect(() => { 242 + return toastManager.subscribeHover(setIsHovered); 243 + }, []); 244 + 245 + useEffect(() => { 246 + if (isHovered) { 247 + // Stack vertically with proper spacing when hovered 248 + const spacing = height + 20; // Height of each toast + gap 249 + translateY.value = withTiming(-index * spacing, { 250 + duration: 300, 251 + easing: Easing.out(Easing.exp), 252 + }); 253 + scale.value = withTiming(1, { 254 + duration: 300, 255 + easing: Easing.out(Easing.exp), 256 + }); 257 + opacity.value = withTiming(1, { 258 + duration: 300, 259 + easing: Easing.out(Easing.exp), 260 + }); 261 + } else { 262 + // Compact stacked view when not hovered 263 + translateY.value = withTiming(index * 10, { 264 + duration: 300, 265 + easing: Easing.out(Easing.exp), 266 + }); 267 + scale.value = withTiming(1 - index * 0.05, { 268 + duration: 300, 269 + easing: Easing.out(Easing.exp), 270 + }); 271 + opacity.value = withTiming(isLatest ? 1 : 0.8, { 272 + duration: 300, 273 + easing: Easing.out(Easing.exp), 274 + }); 275 + } 276 + }, [isHovered, index, isLatest]); 277 + 278 + const animatedStyle = useAnimatedStyle(() => { 279 + return { 280 + opacity: opacity.value, 281 + transform: [{ translateY: translateY.value }, { scale: scale.value }], 282 + zIndex: 1000 - index, 283 + }; 284 + }); 285 + 171 286 const containerPosition: ViewStyle = isDesktop 172 287 ? { 173 - top: undefined, 174 288 bottom: theme.spacing[4], 175 - right: theme.spacing[4], // <-- use spacing, not 1 289 + right: theme.spacing[4], 176 290 alignItems: "flex-end", 177 291 minWidth: 400, 178 292 width: 400, 179 - // Do NOT set left at all 180 293 } 181 294 : { 182 295 bottom: insets.bottom + theme.spacing[1], ··· 184 297 right: 0, 185 298 alignItems: "center", 186 299 width: "100%", 187 - maxWidth: undefined, 188 300 }; 189 301 190 302 useEffect(() => { 191 - let interval: ReturnType<typeof setInterval> | null = null; 192 - 193 - if (open) { 194 - setSeconds(duration); 195 - Animated.timing(fadeAnim, { 196 - toValue: 1, 197 - duration: 200, 198 - useNativeDriver: true, 199 - }).start(); 200 - 201 - interval = setInterval(() => { 303 + if (open && !isHovered) { 304 + // Start or resume the timer 305 + intervalRef.current = setInterval(() => { 202 306 setSeconds((prev) => { 203 307 if (prev <= 1) { 204 - onOpenChange(false); 205 - if (interval) clearInterval(interval); 308 + runOnJS(onOpenChange)(false); 309 + if (intervalRef.current) clearInterval(intervalRef.current); 206 310 return duration; 207 311 } 208 312 return prev - 1; 209 313 }); 210 314 }, 1000); 211 315 } else { 212 - if (interval) clearInterval(interval); 213 - Animated.timing(fadeAnim, { 214 - toValue: 0, 215 - duration: 150, 216 - useNativeDriver: true, 217 - }).start(); 218 - setSeconds(duration); 316 + // Pause timer when hovered or not open 317 + if (intervalRef.current) { 318 + clearInterval(intervalRef.current); 319 + intervalRef.current = null; 320 + } 321 + } 322 + 323 + if (!open) { 324 + opacity.value = withTiming(0, { duration: 150 }); 325 + translateY.value = withTiming(100, { duration: 150 }); 219 326 } 220 327 221 328 return () => { 222 - if (interval) clearInterval(interval); 329 + if (intervalRef.current) { 330 + clearInterval(intervalRef.current); 331 + intervalRef.current = null; 332 + } 223 333 }; 224 - // eslint-disable-next-line 225 - }, [open, duration]); 334 + }, [open, isHovered]); 226 335 227 - if (!open) return null; 336 + useEffect(() => { 337 + if (open) { 338 + setSeconds(duration); 339 + 340 + opacity.value = withTiming(isLatest ? 1 : 0.8, { 341 + duration: 200, 342 + easing: Easing.out(Easing.exp), 343 + }); 344 + translateY.value = withTiming(index * 10, { 345 + duration: 200, 346 + easing: Easing.out(Easing.exp), 347 + }); 348 + scale.value = withTiming(1 - index * 0.05, { 349 + duration: 250, 350 + easing: Easing.out(Easing.exp), 351 + }); 352 + } 353 + }, [open, duration, index]); 354 + 355 + const variantStyles = { 356 + default: { 357 + backgroundColor: theme.colors.secondary, 358 + borderColor: theme.colors.border, 359 + }, 360 + success: { 361 + backgroundColor: theme.colors.success, 362 + borderColor: theme.colors.success, 363 + }, 364 + error: { 365 + backgroundColor: theme.colors.destructive, 366 + borderColor: theme.colors.destructive, 367 + }, 368 + info: { 369 + backgroundColor: theme.colors.info, 370 + borderColor: theme.colors.info, 371 + }, 372 + }; 228 373 229 374 return ( 230 - <Portal name="toast"> 375 + <Portal name={`toast-${index}`}> 231 376 <Animated.View 232 - style={[styles.container, containerPosition, { opacity: fadeAnim }]} 233 - pointerEvents="box-none" 377 + onLayout={(l) => setHeight(l.nativeEvent.layout.height)} 378 + style={[styles.container, containerPosition, animatedStyle]} 234 379 > 235 - <View 380 + <Pressable 381 + onHoverIn={() => isWeb && toastManager.setHovered(true)} 382 + onHoverOut={() => isWeb && toastManager.setHovered(false)} 236 383 style={[ 237 384 styles.toast, 238 385 { 239 - backgroundColor: theme.colors.secondary, 240 - borderColor: theme.colors.border, 241 386 borderRadius: theme.borderRadius.xl, 242 387 flexDirection: "column", 243 388 justifyContent: "space-between", ··· 245 390 padding: theme.spacing[4], 246 391 width: isDesktop ? "100%" : "95%", 247 392 }, 393 + variantStyles[variant], 248 394 ]} 249 395 > 250 396 <View style={{ gap: theme.spacing[1], width: "100%" }}> 251 397 <Text 252 - style={[ 253 - { 254 - color: theme.colors.foreground, 255 - fontSize: 16, 256 - fontWeight: "500", 257 - }, 258 - ]} 398 + style={{ 399 + color: theme.colors.foreground, 400 + fontSize: 16, 401 + fontWeight: "500", 402 + }} 259 403 > 260 404 {title} 261 405 </Text> 262 406 {description ? ( 263 - <Text style={[{ color: theme.colors.foreground, fontSize: 14 }]}> 407 + <Text style={{ color: theme.colors.foreground, fontSize: 14 }}> 264 408 {description} 265 409 </Text> 266 410 ) : null} 267 411 </View> 412 + 268 413 <View 269 414 style={{ 270 415 gap: theme.spacing[1], ··· 304 449 <Text style={{ color: theme.colors.foreground }}>Close</Text> 305 450 </Pressable> 306 451 </View> 307 - </View> 452 + </Pressable> 308 453 </Animated.View> 309 454 </Portal> 310 455 );
+7
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; ··· 355 359 warning: 356 360 Platform.OS === "ios" ? colors.ios.systemOrange : colors.warning[500], 357 361 warningForeground: colors.white, 362 + 363 + info: Platform.OS === "ios" ? colors.ios.systemTeal : colors.teal[500], 364 + infoForeground: isDark ? palette[50] : palette[900], 358 365 359 366 border: isDark ? palette[500] + "30" : palette[200] + "30", 360 367 input: isDark ? palette[800] : palette[200],