Better notifications

This commit is contained in:
Gregory Schier
2024-05-13 16:52:20 -07:00
parent 22aa14cdc2
commit 50dc494b58
8 changed files with 296 additions and 122 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getCurrent } from '@tauri-apps/api/webviewWindow';
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useCommandPalette } from '../hooks/useCommandPalette';
import { cookieJarsQueryKey } from '../hooks/useCookieJars';
@@ -24,12 +24,12 @@ import { workspacesQueryKey } from '../hooks/useWorkspaces';
import type { Model } from '../lib/models';
import { modelsEq } from '../lib/models';
import { setPathname } from '../lib/persistPathname';
import { useNotificationToast } from '../hooks/useNotificationToast';
const DEFAULT_FONT_SIZE = 16;
export function GlobalHooks() {
// Include here so they always update, even
// if no component references them
// Include here so they always update, even if no component references them
useRecentWorkspaces();
useRecentEnvironments();
useRecentRequests();
@@ -38,6 +38,7 @@ export function GlobalHooks() {
useSyncWindowTitle();
useGlobalCommands();
useCommandPalette();
useNotificationToast();
const queryClient = useQueryClient();
const { wasUpdatedExternally } = useRequestUpdateKey(null);

View File

@@ -1,5 +1,5 @@
import { open } from '@tauri-apps/plugin-shell';
import { useRef, useState } from 'react';
import { useRef } from 'react';
import { useAppInfo } from '../hooks/useAppInfo';
import { useCheckForUpdates } from '../hooks/useCheckForUpdates';
import { useExportData } from '../hooks/useExportData';
@@ -20,11 +20,6 @@ export function SettingsDropdown() {
const dropdownRef = useRef<DropdownRef>(null);
const dialog = useDialog();
const checkForUpdates = useCheckForUpdates();
const [showChangelog, setShowChangelog] = useState<boolean>(false);
useListenToTauriEvent('show_changelog', () => {
setShowChangelog(true);
});
const showSettings = () => {
dialog.show({
@@ -40,7 +35,6 @@ export function SettingsDropdown() {
return (
<Dropdown
ref={dropdownRef}
onClose={() => setShowChangelog(false)}
items={[
{
key: 'settings',
@@ -92,20 +86,13 @@ export function SettingsDropdown() {
{
key: 'changelog',
label: 'Changelog',
variant: showChangelog ? 'notify' : 'default',
leftSlot: <Icon icon="cake" />,
rightSlot: <Icon icon="externalLink" />,
onSelect: () => open(`https://yaak.app/changelog/${appInfo.data?.version}`),
},
]}
>
<IconButton
size="sm"
title="Main Menu"
icon="settings"
className="pointer-events-auto"
showBadge={showChangelog}
/>
<IconButton size="sm" title="Main Menu" icon="settings" className="pointer-events-auto" />
</Dropdown>
);
}

View File

@@ -7,13 +7,15 @@ import { Portal } from './Portal';
import { AnimatePresence } from 'framer-motion';
type ToastEntry = {
id?: string;
message: ReactNode;
timeout?: number;
timeout?: number | null;
onClose?: ToastProps['onClose'];
} & Omit<ToastProps, 'onClose' | 'open' | 'children' | 'timeout'>;
type PrivateToastEntry = ToastEntry & {
id: string;
timeout: number;
timeout: number | null;
};
interface State {
@@ -34,16 +36,26 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
const timeoutRef = useRef<NodeJS.Timeout>();
const actions = useMemo<Actions>(
() => ({
show({ timeout = 4000, ...props }: ToastEntry) {
const id = generateId();
timeoutRef.current = setTimeout(() => {
this.hide(id);
}, timeout);
setToasts((a) => [...a.filter((d) => d.id !== id), { id, timeout, ...props }]);
show({ id, timeout = 4000, ...props }: ToastEntry) {
id = id ?? generateId();
if (timeout != null) {
timeoutRef.current = setTimeout(() => this.hide(id), timeout);
}
setToasts((a) => {
if (a.some((v) => v.id === id)) {
// It's already visible with this id
return a;
}
return [...a, { id, timeout, ...props }];
});
return id;
},
hide: (id: string) => {
setToasts((a) => a.filter((d) => d.id !== id));
setToasts((all) => {
const t = all.find((t) => t.id === id);
t?.onClose?.();
return all.filter((t) => t.id !== id);
});
},
}),
[],
@@ -70,7 +82,14 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
function ToastInstance({ id, message, timeout, ...props }: PrivateToastEntry) {
const { actions } = useContext(ToastContext);
return (
<Toast open timeout={timeout} onClose={() => actions.hide(id)} {...props}>
<Toast
open
timeout={timeout}
{...props}
// We call onClose inside actions.hide instead of passing to toast so that
// it gets called from external close calls as well
onClose={() => actions.hide(id)}
>
{message}
</Toast>
);

View File

@@ -12,7 +12,8 @@ export interface ToastProps {
open: boolean;
onClose: () => void;
className?: string;
timeout: number;
timeout: number | null;
action?: ReactNode;
variant?: 'copied' | 'success' | 'info' | 'warning' | 'error';
}
@@ -30,7 +31,8 @@ export function Toast({
open,
onClose,
timeout,
variant = 'info',
action,
variant,
}: ToastProps) {
useKey(
'Escape',
@@ -61,16 +63,21 @@ export function Toast({
)}
>
<div className="px-3 py-2 flex items-center gap-2">
<Icon
icon={ICONS[variant]}
className={classNames(
variant === 'success' && 'text-green-500',
variant === 'warning' && 'text-orange-500',
variant === 'error' && 'text-red-500',
variant === 'copied' && 'text-violet-500',
)}
/>
<div className="flex items-center gap-2">{children}</div>
{variant != null && (
<Icon
icon={ICONS[variant]}
className={classNames(
variant === 'success' && 'text-green-500',
variant === 'warning' && 'text-orange-500',
variant === 'error' && 'text-red-500',
variant === 'copied' && 'text-violet-500',
)}
/>
)}
<div className="flex flex-col gap-1 w-full">
<div>{children}</div>
{action}
</div>
</div>
<IconButton
@@ -80,14 +87,17 @@ export function Toast({
icon="x"
onClick={onClose}
/>
<div className="w-full absolute bottom-0 left-0 right-0">
<motion.div
className="bg-highlight h-0.5"
initial={{ width: '100%' }}
animate={{ width: '0%', opacity: 0.2 }}
transition={{ duration: timeout / 1000, ease: 'linear' }}
/>
</div>
{timeout != null && (
<div className="w-full absolute bottom-0 left-0 right-0">
<motion.div
className="bg-highlight h-0.5"
initial={{ width: '100%' }}
animate={{ width: '0%', opacity: 0.2 }}
transition={{ duration: timeout / 1000, ease: 'linear' }}
/>
</div>
)}
</motion.div>
);
}