@streamplace/components 0.7.35 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/components/content-metadata/content-metadata-form.js +467 -0
  2. package/dist/components/content-metadata/content-rights.js +78 -0
  3. package/dist/components/content-metadata/content-warnings.js +68 -0
  4. package/dist/components/content-metadata/index.js +11 -0
  5. package/dist/components/mobile-player/player.js +4 -0
  6. package/dist/components/mobile-player/ui/report-modal.js +3 -2
  7. package/dist/components/ui/checkbox.js +87 -0
  8. package/dist/components/ui/dialog.js +188 -83
  9. package/dist/components/ui/primitives/input.js +13 -1
  10. package/dist/components/ui/primitives/modal.js +2 -2
  11. package/dist/components/ui/select.js +89 -0
  12. package/dist/components/ui/textarea.js +23 -4
  13. package/dist/components/ui/toast.js +464 -114
  14. package/dist/components/ui/tooltip.js +103 -0
  15. package/dist/index.js +2 -0
  16. package/dist/lib/metadata-constants.js +157 -0
  17. package/dist/lib/theme/theme.js +5 -3
  18. package/dist/streamplace-provider/index.js +14 -4
  19. package/dist/streamplace-store/content-metadata-actions.js +124 -0
  20. package/dist/streamplace-store/streamplace-store.js +22 -5
  21. package/dist/streamplace-store/user.js +67 -7
  22. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/37be0eec +0 -0
  23. package/package.json +3 -3
  24. package/src/components/content-metadata/content-metadata-form.tsx +893 -0
  25. package/src/components/content-metadata/content-rights.tsx +104 -0
  26. package/src/components/content-metadata/content-warnings.tsx +100 -0
  27. package/src/components/content-metadata/index.tsx +10 -0
  28. package/src/components/mobile-player/player.tsx +5 -0
  29. package/src/components/mobile-player/ui/report-modal.tsx +13 -7
  30. package/src/components/ui/checkbox.tsx +147 -0
  31. package/src/components/ui/dialog.tsx +319 -99
  32. package/src/components/ui/primitives/input.tsx +19 -2
  33. package/src/components/ui/primitives/modal.tsx +4 -2
  34. package/src/components/ui/select.tsx +175 -0
  35. package/src/components/ui/textarea.tsx +47 -29
  36. package/src/components/ui/toast.tsx +785 -179
  37. package/src/components/ui/tooltip.tsx +131 -0
  38. package/src/index.tsx +3 -0
  39. package/src/lib/metadata-constants.ts +180 -0
  40. package/src/lib/theme/theme.tsx +10 -6
  41. package/src/streamplace-provider/index.tsx +20 -2
  42. package/src/streamplace-store/content-metadata-actions.tsx +145 -0
  43. package/src/streamplace-store/streamplace-store.tsx +41 -4
  44. package/src/streamplace-store/user.tsx +71 -7
  45. package/tsconfig.tsbuildinfo +1 -1
@@ -1,85 +1,160 @@
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";
14
-
23
+ import { Circle, Svg } from "react-native-svg";
15
24
  import { useTheme } from "../../ui";
25
+ import { Button } from "./button";
26
+ import { Text } from "./text";
27
+
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();
39
79
 
40
- show(config: ToastConfig) {
41
- if (this.timeoutId) {
42
- clearTimeout(this.timeoutId);
43
- }
80
+ private hoverListeners: Set<(isHovered: boolean) => void> = new Set();
81
+ private isHovered: boolean = false;
44
82
 
83
+ show(config: ToastConfig) {
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,6 +168,12 @@ export const toast = {
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,223 +182,748 @@ export const toast = {
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
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
+ }, []);
582
+
583
+ useEffect(() => {
584
+ if (open && !wasOpen.current) {
585
+ // Toast just opened
586
+ progress.value = 1;
587
+ remainingTime.value = duration * 1000;
588
+ }
589
+ wasOpen.current = open;
590
+
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
+ }
180
607
  }
181
- : {
182
- bottom: insets.bottom + theme.spacing[1],
183
- left: 0,
184
- right: 0,
185
- alignItems: "center",
186
- width: "100%",
187
- maxWidth: undefined,
188
- };
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]);
189
640
 
190
641
  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(() => {
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);
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
+ });
753
+
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
+ };
226
772
 
227
- if (!open) return null;
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
  },