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

View file

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

View file

@ -29,7 +29,7 @@ import {
FormItem,
FormLabel,
} 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 { useForm } from "react-hook-form";
@ -45,8 +45,6 @@ export function InstanceSettingsForm({
const { t } = useTranslation();
const { toast } = useToast();
return (
<Form {...form}>
<form
@ -57,15 +55,17 @@ export function InstanceSettingsForm({
form.reset(data);
} catch (error) {
console.error(error);
toast({
title: t("unexpectedError", {
toast.error(
t("unexpectedError", {
defaultValue: "Unexpected Error",
}),
description: t("unexpectedErrorDescription", {
defaultValue:
"There was an unexpected error. Please try again later.",
}),
});
{
description: t("unexpectedErrorDescription", {
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 { PostHogProvider, posthog } from "@rallly/posthog/client";
import { Toaster as SonnerToast } from "@rallly/ui/sonner";
import { Toaster } from "@rallly/ui/toaster";
import { Toaster } from "@rallly/ui/sonner";
import { TooltipProvider } from "@rallly/ui/tooltip";
import { LazyMotion, domAnimation } from "motion/react";
import type { Viewport } from "next";
@ -72,7 +71,6 @@ export default async function Root({
<body>
<FeatureFlagsProvider value={{ storage: isStorageEnabled }}>
<Toaster />
<SonnerToast />
<I18nProvider locale={locale}>
<TRPCProvider>
<LazyMotion features={domAnimation}>

View file

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

View file

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

View file

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

View file

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

View file

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