♻️ Replace old toaster with sonner (#1801)

This commit is contained in:
Luke Vella 2025-07-09 12:08:00 +03:00 committed by GitHub
parent ac75e690a8
commit 961a493a29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 87 additions and 312 deletions

View file

@ -12,7 +12,6 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@rallly/ui/form"; } from "@rallly/ui/form";
import { useToast } from "@rallly/ui/hooks/use-toast";
import { Input } from "@rallly/ui/input"; import { Input } from "@rallly/ui/input";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { InfoIcon } from "lucide-react"; import { InfoIcon } from "lucide-react";
@ -24,6 +23,7 @@ import { z } from "zod";
import { Trans } from "@/components/trans"; import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider"; import { useUser } from "@/components/user-provider";
import { trpc } from "@/trpc/client"; import { trpc } from "@/trpc/client";
import { toast } from "@rallly/ui/sonner";
const emailChangeFormData = z.object({ const emailChangeFormData = z.object({
email: z.string().email(), email: z.string().email(),
@ -41,7 +41,6 @@ export const ProfileEmailAddress = () => {
resolver: zodResolver(emailChangeFormData), resolver: zodResolver(emailChangeFormData),
}); });
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const { toast } = useToast();
const [didRequestEmailChange, setDidRequestEmailChange] = const [didRequestEmailChange, setDidRequestEmailChange] =
React.useState(false); React.useState(false);
@ -52,35 +51,38 @@ export const ProfileEmailAddress = () => {
if (success) { if (success) {
posthog.capture("email change completed"); posthog.capture("email change completed");
toast({ toast.message(
title: t("emailChangeSuccess", { t("emailChangeSuccess", {
defaultValue: "Email changed successfully", defaultValue: "Email changed successfully",
}), }),
description: t("emailChangeSuccessDescription", { {
defaultValue: "Your email has been updated", description: t("emailChangeSuccessDescription", {
}), defaultValue: "Your email has been updated",
}); }),
},
);
} }
if (error) { if (error) {
posthog.capture("email change failed", { error }); posthog.capture("email change failed", { error });
toast({ toast.error(
variant: "destructive", t("emailChangeFailed", {
title: t("emailChangeFailed", {
defaultValue: "Email change failed", defaultValue: "Email change failed",
}), }),
description: {
error === "invalidToken" description:
? t("emailChangeInvalidToken", { error === "invalidToken"
defaultValue: ? t("emailChangeInvalidToken", {
"The verification link is invalid or has expired. Please try again.", defaultValue:
}) "The verification link is invalid or has expired. Please try again.",
: t("emailChangeError", { })
defaultValue: "An error occurred while changing your email", : t("emailChangeError", {
}), defaultValue: "An error occurred while changing your email",
}); }),
},
);
} }
}, [posthog, t, toast]); }, [posthog, t]);
const { handleSubmit, formState, reset } = form; const { handleSubmit, formState, reset } = form;
return ( return (

View file

@ -2,7 +2,6 @@
import { usePostHog } from "@rallly/posthog/client"; import { usePostHog } from "@rallly/posthog/client";
import { Button } from "@rallly/ui/button"; import { Button } from "@rallly/ui/button";
import { useToast } from "@rallly/ui/hooks/use-toast";
import React, { useState } from "react"; import React, { useState } from "react";
import { z } from "zod"; import { z } from "zod";
@ -12,6 +11,7 @@ import { useUser } from "@/components/user-provider";
import { useFeatureFlag } from "@/features/feature-flags/client"; import { useFeatureFlag } from "@/features/feature-flags/client";
import { useTranslation } from "@/i18n/client"; import { useTranslation } from "@/i18n/client";
import { trpc } from "@/trpc/client"; import { trpc } from "@/trpc/client";
import { toast } from "@rallly/ui/sonner";
const allowedMimeTypes = z.enum(["image/jpeg", "image/png"]); const allowedMimeTypes = z.enum(["image/jpeg", "image/png"]);
@ -19,7 +19,6 @@ function ChangeAvatarButton({ onSuccess }: { onSuccess: () => void }) {
const getPresignedUrl = trpc.user.getAvatarUploadUrl.useMutation(); const getPresignedUrl = trpc.user.getAvatarUploadUrl.useMutation();
const updateAvatar = trpc.user.updateAvatar.useMutation(); const updateAvatar = trpc.user.updateAvatar.useMutation();
const { t } = useTranslation(); const { t } = useTranslation();
const { toast } = useToast();
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const handleFileChange = async ( const handleFileChange = async (
@ -32,28 +31,32 @@ function ChangeAvatarButton({ onSuccess }: { onSuccess: () => void }) {
const parsedFileType = allowedMimeTypes.safeParse(file.type); const parsedFileType = allowedMimeTypes.safeParse(file.type);
if (!parsedFileType.success) { if (!parsedFileType.success) {
toast({ toast.message(
title: t("invalidFileType", { t("invalidFileType", {
defaultValue: "Invalid file type", defaultValue: "Invalid file type",
}), }),
description: t("invalidFileTypeDescription", { {
defaultValue: "Please upload a JPG or PNG file.", description: t("invalidFileTypeDescription", {
}), defaultValue: "Please upload a JPG or PNG file.",
}); }),
},
);
return; return;
} }
const fileType = parsedFileType.data; const fileType = parsedFileType.data;
if (file.size > 2 * 1024 * 1024) { if (file.size > 2 * 1024 * 1024) {
toast({ toast.message(
title: t("fileTooLarge", { t("fileTooLarge", {
defaultValue: "File too large", defaultValue: "File too large",
}), }),
description: t("fileTooLargeDescription", { {
defaultValue: "Please upload a file smaller than 2MB.", description: t("fileTooLargeDescription", {
}), defaultValue: "Please upload a file smaller than 2MB.",
}); }),
},
);
return; return;
} }
setIsUploading(true); setIsUploading(true);
@ -81,15 +84,17 @@ function ChangeAvatarButton({ onSuccess }: { onSuccess: () => void }) {
onSuccess(); onSuccess();
} catch { } catch {
toast({ toast.error(
title: t("errorUploadPicture", { t("errorUploadPicture", {
defaultValue: "Failed to upload", defaultValue: "Failed to upload",
}), }),
description: t("errorUploadPictureDescription", { {
defaultValue: description: t("errorUploadPictureDescription", {
"There was an issue uploading your picture. Please try again later.", defaultValue:
}), "There was an issue uploading your picture. Please try again later.",
}); }),
},
);
} finally { } finally {
setIsUploading(false); setIsUploading(false);
} }

View file

@ -29,7 +29,7 @@ import {
FormItem, FormItem,
FormLabel, FormLabel,
} from "@rallly/ui/form"; } from "@rallly/ui/form";
import { useToast } from "@rallly/ui/hooks/use-toast"; import { toast } from "@rallly/ui/sonner";
import { Switch } from "@rallly/ui/switch"; import { Switch } from "@rallly/ui/switch";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -45,8 +45,6 @@ export function InstanceSettingsForm({
const { t } = useTranslation(); const { t } = useTranslation();
const { toast } = useToast();
return ( return (
<Form {...form}> <Form {...form}>
<form <form
@ -57,15 +55,17 @@ export function InstanceSettingsForm({
form.reset(data); form.reset(data);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast({ toast.error(
title: t("unexpectedError", { t("unexpectedError", {
defaultValue: "Unexpected Error", defaultValue: "Unexpected Error",
}), }),
description: t("unexpectedErrorDescription", { {
defaultValue: description: t("unexpectedErrorDescription", {
"There was an unexpected error. Please try again later.", defaultValue:
}), "There was an unexpected error. Please try again later.",
}); }),
},
);
} }
})} })}
> >

View file

@ -2,8 +2,7 @@ import "../../style.css";
import { supportedLngs } from "@rallly/languages"; import { supportedLngs } from "@rallly/languages";
import { PostHogProvider, posthog } from "@rallly/posthog/client"; import { PostHogProvider, posthog } from "@rallly/posthog/client";
import { Toaster as SonnerToast } from "@rallly/ui/sonner"; import { Toaster } from "@rallly/ui/sonner";
import { Toaster } from "@rallly/ui/toaster";
import { TooltipProvider } from "@rallly/ui/tooltip"; import { TooltipProvider } from "@rallly/ui/tooltip";
import { LazyMotion, domAnimation } from "motion/react"; import { LazyMotion, domAnimation } from "motion/react";
import type { Viewport } from "next"; import type { Viewport } from "next";
@ -72,7 +71,6 @@ export default async function Root({
<body> <body>
<FeatureFlagsProvider value={{ storage: isStorageEnabled }}> <FeatureFlagsProvider value={{ storage: isStorageEnabled }}>
<Toaster /> <Toaster />
<SonnerToast />
<I18nProvider locale={locale}> <I18nProvider locale={locale}>
<TRPCProvider> <TRPCProvider>
<LazyMotion features={domAnimation}> <LazyMotion features={domAnimation}>

View file

@ -1,13 +1,12 @@
"use client"; "use client";
import { useToast } from "@rallly/ui/hooks/use-toast"; import { toast } from "@rallly/ui/sonner";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export function UnsubscribeAlert() { export function UnsubscribeAlert() {
const { toast } = useToast();
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const urlId = useParams<{ urlId: string }>()?.urlId; const urlId = useParams<{ urlId: string }>()?.urlId;
@ -18,17 +17,19 @@ export function UnsubscribeAlert() {
const unsubscribed = Cookies.get(cookieName); const unsubscribed = Cookies.get(cookieName);
if (unsubscribed) { if (unsubscribed) {
Cookies.remove(cookieName); Cookies.remove(cookieName);
toast({ toast.message(
title: t("unsubscribeToastTitle", { t("unsubscribeToastTitle", {
defaultValue: "You have disabled notifications", defaultValue: "You have disabled notifications",
}), }),
description: t("unsubscribeToastDescription", { {
defaultValue: description: t("unsubscribeToastDescription", {
"You will no longer receive notifications for this poll", defaultValue:
}), "You will no longer receive notifications for this poll",
}); }),
},
);
} }
}, [t, toast, urlId]); }, [t, urlId]);
return null; return null;
} }

View file

@ -9,7 +9,6 @@ import {
CardTitle, CardTitle,
} from "@rallly/ui/card"; } from "@rallly/ui/card";
import { Form } from "@rallly/ui/form"; import { Form } from "@rallly/ui/form";
import { useToast } from "@rallly/ui/hooks/use-toast";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type React from "react"; import type React from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -21,6 +20,7 @@ import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider"; import { useUser } from "@/components/user-provider";
import { trpc } from "@/trpc/client"; import { trpc } from "@/trpc/client";
import { toast } from "@rallly/ui/sonner";
import type { NewEventData } from "./forms"; import type { NewEventData } from "./forms";
import { PollDetailsForm, PollOptionsForm } from "./forms"; import { PollDetailsForm, PollOptionsForm } from "./forms";
@ -42,7 +42,6 @@ export interface CreatePollPageProps {
export const CreatePoll: React.FunctionComponent = () => { export const CreatePoll: React.FunctionComponent = () => {
const router = useRouter(); const router = useRouter();
const { user, createGuestIfNeeded } = useUser(); const { user, createGuestIfNeeded } = useUser();
const { toast } = useToast();
const form = useForm<NewEventData>({ const form = useForm<NewEventData>({
defaultValues: { defaultValues: {
title: "", title: "",
@ -69,10 +68,7 @@ export const CreatePoll: React.FunctionComponent = () => {
networkMode: "always", networkMode: "always",
onError: (error) => { onError: (error) => {
if (error.data?.code === "BAD_REQUEST") { if (error.data?.code === "BAD_REQUEST") {
toast({ toast.error(error.message);
title: "Error",
description: error.message,
});
} }
}, },
}); });

View file

@ -16,7 +16,6 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu"; } from "@rallly/ui/dropdown-menu";
import { useToast } from "@rallly/ui/hooks/use-toast";
import { Icon } from "@rallly/ui/icon"; import { Icon } from "@rallly/ui/icon";
import { Input } from "@rallly/ui/input"; import { Input } from "@rallly/ui/input";
import { Textarea } from "@rallly/ui/textarea"; import { Textarea } from "@rallly/ui/textarea";
@ -38,6 +37,7 @@ import { useRole } from "@/contexts/role";
import { useTranslation } from "@/i18n/client"; import { useTranslation } from "@/i18n/client";
import { trpc } from "@/trpc/client"; import { trpc } from "@/trpc/client";
import { toast } from "@rallly/ui/sonner";
import { requiredString } from "../../utils/form-validation"; import { requiredString } from "../../utils/form-validation";
import TruncatedLinkify from "../poll/truncated-linkify"; import TruncatedLinkify from "../poll/truncated-linkify";
import { useUser } from "../user-provider"; import { useUser } from "../user-provider";
@ -79,17 +79,13 @@ function NewCommentForm({
content: "", content: "",
}, },
}); });
const { toast } = useToast();
const addComment = trpc.polls.comments.add.useMutation({ const addComment = trpc.polls.comments.add.useMutation({
onSuccess: () => { onSuccess: () => {
posthog?.capture("created comment"); posthog?.capture("created comment");
}, },
onError: (error) => { onError: (error) => {
toast({ toast.error(error.message);
title: "Error",
description: error.message,
});
}, },
}); });
return ( return (

View file

@ -1,9 +1,9 @@
import { usePostHog } from "@rallly/posthog/client"; import { usePostHog } from "@rallly/posthog/client";
import { useToast } from "@rallly/ui/hooks/use-toast";
import { usePoll } from "@/components/poll-context"; import { usePoll } from "@/components/poll-context";
import { trpc } from "@/trpc/client"; import { trpc } from "@/trpc/client";
import { toast } from "@rallly/ui/sonner";
import type { ParticipantForm } from "./types"; import type { ParticipantForm } from "./types";
export const normalizeVotes = ( export const normalizeVotes = (
@ -81,7 +81,6 @@ export const useDeleteParticipantMutation = () => {
export const useUpdatePollMutation = () => { export const useUpdatePollMutation = () => {
const posthog = usePostHog(); const posthog = usePostHog();
const { toast } = useToast();
return trpc.polls.update.useMutation({ return trpc.polls.update.useMutation({
onSuccess: (_data, { urlId }) => { onSuccess: (_data, { urlId }) => {
posthog?.capture("updated poll", { posthog?.capture("updated poll", {
@ -90,10 +89,7 @@ export const useUpdatePollMutation = () => {
}, },
onError: (error) => { onError: (error) => {
if (error.data?.code === "BAD_REQUEST") { if (error.data?.code === "BAD_REQUEST") {
toast({ toast.error(error.message);
title: "Error",
description: error.message,
});
} }
}, },
}); });

View file

@ -1,6 +1,5 @@
"use client"; "use client";
import { usePostHog } from "@rallly/posthog/client"; import { usePostHog } from "@rallly/posthog/client";
import { useToast } from "@rallly/ui/hooks/use-toast";
import { import {
MutationCache, MutationCache,
QueryClient, QueryClient,
@ -13,11 +12,11 @@ import superjson from "superjson";
import { useTranslation } from "@/i18n/client"; import { useTranslation } from "@/i18n/client";
import { toast } from "@rallly/ui/sonner";
import { trpc } from "../client"; import { trpc } from "../client";
export function TRPCProvider(props: { children: React.ReactNode }) { export function TRPCProvider(props: { children: React.ReactNode }) {
const posthog = usePostHog(); const posthog = usePostHog();
const { toast } = useToast();
const { t } = useTranslation(); const { t } = useTranslation();
const [queryClient] = useState( const [queryClient] = useState(
() => () =>
@ -41,14 +40,16 @@ export function TRPCProvider(props: { children: React.ReactNode }) {
window.location.href = "/login"; window.location.href = "/login";
break; break;
case "TOO_MANY_REQUESTS": case "TOO_MANY_REQUESTS":
toast({ toast.error(
title: t("tooManyRequests", { t("tooManyRequests", {
defaultValue: "Too many requests", defaultValue: "Too many requests",
}), }),
description: t("tooManyRequestsDescription", { {
defaultValue: "Please try again later.", description: t("tooManyRequestsDescription", {
}), defaultValue: "Please try again later.",
}); }),
},
);
break; break;
case "FORBIDDEN": case "FORBIDDEN":
signOut({ signOut({

View file

@ -1,190 +0,0 @@
// Inspired by react-hot-toast library
import * as React from "react";
import type { ToastActionElement, ToastProps } from "../toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = {
ADD_TOAST: "ADD_TOAST";
UPDATE_TOAST: "UPDATE_TOAST";
DISMISS_TOAST: "DISMISS_TOAST";
REMOVE_TOAST: "REMOVE_TOAST";
};
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
// biome-ignore lint/complexity/noForEach: Fix this later
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
// biome-ignore lint/complexity/noForEach: Fix this later
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
// biome-ignore lint/correctness/useExhaustiveDependencies: I think this needs to be here
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { toast, useToast };

View file

@ -1,30 +0,0 @@
"use client";
import { useToast } from "./hooks/use-toast";
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "./toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider duration={2000}>
{toasts.map(({ id, title, description, action, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
))}
<ToastViewport />
</ToastProvider>
);
}