1import { Button } from 'expo-dev-client-components';
2import * as React from 'react';
3import { Animated, LayoutRectangle, useWindowDimensions, StyleSheet } from 'react-native';
4
5import {
6  createAsyncStack,
7  StackItem,
8  Status,
9  StackItemComponent,
10  useStackItems,
11} from '../functions/createAsyncStack';
12
13export type ToastOptions = {
14  durationMs?: number;
15};
16
17export type ToastStackItem = {
18  element: StackItemComponent;
19  toastProps?: ToastOptions;
20};
21
22export type ToastProps = {
23  animatedValue: Animated.Value;
24  pop: () => void;
25  status: Status;
26};
27
28const defaultDistanceFromBottom = 100;
29
30type ToastStackContextProps = {
31  push: (element: StackItemComponent, options?: ToastOptions) => StackItem<ToastStackItem>;
32  pop: (amount?: number) => StackItem<ToastStackItem>[];
33  getItems: () => StackItem<ToastStackItem>[];
34};
35
36const ToastStackContext = React.createContext<ToastStackContextProps | null>(null);
37export const useToastStack = () => {
38  const context = React.useContext(ToastStackContext);
39
40  if (!context) {
41    throw new Error(`useToastStack() was called outside of a <ToastStackContext /> provider`);
42  }
43
44  return context;
45};
46
47export function ToastStackProvider({ children }) {
48  const toastStack = React.useRef(createAsyncStack<ToastStackItem>()).current;
49  const toasts = useStackItems(toastStack);
50
51  function push(element: StackItemComponent, options?: ToastOptions): StackItem<ToastStackItem> {
52    return toastStack.push({ element, toastProps: options });
53  }
54
55  function pop(amount: number = 1) {
56    return toastStack.pop(amount);
57  }
58
59  function getItems() {
60    return toastStack.getState().items;
61  }
62
63  return (
64    <ToastStackContext.Provider value={{ push, pop, getItems }}>
65      {children}
66      <Animated.View pointerEvents="box-none" style={[StyleSheet.absoluteFill]}>
67        {toasts.map((toast) => (
68          <ToastItem {...toast} />
69        ))}
70      </Animated.View>
71    </ToastStackContext.Provider>
72  );
73}
74
75function ToastItem(props: StackItem<ToastStackItem>) {
76  const { status, data, onPopEnd, onPushEnd, pop, animatedValue } = props;
77  const { toastProps, element: Element } = data;
78
79  const { height } = useWindowDimensions();
80
81  const [layout, setLayout] = React.useState<LayoutRectangle | null>(null);
82  const timerRef = React.useRef<NodeJS.Timeout | null>(null);
83
84  React.useEffect(() => {
85    if (status === 'pushing') {
86      Animated.spring(animatedValue, {
87        toValue: 1,
88        useNativeDriver: true,
89      }).start(onPushEnd);
90    }
91
92    if (status === 'popping') {
93      Animated.spring(animatedValue, {
94        toValue: 2,
95        useNativeDriver: true,
96      }).start(() => {
97        onPopEnd();
98
99        if (timerRef.current != null) {
100          clearTimeout(timerRef.current);
101          timerRef.current = null;
102        }
103      });
104    }
105
106    if (status === 'settled') {
107      timerRef.current = setTimeout(() => {
108        pop();
109        timerRef.current = null;
110      }, toastProps?.durationMs || 2000);
111    }
112
113    return () => {
114      if (timerRef.current != null) {
115        clearTimeout(timerRef.current);
116      }
117    };
118  }, [status, pop, toastProps?.durationMs]);
119
120  let distanceFromBottom = defaultDistanceFromBottom;
121
122  if (layout != null) {
123    distanceFromBottom = distanceFromBottom + layout.height;
124  }
125
126  const translateY = animatedValue.interpolate({
127    inputRange: [0, 1, 2],
128    outputRange: [height, height - distanceFromBottom, height - distanceFromBottom],
129  });
130
131  const opacity = animatedValue.interpolate({
132    inputRange: [0, 1, 2],
133    outputRange: [1, 1, 0],
134  });
135
136  const isPopping = status === 'popping' || status === 'popped';
137
138  return (
139    <Animated.View
140      onLayout={({ nativeEvent: { layout } }) => setLayout(layout)}
141      pointerEvents={isPopping ? 'none' : 'box-none'}
142      style={[
143        {
144          position: 'absolute',
145          left: 0,
146          right: 0,
147          opacity,
148          transform: [{ translateY }],
149        },
150      ]}>
151      <Button.FadeOnPressContainer onPress={pop}>
152        <Element {...props} />
153      </Button.FadeOnPressContainer>
154    </Animated.View>
155  );
156}
157