diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c1eff932..9e23d5eb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 45d8908e..1247928d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { }); } +#[tauri::command] +async fn cmd_dismiss_notification( + app: AppHandle, + notification_id: &str, + yaak_notifier: State<'_, Mutex>, +) -> 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, model: ModelType, ids: &mut HashMap| - -> Option { + -> Option { 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, 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> = 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::() * 30.0, - 100.0 + random::() * 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::() * 30.0, + 100.0 + random::() * 30.0, + ) + .title(handle.package_info().name.to_string()); // Add macOS-only things #[cfg(target_os = "macos")] diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs new file mode 100644 index 00000000..a9be51ab --- /dev/null +++ b/src-tauri/src/notifications.rs @@ -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, +} + +#[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::() + .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, 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()), + } +} diff --git a/src-web/components/GlobalHooks.tsx b/src-web/components/GlobalHooks.tsx index c43c78dd..d6958316 100644 --- a/src-web/components/GlobalHooks.tsx +++ b/src-web/components/GlobalHooks.tsx @@ -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); diff --git a/src-web/components/SettingsDropdown.tsx b/src-web/components/SettingsDropdown.tsx index 400fee9d..30e8904a 100644 --- a/src-web/components/SettingsDropdown.tsx +++ b/src-web/components/SettingsDropdown.tsx @@ -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(null); const dialog = useDialog(); const checkForUpdates = useCheckForUpdates(); - const [showChangelog, setShowChangelog] = useState(false); - - useListenToTauriEvent('show_changelog', () => { - setShowChangelog(true); - }); const showSettings = () => { dialog.show({ @@ -40,7 +35,6 @@ export function SettingsDropdown() { return ( setShowChangelog(false)} items={[ { key: 'settings', @@ -92,20 +86,13 @@ export function SettingsDropdown() { { key: 'changelog', label: 'Changelog', - variant: showChangelog ? 'notify' : 'default', leftSlot: , rightSlot: , onSelect: () => open(`https://yaak.app/changelog/${appInfo.data?.version}`), }, ]} > - + ); } diff --git a/src-web/components/ToastContext.tsx b/src-web/components/ToastContext.tsx index 37b034ec..ada47ec5 100644 --- a/src-web/components/ToastContext.tsx +++ b/src-web/components/ToastContext.tsx @@ -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; 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(); const actions = useMemo( () => ({ - 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 ( - actions.hide(id)} {...props}> + actions.hide(id)} + > {message} ); diff --git a/src-web/components/core/Toast.tsx b/src-web/components/core/Toast.tsx index 01bd866c..72a6ab07 100644 --- a/src-web/components/core/Toast.tsx +++ b/src-web/components/core/Toast.tsx @@ -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({ )} >
- -
{children}
+ {variant != null && ( + + )} +
+
{children}
+ {action} +
-
- -
+ + {timeout != null && ( +
+ +
+ )} ); } diff --git a/src-web/hooks/useNotificationToast.tsx b/src-web/hooks/useNotificationToast.tsx new file mode 100644 index 00000000..bf553806 --- /dev/null +++ b/src-web/hooks/useNotificationToast.tsx @@ -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 ? ( + + ) : null, + }); + }); +}