mobile: update toast message ui

This commit is contained in:
Ammar Ahmed
2026-07-14 12:28:28 +05:00
parent 8ce8b57fc5
commit bc02916243
2 changed files with 201 additions and 129 deletions

View File

@@ -18,197 +18,269 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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<ToastOptions | undefined>();
const hideTimeout = useRef<NodeJS.Timeout | undefined>(undefined);
const insets = useGlobalSafeAreaInsets();
const [visible, setVisible] = useState(false);
const toastMessages = useRef<ToastOptions[]>([]);
const dimensions = useWindowDimensions();
const keyboard = useKeyboard();
type ToastType = NonNullable<ToastOptions["type"]>;
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<ToastMessage | undefined>();
const currentToast = useRef<ToastMessage | undefined>(toastOptions);
const hideTimeout = useRef<NodeJS.Timeout | undefined>(undefined);
const toastMessages = useRef<ToastMessage[]>([]);
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 ? (
<TouchableOpacity
onPress={() => {
hideToast();
}}
activeOpacity={1}
const variant = useMemo(() => {
const accents: Record<ToastType, string> = {
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 (
<View
pointerEvents="box-none"
style={{
width: DDS.isTab ? dimensions.width / 2 : "100%",
alignItems: "center",
alignSelf: "center",
bottom: insets.bottom + 15,
bottom: insets.bottom + Spacing.LEVEL_3,
position: "absolute",
zIndex: 999,
elevation: 15
paddingHorizontal: Spacing.LEVEL_3
}}
>
<View
style={{
...getElevationStyle(5),
backgroundColor: isDark ? colors.static.black : colors.static.white,
alignSelf: "center",
borderRadius: Radius.MD,
paddingVertical: Spacing.LEVEL_2,
paddingHorizontal: Spacing.LEVEL_2,
justifyContent: "space-between",
flexDirection: "row",
alignItems: "center",
maxWidth: "90%",
gap: Spacing.LEVEL_1,
flexShrink: 1
}}
>
<View
{toastOptions ? (
<Animated.View
key={toastOptions.id}
entering={FadeInDown.duration(250)}
exiting={FadeOutDown.duration(200)}
style={{
maxWidth: "100%",
flexDirection: "row",
alignItems: "center",
alignItems: "flex-start",
gap: Spacing.LEVEL_1,
flexShrink: 1
padding: Spacing.LEVEL_1,
borderRadius: Radius.S,
borderWidth: 1,
borderColor: variant.border,
backgroundColor: variant.background,
shadowColor: "#272727",
shadowOffset: { width: 0, height: 5 },
shadowOpacity: 0.06,
shadowRadius: 13.5,
elevation: 5
}}
>
<Icon
name={
toastOptions.icon
? toastOptions.icon
: toastOptions.type === "success"
? "check"
: toastOptions.type === "info"
? "information"
: "close"
}
size={isFullToastMessage ? AppFontSize.xxxl : AppFontSize.xl}
color={
toastOptions?.icon
? toastOptions?.icon
: toastOptions.type === "error"
? colors.error.icon
: toastOptions.type === "info"
? isDark
? colors.static.white
: colors.static.black
: colors.success.icon
}
<AppIcon
name={toastOptions.icon || variant.icon.name}
iconFamily={toastOptions.icon ? "material" : variant.icon.family}
size={16}
color={variant.accent}
style={{
marginTop: description ? 1 : 0
}}
/>
<View
style={{
flexShrink: 1
flexShrink: 1,
gap: Spacing.LEVEL_1
}}
>
{isFullToastMessage ? (
<Heading
color={!isDark ? colors.static.black : colors.static.white}
size={AppFontSize.sm}
>
{toastOptions.heading}
</Heading>
) : null}
<View style={{ gap: Spacing.LEVEL_0, flexShrink: 1 }}>
{title ? (
<Heading
fontSize="XS"
lineHeight="100%"
color={colors.primary.heading}
>
{title}
</Heading>
) : null}
{toastOptions.message || toastOptions.heading ? (
<Paragraph
color={!isDark ? colors.static.black : colors.static.white}
size={AppFontSize.sm}
{description ? (
<Paragraph
fontSize="XXS"
lineHeight="130%"
color={colors.secondary.paragraph}
>
{description}
</Paragraph>
) : null}
</View>
{toastOptions.func && toastOptions.actionText ? (
<Pressable
testID={notesnook.toast.button}
type="transparent"
onPress={() => {
toastOptions.func?.();
showNext();
}}
style={{
alignSelf: "flex-start",
paddingVertical: 0,
paddingHorizontal: 0,
width: "auto"
}}
>
{toastOptions.message || toastOptions.heading}
</Paragraph>
<Heading fontSize="XS" lineHeight="100%" color={variant.accent}>
{toastOptions.actionText}
</Heading>
</Pressable>
) : null}
</View>
</View>
{toastOptions.func ? (
<Button
testID={notesnook.toast.button}
fontSize={AppFontSize.xs}
type={
toastOptions.type === "error" ? "errorShade" : "secondaryAccented"
}
onPress={toastOptions.func}
title={toastOptions.actionText}
height={35}
<IconButton
name="close"
iconFamily="notesnook"
size={12}
color={colors.secondary.icon}
onPress={showNext}
style={{
zIndex: 10
width: 16,
height: 16,
alignSelf: "flex-start"
}}
/>
) : null}
</View>
</TouchableOpacity>
) : null;
</Animated.View>
) : null}
</View>
);
};

View File

@@ -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;