Files
yaak-mountain-loop/apps/yaak-client/components/core/Toast.tsx
T
Gregory Schier b1f1363502 Add in-app micro-feedback prompts for new features
Show a one-time toast asking how a feature is working after its third
successful use, with an optional comment sent anonymously to the Yaak
API (feature key, text, app version, and OS only — nothing identifying,
and nothing is sent unless the user clicks Send).

- New cmd_send_feedback Tauri command posts fire-and-forget via the
  shared API client (localhost in dev)
- Feature keys registry (cookie-editor, response-history, sse-summary,
  git-sync) with per-feature use counting in the key-value store
- "Never ask for feedback" setting to disable prompts entirely
- Toast gains dynamicHeight and hideDismiss props for richer content
- Fix missing vertical padding on xs/2xs multiline inputs
- Fix unused-import and dead-code warnings in yaak-system-appearance
  on non-Linux builds

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 15:06:21 -07:00

115 lines
3.2 KiB
TypeScript

import type { ShowToastRequest } from "@yaakapp-internal/plugins";
import { Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import * as m from "motion/react-m";
import type { ReactNode } from "react";
import { useKey } from "react-use";
import { IconButton } from "./IconButton";
export interface ToastProps {
children: ReactNode;
open: boolean;
onClose: () => void;
className?: string;
timeout: number | null;
action?: (args: { hide: () => void }) => ReactNode;
icon?: ShowToastRequest["icon"] | null;
color?: ShowToastRequest["color"];
// Grow with the content (up to the viewport) instead of scrolling internally
// past the default max height
dynamicHeight?: boolean;
// Hide the close button, for toasts that render their own dismiss action.
// Escape still closes the toast
hideDismiss?: boolean;
}
const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = {
custom: null,
danger: "alert_triangle",
info: "info",
notice: "alert_triangle",
primary: "info",
secondary: "info",
success: "check_circle",
warning: "alert_triangle",
};
export function Toast({
children,
open,
onClose,
timeout,
action,
icon,
color,
dynamicHeight,
hideDismiss,
}: ToastProps) {
useKey(
"Escape",
() => {
if (!open) return;
onClose();
},
{},
[open],
);
const toastIcon = icon === null ? null : (icon ?? (color && color in ICONS && ICONS[color]));
return (
<m.div
initial={{ opacity: 0, right: "-10%" }}
animate={{ opacity: 100, right: 0 }}
exit={{ opacity: 0, right: "-100%" }}
transition={{ duration: 0.2 }}
className={classNames("bg-surface m-2 rounded-lg")}
>
<div
className={classNames(
`x-theme-toast x-theme-toast--${color}`,
"pointer-events-auto overflow-hidden",
"relative pointer-events-auto bg-surface text-text rounded-lg",
"border border-border shadow-lg w-100",
)}
>
<div
className={classNames(
"pl-3 py-3 flex items-start gap-2 w-full overflow-auto",
hideDismiss ? "pr-3" : "pr-10",
dynamicHeight ? "max-h-[80vh]" : "max-h-44",
)}
>
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
<VStack space={2} className="w-full min-w-0">
<div className="select-auto">{children}</div>
{action?.({ hide: onClose })}
</VStack>
</div>
{!hideDismiss && (
<IconButton
color={color}
variant="border"
className="opacity-60 border-0 absolute! top-2 right-2"
title="Dismiss"
icon="x"
onClick={onClose}
/>
)}
{timeout != null && (
<div className="w-full absolute bottom-0 left-0 right-0">
<m.div
className="bg-surface-highlight h-[3px]"
initial={{ width: "100%" }}
animate={{ width: "0%", opacity: 0.2 }}
transition={{ duration: timeout / 1000, ease: "linear" }}
/>
</div>
)}
</div>
</m.div>
);
}