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 ? (
-
- ) : null}
-
-
- ) : null;
+
+ ) : null}
+
+ );
};
diff --git a/apps/mobile/app/services/event-manager.ts b/apps/mobile/app/services/event-manager.ts
index 5e5b9812c..ee8090d21 100644
--- a/apps/mobile/app/services/event-manager.ts
+++ b/apps/mobile/app/services/event-manager.ts
@@ -170,7 +170,7 @@ export type ToastOptions = {
heading?: string;
message?: string;
context?: any;
- type?: "error" | "success" | "info";
+ type?: "error" | "success" | "info" | "warning";
duration?: number;
func?: () => void;
actionText?: string;