Better notifications

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

View File

@@ -30,7 +30,7 @@ boa_runtime = { version = "0.18.0" }
chrono = { version = "0.4.31", features = ["serde"] } chrono = { version = "0.4.31", features = ["serde"] }
http = "0.2.10" http = "0.2.10"
rand = "0.8.5" rand = "0.8.5"
reqwest = { version = "0.11.23", features = ["multipart", "cookies", "gzip", "brotli", "deflate"] } reqwest = { version = "0.11.23", features = ["multipart", "cookies", "gzip", "brotli", "deflate", "json"] }
serde = { version = "1.0.198", features = ["derive"] } serde = { version = "1.0.198", features = ["derive"] }
serde_json = { version = "1.0.116", features = ["raw_value"] } serde_json = { version = "1.0.116", features = ["raw_value"] }
sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time"] } sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time"] }

View File

@@ -10,6 +10,7 @@ use std::fs::{create_dir_all, File, read_to_string};
use std::path::PathBuf; use std::path::PathBuf;
use std::process::exit; use std::process::exit;
use std::str::FromStr; use std::str::FromStr;
use std::time::Duration;
use ::http::Uri; use ::http::Uri;
use ::http::uri::InvalidUri; use ::http::uri::InvalidUri;
@@ -30,7 +31,6 @@ use tauri::TitleBarStyle;
use tauri_plugin_log::{fern, Target, TargetKind}; use tauri_plugin_log::{fern, Target, TargetKind};
use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::ShellExt;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::time::sleep;
use ::grpc::{Code, deserialize_message, serialize_message, ServiceDefinition}; use ::grpc::{Code, deserialize_message, serialize_message, ServiceDefinition};
use ::grpc::manager::{DynamicMessage, GrpcHandle}; use ::grpc::manager::{DynamicMessage, GrpcHandle};
@@ -55,6 +55,7 @@ use crate::models::{
upsert_environment, upsert_folder, upsert_grpc_connection, upsert_grpc_event, upsert_grpc_request, upsert_http_request, upsert_workspace, upsert_environment, upsert_folder, upsert_grpc_connection, upsert_grpc_event, upsert_grpc_request, upsert_http_request, upsert_workspace,
Workspace, WorkspaceExportResources, Workspace, WorkspaceExportResources,
}; };
use crate::notifications::YaakNotifier;
use crate::plugin::{ImportResult, run_plugin_export_curl, run_plugin_import}; use crate::plugin::{ImportResult, run_plugin_export_curl, run_plugin_import};
use crate::render::render_request; use crate::render::render_request;
use crate::updates::{UpdateMode, YaakUpdater}; use crate::updates::{UpdateMode, YaakUpdater};
@@ -64,6 +65,7 @@ mod analytics;
mod grpc; mod grpc;
mod http; mod http;
mod models; mod models;
mod notifications;
mod plugin; mod plugin;
mod render; mod render;
mod updates; mod updates;
@@ -107,6 +109,16 @@ async fn cmd_metadata(app_handle: AppHandle) -> Result<AppMetaData, ()> {
}); });
} }
#[tauri::command]
async fn cmd_dismiss_notification(
app: AppHandle,
notification_id: &str,
yaak_notifier: State<'_, Mutex<YaakNotifier>>,
) -> Result<(), String> {
info!("SEEN? {notification_id}");
yaak_notifier.lock().await.seen(&app, notification_id).await
}
#[tauri::command] #[tauri::command]
async fn cmd_grpc_reflect( async fn cmd_grpc_reflect(
request_id: &str, request_id: &str,
@@ -218,8 +230,8 @@ async fn cmd_grpc_go(
..Default::default() ..Default::default()
}, },
) )
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
}; };
let conn_id = conn.id.clone(); let conn_id = conn.id.clone();
@@ -316,8 +328,8 @@ async fn cmd_grpc_go(
..base_msg.clone() ..base_msg.clone()
}, },
) )
.await .await
.unwrap(); .unwrap();
}); });
return; return;
} }
@@ -332,8 +344,8 @@ async fn cmd_grpc_go(
..base_msg.clone() ..base_msg.clone()
}, },
) )
.await .await
.unwrap(); .unwrap();
}); });
} }
Ok(IncomingMsg::Commit) => { Ok(IncomingMsg::Commit) => {
@@ -372,8 +384,8 @@ async fn cmd_grpc_go(
..base_event.clone() ..base_event.clone()
}, },
) )
.await .await
.unwrap(); .unwrap();
async move { async move {
let (maybe_stream, maybe_msg) = match ( let (maybe_stream, maybe_msg) = match (
@@ -419,8 +431,8 @@ async fn cmd_grpc_go(
..base_event.clone() ..base_event.clone()
}, },
) )
.await .await
.unwrap(); .unwrap();
} }
match maybe_msg { match maybe_msg {
@@ -434,13 +446,13 @@ async fn cmd_grpc_go(
} else { } else {
"Received response with metadata" "Received response with metadata"
} }
.to_string(), .to_string(),
event_type: GrpcEventType::Info, event_type: GrpcEventType::Info,
..base_event.clone() ..base_event.clone()
}, },
) )
.await .await
.unwrap(); .unwrap();
upsert_grpc_event( upsert_grpc_event(
&w, &w,
&GrpcEvent { &GrpcEvent {
@@ -449,8 +461,8 @@ async fn cmd_grpc_go(
..base_event.clone() ..base_event.clone()
}, },
) )
.await .await
.unwrap(); .unwrap();
upsert_grpc_event( upsert_grpc_event(
&w, &w,
&GrpcEvent { &GrpcEvent {
@@ -460,8 +472,8 @@ async fn cmd_grpc_go(
..base_event.clone() ..base_event.clone()
}, },
) )
.await .await
.unwrap(); .unwrap();
} }
Some(Err(e)) => { Some(Err(e)) => {
upsert_grpc_event( upsert_grpc_event(
@@ -484,8 +496,8 @@ async fn cmd_grpc_go(
}, },
}), }),
) )
.await .await
.unwrap(); .unwrap();
} }
None => { None => {
// Server streaming doesn't return initial message // Server streaming doesn't return initial message
@@ -503,13 +515,13 @@ async fn cmd_grpc_go(
} else { } else {
"Received response with metadata" "Received response with metadata"
} }
.to_string(), .to_string(),
event_type: GrpcEventType::Info, event_type: GrpcEventType::Info,
..base_event.clone() ..base_event.clone()
}, },
) )
.await .await
.unwrap(); .unwrap();
stream.into_inner() stream.into_inner()
} }
Some(Err(e)) => { Some(Err(e)) => {
@@ -533,8 +545,8 @@ async fn cmd_grpc_go(
}, },
}), }),
) )
.await .await
.unwrap(); .unwrap();
return; return;
} }
None => return, None => return,
@@ -552,8 +564,8 @@ async fn cmd_grpc_go(
..base_event.clone() ..base_event.clone()
}, },
) )
.await .await
.unwrap(); .unwrap();
} }
Ok(None) => { Ok(None) => {
let trailers = stream let trailers = stream
@@ -571,8 +583,8 @@ async fn cmd_grpc_go(
..base_event.clone() ..base_event.clone()
}, },
) )
.await .await
.unwrap(); .unwrap();
break; break;
} }
Err(status) => { Err(status) => {
@@ -586,8 +598,8 @@ async fn cmd_grpc_go(
..base_event.clone() ..base_event.clone()
}, },
) )
.await .await
.unwrap(); .unwrap();
} }
} }
} }
@@ -688,7 +700,7 @@ async fn cmd_send_ephemeral_request(
None, None,
&mut cancel_rx, &mut cancel_rx,
) )
.await .await
} }
#[tauri::command] #[tauri::command]
@@ -755,7 +767,7 @@ async fn cmd_import_data(
AnalyticsAction::Import, AnalyticsAction::Import,
Some(json!({ "plugin": plugin_name })), Some(json!({ "plugin": plugin_name })),
) )
.await; .await;
result = Some(r); result = Some(r);
break; break;
} }
@@ -786,7 +798,7 @@ async fn cmd_import_data(
let maybe_gen_id_opt = |id: Option<String>, let maybe_gen_id_opt = |id: Option<String>,
model: ModelType, model: ModelType,
ids: &mut HashMap<String, String>| ids: &mut HashMap<String, String>|
-> Option<String> { -> Option<String> {
match id { match id {
Some(id) => Some(maybe_gen_id(id.as_str(), model, ids)), Some(id) => Some(maybe_gen_id(id.as_str(), model, ids)),
None => None, None => None,
@@ -932,7 +944,7 @@ async fn cmd_export_data(
AnalyticsAction::Export, AnalyticsAction::Export,
None, None,
) )
.await; .await;
Ok(()) Ok(())
} }
@@ -983,8 +995,8 @@ async fn cmd_send_http_request(
None, None,
None, None,
) )
.await .await
.expect("Failed to create response"); .expect("Failed to create response");
let download_path = if let Some(p) = download_dir { let download_path = if let Some(p) = download_dir {
Some(std::path::Path::new(p).to_path_buf()) Some(std::path::Path::new(p).to_path_buf())
@@ -1009,7 +1021,7 @@ async fn cmd_send_http_request(
download_path, download_path,
&mut cancel_rx, &mut cancel_rx,
) )
.await .await
} }
async fn response_err( async fn response_err(
@@ -1117,8 +1129,8 @@ async fn cmd_create_cookie_jar(
..Default::default() ..Default::default()
}, },
) )
.await .await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
@@ -1137,8 +1149,8 @@ async fn cmd_create_environment(
..Default::default() ..Default::default()
}, },
) )
.await .await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
@@ -1159,8 +1171,8 @@ async fn cmd_create_grpc_request(
..Default::default() ..Default::default()
}, },
) )
.await .await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
@@ -1194,8 +1206,8 @@ async fn cmd_create_http_request(
..Default::default() ..Default::default()
}, },
) )
.await .await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
@@ -1287,8 +1299,8 @@ async fn cmd_create_folder(
..Default::default() ..Default::default()
}, },
) )
.await .await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
@@ -1418,8 +1430,8 @@ async fn cmd_list_cookie_jars(
..Default::default() ..Default::default()
}, },
) )
.await .await
.expect("Failed to create CookieJar"); .expect("Failed to create CookieJar");
Ok(vec![cookie_jar]) Ok(vec![cookie_jar])
} else { } else {
Ok(cookie_jars) Ok(cookie_jars)
@@ -1488,8 +1500,8 @@ async fn cmd_list_workspaces(w: WebviewWindow) -> Result<Vec<Workspace>, String>
..Default::default() ..Default::default()
}, },
) )
.await .await
.expect("Failed to create Workspace"); .expect("Failed to create Workspace");
Ok(vec![workspace]) Ok(vec![workspace])
} else { } else {
Ok(workspaces) Ok(workspaces)
@@ -1588,6 +1600,10 @@ pub fn run() {
let yaak_updater = YaakUpdater::new(); let yaak_updater = YaakUpdater::new();
app.manage(Mutex::new(yaak_updater)); app.manage(Mutex::new(yaak_updater));
// Add notifier
let yaak_notifier = YaakNotifier::new();
app.manage(Mutex::new(yaak_notifier));
// Add GRPC manager // Add GRPC manager
let grpc_handle = GrpcHandle::new(&app.app_handle()); let grpc_handle = GrpcHandle::new(&app.app_handle());
app.manage(Mutex::new(grpc_handle)); app.manage(Mutex::new(grpc_handle));
@@ -1656,6 +1672,7 @@ pub fn run() {
cmd_metadata, cmd_metadata,
cmd_new_window, cmd_new_window,
cmd_request_to_curl, cmd_request_to_curl,
cmd_dismiss_notification,
cmd_send_ephemeral_request, cmd_send_ephemeral_request,
cmd_send_http_request, cmd_send_http_request,
cmd_set_key_value, cmd_set_key_value,
@@ -1674,21 +1691,11 @@ pub fn run() {
.run(|app_handle, event| { .run(|app_handle, event| {
match event { match event {
RunEvent::Ready => { RunEvent::Ready => {
let w = create_window(app_handle, None); create_window(app_handle, None);
// if let Err(e) = w.restore_state(StateFlags::all()) {
// error!("Failed to restore window state {}", e);
// }
let h = app_handle.clone(); let h = app_handle.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let info = analytics::track_launch_event(&h).await; let info = analytics::track_launch_event(&h).await;
debug!("Launched Yaak {:?}", info); debug!("Launched Yaak {:?}", info);
// Wait for window render and give a chance for the user to notice
if info.launched_after_update && info.num_launches > 1 {
sleep(std::time::Duration::from_secs(5)).await;
let _ = w.emit("show_changelog", true);
}
}); });
} }
RunEvent::WindowEvent { RunEvent::WindowEvent {
@@ -1703,6 +1710,16 @@ pub fn run() {
let update_mode = get_update_mode(&h).await; let update_mode = get_update_mode(&h).await;
_ = val.lock().await.check(&h, update_mode).await; _ = val.lock().await.check(&h, update_mode).await;
}); });
let h = app_handle.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(Duration::from_millis(4000)).await;
let val: State<'_, Mutex<YaakNotifier>> = h.state();
let mut n = val.lock().await;
if let Err(e) = n.check(&h).await {
warn!("Failed to check for notifications {}", e)
}
});
} }
_ => {} _ => {}
}; };
@@ -1731,16 +1748,16 @@ fn create_window(handle: &AppHandle, url: Option<&str>) -> WebviewWindow {
window_id, window_id,
WebviewUrl::App(url.unwrap_or_default().into()), WebviewUrl::App(url.unwrap_or_default().into()),
) )
.resizable(true) .resizable(true)
.fullscreen(false) .fullscreen(false)
.disable_drag_drop_handler() // Required for frontend Dnd on windows .disable_drag_drop_handler() // Required for frontend Dnd on windows
.inner_size(1100.0, 600.0) .inner_size(1100.0, 600.0)
.position( .position(
// Randomly offset so windows don't stack exactly // Randomly offset so windows don't stack exactly
100.0 + random::<f64>() * 30.0, 100.0 + random::<f64>() * 30.0,
100.0 + random::<f64>() * 30.0, 100.0 + random::<f64>() * 30.0,
) )
.title(handle.package_info().name.to_string()); .title(handle.package_info().name.to_string());
// Add macOS-only things // Add macOS-only things
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]

