mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-05 20:41:58 +02:00
Add in-app micro-feedback prompts (#497)
This commit is contained in:
@@ -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<string>("");
|
||||||
|
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 (
|
||||||
|
<VStack space={2}>
|
||||||
|
<p className="text-sm font-semibold">{FEEDBACK_FEATURES[feature]}</p>
|
||||||
|
<div className="h-20">
|
||||||
|
<Input
|
||||||
|
size="xs"
|
||||||
|
// The editor forces its mono font on the scroller, so the override
|
||||||
|
// has to target it directly
|
||||||
|
className="[&_.cm-scroller]:font-sans! [&_.cm-scroller]:text-sm!"
|
||||||
|
label="Feedback"
|
||||||
|
hideLabel
|
||||||
|
stateKey={null}
|
||||||
|
multiLine
|
||||||
|
fullHeight
|
||||||
|
placeholder="Your thoughts..."
|
||||||
|
onChange={setText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<HStack space={1.5} justifyContent="end">
|
||||||
|
<Button size="xs" color="secondary" variant="border" onClick={handleDismiss}>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="primary"
|
||||||
|
disabled={sent || text.trim().length === 0}
|
||||||
|
onClick={handleSend}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -92,7 +92,9 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
|
|||||||
</HStack>
|
</HStack>
|
||||||
),
|
),
|
||||||
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||||
onSelect: () => onPinnedResponseId(r.id),
|
onSelect: () => {
|
||||||
|
onPinnedResponseId(r.id);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,17 @@ export function SettingsGeneral() {
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</CargoFeature>
|
</CargoFeature>
|
||||||
|
|
||||||
|
<CargoFeature feature="license">
|
||||||
|
<SettingsSection title="Feedback">
|
||||||
|
<SettingRowBoolean
|
||||||
|
title="Prompt for feedback"
|
||||||
|
description="Show rare one-time prompts asking how new features are working."
|
||||||
|
checked={settings.promptFeedback}
|
||||||
|
onChange={(promptFeedback) => patchModel(settings, { promptFeedback })}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
</CargoFeature>
|
||||||
|
|
||||||
{showWorkspaceSettingsMovedBanner && (
|
{showWorkspaceSettingsMovedBanner && (
|
||||||
<DismissibleBanner
|
<DismissibleBanner
|
||||||
id="workspace-settings-moved-2026-06-30"
|
id="workspace-settings-moved-2026-06-30"
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ function BaseInput({
|
|||||||
editorClassName,
|
editorClassName,
|
||||||
multiLine && size === "md" && "py-1.5",
|
multiLine && size === "md" && "py-1.5",
|
||||||
multiLine && size === "sm" && "py-1",
|
multiLine && size === "sm" && "py-1",
|
||||||
|
multiLine && (size === "xs" || size === "2xs") && "py-0.5",
|
||||||
)}
|
)}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import * as m from "motion/react-m";
|
import * as m from "motion/react-m";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useKey } from "react-use";
|
import { useKey } from "react-use";
|
||||||
import { IconButton } from "./IconButton";
|
import { IconButton } from "./IconButton";
|
||||||
|
|
||||||
@@ -15,6 +16,12 @@ export interface ToastProps {
|
|||||||
action?: (args: { hide: () => void }) => ReactNode;
|
action?: (args: { hide: () => void }) => ReactNode;
|
||||||
icon?: ShowToastRequest["icon"] | null;
|
icon?: ShowToastRequest["icon"] | null;
|
||||||
color?: ShowToastRequest["color"];
|
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<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = {
|
const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = {
|
||||||
@@ -28,7 +35,47 @@ const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon
|
|||||||
warning: "alert_triangle",
|
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<ReturnType<typeof setTimeout> | 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(
|
useKey(
|
||||||
"Escape",
|
"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",
|
"relative pointer-events-auto bg-surface text-text rounded-lg",
|
||||||
"border border-border shadow-lg w-100",
|
"border border-border shadow-lg w-100",
|
||||||
)}
|
)}
|
||||||
|
onFocusCapture={cancelAutoHide}
|
||||||
|
onKeyDownCapture={cancelAutoHide}
|
||||||
|
onPointerDownCapture={cancelAutoHide}
|
||||||
>
|
>
|
||||||
<div className="pl-3 py-3 pr-10 flex items-start gap-2 w-full max-h-44 overflow-auto">
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"pl-3 py-3 flex items-start gap-2 w-full overflow-auto",
|
||||||
|
hideDismiss ? "pr-3" : "pr-10",
|
||||||
|
dynamicHeight ? "max-h-[80vh]" : "max-h-44",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
|
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
|
||||||
<VStack space={2} className="w-full min-w-0">
|
<VStack space={2} className="w-full min-w-0">
|
||||||
<div className="select-auto">{children}</div>
|
<div className="select-auto">{children}</div>
|
||||||
@@ -65,16 +121,18 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
|
|||||||
</VStack>
|
</VStack>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<IconButton
|
{!hideDismiss && (
|
||||||
color={color}
|
<IconButton
|
||||||
variant="border"
|
color={color}
|
||||||
className="opacity-60 border-0 absolute! top-2 right-2"
|
variant="border"
|
||||||
title="Dismiss"
|
className="opacity-60 border-0 absolute! top-2 right-2"
|
||||||
icon="x"
|
title="Dismiss"
|
||||||
onClick={onClose}
|
icon="x"
|
||||||
/>
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{timeout != null && (
|
{timeout != null && !autoHideCanceled && (
|
||||||
<div className="w-full absolute bottom-0 left-0 right-0">
|
<div className="w-full absolute bottom-0 left-0 right-0">
|
||||||
<m.div
|
<m.div
|
||||||
className="bg-surface-highlight h-[3px]"
|
className="bg-surface-highlight h-[3px]"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { modelToYaml } from "../../lib/diffYaml";
|
import { modelToYaml } from "../../lib/diffYaml";
|
||||||
|
import { trackFeatureUsage } from "../../lib/featureFeedback";
|
||||||
import { resolvedModelName } from "../../lib/resolvedModelName";
|
import { resolvedModelName } from "../../lib/resolvedModelName";
|
||||||
import { showConfirm } from "../../lib/confirm";
|
import { showConfirm } from "../../lib/confirm";
|
||||||
import { showErrorToast } from "../../lib/toast";
|
import { showErrorToast } from "../../lib/toast";
|
||||||
@@ -55,6 +56,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
setCommitError(null);
|
setCommitError(null);
|
||||||
try {
|
try {
|
||||||
await commit.mutateAsync({ message });
|
await commit.mutateAsync({ message });
|
||||||
|
trackFeatureUsage("git-sync");
|
||||||
onDone();
|
onDone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCommitError(String(err));
|
setCommitError(String(err));
|
||||||
@@ -66,6 +68,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
try {
|
try {
|
||||||
const r = await commitAndPush.mutateAsync({ message });
|
const r = await commitAndPush.mutateAsync({ message });
|
||||||
handlePushResult(r);
|
handlePushResult(r);
|
||||||
|
trackFeatureUsage("git-sync");
|
||||||
onDone();
|
onDone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorToast({
|
showErrorToast({
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ function ActualEventStreamViewer({ response }: Props) {
|
|||||||
summary.data.fragmentCount === 0 &&
|
summary.data.fragmentCount === 0 &&
|
||||||
!summary.isFetching &&
|
!summary.isFetching &&
|
||||||
summary.error == null;
|
summary.error == null;
|
||||||
|
|
||||||
const filterEventPreviews = showExtractedText && filterEventPreviewsSetting.value === true;
|
const filterEventPreviews = showExtractedText && filterEventPreviewsSetting.value === true;
|
||||||
const applyToDetails = showExtractedText && applyToDetailsSetting.value === true;
|
const applyToDetails = showExtractedText && applyToDetailsSetting.value === true;
|
||||||
const renderMarkdown = showExtractedText && renderMarkdownSetting.value === true;
|
const renderMarkdown = showExtractedText && renderMarkdownSetting.value === true;
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
|
import { FeedbackToast } from "../components/FeedbackToast";
|
||||||
|
import { appInfo } from "./appInfo";
|
||||||
|
import type { FeedbackFeature } from "./featureFeedbackConstants";
|
||||||
|
import { dialogsAtom } from "./dialog";
|
||||||
|
import { jotaiStore } from "./jotai";
|
||||||
|
import { getKeyValue, setKeyValue } from "./keyValueStore";
|
||||||
|
import { showToast } from "./toast";
|
||||||
|
|
||||||
|
interface FeatureFeedbackState {
|
||||||
|
uses: number;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FEEDBACK_PROMPT_DELAY_MS = 1500;
|
||||||
|
const FEEDBACK_PROMPT_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
// Ask once the user has used a feature enough times to have formed an opinion
|
||||||
|
const PROMPT_AFTER_USES = 3;
|
||||||
|
|
||||||
|
// Show at most one feedback prompt per app session to stay unobtrusive
|
||||||
|
let promptedThisSession = false;
|
||||||
|
|
||||||
|
const lastTrackedAt: Partial<Record<FeedbackFeature, number>> = {};
|
||||||
|
const FEATURE_USE_DEBOUNCE_MS = 10_000;
|
||||||
|
|
||||||
|
const kvArgs = (feature: FeedbackFeature) => ({
|
||||||
|
namespace: "global",
|
||||||
|
key: ["feature-feedback", feature],
|
||||||
|
});
|
||||||
|
|
||||||
|
function getFeatureFeedbackState(feature: FeedbackFeature): FeatureFeedbackState {
|
||||||
|
return getKeyValue<FeatureFeedbackState>({
|
||||||
|
...kvArgs(feature),
|
||||||
|
fallback: { uses: 0, done: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchFeatureFeedbackState(feature: FeedbackFeature, patch: Partial<FeatureFeedbackState>) {
|
||||||
|
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: (
|
||||||
|
<FeedbackToast feature={feature} onDone={() => 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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -48,6 +48,7 @@ type TauriCmd =
|
|||||||
| "cmd_save_response"
|
| "cmd_save_response"
|
||||||
| "cmd_secure_template"
|
| "cmd_secure_template"
|
||||||
| "cmd_send_ephemeral_request"
|
| "cmd_send_ephemeral_request"
|
||||||
|
| "cmd_send_feedback"
|
||||||
| "cmd_send_http_request"
|
| "cmd_send_http_request"
|
||||||
| "cmd_template_function_summaries"
|
| "cmd_template_function_summaries"
|
||||||
| "cmd_template_function_config"
|
| "cmd_template_function_config"
|
||||||
|
|||||||
@@ -28,15 +28,17 @@ export function showToast({
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const newToast: ToastInstance = { id, uniqueKey, timeout, ...props };
|
const newToast: ToastInstance = { id, uniqueKey, timeout, ...props };
|
||||||
if (timeout != null) {
|
|
||||||
setTimeout(() => hideToast(newToast), timeout);
|
|
||||||
}
|
|
||||||
jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]);
|
jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]);
|
||||||
}, delay);
|
}, delay);
|
||||||
|
|
||||||
return id;
|
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) {
|
export function hideToast(toHide: ToastInstance) {
|
||||||
jotaiStore.set(toastsAtom, (all) => {
|
jotaiStore.set(toastsAtom, (all) => {
|
||||||
const t = all.find((t) => t.uniqueKey === toHide.uniqueKey);
|
const t = all.find((t) => t.uniqueKey === toHide.uniqueKey);
|
||||||
|
|||||||
@@ -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<R: Runtime>(app_handle: &AppHandle<R>, 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!("<failed to read response body: {e:?}>"));
|
||||||
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@ use yaak_tls::find_client_certificate;
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod encoding;
|
mod encoding;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod feedback;
|
||||||
mod git_ext;
|
mod git_ext;
|
||||||
mod git_watcher;
|
mod git_watcher;
|
||||||
mod grpc;
|
mod grpc;
|
||||||
@@ -292,6 +293,16 @@ async fn cmd_render_template<R: Runtime>(
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn cmd_send_feedback<R: Runtime>(
|
||||||
|
app_handle: AppHandle<R>,
|
||||||
|
feature: String,
|
||||||
|
text: String,
|
||||||
|
) -> YaakResult<()> {
|
||||||
|
feedback::send_feedback(&app_handle, feature, text).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn cmd_dismiss_notification<R: Runtime>(
|
async fn cmd_dismiss_notification<R: Runtime>(
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
@@ -1819,6 +1830,7 @@ pub fn run() {
|
|||||||
cmd_delete_send_history,
|
cmd_delete_send_history,
|
||||||
cmd_dismiss_notification,
|
cmd_dismiss_notification,
|
||||||
cmd_export_data,
|
cmd_export_data,
|
||||||
|
cmd_send_feedback,
|
||||||
cmd_http_request_body,
|
cmd_http_request_body,
|
||||||
cmd_http_response_body,
|
cmd_http_response_body,
|
||||||
cmd_format_json,
|
cmd_format_json,
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
use log::{debug, warn};
|
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_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__";
|
||||||
pub const INITIAL_APPEARANCE_SOURCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE_SOURCE__";
|
pub const INITIAL_APPEARANCE_SOURCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE_SOURCE__";
|
||||||
pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change";
|
pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change";
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
@@ -42,6 +47,8 @@ impl InitialAppearanceSource {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SystemAppearanceState {
|
pub struct SystemAppearanceState {
|
||||||
|
// Only read by the Linux polling thread
|
||||||
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||||
last_appearance: Arc<Mutex<Option<Appearance>>>,
|
last_appearance: Arc<Mutex<Option<Appearance>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
@@ -402,6 +402,7 @@ export type Settings = {
|
|||||||
themeLight: string;
|
themeLight: string;
|
||||||
updateChannel: string;
|
updateChannel: string;
|
||||||
hideLicenseBadge: boolean;
|
hideLicenseBadge: boolean;
|
||||||
|
promptFeedback: boolean;
|
||||||
autoupdate: boolean;
|
autoupdate: boolean;
|
||||||
autoDownloadUpdates: boolean;
|
autoDownloadUpdates: boolean;
|
||||||
checkNotifications: boolean;
|
checkNotifications: boolean;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -246,6 +246,7 @@ pub struct Settings {
|
|||||||
pub theme_light: String,
|
pub theme_light: String,
|
||||||
pub update_channel: String,
|
pub update_channel: String,
|
||||||
pub hide_license_badge: bool,
|
pub hide_license_badge: bool,
|
||||||
|
pub prompt_feedback: bool,
|
||||||
pub autoupdate: bool,
|
pub autoupdate: bool,
|
||||||
pub auto_download_updates: bool,
|
pub auto_download_updates: bool,
|
||||||
pub check_notifications: bool,
|
pub check_notifications: bool,
|
||||||
@@ -303,6 +304,7 @@ impl UpsertModelInfo for Settings {
|
|||||||
(ThemeLight, self.theme_light.as_str().into()),
|
(ThemeLight, self.theme_light.as_str().into()),
|
||||||
(UpdateChannel, self.update_channel.into()),
|
(UpdateChannel, self.update_channel.into()),
|
||||||
(HideLicenseBadge, self.hide_license_badge.into()),
|
(HideLicenseBadge, self.hide_license_badge.into()),
|
||||||
|
(PromptFeedback, self.prompt_feedback.into()),
|
||||||
(Autoupdate, self.autoupdate.into()),
|
(Autoupdate, self.autoupdate.into()),
|
||||||
(AutoDownloadUpdates, self.auto_download_updates.into()),
|
(AutoDownloadUpdates, self.auto_download_updates.into()),
|
||||||
(ColoredMethods, self.colored_methods.into()),
|
(ColoredMethods, self.colored_methods.into()),
|
||||||
@@ -332,6 +334,7 @@ impl UpsertModelInfo for Settings {
|
|||||||
SettingsIden::ThemeLight,
|
SettingsIden::ThemeLight,
|
||||||
SettingsIden::UpdateChannel,
|
SettingsIden::UpdateChannel,
|
||||||
SettingsIden::HideLicenseBadge,
|
SettingsIden::HideLicenseBadge,
|
||||||
|
SettingsIden::PromptFeedback,
|
||||||
SettingsIden::Autoupdate,
|
SettingsIden::Autoupdate,
|
||||||
SettingsIden::AutoDownloadUpdates,
|
SettingsIden::AutoDownloadUpdates,
|
||||||
SettingsIden::ColoredMethods,
|
SettingsIden::ColoredMethods,
|
||||||
@@ -372,6 +375,7 @@ impl UpsertModelInfo for Settings {
|
|||||||
autoupdate: row.get("autoupdate")?,
|
autoupdate: row.get("autoupdate")?,
|
||||||
auto_download_updates: row.get("auto_download_updates")?,
|
auto_download_updates: row.get("auto_download_updates")?,
|
||||||
hide_license_badge: row.get("hide_license_badge")?,
|
hide_license_badge: row.get("hide_license_badge")?,
|
||||||
|
prompt_feedback: row.get("prompt_feedback")?,
|
||||||
colored_methods: row.get("colored_methods")?,
|
colored_methods: row.get("colored_methods")?,
|
||||||
check_notifications: row.get("check_notifications")?,
|
check_notifications: row.get("check_notifications")?,
|
||||||
hotkeys: serde_json::from_str(&hotkeys).unwrap_or_default(),
|
hotkeys: serde_json::from_str(&hotkeys).unwrap_or_default(),
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ impl<'a> ClientDb<'a> {
|
|||||||
autoupdate: true,
|
autoupdate: true,
|
||||||
colored_methods: false,
|
colored_methods: false,
|
||||||
hide_license_badge: false,
|
hide_license_badge: false,
|
||||||
|
prompt_feedback: true,
|
||||||
auto_download_updates: true,
|
auto_download_updates: true,
|
||||||
check_notifications: true,
|
check_notifications: true,
|
||||||
hotkeys: HashMap::new(),
|
hotkeys: HashMap::new(),
|
||||||
|
|||||||
Reference in New Issue
Block a user