Better notifications

This commit is contained in:
Gregory Schier
2024-05-13 16:52:20 -07:00
parent 22aa14cdc2
commit 50dc494b58
8 changed files with 296 additions and 122 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ boa_runtime = { version = "0.18.0" }
chrono = { version = "0.4.31", features = ["serde"] } chrono = { version = "0.4.31", features = ["serde"] }
http = "0.2.10" http = "0.2.10"
rand = "0.8.5" rand = "0.8.5"
reqwest = { version = "0.11.23", features = ["multipart", "cookies", "gzip", "brotli", "deflate"] } reqwest = { version = "0.11.23", features = ["multipart", "cookies", "gzip", "brotli", "deflate", "json"] }
serde = { version = "1.0.198", features = ["derive"] } serde = { version = "1.0.198", features = ["derive"] }
serde_json = { version = "1.0.116", features = ["raw_value"] } serde_json = { version = "1.0.116", features = ["raw_value"] }
sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time"] } sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio-rustls", "json", "chrono", "time"] }
+29 -12
View File
@@ -10,6 +10,7 @@ use std::fs::{create_dir_all, File, read_to_string};
use std::path::PathBuf; use std::path::PathBuf;
use std::process::exit; use std::process::exit;
use std::str::FromStr; use std::str::FromStr;
use std::time::Duration;
use ::http::Uri; use ::http::Uri;
use ::http::uri::InvalidUri; use ::http::uri::InvalidUri;
@@ -30,7 +31,6 @@ use tauri::TitleBarStyle;
use tauri_plugin_log::{fern, Target, TargetKind}; use tauri_plugin_log::{fern, Target, TargetKind};
use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::ShellExt;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::time::sleep;
use ::grpc::{Code, deserialize_message, serialize_message, ServiceDefinition}; use ::grpc::{Code, deserialize_message, serialize_message, ServiceDefinition};
use ::grpc::manager::{DynamicMessage, GrpcHandle}; use ::grpc::manager::{DynamicMessage, GrpcHandle};
@@ -55,6 +55,7 @@ use crate::models::{
upsert_environment, upsert_folder, upsert_grpc_connection, upsert_grpc_event, upsert_grpc_request, upsert_http_request, upsert_workspace, upsert_environment, upsert_folder, upsert_grpc_connection, upsert_grpc_event, upsert_grpc_request, upsert_http_request, upsert_workspace,
Workspace, WorkspaceExportResources, Workspace, WorkspaceExportResources,
}; };
use crate::notifications::YaakNotifier;
use crate::plugin::{ImportResult, run_plugin_export_curl, run_plugin_import}; use crate::plugin::{ImportResult, run_plugin_export_curl, run_plugin_import};
use crate::render::render_request; use crate::render::render_request;
use crate::updates::{UpdateMode, YaakUpdater}; use crate::updates::{UpdateMode, YaakUpdater};
@@ -64,6 +65,7 @@ mod analytics;
mod grpc; mod grpc;
mod http; mod http;
mod models; mod models;
mod notifications;
mod plugin; mod plugin;
mod render; mod render;
mod updates; mod updates;
@@ -107,6 +109,16 @@ async fn cmd_metadata(app_handle: AppHandle) -> Result<AppMetaData, ()> {
}); });
} }
#[tauri::command]
async fn cmd_dismiss_notification(
app: AppHandle,
notification_id: &str,
yaak_notifier: State<'_, Mutex<YaakNotifier>>,
) -> Result<(), String> {
info!("SEEN? {notification_id}");
yaak_notifier.lock().await.seen(&app, notification_id).await
}
#[tauri::command] #[tauri::command]
async fn cmd_grpc_reflect( async fn cmd_grpc_reflect(
request_id: &str, request_id: &str,
@@ -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)
}
});
} }
_ => {} _ => {}
}; };
+94
View File
@@ -0,0 +1,94 @@
use std::time::SystemTime;
use chrono::{Duration, NaiveDateTime, Utc};
use http::Method;
use log::debug;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
use crate::models::{get_key_value_raw, set_key_value_raw};
// Check for updates every hour
const MAX_UPDATE_CHECK_SECONDS: u64 = 60 * 60;
const KV_NAMESPACE: &str = "notifications";
const KV_KEY: &str = "seen";
// Create updater struct
pub struct YaakNotifier {
last_check: SystemTime,
}
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct YaakNotification {
timestamp: NaiveDateTime,
id: String,
message: String,
action: Option<YaakNotificationAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct YaakNotificationAction {
label: String,
url: String,
}
impl YaakNotifier {
pub fn new() -> Self {
Self {
last_check: SystemTime::UNIX_EPOCH,
}
}
pub async fn seen(&mut self, app: &AppHandle, id: &str) -> Result<(), String> {
let mut seen = get_kv(app).await?;
seen.push(id.to_string());
debug!("Marked notification as seen {}", id);
let seen_json = serde_json::to_string(&seen).map_err(|e| e.to_string())?;
set_key_value_raw(app, KV_NAMESPACE, KV_KEY, seen_json.as_str()).await;
Ok(())
}
pub async fn check(&mut self, app: &AppHandle) -> Result<(), String> {
let ignore_check = self.last_check.elapsed().unwrap().as_secs() < MAX_UPDATE_CHECK_SECONDS;
if ignore_check {
return Ok(());
}
self.last_check = SystemTime::now();
let info = app.package_info().clone();
let req = reqwest::Client::default()
.request(Method::GET, "https://notify.yaak.app/notifications")
.query(&[("version", info.version)]);
let resp = req.send().await.map_err(|e| e.to_string())?;
let notification = resp
.json::<YaakNotification>()
.await
.map_err(|e| e.to_string())?;
let age = notification
.timestamp
.signed_duration_since(Utc::now().naive_utc());
let seen = get_kv(app).await?;
if seen.contains(&notification.id) || (age > Duration::days(1)) {
debug!("Already seen notification {}", notification.id);
return Ok(());
}
debug!("Got notification {:?}", notification);
let _ = app.emit("notification", notification.clone());
Ok(())
}
}
async fn get_kv(app: &AppHandle) -> Result<Vec<String>, String> {
match get_key_value_raw(app, "notifications", "seen").await {
None => Ok(Vec::new()),
Some(v) => serde_json::from_str(&v.value).map_err(|e| e.to_string()),
}
}
+4 -3
View File
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { getCurrent } from '@tauri-apps/api/webviewWindow'; import { getCurrent } from '@tauri-apps/api/webviewWindow';
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useCommandPalette } from '../hooks/useCommandPalette'; import { useCommandPalette } from '../hooks/useCommandPalette';
import { cookieJarsQueryKey } from '../hooks/useCookieJars'; import { cookieJarsQueryKey } from '../hooks/useCookieJars';
@@ -24,12 +24,12 @@ import { workspacesQueryKey } from '../hooks/useWorkspaces';
import type { Model } from '../lib/models'; import type { Model } from '../lib/models';
import { modelsEq } from '../lib/models'; import { modelsEq } from '../lib/models';
import { setPathname } from '../lib/persistPathname'; import { setPathname } from '../lib/persistPathname';
import { useNotificationToast } from '../hooks/useNotificationToast';
const DEFAULT_FONT_SIZE = 16; const DEFAULT_FONT_SIZE = 16;
export function GlobalHooks() { export function GlobalHooks() {
// Include here so they always update, even // Include here so they always update, even if no component references them
// if no component references them
useRecentWorkspaces(); useRecentWorkspaces();
useRecentEnvironments(); useRecentEnvironments();
useRecentRequests(); useRecentRequests();
@@ -38,6 +38,7 @@ export function GlobalHooks() {
useSyncWindowTitle(); useSyncWindowTitle();
useGlobalCommands(); useGlobalCommands();
useCommandPalette(); useCommandPalette();
useNotificationToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { wasUpdatedExternally } = useRequestUpdateKey(null); const { wasUpdatedExternally } = useRequestUpdateKey(null);
+2 -15
View File
@@ -1,5 +1,5 @@
import { open } from '@tauri-apps/plugin-shell'; import { open } from '@tauri-apps/plugin-shell';
import { useRef, useState } from 'react'; import { useRef } from 'react';
import { useAppInfo } from '../hooks/useAppInfo'; import { useAppInfo } from '../hooks/useAppInfo';
import { useCheckForUpdates } from '../hooks/useCheckForUpdates'; import { useCheckForUpdates } from '../hooks/useCheckForUpdates';
import { useExportData } from '../hooks/useExportData'; import { useExportData } from '../hooks/useExportData';
@@ -20,11 +20,6 @@ export function SettingsDropdown() {
const dropdownRef = useRef<DropdownRef>(null); const dropdownRef = useRef<DropdownRef>(null);
const dialog = useDialog(); const dialog = useDialog();
const checkForUpdates = useCheckForUpdates(); const checkForUpdates = useCheckForUpdates();
const [showChangelog, setShowChangelog] = useState<boolean>(false);
useListenToTauriEvent('show_changelog', () => {
setShowChangelog(true);
});
const showSettings = () => { const showSettings = () => {
dialog.show({ dialog.show({
@@ -40,7 +35,6 @@ export function SettingsDropdown() {
return ( return (
<Dropdown <Dropdown
ref={dropdownRef} ref={dropdownRef}
onClose={() => setShowChangelog(false)}
items={[ items={[
{ {
key: 'settings', key: 'settings',
@@ -92,20 +86,13 @@ export function SettingsDropdown() {
{ {
key: 'changelog', key: 'changelog',
label: 'Changelog', label: 'Changelog',
variant: showChangelog ? 'notify' : 'default',
leftSlot: <Icon icon="cake" />, leftSlot: <Icon icon="cake" />,
rightSlot: <Icon icon="externalLink" />, rightSlot: <Icon icon="externalLink" />,
onSelect: () => open(`https://yaak.app/changelog/${appInfo.data?.version}`), onSelect: () => open(`https://yaak.app/changelog/${appInfo.data?.version}`),
}, },
]} ]}
> >
<IconButton <IconButton size="sm" title="Main Menu" icon="settings" className="pointer-events-auto" />
size="sm"
title="Main Menu"
icon="settings"
className="pointer-events-auto"
showBadge={showChangelog}
/>
</Dropdown> </Dropdown>
); );
} }
+29 -10
View File
@@ -7,13 +7,15 @@ import { Portal } from './Portal';
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
type ToastEntry = { type ToastEntry = {
id?: string;
message: ReactNode; message: ReactNode;
timeout?: number; timeout?: number | null;
onClose?: ToastProps['onClose'];
} & Omit<ToastProps, 'onClose' | 'open' | 'children' | 'timeout'>; } & Omit<ToastProps, 'onClose' | 'open' | 'children' | 'timeout'>;
type PrivateToastEntry = ToastEntry & { type PrivateToastEntry = ToastEntry & {
id: string; id: string;
timeout: number; timeout: number | null;
}; };
interface State { interface State {
@@ -34,16 +36,26 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
const timeoutRef = useRef<NodeJS.Timeout>(); const timeoutRef = useRef<NodeJS.Timeout>();
const actions = useMemo<Actions>( const actions = useMemo<Actions>(
() => ({ () => ({
show({ timeout = 4000, ...props }: ToastEntry) { show({ id, timeout = 4000, ...props }: ToastEntry) {
const id = generateId(); id = id ?? generateId();
timeoutRef.current = setTimeout(() => { if (timeout != null) {
this.hide(id); timeoutRef.current = setTimeout(() => this.hide(id), timeout);
}, timeout); }
setToasts((a) => [...a.filter((d) => d.id !== id), { id, timeout, ...props }]); setToasts((a) => {
if (a.some((v) => v.id === id)) {
// It's already visible with this id
return a;
}
return [...a, { id, timeout, ...props }];
});
return id; return id;
}, },
hide: (id: string) => { hide: (id: string) => {
setToasts((a) => a.filter((d) => d.id !== id)); setToasts((all) => {
const t = all.find((t) => t.id === id);
t?.onClose?.();
return all.filter((t) => t.id !== id);
});
}, },
}), }),
[], [],
@@ -70,7 +82,14 @@ export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
function ToastInstance({ id, message, timeout, ...props }: PrivateToastEntry) { function ToastInstance({ id, message, timeout, ...props }: PrivateToastEntry) {
const { actions } = useContext(ToastContext); const { actions } = useContext(ToastContext);
return ( return (
<Toast open timeout={timeout} onClose={() => actions.hide(id)} {...props}> <Toast
open
timeout={timeout}
{...props}
// We call onClose inside actions.hide instead of passing to toast so that
// it gets called from external close calls as well
onClose={() => actions.hide(id)}
>
{message} {message}
</Toast> </Toast>
); );
+13 -3
View File
@@ -12,7 +12,8 @@ export interface ToastProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
className?: string; className?: string;
timeout: number; timeout: number | null;
action?: ReactNode;
variant?: 'copied' | 'success' | 'info' | 'warning' | 'error'; variant?: 'copied' | 'success' | 'info' | 'warning' | 'error';
} }
@@ -30,7 +31,8 @@ export function Toast({
open, open,
onClose, onClose,
timeout, timeout,
variant = 'info', action,
variant,
}: ToastProps) { }: ToastProps) {
useKey( useKey(
'Escape', 'Escape',
@@ -61,6 +63,7 @@ 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">
{variant != null && (
<Icon <Icon
icon={ICONS[variant]} icon={ICONS[variant]}
className={classNames( className={classNames(
@@ -70,7 +73,11 @@ export function Toast({
variant === 'copied' && 'text-violet-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,6 +87,8 @@ export function Toast({
icon="x" icon="x"
onClick={onClose} onClick={onClose}
/> />
{timeout != null && (
<div className="w-full absolute bottom-0 left-0 right-0"> <div className="w-full absolute bottom-0 left-0 right-0">
<motion.div <motion.div
className="bg-highlight h-0.5" className="bg-highlight h-0.5"
@@ -88,6 +97,7 @@ export function Toast({
transition={{ duration: timeout / 1000, ease: 'linear' }} transition={{ duration: timeout / 1000, ease: 'linear' }}
/> />
</div> </div>
)}
</motion.div> </motion.div>
); );
} }
+46
View File
@@ -0,0 +1,46 @@
import { useToast } from '../components/ToastContext';
import { useListenToTauriEvent } from './useListenToTauriEvent';
import { Button } from '../components/core/Button';
import { open } from '@tauri-apps/plugin-shell';
import { invoke } from '@tauri-apps/api/core';
export function useNotificationToast() {
const toast = useToast();
const markRead = (id: string) => {
invoke('cmd_dismiss_notification', { notificationId: id }).catch(console.error);
};
useListenToTauriEvent<{
id: string;
timestamp: string;
message: string;
action?: null | {
url: string;
label: string;
};
}>('notification', ({ payload }) => {
const actionUrl = payload.action?.url;
const actionLabel = payload.action?.label;
toast.show({
id: payload.id,
timeout: null,
message: payload.message,
onClose: () => markRead(payload.id),
action:
actionLabel && actionUrl ? (
<Button
size="xs"
color="gray"
className="mr-auto min-w-[5rem]"
onClick={() => {
toast.hide(payload.id);
return open(actionUrl);
}}
>
{actionLabel}
</Button>
) : null,
});
});
}