mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-23 09:08:32 +02: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"] }
|
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"] }
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
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 { 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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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