mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-17 23:13:51 +01:00
Better notifications
This commit is contained in:
@@ -30,7 +30,7 @@ boa_runtime = { version = "0.18.0" }
|
||||
chrono = { version = "0.4.31", features = ["serde"] }
|
||||
http = "0.2.10"
|
||||
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_json = { version = "1.0.116", features = ["raw_value"] }
|
||||
sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time"] }
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::fs::{create_dir_all, File, read_to_string};
|
||||
use std::path::PathBuf;
|
||||
use std::process::exit;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
||||
use ::http::Uri;
|
||||
use ::http::uri::InvalidUri;
|
||||
@@ -30,7 +31,6 @@ use tauri::TitleBarStyle;
|
||||
use tauri_plugin_log::{fern, Target, TargetKind};
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use ::grpc::{Code, deserialize_message, serialize_message, ServiceDefinition};
|
||||
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,
|
||||
Workspace, WorkspaceExportResources,
|
||||
};
|
||||
use crate::notifications::YaakNotifier;
|
||||
use crate::plugin::{ImportResult, run_plugin_export_curl, run_plugin_import};
|
||||
use crate::render::render_request;
|
||||
use crate::updates::{UpdateMode, YaakUpdater};
|
||||
@@ -64,6 +65,7 @@ mod analytics;
|
||||
mod grpc;
|
||||
mod http;
|
||||
mod models;
|
||||
mod notifications;
|
||||
mod plugin;
|
||||
mod render;
|
||||
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]
|
||||
async fn cmd_grpc_reflect(
|
||||
request_id: &str,
|
||||
@@ -218,8 +230,8 @@ async fn cmd_grpc_go(
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
};
|
||||
let conn_id = conn.id.clone();
|
||||
|
||||
@@ -316,8 +328,8 @@ async fn cmd_grpc_go(
|
||||
..base_msg.clone()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -332,8 +344,8 @@ async fn cmd_grpc_go(
|
||||
..base_msg.clone()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
Ok(IncomingMsg::Commit) => {
|
||||
@@ -372,8 +384,8 @@ async fn cmd_grpc_go(
|
||||
..base_event.clone()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
async move {
|
||||
let (maybe_stream, maybe_msg) = match (
|
||||
@@ -419,8 +431,8 @@ async fn cmd_grpc_go(
|
||||
..base_event.clone()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
match maybe_msg {
|
||||
@@ -434,13 +446,13 @@ async fn cmd_grpc_go(
|
||||
} else {
|
||||
"Received response with metadata"
|
||||
}
|
||||
.to_string(),
|
||||
.to_string(),
|
||||
event_type: GrpcEventType::Info,
|
||||
..base_event.clone()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
upsert_grpc_event(
|
||||
&w,
|
||||
&GrpcEvent {
|
||||
@@ -449,8 +461,8 @@ async fn cmd_grpc_go(
|
||||
..base_event.clone()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
upsert_grpc_event(
|
||||
&w,
|
||||
&GrpcEvent {
|
||||
@@ -460,8 +472,8 @@ async fn cmd_grpc_go(
|
||||
..base_event.clone()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
upsert_grpc_event(
|
||||
@@ -484,8 +496,8 @@ async fn cmd_grpc_go(
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
None => {
|
||||
// Server streaming doesn't return initial message
|
||||
@@ -503,13 +515,13 @@ async fn cmd_grpc_go(
|
||||
} else {
|
||||
"Received response with metadata"
|
||||
}
|
||||
.to_string(),
|
||||
.to_string(),
|
||||
event_type: GrpcEventType::Info,
|
||||
..base_event.clone()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
stream.into_inner()
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
@@ -533,8 +545,8 @@ async fn cmd_grpc_go(
|
||||
},
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
return;
|
||||
}
|
||||
None => return,
|
||||
@@ -552,8 +564,8 @@ async fn cmd_grpc_go(
|
||||
..base_event.clone()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
Ok(None) => {
|
||||
let trailers = stream
|
||||
@@ -571,8 +583,8 @@ async fn cmd_grpc_go(
|
||||
..base_event.clone()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
break;
|
||||
}
|
||||
Err(status) => {
|
||||
@@ -586,8 +598,8 @@ async fn cmd_grpc_go(
|
||||
..base_event.clone()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -688,7 +700,7 @@ async fn cmd_send_ephemeral_request(
|
||||
None,
|
||||
&mut cancel_rx,
|
||||
)
|
||||
.await
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -755,7 +767,7 @@ async fn cmd_import_data(
|
||||
AnalyticsAction::Import,
|
||||
Some(json!({ "plugin": plugin_name })),
|
||||
)
|
||||
.await;
|
||||
.await;
|
||||
result = Some(r);
|
||||
break;
|
||||
}
|
||||
@@ -786,7 +798,7 @@ async fn cmd_import_data(
|
||||
let maybe_gen_id_opt = |id: Option<String>,
|
||||
model: ModelType,
|
||||
ids: &mut HashMap<String, String>|
|
||||
-> Option<String> {
|
||||
-> Option<String> {
|
||||
match id {
|
||||
Some(id) => Some(maybe_gen_id(id.as_str(), model, ids)),
|
||||
None => None,
|
||||
@@ -932,7 +944,7 @@ async fn cmd_export_data(
|
||||
AnalyticsAction::Export,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -983,8 +995,8 @@ async fn cmd_send_http_request(
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create response");
|
||||
.await
|
||||
.expect("Failed to create response");
|
||||
|
||||
let download_path = if let Some(p) = download_dir {
|
||||
Some(std::path::Path::new(p).to_path_buf())
|
||||
@@ -1009,7 +1021,7 @@ async fn cmd_send_http_request(
|
||||
download_path,
|
||||
&mut cancel_rx,
|
||||
)
|
||||
.await
|
||||
.await
|
||||
}
|
||||
|
||||
async fn response_err(
|
||||
@@ -1117,8 +1129,8 @@ async fn cmd_create_cookie_jar(
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1137,8 +1149,8 @@ async fn cmd_create_environment(
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1159,8 +1171,8 @@ async fn cmd_create_grpc_request(
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1194,8 +1206,8 @@ async fn cmd_create_http_request(
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1287,8 +1299,8 @@ async fn cmd_create_folder(
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1418,8 +1430,8 @@ async fn cmd_list_cookie_jars(
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create CookieJar");
|
||||
.await
|
||||
.expect("Failed to create CookieJar");
|
||||
Ok(vec![cookie_jar])
|
||||
} else {
|
||||
Ok(cookie_jars)
|
||||
@@ -1488,8 +1500,8 @@ async fn cmd_list_workspaces(w: WebviewWindow) -> Result<Vec<Workspace>, String>
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("Failed to create Workspace");
|
||||
.await
|
||||
.expect("Failed to create Workspace");
|
||||
Ok(vec![workspace])
|
||||
} else {
|
||||
Ok(workspaces)
|
||||
@@ -1588,6 +1600,10 @@ pub fn run() {
|
||||
let yaak_updater = YaakUpdater::new();
|
||||
app.manage(Mutex::new(yaak_updater));
|
||||
|
||||
// Add notifier
|
||||
let yaak_notifier = YaakNotifier::new();
|
||||
app.manage(Mutex::new(yaak_notifier));
|
||||
|
||||
// Add GRPC manager
|
||||
let grpc_handle = GrpcHandle::new(&app.app_handle());
|
||||
app.manage(Mutex::new(grpc_handle));
|
||||
@@ -1656,6 +1672,7 @@ pub fn run() {
|
||||
cmd_metadata,
|
||||
cmd_new_window,
|
||||
cmd_request_to_curl,
|
||||
cmd_dismiss_notification,
|
||||
cmd_send_ephemeral_request,
|
||||
cmd_send_http_request,
|
||||
cmd_set_key_value,
|
||||
@@ -1674,21 +1691,11 @@ pub fn run() {
|
||||
.run(|app_handle, event| {
|
||||
match event {
|
||||
RunEvent::Ready => {
|
||||
let w = create_window(app_handle, None);
|
||||
// if let Err(e) = w.restore_state(StateFlags::all()) {
|
||||
// error!("Failed to restore window state {}", e);
|
||||
// }
|
||||
|
||||
create_window(app_handle, None);
|
||||
let h = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let info = analytics::track_launch_event(&h).await;
|
||||
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 {
|
||||
@@ -1703,6 +1710,16 @@ pub fn run() {
|
||||
let update_mode = get_update_mode(&h).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,
|
||||
WebviewUrl::App(url.unwrap_or_default().into()),
|
||||
)
|
||||
.resizable(true)
|
||||
.fullscreen(false)
|
||||
.disable_drag_drop_handler() // Required for frontend Dnd on windows
|
||||
.inner_size(1100.0, 600.0)
|
||||
.position(
|
||||
// Randomly offset so windows don't stack exactly
|
||||
100.0 + random::<f64>() * 30.0,
|
||||
100.0 + random::<f64>() * 30.0,
|
||||
)
|
||||
.title(handle.package_info().name.to_string());
|
||||
.resizable(true)
|
||||
.fullscreen(false)
|
||||
.disable_drag_drop_handler() // Required for frontend Dnd on windows
|
||||
.inner_size(1100.0, 600.0)
|
||||
.position(
|
||||
// Randomly offset so windows don't stack exactly
|
||||
100.0 + random::<f64>() * 30.0,
|
||||
100.0 + random::<f64>() * 30.0,
|
||||
)
|
||||
.title(handle.package_info().name.to_string());
|
||||
|
||||
// Add macOS-only things
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
94
src-tauri/src/notifications.rs
Normal file
94
src-tauri/src/notifications.rs
Normal 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(¬ification.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()),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
46
src-web/hooks/useNotificationToast.tsx
Normal file
46
src-web/hooks/useNotificationToast.tsx
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user