mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-05 04:21:50 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1f1363502 |
@@ -13,6 +13,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { trackFeatureUsage } from "../lib/featureFeedback";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { cookieDomain } from "../lib/model_util";
|
||||
import {
|
||||
@@ -131,6 +132,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
|
||||
});
|
||||
|
||||
void patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
|
||||
trackFeatureUsage("cookie-editor");
|
||||
setSelectedCookieKey(nextCookieKey);
|
||||
setEditingCookieKey(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>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import { deepEqualAtom } from "../lib/atoms";
|
||||
import { languageFromContentType } from "../lib/contentType";
|
||||
import { generateId } from "../lib/generateId";
|
||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
||||
import { convertRequestBody } from "../lib/requestBodyConversion";
|
||||
import {
|
||||
BODY_TYPE_BINARY,
|
||||
BODY_TYPE_FORM_MULTIPART,
|
||||
@@ -196,14 +195,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
});
|
||||
};
|
||||
|
||||
const patch: Partial<HttpRequest> = {
|
||||
bodyType,
|
||||
body: convertRequestBody({
|
||||
body: activeRequest.body,
|
||||
fromBodyType: activeRequest.bodyType,
|
||||
toBodyType: bodyType,
|
||||
}),
|
||||
};
|
||||
const patch: Partial<HttpRequest> = { bodyType };
|
||||
let newContentType: string | null | undefined;
|
||||
if (bodyType === BODY_TYPE_NONE) {
|
||||
newContentType = null;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "date-fns";
|
||||
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
|
||||
import { useKeyValue } from "../hooks/useKeyValue";
|
||||
import { trackFeatureUsage } from "../lib/featureFeedback";
|
||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
||||
@@ -92,7 +93,10 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
|
||||
</HStack>
|
||||
),
|
||||
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."
|
||||
/>
|
||||
|
||||
<ModelSettingRowBoolean
|
||||
model={settings}
|
||||
modelKey="hideFeedbackPrompts"
|
||||
title="Never ask for feedback"
|
||||
description="Hide the rare one-time prompts asking how new features are working."
|
||||
/>
|
||||
|
||||
<SettingRowBoolean
|
||||
title="Send anonymous usage statistics"
|
||||
description="Yaak is local-first and does not collect analytics or usage data."
|
||||
|
||||
@@ -318,6 +318,7 @@ function BaseInput({
|
||||
editorClassName,
|
||||
multiLine && size === "md" && "py-1.5",
|
||||
multiLine && size === "sm" && "py-1",
|
||||
multiLine && (size === "xs" || size === "2xs") && "py-0.5",
|
||||
)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
|
||||
@@ -15,6 +15,12 @@ export interface ToastProps {
|
||||
action?: (args: { hide: () => 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<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = {
|
||||
@@ -28,7 +34,17 @@ const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, 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) {
|
||||
useKey(
|
||||
"Escape",
|
||||
() => {
|
||||
@@ -57,7 +73,13 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
|
||||
"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" />}
|
||||
<VStack space={2} className="w-full min-w-0">
|
||||
<div className="select-auto">{children}</div>
|
||||
@@ -65,14 +87,16 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
|
||||
</VStack>
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
color={color}
|
||||
variant="border"
|
||||
className="opacity-60 border-0 absolute! top-2 right-2"
|
||||
title="Dismiss"
|
||||
icon="x"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{!hideDismiss && (
|
||||
<IconButton
|
||||
color={color}
|
||||
variant="border"
|
||||
className="opacity-60 border-0 absolute! top-2 right-2"
|
||||
title="Dismiss"
|
||||
icon="x"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{timeout != null && (
|
||||
<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 { useCallback, useMemo, useState } from "react";
|
||||
import { modelToYaml } from "../../lib/diffYaml";
|
||||
import { trackFeatureUsage } from "../../lib/featureFeedback";
|
||||
import { resolvedModelName } from "../../lib/resolvedModelName";
|
||||
import { showConfirm } from "../../lib/confirm";
|
||||
import { showErrorToast } from "../../lib/toast";
|
||||
@@ -55,6 +56,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
setCommitError(null);
|
||||
try {
|
||||
await commit.mutateAsync({ message });
|
||||
trackFeatureUsage("git-sync");
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setCommitError(String(err));
|
||||
@@ -66,6 +68,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
try {
|
||||
const r = await commitAndPush.mutateAsync({ message });
|
||||
handlePushResult(r);
|
||||
trackFeatureUsage("git-sync");
|
||||
onDone();
|
||||
} catch (err) {
|
||||
showErrorToast({
|
||||
|
||||
@@ -14,7 +14,6 @@ import { Editor } from "../core/Editor/LazyEditor";
|
||||
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
|
||||
import { Separator } from "../core/Separator";
|
||||
import { tryFormatGraphql } from "../../lib/formatters";
|
||||
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
|
||||
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
|
||||
|
||||
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
|
||||
@@ -39,7 +38,17 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
const [currentBody, setCurrentBody] = useStateWithDeps<{
|
||||
query: string;
|
||||
variables: string | undefined;
|
||||
}>(() => normalizeGraphQLBody(request.body), [extraEditorProps.forceUpdateKey]);
|
||||
}>(() => {
|
||||
// Migrate text bodies to GraphQL format
|
||||
// NOTE: This is how GraphQL used to be stored
|
||||
if ("text" in request.body) {
|
||||
const b = tryParseJson(request.body.text, {});
|
||||
const variables = JSON.stringify(b.variables || undefined, null, 2);
|
||||
return { query: b.query ?? "", variables };
|
||||
}
|
||||
|
||||
return { query: request.body.query ?? "", variables: request.body.variables ?? "" };
|
||||
}, [extraEditorProps.forceUpdateKey]);
|
||||
|
||||
const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom);
|
||||
const isDocOpen = isDocOpenRecord[request.id] !== undefined;
|
||||
@@ -227,3 +236,11 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function tryParseJson(text: string, fallback: unknown) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { extractSseValueAtPath, type ServerSentEvent } from "@yaakapp-internal/s
|
||||
import { HStack, Icon, InlineCode, SplitLayout, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
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 { useFormatText } from "../../hooks/useFormatText";
|
||||
import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource";
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useSseSummaryResultKeyPath,
|
||||
} from "../../hooks/useSseSummaryResultKeyPath";
|
||||
import { isJSON } from "../../lib/contentType";
|
||||
import { trackFeatureUsage } from "../../lib/featureFeedback";
|
||||
import { EmptyStateText } from "../EmptyStateText";
|
||||
import { Markdown } from "../Markdown";
|
||||
import { Button } from "../core/Button";
|
||||
@@ -71,6 +72,16 @@ function ActualEventStreamViewer({ response }: Props) {
|
||||
summary.data.fragmentCount === 0 &&
|
||||
!summary.isFetching &&
|
||||
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 applyToDetails = showExtractedText && applyToDetailsSetting.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} />,
|
||||
});
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
BODY_TYPE_FORM_URLENCODED,
|
||||
BODY_TYPE_GRAPHQL,
|
||||
BODY_TYPE_JSON,
|
||||
BODY_TYPE_OTHER,
|
||||
BODY_TYPE_XML,
|
||||
} from "./model_util";
|
||||
import { convertRequestBody } from "./requestBodyConversion";
|
||||
|
||||
describe("convertRequestBody", () => {
|
||||
test("converts imported JSON GraphQL bodies to GraphQL shape", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_JSON,
|
||||
toBodyType: BODY_TYPE_GRAPHQL,
|
||||
body: {
|
||||
text: JSON.stringify({
|
||||
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||
variables: { id: "123" },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||
variables: '{\n "id": "123"\n}',
|
||||
});
|
||||
});
|
||||
|
||||
test("converts GraphQL bodies to JSON text", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_GRAPHQL,
|
||||
toBodyType: BODY_TYPE_JSON,
|
||||
body: {
|
||||
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||
variables: '{ "id": "123" }',
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: JSON.stringify(
|
||||
{
|
||||
query: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||
variables: { id: "123" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
test("converts urlencoded forms to urlencoded text for text-like bodies", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_FORM_URLENCODED,
|
||||
toBodyType: BODY_TYPE_OTHER,
|
||||
body: {
|
||||
form: [
|
||||
{ enabled: true, name: "basic", value: "aaa" },
|
||||
{ enabled: true, name: "funky stuff", value: "*)%&#$)@ *$#)@&" },
|
||||
{ enabled: false, name: "disabled", value: "hidden" },
|
||||
{ enabled: true, name: "", value: "unnamed" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: "basic=aaa&funky+stuff=*%29%25%26%23%24%29%40+*%24%23%29%40%26",
|
||||
});
|
||||
});
|
||||
|
||||
test("converts urlencoded forms to JSON text for JSON bodies", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_FORM_URLENCODED,
|
||||
toBodyType: BODY_TYPE_JSON,
|
||||
body: {
|
||||
form: [
|
||||
{ enabled: true, name: "tag", value: "one" },
|
||||
{ enabled: true, name: "tag", value: "two" },
|
||||
{ enabled: true, name: "limit", value: "10" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
text: JSON.stringify({ tag: ["one", "two"], limit: "10" }, null, 2),
|
||||
});
|
||||
});
|
||||
|
||||
test("converts urlencoded text to form pairs", () => {
|
||||
const body = convertRequestBody({
|
||||
fromBodyType: BODY_TYPE_XML,
|
||||
toBodyType: BODY_TYPE_FORM_URLENCODED,
|
||||
body: { text: "a=1&b=two+words" },
|
||||
});
|
||||
|
||||
expect(body).toEqual({
|
||||
form: [
|
||||
{ enabled: true, name: "a", value: "1" },
|
||||
{ enabled: true, name: "b", value: "two words" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,224 +0,0 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import {
|
||||
BODY_TYPE_BINARY,
|
||||
BODY_TYPE_FORM_MULTIPART,
|
||||
BODY_TYPE_FORM_URLENCODED,
|
||||
BODY_TYPE_GRAPHQL,
|
||||
BODY_TYPE_JSON,
|
||||
BODY_TYPE_NONE,
|
||||
} from "./model_util";
|
||||
|
||||
type Body = HttpRequest["body"];
|
||||
type BodyType = HttpRequest["bodyType"];
|
||||
type GraphQLBody = {
|
||||
query: string;
|
||||
variables: string | undefined;
|
||||
};
|
||||
|
||||
export function convertRequestBody({
|
||||
body,
|
||||
fromBodyType,
|
||||
toBodyType,
|
||||
}: {
|
||||
body: Body;
|
||||
fromBodyType: BodyType;
|
||||
toBodyType: BodyType;
|
||||
}): Body {
|
||||
if (toBodyType === BODY_TYPE_NONE) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (toBodyType === BODY_TYPE_GRAPHQL) {
|
||||
return toGraphQLBody(body);
|
||||
}
|
||||
|
||||
if (toBodyType === BODY_TYPE_FORM_URLENCODED || toBodyType === BODY_TYPE_FORM_MULTIPART) {
|
||||
return toFormBody(body);
|
||||
}
|
||||
|
||||
if (toBodyType === BODY_TYPE_BINARY) {
|
||||
return typeof body.filePath === "string" ? { filePath: body.filePath } : {};
|
||||
}
|
||||
|
||||
return toTextBody(body, fromBodyType, toBodyType);
|
||||
}
|
||||
|
||||
export function normalizeGraphQLBody(body: Body): GraphQLBody {
|
||||
return toGraphQLBody(body);
|
||||
}
|
||||
|
||||
function toGraphQLBody(body: Body): GraphQLBody {
|
||||
if (typeof body.query === "string") {
|
||||
return {
|
||||
query: body.query,
|
||||
variables: typeof body.variables === "string" ? body.variables : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof body.text === "string") {
|
||||
const parsed = parseJsonObject(body.text);
|
||||
if (parsed != null) {
|
||||
const query = typeof parsed.query === "string" ? parsed.query : "";
|
||||
const variables =
|
||||
parsed.variables == null ? undefined : JSON.stringify(parsed.variables, null, 2);
|
||||
|
||||
return { query, variables };
|
||||
}
|
||||
|
||||
return { query: body.text, variables: undefined };
|
||||
}
|
||||
|
||||
return { query: "", variables: undefined };
|
||||
}
|
||||
|
||||
function toFormBody(body: Body): Body {
|
||||
if (Array.isArray(body.form)) {
|
||||
return {
|
||||
form: body.form.map((p) => ({
|
||||
enabled: p.enabled !== false,
|
||||
name: typeof p.name === "string" ? p.name : "",
|
||||
value: stringifyFormValue(p.value ?? p.file),
|
||||
contentType: typeof p.contentType === "string" ? p.contentType : undefined,
|
||||
filename: typeof p.filename === "string" ? p.filename : undefined,
|
||||
file: typeof p.file === "string" ? p.file : undefined,
|
||||
id: typeof p.id === "string" ? p.id : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof body.text === "string") {
|
||||
const parsed = parseJsonObject(body.text);
|
||||
if (parsed != null) {
|
||||
return { form: objectToFormPairs(parsed) };
|
||||
}
|
||||
|
||||
return { form: urlEncodedTextToFormPairs(body.text) };
|
||||
}
|
||||
|
||||
if (typeof body.query === "string") {
|
||||
return {
|
||||
form: [
|
||||
{ enabled: true, name: "query", value: body.query },
|
||||
...(typeof body.variables === "string"
|
||||
? [{ enabled: true, name: "variables", value: body.variables }]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return { form: [] };
|
||||
}
|
||||
|
||||
function toTextBody(body: Body, fromBodyType: BodyType, toBodyType: BodyType): Body {
|
||||
const sendJsonComments =
|
||||
typeof body.sendJsonComments === "boolean" ? { sendJsonComments: body.sendJsonComments } : {};
|
||||
|
||||
if (typeof body.text === "string") {
|
||||
return { text: body.text, ...sendJsonComments };
|
||||
}
|
||||
|
||||
if (Array.isArray(body.form)) {
|
||||
if (toBodyType === BODY_TYPE_JSON) {
|
||||
return { text: JSON.stringify(formBodyToObject(body.form), null, 2) };
|
||||
}
|
||||
|
||||
return { text: formBodyToUrlEncodedText(body.form) };
|
||||
}
|
||||
|
||||
if (typeof body.query === "string") {
|
||||
if (toBodyType === BODY_TYPE_JSON || fromBodyType === BODY_TYPE_GRAPHQL) {
|
||||
const value: Record<string, unknown> = { query: body.query };
|
||||
if (typeof body.variables === "string" && body.variables.trim() !== "") {
|
||||
value.variables = parseJson(body.variables) ?? body.variables;
|
||||
}
|
||||
|
||||
return { text: JSON.stringify(value, null, 2) };
|
||||
}
|
||||
|
||||
return { text: body.query };
|
||||
}
|
||||
|
||||
if (typeof body.filePath === "string") {
|
||||
return { text: body.filePath };
|
||||
}
|
||||
|
||||
return { text: "" };
|
||||
}
|
||||
|
||||
function formBodyToUrlEncodedText(form: unknown[]): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const pair of form) {
|
||||
if (!isRecord(pair)) continue;
|
||||
if (pair.enabled === false) continue;
|
||||
if (typeof pair.name !== "string" || pair.name === "") continue;
|
||||
params.append(pair.name, stringifyFormValue(pair.value));
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function urlEncodedTextToFormPairs(text: string) {
|
||||
if (!text.includes("=") && !text.includes("&")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(new URLSearchParams(text).entries()).map(([name, value]) => ({
|
||||
enabled: true,
|
||||
name,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
function formBodyToObject(form: unknown[]) {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const pair of form) {
|
||||
if (!isRecord(pair)) continue;
|
||||
if (pair.enabled === false) continue;
|
||||
if (typeof pair.name !== "string" || pair.name === "") continue;
|
||||
|
||||
const value = stringifyFormValue(pair.value);
|
||||
if (pair.name in result) {
|
||||
const existing = result[pair.name];
|
||||
result[pair.name] = Array.isArray(existing) ? [...existing, value] : [existing, value];
|
||||
} else {
|
||||
result[pair.name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function objectToFormPairs(value: Record<string, unknown>) {
|
||||
return Object.entries(value).flatMap(([name, rawValue]) => {
|
||||
if (Array.isArray(rawValue)) {
|
||||
return rawValue.map((item) => ({ enabled: true, name, value: stringifyFormValue(item) }));
|
||||
}
|
||||
|
||||
return [{ enabled: true, name, value: stringifyFormValue(rawValue) }];
|
||||
});
|
||||
}
|
||||
|
||||
function stringifyFormValue(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
const parsed = parseJson(text);
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseJson(text: string): unknown | null {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -37,6 +37,11 @@ export function showToast({
|
||||
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);
|
||||
|
||||
@@ -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 encoding;
|
||||
mod error;
|
||||
mod feedback;
|
||||
mod git_ext;
|
||||
mod git_watcher;
|
||||
mod grpc;
|
||||
@@ -292,6 +293,16 @@ async fn cmd_render_template<R: Runtime>(
|
||||
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]
|
||||
async fn cmd_dismiss_notification<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Mutex<Option<Appearance>>>,
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -402,6 +402,7 @@ export type Settings = {
|
||||
themeLight: string;
|
||||
updateChannel: string;
|
||||
hideLicenseBadge: boolean;
|
||||
hideFeedbackPrompts: boolean;
|
||||
autoupdate: boolean;
|
||||
autoDownloadUpdates: 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 update_channel: String,
|
||||
pub hide_license_badge: bool,
|
||||
pub hide_feedback_prompts: 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()),
|
||||
(HideFeedbackPrompts, self.hide_feedback_prompts.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::HideFeedbackPrompts,
|
||||
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")?,
|
||||
hide_feedback_prompts: row.get("hide_feedback_prompts")?,
|
||||
colored_methods: row.get("colored_methods")?,
|
||||
check_notifications: row.get("check_notifications")?,
|
||||
hotkeys: serde_json::from_str(&hotkeys).unwrap_or_default(),
|
||||
|
||||
@@ -38,6 +38,7 @@ impl<'a> ClientDb<'a> {
|
||||
autoupdate: true,
|
||||
colored_methods: false,
|
||||
hide_license_badge: false,
|
||||
hide_feedback_prompts: false,
|
||||
auto_download_updates: true,
|
||||
check_notifications: true,
|
||||
hotkeys: HashMap::new(),
|
||||
|
||||
Reference in New Issue
Block a user