diff --git a/apps/mobile/app/components/toast/index.tsx b/apps/mobile/app/components/toast/index.tsx index 26fceefa5..f8774cef2 100644 --- a/apps/mobile/app/components/toast/index.tsx +++ b/apps/mobile/app/components/toast/index.tsx @@ -18,197 +18,269 @@ along with this program. If not, see . */ import { useThemeColors } from "@notesnook/theme"; -import React, { useCallback, useEffect, useRef, useState } from "react"; -import { TouchableOpacity, useWindowDimensions, View } from "react-native"; -import Icon from "react-native-vector-icons/MaterialCommunityIcons"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState +} from "react"; +import { useWindowDimensions, View } from "react-native"; +import Animated, { FadeInDown, FadeOutDown } from "react-native-reanimated"; import { notesnook } from "../../../e2e/test.ids"; import { Radius, Spacing } from "../../common/design/spacing"; import useGlobalSafeAreaInsets from "../../hooks/use-global-safe-area-insets"; -import useKeyboard from "../../hooks/use-keyboard"; import { DDS } from "../../services/device-detection"; import { eSubscribeEvent, eUnSubscribeEvent, ToastOptions } from "../../services/event-manager"; -import { getElevationStyle } from "../../utils/elevation"; import { eHideToast, eShowToast } from "../../utils/events"; -import { AppFontSize } from "../../utils/size"; -import { Button } from "../ui/button"; +import AppIcon from "../ui/AppIcon"; +import { IconButton } from "../ui/icon-button"; +import { Pressable } from "../ui/pressable"; import Heading from "../ui/typography/heading"; import Paragraph from "../ui/typography/paragraph"; -export const Toast = ({ context = "global" }) => { - const { colors, isDark } = useThemeColors(); - const [toastOptions, setToastOptions] = useState(); - const hideTimeout = useRef(undefined); - const insets = useGlobalSafeAreaInsets(); - const [visible, setVisible] = useState(false); - const toastMessages = useRef([]); - const dimensions = useWindowDimensions(); - const keyboard = useKeyboard(); +type ToastType = NonNullable; +type ToastMessage = ToastOptions & { id: number }; - const hideToast = useCallback(() => { - const nextToastMessage = toastMessages.current.shift(); - if (nextToastMessage) { - if (hideTimeout.current) { - clearTimeout(hideTimeout.current); - } - setVisible(true); - setToastOptions(nextToastMessage); +/** + * Blend two opaque hex colors so the result stays opaque and adapts to the + * current (light/dark) surface. Used to derive the tinted toast background and + * border from a semantic accent color over the theme background. + */ +function blendHex(base: string, overlay: string, ratio: number) { + const parse = (hex: string) => { + const h = hex.replace("#", ""); + const full = + h.length === 3 + ? h + .split("") + .map((c) => c + c) + .join("") + : h; + return [ + parseInt(full.slice(0, 2), 16), + parseInt(full.slice(2, 4), 16), + parseInt(full.slice(4, 6), 16) + ]; + }; + const b = parse(base); + const o = parse(overlay); + return `#${b + .map((c, i) => + Math.round(c * (1 - ratio) + o[i] * ratio) + .toString(16) + .padStart(2, "0") + ) + .join("")}`; +} + +export const Toast = ({ context = "global" }) => { + const { colors } = useThemeColors(); + const [toastOptions, setToastOptions] = useState(); + const currentToast = useRef(toastOptions); + const hideTimeout = useRef(undefined); + const toastMessages = useRef([]); + const idCounter = useRef(0); + const insets = useGlobalSafeAreaInsets(); + const dimensions = useWindowDimensions(); + + const showNext = useCallback(() => { + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + hideTimeout.current = undefined; + } + const next = toastMessages.current.shift(); + currentToast.current = next; + setToastOptions(next); + if (next) { hideTimeout.current = setTimeout(() => { - hideToast(); - }, nextToastMessage?.duration); - } else { - setVisible(false); - setToastOptions(undefined); - if (hideTimeout.current) { - clearTimeout(hideTimeout.current); - } + showNext(); + }, next.duration); } }, []); const showToast = useCallback( (data?: ToastOptions) => { + if (!data || data.context !== context) return; + + const isDuplicate = (message?: ToastMessage) => + message?.heading === data.heading && message?.message === data.message; + if ( - !data || - data.context !== context || - toastMessages.current.findIndex((m) => m.message === data.message) != -1 + isDuplicate(currentToast.current) || + toastMessages.current.findIndex(isDuplicate) !== -1 ) return; - toastMessages.current.push(data); - if (toastMessages.current?.length > 1) return; + idCounter.current += 1; + toastMessages.current.push({ ...data, id: idCounter.current }); - if (hideTimeout.current) { - clearTimeout(hideTimeout.current); - } - setVisible(true); - const nextToastMessage = toastMessages.current.shift(); - setToastOptions(nextToastMessage); - hideTimeout.current = setTimeout(() => { - hideToast(); - }, nextToastMessage?.duration); + // Nothing is currently on screen, show the queued message right away. + if (!currentToast.current) showNext(); }, - [context, hideToast] + [context, showNext] ); + const hideToast = useCallback(() => { + showNext(); + }, [showNext]); + useEffect(() => { eSubscribeEvent(eShowToast, showToast); eSubscribeEvent(eHideToast, hideToast); return () => { eUnSubscribeEvent(eShowToast, showToast); eUnSubscribeEvent(eHideToast, hideToast); + if (hideTimeout.current) clearTimeout(hideTimeout.current); }; }, [hideToast, showToast]); - const isFullToastMessage = toastOptions?.heading && toastOptions?.message; + const type = (toastOptions?.type || "error") as ToastType; - return visible && toastOptions ? ( - { - hideToast(); - }} - activeOpacity={1} + const variant = useMemo(() => { + const accents: Record = { + success: colors.static.green, + error: colors.static.red, + info: colors.static.blue, + warning: colors.static.orange + }; + const icons: Record< + ToastType, + { name: string; family: "notesnook" | "material" } + > = { + success: { name: "check-circle", family: "notesnook" }, + error: { name: "warning-circle", family: "notesnook" }, + info: { name: "information-outline", family: "material" }, + warning: { name: "warning", family: "notesnook" } + }; + const accent = accents[type]; + const base = colors.primary.background; + return { + accent, + icon: icons[type], + background: blendHex(base, accent, 0.09), + border: blendHex(base, accent, 0.45) + }; + }, [type, colors.static, colors.primary.background]); + + // When only one line of copy is provided it becomes the title; when both are + // present the heading is the title and the message becomes the description. + const title = toastOptions?.heading || toastOptions?.message; + const description = toastOptions?.heading ? toastOptions?.message : undefined; + + return ( + - - - - {isFullToastMessage ? ( - - {toastOptions.heading} - - ) : null} + + {title ? ( + + {title} + + ) : null} - {toastOptions.message || toastOptions.heading ? ( - + {description} + + ) : null} + + + {toastOptions.func && toastOptions.actionText ? ( + { + toastOptions.func?.(); + showNext(); + }} + style={{ + alignSelf: "flex-start", + paddingVertical: 0, + paddingHorizontal: 0, + width: "auto" + }} > - {toastOptions.message || toastOptions.heading} - + + {toastOptions.actionText} + + ) : null} - - {toastOptions.func ? ( -