View File

@@ -0,0 +1,94 @@
use std::time::SystemTime;
use chrono::{Duration, NaiveDateTime, Utc};
use http::Method;
use log::debug;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
use crate::models::{get_key_value_raw, set_key_value_raw};
// Check for updates every hour
const MAX_UPDATE_CHECK_SECONDS: u64 = 60 * 60;
const KV_NAMESPACE: &str = "notifications";
const KV_KEY: &str = "seen";
// Create updater struct
pub struct YaakNotifier {
last_check: SystemTime,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct YaakNotification {
timestamp: NaiveDateTime,
id: String,
message: String,
action: Option<YaakNotificationAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct YaakNotificationAction {
label: String,
url: String,
}
impl YaakNotifier {
pub fn new() -> Self {
Self {
last_check: SystemTime::UNIX_EPOCH,
}
}
pub async fn seen(&mut self, app: &AppHandle, id: &str) -> Result<(), String> {
let mut seen = get_kv(app).await?;
seen.push(id.to_string());
debug!("Marked notification as seen {}", id);
let seen_json = serde_json::to_string(&seen).map_err(|e| e.to_string())?;
set_key_value_raw(app, KV_NAMESPACE, KV_KEY, seen_json.as_str()).await;
Ok(())
}
pub async fn check(&mut self, app: &AppHandle) -> Result<(), String> {
let ignore_check = self.last_check.elapsed().unwrap().as_secs() < MAX_UPDATE_CHECK_SECONDS;
if ignore_check {
return Ok(());
}
self.last_check = SystemTime::now();
let info = app.package_info().clone();
let req = reqwest::Client::default()
.request(Method::GET, "https://notify.yaak.app/notifications")
.query(&[("version", info.version)]);
let resp = req.send().await.map_err(|e| e.to_string())?;
let notification = resp
.json::<YaakNotification>()
.await
.map_err(|e| e.to_string())?;
let age = notification
.timestamp
.signed_duration_since(Utc::now().naive_utc());
let seen = get_kv(app).await?;
if seen.contains(&notification.id) || (age > Duration::days(1)) {
debug!("Already seen notification {}", notification.id);
return Ok(());
}
debug!("Got notification {:?}", notification);
let _ = app.emit("notification", notification.clone());
Ok(())
}
}
async fn get_kv(app: &AppHandle) -> Result<Vec<String>, String> {
match get_key_value_raw(app, "notifications", "seen").await {
None => Ok(Vec::new()),
Some(v) => serde_json::from_str(&v.value).map_err(|e| e.to_string()),
}
}

View File

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

View File

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

View File

@@ -7,13 +7,15 @@ import { Portal } from './Portal';
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
type ToastEntry = { type ToastEntry = {
id?: string;
message: ReactNode; message: ReactNode;
timeout?: number; timeout?: number | null;
onClose?: ToastProps['onClose'];
} & Omit<ToastProps, 'onClose' | 'open' | 'children' | 'timeout'>; } & Omit<ToastProps, 'onClose' | 'open' | 'children' | 'timeout'>;
type PrivateToastEntry = ToastEntry & { type PrivateToastEntry = ToastEntry & {
id: string; id: string;
timeout: number; timeout: number | null;
}; };
interface State { interface State {
@@ -34,16 +36,26 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
const timeoutRef = useRef<NodeJS.Timeout>(); const timeoutRef = useRef<NodeJS.Timeout>();
const actions = useMemo<Actions>( const actions = useMemo<Actions>(
() => ({ () => ({
show({ timeout = 4000, ...props }: ToastEntry) { show({ id, timeout = 4000, ...props }: ToastEntry) {
const id = generateId(); id = id ?? generateId();
timeoutRef.current = setTimeout(() => { if (timeout != null) {
this.hide(id); timeoutRef.current = setTimeout(() => this.hide(id), timeout);
}, timeout); }
setToasts((a) => [...a.filter((d) => d.id !== id), { id, timeout, ...props }]); 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; return id;
}, },
hide: (id: string) => { 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) { function ToastInstance({ id, message, timeout, ...props }: PrivateToastEntry) {
const { actions } = useContext(ToastContext); const { actions } = useContext(ToastContext);
return ( 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} {message}
</Toast> </Toast>
); );

View File

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

View File

@@ -0,0 +1,46 @@
import { useToast } from '../components/ToastContext';
import { useListenToTauriEvent } from './useListenToTauriEvent';
import { Button } from '../components/core/Button';
import { open } from '@tauri-apps/plugin-shell';
import { invoke } from '@tauri-apps/api/core';
export function useNotificationToast() {
const toast = useToast();
const markRead = (id: string) => {
invoke('cmd_dismiss_notification', { notificationId: id }).catch(console.error);
};
useListenToTauriEvent<{
id: string;
timestamp: string;
message: string;
action?: null | {
url: string;
label: string;
};
}>('notification', ({ payload }) => {
const actionUrl = payload.action?.url;
const actionLabel = payload.action?.label;
toast.show({
id: payload.id,
timeout: null,
message: payload.message,
onClose: () => markRead(payload.id),
action:
actionLabel && actionUrl ? (
<Button
size="xs"
color="gray"
className="mr-auto min-w-[5rem]"
onClick={() => {
toast.hide(payload.id);
return open(actionUrl);
}}
>
{actionLabel}
</Button>
) : null,
});
});
}