mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-05 12:31:47 +02:00
Add in-app micro-feedback prompts for new features
Show a one-time toast asking how a feature is working after its third successful use, with an optional comment sent anonymously to the Yaak API (feature key, text, app version, and OS only — nothing identifying, and nothing is sent unless the user clicks Send). - New cmd_send_feedback Tauri command posts fire-and-forget via the shared API client (localhost in dev) - Feature keys registry (cookie-editor, response-history, sse-summary, git-sync) with per-feature use counting in the key-value store - "Never ask for feedback" setting to disable prompts entirely - Toast gains dynamicHeight and hideDismiss props for richer content - Fix missing vertical padding on xs/2xs multiline inputs - Fix unused-import and dead-code warnings in yaak-system-appearance on non-Linux builds Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { showDialog } from "../lib/dialog";
|
import { showDialog } from "../lib/dialog";
|
||||||
|
import { trackFeatureUsage } from "../lib/featureFeedback";
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
import { cookieDomain } from "../lib/model_util";
|
import { cookieDomain } from "../lib/model_util";
|
||||||
import {
|
import {
|
||||||
@@ -131,6 +132,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
void patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
|
void patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
|
||||||
|
trackFeatureUsage("cookie-editor");
|
||||||
setSelectedCookieKey(nextCookieKey);
|
setSelectedCookieKey(nextCookieKey);
|
||||||
setEditingCookieKey(null);
|
setEditingCookieKey(null);
|
||||||
setDraftCookie(null);
|
setDraftCookie(null);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { HStack, VStack } from "@yaakapp-internal/ui";
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { FeedbackFeature } from "../lib/featureFeedback";
|
||||||
|
import { FEEDBACK_FEATURES } from "../lib/featureFeedback";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedbackToast({ feature }: Props) {
|
||||||
|
const [text, setText] = useState<string>("");
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
hideToastById(`feature-feedback-${feature}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
// Fire-and-forget; failures are intentionally ignored
|
||||||
|
invokeCmd("cmd_send_feedback", { feature, text: text.trim() }).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={text.trim().length === 0}
|
||||||
|
onClick={handleSend}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
|
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
|
||||||
import { useKeyValue } from "../hooks/useKeyValue";
|
import { useKeyValue } from "../hooks/useKeyValue";
|
||||||
|
import { trackFeatureUsage } from "../lib/featureFeedback";
|
||||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
import { DismissibleBanner } from "./core/DismissibleBanner";
|
||||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
import { formatMillis } from "./core/HttpResponseDurationTag";
|
||||||
@@ -92,7 +93,10 @@ 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: () => {
|
||||||
|
if (r.id !== latestResponseId) trackFeatureUsage("response-history");
|
||||||
|
onPinnedResponseId(r.id);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,13 @@ export function SettingsGeneral() {
|
|||||||
description="Periodically ping Yaak servers to check for relevant notifications."
|
description="Periodically ping Yaak servers to check for relevant notifications."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ModelSettingRowBoolean
|
||||||
|
model={settings}
|
||||||
|
modelKey="hideFeedbackPrompts"
|
||||||
|
title="Never ask for feedback"
|
||||||
|
description="Hide the rare one-time prompts asking how new features are working."
|
||||||
|
/>
|
||||||
|
|
||||||
<SettingRowBoolean
|
<SettingRowBoolean
|
||||||
title="Send anonymous usage statistics"
|
title="Send anonymous usage statistics"
|
||||||
description="Yaak is local-first and does not collect analytics or usage data."
|
description="Yaak is local-first and does not collect analytics or usage data."
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -15,6 +15,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 +34,17 @@ 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) {
|
||||||
useKey(
|
useKey(
|
||||||
"Escape",
|
"Escape",
|
||||||
() => {
|
() => {
|
||||||
@@ -57,7 +73,13 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
|
|||||||
"border border-border shadow-lg w-100",
|
"border border-border shadow-lg w-100",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<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,14 +87,16 @@ 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 && (
|
||||||
<div className="w-full absolute bottom-0 left-0 right-0">
|
<div className="w-full absolute bottom-0 left-0 right-0">
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { extractSseValueAtPath, type ServerSentEvent } from "@yaakapp-internal/s
|
|||||||
import { HStack, Icon, InlineCode, SplitLayout, VStack } from "@yaakapp-internal/ui";
|
import { HStack, Icon, InlineCode, SplitLayout, VStack } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
import { Fragment, useMemo, useState } from "react";
|
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||||
import { useFormatText } from "../../hooks/useFormatText";
|
import { useFormatText } from "../../hooks/useFormatText";
|
||||||
import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource";
|
import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource";
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
useSseSummaryResultKeyPath,
|
useSseSummaryResultKeyPath,
|
||||||
} from "../../hooks/useSseSummaryResultKeyPath";
|
} from "../../hooks/useSseSummaryResultKeyPath";
|
||||||
import { isJSON } from "../../lib/contentType";
|
import { isJSON } from "../../lib/contentType";
|
||||||
|
import { trackFeatureUsage } from "../../lib/featureFeedback";
|
||||||
import { EmptyStateText } from "../EmptyStateText";
|
import { EmptyStateText } from "../EmptyStateText";
|
||||||
import { Markdown } from "../Markdown";
|
import { Markdown } from "../Markdown";
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
@@ -71,6 +72,16 @@ function ActualEventStreamViewer({ response }: Props) {
|
|||||||
summary.data.fragmentCount === 0 &&
|
summary.data.fragmentCount === 0 &&
|
||||||
!summary.isFetching &&
|
!summary.isFetching &&
|
||||||
summary.error == null;
|
summary.error == null;
|
||||||
|
// The component remounts per response (keyed above), so this counts at most
|
||||||
|
// one use for each response that successfully extracts summary text
|
||||||
|
const hasSummaryFragments = showExtractedText && (summary.data?.fragmentCount ?? 0) > 0;
|
||||||
|
const [trackedSummaryUse, setTrackedSummaryUse] = useState<boolean>(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasSummaryFragments || trackedSummaryUse) return;
|
||||||
|
setTrackedSummaryUse(true);
|
||||||
|
trackFeatureUsage("sse-summary");
|
||||||
|
}, [hasSummaryFragments, trackedSummaryUse]);
|
||||||
|
|
||||||
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,72 @@
|
|||||||
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
|
import { FeedbackToast } from "../components/FeedbackToast";
|
||||||
|
import { jotaiStore } from "./jotai";
|
||||||
|
import { getKeyValue, setKeyValue } from "./keyValueStore";
|
||||||
|
import { showToast } from "./toast";
|
||||||
|
|
||||||
|
// 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 = {
|
||||||
|
"cookie-editor": "How is cookie editing working for you?",
|
||||||
|
"response-history": "How is the new response history menu working for you?",
|
||||||
|
"sse-summary": "How is extracted text for event streams working for you?",
|
||||||
|
"git-sync": "How is Git sync working for you?",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type FeedbackFeature = keyof typeof FEEDBACK_FEATURES;
|
||||||
|
|
||||||
|
interface FeatureFeedbackState {
|
||||||
|
uses: number;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (jotaiStore.get(settingsAtom).hideFeedbackPrompts) return;
|
||||||
|
|
||||||
|
const state = getFeatureFeedbackState(feature);
|
||||||
|
if (state.done) return;
|
||||||
|
|
||||||
|
const uses = state.uses + 1;
|
||||||
|
const shouldPrompt = uses >= PROMPT_AFTER_USES && !promptedThisSession;
|
||||||
|
|
||||||
|
// Mark done when prompting so the toast can only ever appear once, even if
|
||||||
|
// the app quits before the user interacts with it
|
||||||
|
patchFeatureFeedbackState(feature, { uses, done: shouldPrompt });
|
||||||
|
if (!shouldPrompt) return;
|
||||||
|
|
||||||
|
promptedThisSession = true;
|
||||||
|
showToast({
|
||||||
|
id: `feature-feedback-${feature}`,
|
||||||
|
timeout: null,
|
||||||
|
dynamicHeight: true,
|
||||||
|
hideDismiss: true,
|
||||||
|
message: <FeedbackToast feature={feature} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export function showToast({
|
|||||||
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,47 @@
|
|||||||
|
use log::debug;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match client.post(build_url("/app-feedback")).json(&payload).send().await {
|
||||||
|
Ok(resp) => debug!("Sent feedback with status {}", resp.status()),
|
||||||
|
Err(e) => debug!("Failed to send 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;
|
||||||
|
hideFeedbackPrompts: boolean;
|
||||||
autoupdate: boolean;
|
autoupdate: boolean;
|
||||||
autoDownloadUpdates: boolean;
|
autoDownloadUpdates: boolean;
|
||||||
checkNotifications: boolean;
|
checkNotifications: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add a setting to disable in-app feature feedback prompts
|
||||||
|
ALTER TABLE settings
|
||||||
|
ADD COLUMN hide_feedback_prompts BOOLEAN DEFAULT FALSE 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 hide_feedback_prompts: 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()),
|
||||||
|
(HideFeedbackPrompts, self.hide_feedback_prompts.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::HideFeedbackPrompts,
|
||||||
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")?,
|
||||||
|
hide_feedback_prompts: row.get("hide_feedback_prompts")?,
|
||||||
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,
|
||||||
|
hide_feedback_prompts: false,
|
||||||
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