mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-02 04:01:43 +02:00
mobile: update toast message ui
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user