mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-16 09:43:06 +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"] }
|
||||
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"] }
|
||||
|
||||
+29
-12
@@ -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,
|
||||
@@ -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)
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
|
||||
@@ -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,6 +63,7 @@ export function Toast({
|
||||
)}
|
||||
>
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
{variant != null && (
|
||||
<Icon
|
||||
icon={ICONS[variant]}
|
||||
className={classNames(
|
||||
@@ -70,7 +73,11 @@ export function Toast({
|
||||
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>
|
||||
|
||||
<IconButton
|
||||
@@ -80,6 +87,8 @@ export function Toast({
|
||||
icon="x"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{timeout != null && (
|
||||
<div className="w-full absolute bottom-0 left-0 right-0">
|
||||
<motion.div
|
||||
className="bg-highlight h-0.5"
|
||||
@@ -88,6 +97,7 @@ export function Toast({
|
||||
transition={{ duration: timeout / 1000, ease: 'linear' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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