diff --git a/apps/yaak-client/components/FeedbackToast.tsx b/apps/yaak-client/components/FeedbackToast.tsx new file mode 100644 index 00000000..e80e75c3 --- /dev/null +++ b/apps/yaak-client/components/FeedbackToast.tsx @@ -0,0 +1,76 @@ +import { HStack, VStack } from "@yaakapp-internal/ui"; +import { useRef, useState } from "react"; +import type { FeedbackFeature } from "../lib/featureFeedbackConstants"; +import { FEEDBACK_FEATURES } from "../lib/featureFeedbackConstants"; +import { invokeCmd } from "../lib/tauri"; +import { hideToastById, showToast } from "../lib/toast"; +import { Button } from "./core/Button"; +import { Input } from "./core/Input"; + +interface Props { + feature: FeedbackFeature; + onDone: () => void; +} + +export function FeedbackToast({ feature, onDone }: Props) { + const [text, setText] = useState(""); + const [sent, setSent] = useState(false); + const sentRef = useRef(false); + + const handleDismiss = () => { + onDone(); + hideToastById(`feature-feedback-${feature}`); + }; + + const handleSend = () => { + const trimmedText = text.trim(); + if (sentRef.current || trimmedText.length === 0) return; + + sentRef.current = true; + setSent(true); + onDone(); + + // Fire-and-forget; failures are intentionally ignored + invokeCmd("cmd_send_feedback", { feature, text: trimmedText }).catch(() => {}); + showToast({ + id: `feature-feedback-${feature}`, + timeout: 3000, + color: "success", + message: "Thanks for the feedback!", + }); + }; + + return ( + +

{FEEDBACK_FEATURES[feature]}

+
+ +
+ + + + +
+ ); +} diff --git a/apps/yaak-client/components/RecentHttpResponsesDropdown.tsx b/apps/yaak-client/components/RecentHttpResponsesDropdown.tsx index 6978a26b..4db7a0f5 100644 --- a/apps/yaak-client/components/RecentHttpResponsesDropdown.tsx +++ b/apps/yaak-client/components/RecentHttpResponsesDropdown.tsx @@ -92,7 +92,9 @@ export const RecentHttpResponsesDropdown = function ResponsePane({ ), leftSlot: activeResponse?.id === r.id ? : , - onSelect: () => onPinnedResponseId(r.id), + onSelect: () => { + onPinnedResponseId(r.id); + }, }); } diff --git a/apps/yaak-client/components/Settings/SettingsGeneral.tsx b/apps/yaak-client/components/Settings/SettingsGeneral.tsx index 4ec6804a..bd8a0957 100644 --- a/apps/yaak-client/components/Settings/SettingsGeneral.tsx +++ b/apps/yaak-client/components/Settings/SettingsGeneral.tsx @@ -112,6 +112,17 @@ export function SettingsGeneral() { + + + patchModel(settings, { promptFeedback })} + /> + + + {showWorkspaceSettingsMovedBanner && ( void }) => ReactNode; icon?: ShowToastRequest["icon"] | null; color?: ShowToastRequest["color"]; + // Grow with the content (up to the viewport) instead of scrolling internally + // past the default max height + dynamicHeight?: boolean; + // Hide the close button, for toasts that render their own dismiss action. + // Escape still closes the toast + hideDismiss?: boolean; } const ICONS: Record, IconProps["icon"] | null> = { @@ -28,7 +35,47 @@ const ICONS: Record, IconProps["icon warning: "alert_triangle", }; -export function Toast({ children, open, onClose, timeout, action, icon, color }: ToastProps) { +export function Toast({ + children, + open, + onClose, + timeout, + action, + icon, + color, + dynamicHeight, + hideDismiss, +}: ToastProps) { + const onCloseRef = useRef(onClose); + const timeoutRef = useRef | null>(null); + const [autoHideCanceled, setAutoHideCanceled] = useState(false); + + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + const cancelAutoHide = useCallback(() => { + if (timeoutRef.current == null) return; + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + setAutoHideCanceled(true); + }, []); + + useEffect(() => { + if (!open || timeout == null || autoHideCanceled) return; + + timeoutRef.current = setTimeout(() => { + timeoutRef.current = null; + onCloseRef.current(); + }, timeout); + + return () => { + if (timeoutRef.current == null) return; + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + }; + }, [autoHideCanceled, open, timeout]); + useKey( "Escape", () => { @@ -56,8 +103,17 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }: "relative pointer-events-auto bg-surface text-text rounded-lg", "border border-border shadow-lg w-100", )} + onFocusCapture={cancelAutoHide} + onKeyDownCapture={cancelAutoHide} + onPointerDownCapture={cancelAutoHide} > -
+
{toastIcon && }
{children}
@@ -65,16 +121,18 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
- + {!hideDismiss && ( + + )} - {timeout != null && ( + {timeout != null && !autoHideCanceled && (
> = {}; +const FEATURE_USE_DEBOUNCE_MS = 10_000; + +const kvArgs = (feature: FeedbackFeature) => ({ + namespace: "global", + key: ["feature-feedback", feature], +}); + +function getFeatureFeedbackState(feature: FeedbackFeature): FeatureFeedbackState { + return getKeyValue({ + ...kvArgs(feature), + fallback: { uses: 0, done: false }, + }); +} + +function patchFeatureFeedbackState(feature: FeedbackFeature, patch: Partial) { + const value = { ...getFeatureFeedbackState(feature), ...patch }; + setKeyValue({ ...kvArgs(feature), value }).catch(console.error); +} + +function markFeatureFeedbackDone(feature: FeedbackFeature) { + patchFeatureFeedbackState(feature, { done: true }); +} + +function showFeedbackToast(feature: FeedbackFeature) { + if (!jotaiStore.get(settingsAtom).promptFeedback) return; + + showToast({ + id: `feature-feedback-${feature}`, + timeout: FEEDBACK_PROMPT_TIMEOUT_MS, + dynamicHeight: true, + hideDismiss: true, + message: ( + markFeatureFeedbackDone(feature)} /> + ), + }); +} + +function showFeedbackToastWhenReady(feature: FeedbackFeature) { + setTimeout(() => { + if (!jotaiStore.get(settingsAtom).promptFeedback) return; + + if (jotaiStore.get(dialogsAtom).length === 0) { + showFeedbackToast(feature); + return; + } + + const unsubscribe = jotaiStore.sub(dialogsAtom, () => { + if (jotaiStore.get(dialogsAtom).length > 0) return; + + unsubscribe(); + showFeedbackToast(feature); + }); + }, FEEDBACK_PROMPT_DELAY_MS); +} + +// Record a successful use of a feature, and prompt for feedback on the Nth use. +// Nothing is ever sent to the server from here; showing the toast is local-only +// and a submission only happens when the user clicks Send in it. +export function trackFeatureUsage(feature: FeedbackFeature) { + if (appInfo.featureLicense !== true || !jotaiStore.get(settingsAtom).promptFeedback) return; + + const now = Date.now(); + if (lastTrackedAt[feature] != null && now - lastTrackedAt[feature] < FEATURE_USE_DEBOUNCE_MS) { + return; + } + lastTrackedAt[feature] = now; + + const state = getFeatureFeedbackState(feature); + if (state.done) return; + + const uses = state.uses + 1; + const shouldPrompt = uses >= PROMPT_AFTER_USES && !promptedThisSession; + + patchFeatureFeedbackState(feature, { uses }); + if (!shouldPrompt) return; + + promptedThisSession = true; + showFeedbackToastWhenReady(feature); +} diff --git a/apps/yaak-client/lib/featureFeedbackConstants.ts b/apps/yaak-client/lib/featureFeedbackConstants.ts new file mode 100644 index 00000000..51289f37 --- /dev/null +++ b/apps/yaak-client/lib/featureFeedbackConstants.ts @@ -0,0 +1,8 @@ +// Feature keys are sent to the server and used to group feedback for analysis. +// NEVER rename a key once it has shipped, or historical feedback will be split +// across the old and new names. +export const FEEDBACK_FEATURES = { + "git-sync": "How is Git sync working for you?", +} as const; + +export type FeedbackFeature = keyof typeof FEEDBACK_FEATURES; diff --git a/apps/yaak-client/lib/tauri.ts b/apps/yaak-client/lib/tauri.ts index dc875b37..df9ad069 100644 --- a/apps/yaak-client/lib/tauri.ts +++ b/apps/yaak-client/lib/tauri.ts @@ -48,6 +48,7 @@ type TauriCmd = | "cmd_save_response" | "cmd_secure_template" | "cmd_send_ephemeral_request" + | "cmd_send_feedback" | "cmd_send_http_request" | "cmd_template_function_summaries" | "cmd_template_function_config" diff --git a/apps/yaak-client/lib/toast.tsx b/apps/yaak-client/lib/toast.tsx index e02d86b3..613f6581 100644 --- a/apps/yaak-client/lib/toast.tsx +++ b/apps/yaak-client/lib/toast.tsx @@ -28,15 +28,17 @@ export function showToast({ setTimeout(() => { const newToast: ToastInstance = { id, uniqueKey, timeout, ...props }; - if (timeout != null) { - setTimeout(() => hideToast(newToast), timeout); - } jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]); }, delay); return id; } +export function hideToastById(id: string) { + const toast = jotaiStore.get(toastsAtom).find((t) => t.id === id); + if (toast) hideToast(toast); +} + export function hideToast(toHide: ToastInstance) { jotaiStore.set(toastsAtom, (all) => { const t = all.find((t) => t.uniqueKey === toHide.uniqueKey); diff --git a/crates-tauri/yaak-app-client/src/feedback.rs b/crates-tauri/yaak-app-client/src/feedback.rs new file mode 100644 index 00000000..f436d050 --- /dev/null +++ b/crates-tauri/yaak-app-client/src/feedback.rs @@ -0,0 +1,67 @@ +use log::{debug, warn}; +use serde::Serialize; +use tauri::{AppHandle, Runtime, is_dev}; +use yaak_api::{ApiClientKind, yaak_api_client}; +use yaak_common::platform::get_os_str; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct FeedbackPayload { + feature: String, + text: String, + app_version: String, + os: String, +} + +/// Send explicit user feedback for a feature. Fire-and-forget: errors are +/// logged and swallowed so a failed send never surfaces to the user. +pub async fn send_feedback(app_handle: &AppHandle, feature: String, text: String) { + let app_version = app_handle.package_info().version.to_string(); + let payload = FeedbackPayload { + feature, + text, + app_version: app_version.clone(), + os: get_os_str().to_string(), + }; + + let client = match yaak_api_client(ApiClientKind::App, &app_version) { + Ok(c) => c, + Err(e) => { + debug!("Failed to build feedback client: {e:?}"); + return; + } + }; + + let url = build_url("/app-feedback"); + debug!( + "Sending feature feedback to {url}: feature={}, app_version={}, os={}, text_len={}", + payload.feature, + payload.app_version, + payload.os, + payload.text.len() + ); + + match client.post(&url).json(&payload).send().await { + Ok(resp) => { + let status = resp.status(); + if status.is_success() { + debug!("Sent feature feedback with status {status}"); + } else { + let body = resp + .text() + .await + .unwrap_or_else(|e| format!("")); + warn!("Failed to send feature feedback with status {status}: {body}"); + } + } + Err(e) => warn!("Failed to send feature feedback: {e:?}"), + } +} + +fn build_url(path: &str) -> String { + if is_dev() { + format!("http://localhost:9444/api/v1{path}") + } else { + format!("https://api.yaak.app/api/v1{path}") + } +} diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index 87bb07eb..ea43bced 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -65,6 +65,7 @@ use yaak_tls::find_client_certificate; mod commands; mod encoding; mod error; +mod feedback; mod git_ext; mod git_watcher; mod grpc; @@ -292,6 +293,16 @@ async fn cmd_render_template( Ok(result) } +#[tauri::command] +async fn cmd_send_feedback( + app_handle: AppHandle, + feature: String, + text: String, +) -> YaakResult<()> { + feedback::send_feedback(&app_handle, feature, text).await; + Ok(()) +} + #[tauri::command] async fn cmd_dismiss_notification( window: WebviewWindow, @@ -1819,6 +1830,7 @@ pub fn run() { cmd_delete_send_history, cmd_dismiss_notification, cmd_export_data, + cmd_send_feedback, cmd_http_request_body, cmd_http_response_body, cmd_format_json, diff --git a/crates-tauri/yaak-system-appearance/src/lib.rs b/crates-tauri/yaak-system-appearance/src/lib.rs index cd644390..b6f7466e 100644 --- a/crates-tauri/yaak-system-appearance/src/lib.rs +++ b/crates-tauri/yaak-system-appearance/src/lib.rs @@ -1,13 +1,18 @@ use std::sync::{Arc, Mutex}; +#[cfg(target_os = "linux")] use std::time::Duration; +#[cfg(target_os = "linux")] use log::{debug, warn}; -use tauri::{AppHandle, Emitter, Runtime}; +#[cfg(target_os = "linux")] +use tauri::Emitter; +use tauri::{AppHandle, Runtime}; pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__"; pub const INITIAL_APPEARANCE_SOURCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE_SOURCE__"; pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change"; +#[cfg(target_os = "linux")] const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1); #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -42,6 +47,8 @@ impl InitialAppearanceSource { #[derive(Clone)] pub struct SystemAppearanceState { + // Only read by the Linux polling thread + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] last_appearance: Arc>>, } diff --git a/crates/yaak-models/bindings/gen_models.ts b/crates/yaak-models/bindings/gen_models.ts index 287e9b4e..6a606587 100644 --- a/crates/yaak-models/bindings/gen_models.ts +++ b/crates/yaak-models/bindings/gen_models.ts @@ -402,6 +402,7 @@ export type Settings = { themeLight: string; updateChannel: string; hideLicenseBadge: boolean; + promptFeedback: boolean; autoupdate: boolean; autoDownloadUpdates: boolean; checkNotifications: boolean; diff --git a/crates/yaak-models/migrations/20260704000000_prompt-feedback.sql b/crates/yaak-models/migrations/20260704000000_prompt-feedback.sql new file mode 100644 index 00000000..448c894f --- /dev/null +++ b/crates/yaak-models/migrations/20260704000000_prompt-feedback.sql @@ -0,0 +1,3 @@ +-- Add a setting to enable in-app feature feedback prompts +ALTER TABLE settings + ADD COLUMN prompt_feedback BOOLEAN DEFAULT TRUE NOT NULL; diff --git a/crates/yaak-models/src/models.rs b/crates/yaak-models/src/models.rs index 4dd59248..2148650e 100644 --- a/crates/yaak-models/src/models.rs +++ b/crates/yaak-models/src/models.rs @@ -246,6 +246,7 @@ pub struct Settings { pub theme_light: String, pub update_channel: String, pub hide_license_badge: bool, + pub prompt_feedback: bool, pub autoupdate: bool, pub auto_download_updates: bool, pub check_notifications: bool, @@ -303,6 +304,7 @@ impl UpsertModelInfo for Settings { (ThemeLight, self.theme_light.as_str().into()), (UpdateChannel, self.update_channel.into()), (HideLicenseBadge, self.hide_license_badge.into()), + (PromptFeedback, self.prompt_feedback.into()), (Autoupdate, self.autoupdate.into()), (AutoDownloadUpdates, self.auto_download_updates.into()), (ColoredMethods, self.colored_methods.into()), @@ -332,6 +334,7 @@ impl UpsertModelInfo for Settings { SettingsIden::ThemeLight, SettingsIden::UpdateChannel, SettingsIden::HideLicenseBadge, + SettingsIden::PromptFeedback, SettingsIden::Autoupdate, SettingsIden::AutoDownloadUpdates, SettingsIden::ColoredMethods, @@ -372,6 +375,7 @@ impl UpsertModelInfo for Settings { autoupdate: row.get("autoupdate")?, auto_download_updates: row.get("auto_download_updates")?, hide_license_badge: row.get("hide_license_badge")?, + prompt_feedback: row.get("prompt_feedback")?, colored_methods: row.get("colored_methods")?, check_notifications: row.get("check_notifications")?, hotkeys: serde_json::from_str(&hotkeys).unwrap_or_default(), diff --git a/crates/yaak-models/src/queries/settings.rs b/crates/yaak-models/src/queries/settings.rs index 660e6e42..b2d70832 100644 --- a/crates/yaak-models/src/queries/settings.rs +++ b/crates/yaak-models/src/queries/settings.rs @@ -38,6 +38,7 @@ impl<'a> ClientDb<'a> { autoupdate: true, colored_methods: false, hide_license_badge: false, + prompt_feedback: true, auto_download_updates: true, check_notifications: true, hotkeys: HashMap::new(),