feat(gui): Feedback quick fixes (#331)

* refactor(gui): Simplify FeedbackDialog component and enhance feedback submission process

- Consolidated state management for feedback input using a custom hook.
- Improved user interface for feedback submission by including clearer instructions
- Removed redundant code and improved overall component structure for better maintainability.

* refactor(gui): Enhance FeedbackDialog layout and add mail link

* feat(gui): Add error handling in feedback submission

* feat(docs): Add brand identity to docs

* feat(docs): Add Send Feedback page

* feat(tauri): build base for log export feature

* feat(tauri): update save_txt_files to use HashMap for file content

* feat(gui): Implement log export functionality

* fix(gui): adjust feedback dialog link to show docs page with instructions for mail feedback

* fix(gui): minor style adjustments to export logs button

* feat(gui, tauri): enhance log export functionality to include zip file naming

* fix(docs): clarify docs section about exporting logs

* feat(gui): initialize selected swap in SwapSelectDropDown with most recent swap

* fix(gui): parse logs correctly for saving to log file

* fix(gui): ensure to use the most recent swap info by using a specialized hook

* fmr

---------

Co-authored-by: Binarybaron <binarybaron@protonmail.com>
This commit is contained in:
b-enedict 2025-05-27 12:55:20 +02:00 committed by GitHub
parent 60d2ee9f7e
commit 854b14939e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1043 additions and 355 deletions

251
Cargo.lock generated
View file

@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "addr2line"
@ -328,6 +328,24 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "ashpd"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df"
dependencies = [
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.1",
"raw-window-handle",
"serde",
"serde_repr",
"tokio",
"url",
"zbus",
]
[[package]]
name = "asn1-rs"
version = "0.5.2"
@ -453,7 +471,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "942c7cd7ae39e91bde4820d74132e9862e62c2f386c3aa90ccf55949f5bad63a"
dependencies = [
"brotli 3.5.0",
"bzip2",
"bzip2 0.4.4",
"flate2",
"futures-core",
"memchr",
@ -830,7 +848,7 @@ dependencies = [
[[package]]
name = "bdk_chain"
version = "0.20.0"
source = "git+https://github.com/Einliterflasche/bdk?branch=bump/rusqlite-0.32#2e57dc7495c14ed334fb525bf17f002d0a8ff6df"
source = "git+https://github.com/Einliterflasche/bdk?branch=bump%2Frusqlite-0.32#2e57dc7495c14ed334fb525bf17f002d0a8ff6df"
dependencies = [
"bdk_core",
"bitcoin 0.32.6",
@ -842,7 +860,7 @@ dependencies = [
[[package]]
name = "bdk_core"
version = "0.3.0"
source = "git+https://github.com/Einliterflasche/bdk?branch=bump/rusqlite-0.32#2e57dc7495c14ed334fb525bf17f002d0a8ff6df"
source = "git+https://github.com/Einliterflasche/bdk?branch=bump%2Frusqlite-0.32#2e57dc7495c14ed334fb525bf17f002d0a8ff6df"
dependencies = [
"bitcoin 0.32.6",
"hashbrown 0.14.5",
@ -852,7 +870,7 @@ dependencies = [
[[package]]
name = "bdk_electrum"
version = "0.19.0"
source = "git+https://github.com/Einliterflasche/bdk?branch=bump/rusqlite-0.32#2e57dc7495c14ed334fb525bf17f002d0a8ff6df"
source = "git+https://github.com/Einliterflasche/bdk?branch=bump%2Frusqlite-0.32#2e57dc7495c14ed334fb525bf17f002d0a8ff6df"
dependencies = [
"bdk_core",
"electrum-client 0.22.0",
@ -861,7 +879,7 @@ dependencies = [
[[package]]
name = "bdk_wallet"
version = "1.0.0-beta.5"
source = "git+https://github.com/Einliterflasche/bdk?branch=bump/rusqlite-0.32#2e57dc7495c14ed334fb525bf17f002d0a8ff6df"
source = "git+https://github.com/Einliterflasche/bdk?branch=bump%2Frusqlite-0.32#2e57dc7495c14ed334fb525bf17f002d0a8ff6df"
dependencies = [
"bdk_chain",
"bitcoin 0.32.6",
@ -1329,6 +1347,15 @@ dependencies = [
"libc",
]
[[package]]
name = "bzip2"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
dependencies = [
"bzip2-sys",
]
[[package]]
name = "bzip2-sys"
version = "0.1.13+1.0.8"
@ -1674,6 +1701,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]]
name = "convert_case"
version = "0.4.0"
@ -2134,6 +2167,12 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "deflate64"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b"
[[package]]
name = "der"
version = "0.7.10"
@ -2454,6 +2493,18 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
[[package]]
name = "dispatch2"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a0d569e003ff27784e0e14e4a594048698e0c0f0b66cabcb51511be55a7caa0"
dependencies = [
"bitflags 2.9.1",
"block2 0.6.1",
"libc",
"objc2 0.6.1",
]
[[package]]
name = "dispatch2"
version = "0.3.0"
@ -2950,6 +3001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
dependencies = [
"crc32fast",
"libz-rs-sys",
"miniz_oxide",
]
@ -4729,6 +4781,26 @@ dependencies = [
"winapi",
]
[[package]]
name = "liblzma"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66352d7a8ac12d4877b6e6ea5a9b7650ee094257dc40889955bea5bc5b08c1d0"
dependencies = [
"liblzma-sys",
]
[[package]]
name = "liblzma-sys"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5839bad90c3cc2e0b8c4ed8296b80e86040240f81d46b9c0e9bc8dd51ddd3af1"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "libm"
version = "0.2.15"
@ -5264,6 +5336,15 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "libz-rs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6489ca9bd760fe9642d7644e827b0c9add07df89857b0416ee15c1cc1a3b8c5a"
dependencies = [
"zlib-rs",
]
[[package]]
name = "libz-sys"
version = "1.1.22"
@ -6071,7 +6152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166"
dependencies = [
"bitflags 2.9.1",
"dispatch2",
"dispatch2 0.3.0",
"objc2 0.6.1",
]
@ -6082,7 +6163,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4"
dependencies = [
"bitflags 2.9.1",
"dispatch2",
"dispatch2 0.3.0",
"objc2 0.6.1",
"objc2-core-foundation",
"objc2-io-surface",
@ -6482,6 +6563,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest 0.10.7",
"hmac",
]
[[package]]
name = "pem"
version = "3.0.5"
@ -7527,6 +7618,31 @@ dependencies = [
"subtle",
]
[[package]]
name = "rfd"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80c844748fdc82aae252ee4594a89b6e7ebef1063de7951545564cbc4e57075d"
dependencies = [
"ashpd",
"block2 0.6.1",
"dispatch2 0.2.0",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2 0.6.1",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation 0.3.1",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "ring"
version = "0.16.20"
@ -9568,6 +9684,46 @@ dependencies = [
"thiserror 2.0.12",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a33318fe222fc2a612961de8b0419e2982767f213f54a4d3a21b0d7b85c41df8"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.12",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ead0daec5d305adcefe05af9d970fc437bcc7996052d564e7393eb291252da"
dependencies = [
"anyhow",
"dunce",
"glob",
"percent-encoding",
"schemars",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.12",
"toml",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.2.6"
@ -9993,6 +10149,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.52.0",
]
@ -11596,6 +11753,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-cli",
"tauri-plugin-clipboard-manager",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-shell",
@ -11604,6 +11762,7 @@ dependencies = [
"tauri-plugin-updater",
"tracing",
"uuid",
"zip 4.0.0",
]
[[package]]
@ -13117,6 +13276,7 @@ dependencies = [
"ordered-stream",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"windows-sys 0.59.0",
@ -13254,7 +13414,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93ab48844d61251bb3835145c521d88aa4031d7139e8485990f60ca911fa0815"
dependencies = [
"byteorder",
"bzip2",
"bzip2 0.4.4",
"crc32fast",
"flate2",
"thiserror 1.0.69",
@ -13276,6 +13436,78 @@ dependencies = [
"thiserror 2.0.12",
]
[[package]]
name = "zip"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "153a6fff49d264c4babdcfa6b4d534747f520e56e8f0f384f3b808c4b64cc1fd"
dependencies = [
"aes",
"arbitrary",
"bzip2 0.5.2",
"constant_time_eq",
"crc32fast",
"deflate64",
"flate2",
"getrandom 0.3.3",
"hmac",
"indexmap 2.9.0",
"liblzma",
"memchr",
"pbkdf2",
"sha1",
"time 0.3.41",
"zeroize",
"zopfli",
"zstd",
]
[[package]]
name = "zlib-rs"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "868b928d7949e09af2f6086dfc1e01936064cc7a819253bce650d4e2a2d63ba8"
[[package]]
name = "zopfli"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.15+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zvariant"
version = "5.5.3"
@ -13285,6 +13517,7 @@ dependencies = [
"endi",
"enumflags2",
"serde",
"url",
"winnow 0.7.10",
"zvariant_derive",
"zvariant_utils",

8
docs/components/Logo.tsx Normal file
View file

@ -0,0 +1,8 @@
import Image from 'next/image';
export default function Logo() {
return <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Image src="/favicon.svg" alt="UnstoppableSwap" width={32} height={32} style={{ borderRadius: '20%' }}/>
<span>UnstoppableSwap</span>
</div>;
}

View file

@ -4,5 +4,6 @@
"usage": "Usage",
"advanced": "Advanced",
"becoming_a_maker": "Becoming a Maker",
"send_feedback": "Send Feedback",
"donate": "Donate"
}

View file

@ -0,0 +1,45 @@
import Image from 'next/image'
# Send Feedback
We value your feedback and are committed to providing the best support possible. There are two ways to send feedback:
## In-App Feedback
The easiest way to send feedback is through the app:
1. Go to the feedback tab in the sidebar or use the feedback button within a modal
<Image src='/feedback_button_modal.png' width={600} height={300} alt="Feedback Button in the swap modal" />
2. Write your message in the text field
3. **Important**: If you're reporting an issue, please:
- Select the relevant swap from the dropdown menu
- Check "Attach logs from the current session"
- Review the logs before sending (you can click the eye icon to view them)
- Use the redaction option if you want to remove sensitive information
Your feedback will be answered directly in the app under the Feedback tab.
## Email Support
If you prefer to send an email, please include:
1. A clear description of your issue or feedback
2. Swap logs you want to share or encountered problems with (You can use the "Export Logs" functionality in the history tab)
Send your email to: [help@unstoppableswap.net](mailto:help@unstoppableswap.net)
## Tips for Effective Feedback
To help us assist you better:
- Be specific about what you were trying to do
- Include steps to reproduce the issue
- Mention your operating system and app version
- Attach relevant logs whenever possible
## What to Expect
- We try to answer within 24 hours, but please be patient if you don't receive a message directly.
- You can continue the conversation through the app or email
- We may ask for additional information to help resolve your issue

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

114
docs/public/icon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -0,0 +1,14 @@
{
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View file

@ -1,8 +1,21 @@
import Logo from "./components/Logo";
export default {
logo: <span>UnstoppableSwap</span>,
logo: <Logo />,
project: {
link: "https://github.com/UnstoppableSwap/core",
},
head: (
<>
<title>UnstoppableSwap Docs</title>
<meta name="description" content="UnstoppableSwap Docs" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
</>
),
primaryHue: 14.3,
primarySaturation: 90.68,
};

View file

@ -1,356 +1,247 @@
import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
FormControlLabel,
IconButton,
MenuItem,
Paper,
Select,
Switch,
TextField,
Tooltip,
Typography,
} from "@material-ui/core";
import { useSnackbar } from "notistack";
import { useEffect, useState } from "react";
import TruncatedText from "renderer/components/other/TruncatedText";
import { store } from "renderer/store/storeRenderer";
import { useActiveSwapInfo, useAppSelector } from "store/hooks";
import { logsToRawString, parseDateString } from "utils/parseUtils";
import { submitFeedbackViaHttp, AttachmentInput } from "../../../api";
import LoadingButton from "../../other/LoadingButton";
import { PiconeroAmount } from "../../other/Units";
import { getLogsOfSwap, redactLogs } from "renderer/rpc";
import logger from "utils/logger";
import { Label, Visibility } from "@material-ui/icons";
import CliLogsBox from "renderer/components/other/RenderedCliLog";
import { CliLog, parseCliLogString } from "models/cliModel";
import { addFeedbackId } from "store/features/conversationsSlice";
async function submitFeedback(body: string, swapId: string | null, swapLogs: string | null, daemonLogs: string | null) {
const attachments: AttachmentInput[] = [];
if (swapId !== null) {
const swapInfo = store.getState().rpc.state.swapInfos[swapId];
if (swapInfo) {
// Add swap info as an attachment
attachments.push({
key: `swap_info_${swapId}.json`,
content: JSON.stringify(swapInfo, null, 2), // Pretty print JSON
});
// Retrieve and add logs for the specific swap
try {
const logs = await getLogsOfSwap(swapId, false);
attachments.push({
key: `swap_logs_${swapId}.txt`,
content: logs.logs.map((l) => JSON.stringify(l)).join("\n"),
});
} catch (logError) {
logger.error(logError, "Failed to get logs for swap", { swapId });
// Optionally add an attachment indicating log retrieval failure
attachments.push({ key: `swap_logs_${swapId}.error`, content: "Failed to retrieve swap logs." });
}
} else {
logger.warn("Selected swap info not found in state", { swapId });
attachments.push({ key: `swap_info_${swapId}.error`, content: "Swap info not found." });
}
// Add swap logs as an attachment
if (swapLogs) {
attachments.push({
key: `swap_logs_${swapId}.txt`,
content: swapLogs,
});
}
}
// Handle daemon logs
if (daemonLogs !== null) {
attachments.push({
key: "daemon_logs.txt",
content: daemonLogs,
});
}
// Call the updated API function
const feedbackId = await submitFeedbackViaHttp(body, attachments);
// Dispatch only the ID
store.dispatch(addFeedbackId(feedbackId));
}
/*
* This component is a dialog that allows the user to submit feedback to the
* developers. The user can enter a message and optionally attach logs from a
* specific swap.
* selectedSwap = null means no swap is attached
*/
function SwapSelectDropDown({
selectedSwap,
setSelectedSwap,
}: {
selectedSwap: string | null;
setSelectedSwap: (swapId: string | null) => void;
}) {
const swaps = useAppSelector((state) =>
Object.values(state.rpc.state.swapInfos),
);
return (
<Select
value={selectedSwap ?? ""}
variant="outlined"
onChange={(e) => setSelectedSwap(e.target.value as string || null)}
style={{ width: "100%" }}
displayEmpty
>
<MenuItem value="">Do not attach a swap</MenuItem>
{swaps.map((swap) => (
<MenuItem value={swap.swap_id} key={swap.swap_id}>
Swap{" "}<TruncatedText>{swap.swap_id}</TruncatedText>{" "}from{" "}
{new Date(parseDateString(swap.start_date)).toDateString()} (
<PiconeroAmount amount={swap.xmr_amount} />)
</MenuItem>
))}
</Select>
);
}
const MAX_FEEDBACK_LENGTH = 4000;
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
Paper,
TextField,
Tooltip,
Typography,
} from '@material-ui/core'
import { ErrorOutline, Visibility } from '@material-ui/icons'
import ExternalLink from 'renderer/components/other/ExternalLink'
import SwapSelectDropDown from './SwapSelectDropDown'
import LogViewer from './LogViewer'
import { useFeedback, MAX_FEEDBACK_LENGTH } from './useFeedback'
import { useState } from 'react'
import PromiseInvokeButton from 'renderer/components/PromiseInvokeButton'
export default function FeedbackDialog({
open,
onClose,
open,
onClose,
}: {
open: boolean;
onClose: () => void;
open: boolean
onClose: () => void
}) {
const [pending, setPending] = useState(false);
const [bodyText, setBodyText] = useState("");
const currentSwapId = useActiveSwapInfo();
const [swapLogsEditorOpen, setSwapLogsEditorOpen] = useState(false)
const [daemonLogsEditorOpen, setDaemonLogsEditorOpen] = useState(false)
const { enqueueSnackbar } = useSnackbar();
const { input, setInputState, logs, error, clearState, submitFeedback } =
useFeedback()
const [selectedSwap, setSelectedSwap] = useState<
string | null
>(currentSwapId?.swap_id || null);
const [swapLogs, setSwapLogs] = useState<(string | CliLog)[] | null>(null);
const [attachDaemonLogs, setAttachDaemonLogs] = useState(true);
const [daemonLogs, setDaemonLogs] = useState<(string | CliLog)[] | null>(null);
useEffect(() => {
// Reset logs if no swap is selected
if (selectedSwap === null) {
setSwapLogs(null);
return;
const handleClose = () => {
clearState()
onClose()
}
// Fetch the logs from the rust backend and update the state
getLogsOfSwap(selectedSwap, false).then((response) => setSwapLogs(response.logs.map(parseCliLogString)))
}, [selectedSwap]);
const bodyTooLong = input.bodyText.length > MAX_FEEDBACK_LENGTH
useEffect(() => {
if (attachDaemonLogs === false) {
setDaemonLogs(null);
return;
}
setDaemonLogs(store.getState().rpc?.logs)
}, [attachDaemonLogs]);
// Whether to display the log editor
const [swapLogsEditorOpen, setSwapLogsEditorOpen] = useState(false);
const [daemonLogsEditorOpen, setDaemonLogsEditorOpen] = useState(false);
const bodyTooLong = bodyText.length > MAX_FEEDBACK_LENGTH;
const clearState = () => {
setBodyText("");
setAttachDaemonLogs(false);
setSelectedSwap(null);
}
const sendFeedback = async () => {
if (pending) {
return;
}
try {
setPending(true);
await submitFeedback(
bodyText,
selectedSwap,
logsToRawString(swapLogs ?? []),
logsToRawString(daemonLogs ?? [])
);
enqueueSnackbar("Feedback submitted successfully!", {
variant: "success",
});
clearState()
} catch (e) {
logger.error(`Failed to submit feedback: ${e}`);
enqueueSnackbar(`Failed to submit feedback (${e})`, {
variant: "error",
});
} finally {
setPending(false);
}
onClose();
}
const setSwapLogsRedacted = async (redact: boolean) => {
setSwapLogs((await getLogsOfSwap(selectedSwap, redact)).logs.map(parseCliLogString))
}
const setDaemonLogsRedacted = async (redact: boolean) => {
if (!redact)
return setDaemonLogs(store.getState().rpc?.logs)
const redactedLogs = await redactLogs(daemonLogs);
setDaemonLogs(redactedLogs)
}
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Submit Feedback</DialogTitle>
<DialogContent>
<ul>
<li>Got something to say? Drop us a message below. </li>
<li>If you had an issue with a specific swap, select it from the dropdown to attach the logs.
It will help us figure out what went wrong.
</li>
<li>We appreciate you taking the time to share your thoughts! Every message is read by a core developer!</li>
</ul>
<Box
style={{
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
<TextField
variant="outlined"
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
label={
bodyTooLong
? `Text is too long (${bodyText.length}/${MAX_FEEDBACK_LENGTH})`
: "Message"
}
multiline
minRows={4}
maxRows={4}
fullWidth
error={bodyTooLong}
/>
<Box style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
gap: "1rem",
}}>
<SwapSelectDropDown
selectedSwap={selectedSwap}
setSelectedSwap={setSelectedSwap}
/>
<Tooltip title="View the logs">
<Box style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<IconButton onClick={() => setSwapLogsEditorOpen(true)} disabled={selectedSwap === null}>
<Visibility />
</IconButton>
</Box>
</Tooltip>
</Box>
<LogViewer open={swapLogsEditorOpen} setOpen={setSwapLogsEditorOpen} logs={swapLogs} redact={setSwapLogsRedacted} />
<Box style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
gap: "1rem",
}}>
<Paper variant="outlined" style={{ padding: "0.5rem", width: "100%" }} >
<FormControlLabel
control={
<Checkbox
return (
<Dialog open={open} onClose={handleClose}>
<DialogTitle style={{ paddingBottom: '0.5rem' }}>
Submit Feedback
</DialogTitle>
<DialogContent>
<Box
style={{
display: 'flex',
flexDirection: 'column',
gap: '1.5rem',
}}
>
{error && (
<Box
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'start',
gap: '0.5rem',
width: '100%',
backgroundColor: 'hsla(0, 45%, 17%, 1)',
padding: '0.5rem',
borderRadius: '0.5rem',
border: '1px solid hsla(0, 61%, 32%, 1)',
}}
>
<ErrorOutline style={{ color: 'hsla(0, 77%, 75%, 1)' }} />
<Typography style={{ color: 'hsla(0, 83%, 91%, 1)' }} noWrap>
{error}
</Typography>
</Box>
)}
<Box>
<Typography style={{ marginBottom: '0.5rem' }}>
Have a question or need assistance? Message us below
or{' '}
<ExternalLink href="https://docs.unstoppableswap.net/send_feedback#email-support">
email us
</ExternalLink>
!
</Typography>
<TextField
variant="outlined"
value={input.bodyText}
onChange={(e) =>
setInputState((prev) => ({
...prev,
bodyText: e.target.value,
}))
}
label={
bodyTooLong
? `Text is too long (${input.bodyText.length}/${MAX_FEEDBACK_LENGTH})`
: 'Message'
}
multiline
minRows={4}
maxRows={4}
fullWidth
error={bodyTooLong}
/>
</Box>
<Box>
<Typography style={{ marginBottom: '0.5rem' }}>
Attach logs with your feedback for better support.
</Typography>
<Box
style={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
gap: '1rem',
paddingBottom: '0.5rem',
}}
>
<SwapSelectDropDown
selectedSwap={input.selectedSwap}
setSelectedSwap={(swapId) =>
setInputState((prev) => ({
...prev,
selectedSwap: swapId,
}))
}
/>
<Tooltip title="View the logs">
<Box
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IconButton
onClick={() =>
setSwapLogsEditorOpen(true)
}
disabled={input.selectedSwap === null}
>
<Visibility />
</IconButton>
</Box>
</Tooltip>
</Box>
<LogViewer
open={swapLogsEditorOpen}
setOpen={setSwapLogsEditorOpen}
logs={logs.swapLogs}
setIsRedacted={(redact) =>
setInputState((prev) => ({
...prev,
isSwapLogsRedacted: redact,
}))
}
isRedacted={input.isSwapLogsRedacted}
/>
<Box
style={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
gap: '1rem',
}}
>
<Paper
variant="outlined"
style={{ padding: '0.5rem', width: '100%' }}
>
<FormControlLabel
control={
<Checkbox
color="primary"
checked={input.attachDaemonLogs}
onChange={(e) =>
setInputState((prev) => ({
...prev,
attachDaemonLogs:
e.target.checked,
}))
}
/>
}
label="Attach logs from the current session"
/>
</Paper>
<Tooltip title="View the logs">
<Box
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<IconButton
onClick={() =>
setDaemonLogsEditorOpen(true)
}
disabled={
input.attachDaemonLogs === false
}
>
<Visibility />
</IconButton>
</Box>
</Tooltip>
</Box>
</Box>
<Typography
variant="caption"
color="textSecondary"
style={{ marginBottom: '0.5rem' }}
>
Your feedback will be answered in the app and can be
found in the Feedback tab
</Typography>
<LogViewer
open={daemonLogsEditorOpen}
setOpen={setDaemonLogsEditorOpen}
logs={logs.daemonLogs}
setIsRedacted={(redact) =>
setInputState((prev) => ({
...prev,
isDaemonLogsRedacted: redact,
}))
}
isRedacted={input.isDaemonLogsRedacted}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<PromiseInvokeButton
requiresContext={false}
color="primary"
checked={attachDaemonLogs}
onChange={(e) => setAttachDaemonLogs(e.target.checked)}
/>
}
label="Attach logs from the current session"
/>
</Paper>
<Tooltip title="View the logs">
<Box style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<IconButton onClick={() => setDaemonLogsEditorOpen(true)} disabled={attachDaemonLogs === false}>
<Visibility />
</IconButton>
</Box>
</Tooltip>
</Box>
<LogViewer open={daemonLogsEditorOpen} setOpen={setDaemonLogsEditorOpen} logs={daemonLogs} redact={setDaemonLogsRedacted} />
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => { clearState(); onClose() }}>Cancel</Button>
<LoadingButton
color="primary"
variant="contained"
onClick={sendFeedback}
loading={pending}
>
Submit
</LoadingButton>
</DialogActions>
</Dialog>
);
}
function LogViewer(
{ open,
setOpen,
logs,
redact
}: {
open: boolean,
setOpen: (_: boolean) => void,
logs: (string | CliLog)[] | null,
redact: (_: boolean) => void
}) {
return (
<Dialog open={open} onClose={() => setOpen(false)} fullWidth>
<DialogContent>
<Box>
<DialogContentText>
<Box style={{ display: "flex", flexDirection: "row", alignItems: "center" }}>
<Typography>
These are the logs that would be attached to your feedback message and provided to us developers.
They help us narrow down the problem you encountered.
</Typography>
</Box>
</DialogContentText>
<CliLogsBox label="Logs" logs={logs} topRightButton={<Paper style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', paddingLeft: "0.5rem" }} variant="outlined">
Redact
<Switch color="primary" onChange={(_, checked: boolean) => redact(checked)} />
</Paper>} />
</Box>
</DialogContent>
<DialogActions>
<Button variant="contained" color="primary" onClick={() => setOpen(false)}>
Close
</Button>
</DialogActions>
</Dialog >
)
variant="contained"
onInvoke={submitFeedback}
onSuccess={handleClose}
>
Submit
</PromiseInvokeButton>
</DialogActions>
</Dialog>
)
}

View file

@ -0,0 +1,66 @@
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
Paper,
Switch,
Typography,
} from "@material-ui/core";
import { CliLog } from "models/cliModel";
import CliLogsBox from "renderer/components/other/RenderedCliLog";
interface LogViewerProps {
open: boolean;
setOpen: (_: boolean) => void;
logs: (string | CliLog)[] | null;
setIsRedacted: (_: boolean) => void;
isRedacted: boolean;
}
export default function LogViewer({
open,
setOpen,
logs,
setIsRedacted,
isRedacted
}: LogViewerProps) {
return (
<Dialog open={open} onClose={() => setOpen(false)} fullWidth>
<DialogContent>
<Box>
<DialogContentText>
<Box style={{ display: "flex", flexDirection: "row", alignItems: "center" }}>
<Typography>
These are the logs that would be attached to your feedback message and provided to us developers.
They help us narrow down the problem you encountered.
</Typography>
</Box>
</DialogContentText>
<CliLogsBox
label="Logs"
logs={logs}
topRightButton={
<Paper style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', paddingLeft: "0.5rem" }} variant="outlined">
Redact
<Switch
color="primary"
checked={isRedacted}
onChange={(_, checked: boolean) => setIsRedacted(checked)}
/>
</Paper>
}
/>
</Box>
</DialogContent>
<DialogActions>
<Button variant="contained" color="primary" onClick={() => setOpen(false)}>
Close
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,45 @@
import { MenuItem, Select, Box } from "@material-ui/core";
import TruncatedText from "renderer/components/other/TruncatedText";
import { PiconeroAmount } from "../../other/Units";
import { parseDateString } from "utils/parseUtils";
import { useEffect } from "react";
import { useSwapInfosSortedByDate } from "store/hooks";
interface SwapSelectDropDownProps {
selectedSwap: string | null;
setSelectedSwap: (swapId: string | null) => void;
}
export default function SwapSelectDropDown({
selectedSwap,
setSelectedSwap,
}: SwapSelectDropDownProps) {
const swaps = useSwapInfosSortedByDate();
useEffect(() => {
if (swaps.length > 0) {
setSelectedSwap(swaps[0].swap_id);
}
}, []);
return (
<Select
value={selectedSwap ?? ""}
variant="outlined"
onChange={(e) => setSelectedSwap(e.target.value as string || null)}
style={{ width: "100%" }}
displayEmpty
>
{swaps.map((swap) => (
<MenuItem value={swap.swap_id} key={swap.swap_id}>
<Box component="span" style={{ whiteSpace: 'pre' }}>
Swap <TruncatedText>{swap.swap_id}</TruncatedText> from{' '}
{new Date(parseDateString(swap.start_date)).toDateString()} (
<PiconeroAmount amount={swap.xmr_amount} />)
</Box>
</MenuItem>
))}
<MenuItem value="">Do not attach a swap</MenuItem>
</Select>
);
}

View file

@ -0,0 +1,163 @@
import { useState, useEffect } from 'react'
import { store } from 'renderer/store/storeRenderer'
import { useActiveSwapInfo } from 'store/hooks'
import { logsToRawString } from 'utils/parseUtils'
import { getLogsOfSwap, redactLogs } from 'renderer/rpc'
import { CliLog, parseCliLogString } from 'models/cliModel'
import logger from 'utils/logger'
import { submitFeedbackViaHttp } from 'renderer/api'
import { addFeedbackId } from 'store/features/conversationsSlice'
import { AttachmentInput } from 'models/apiModel'
import { useSnackbar } from 'notistack'
export const MAX_FEEDBACK_LENGTH = 4000
interface FeedbackInputState {
bodyText: string
selectedSwap: string | null
attachDaemonLogs: boolean
isSwapLogsRedacted: boolean
isDaemonLogsRedacted: boolean
}
interface FeedbackLogsState {
swapLogs: (string | CliLog)[] | null
daemonLogs: (string | CliLog)[] | null
}
const initialInputState: FeedbackInputState = {
bodyText: '',
selectedSwap: null,
attachDaemonLogs: true,
isSwapLogsRedacted: false,
isDaemonLogsRedacted: false,
}
const initialLogsState: FeedbackLogsState = {
swapLogs: null,
daemonLogs: null,
}
export function useFeedback() {
const currentSwapId = useActiveSwapInfo()
const { enqueueSnackbar } = useSnackbar()
const [inputState, setInputState] = useState<FeedbackInputState>({
...initialInputState,
selectedSwap: currentSwapId?.swap_id || null,
})
const [logsState, setLogsState] =
useState<FeedbackLogsState>(initialLogsState)
const [isPending, setIsPending] = useState(false)
const [error, setError] = useState<string | null>(null)
const bodyTooLong = inputState.bodyText.length > MAX_FEEDBACK_LENGTH
useEffect(() => {
if (inputState.selectedSwap === null) {
setLogsState((prev) => ({ ...prev, swapLogs: null }))
return
}
getLogsOfSwap(inputState.selectedSwap, inputState.isSwapLogsRedacted)
.then((response) => {
setLogsState((prev) => ({
...prev,
swapLogs: response.logs.map(parseCliLogString),
}))
setError(null)
})
.catch((e) => {
logger.error(`Failed to fetch swap logs: ${e}`)
setLogsState((prev) => ({ ...prev, swapLogs: null }))
setError(`Failed to fetch swap logs: ${e}`)
})
}, [inputState.selectedSwap, inputState.isSwapLogsRedacted])
useEffect(() => {
if (!inputState.attachDaemonLogs) {
setLogsState((prev) => ({ ...prev, daemonLogs: null }))
return
}
try {
if (inputState.isDaemonLogsRedacted) {
redactLogs(store.getState().rpc?.logs)
.then((redactedLogs) => {
setLogsState((prev) => ({
...prev,
daemonLogs: redactedLogs,
}))
setError(null)
})
.catch((e) => {
logger.error(`Failed to redact daemon logs: ${e}`)
setLogsState((prev) => ({ ...prev, daemonLogs: null }))
setError(`Failed to redact daemon logs: ${e}`)
})
} else {
setLogsState((prev) => ({
...prev,
daemonLogs: store.getState().rpc?.logs,
}))
setError(null)
}
} catch (e) {
logger.error(`Failed to fetch daemon logs: ${e}`)
setLogsState((prev) => ({ ...prev, daemonLogs: null }))
setError(`Failed to fetch daemon logs: ${e}`)
}
}, [inputState.attachDaemonLogs, inputState.isDaemonLogsRedacted])
const clearState = () => {
setInputState(initialInputState)
setLogsState(initialLogsState)
setError(null)
}
const submitFeedback = async () => {
if (inputState.bodyText.length === 0) {
setError('Please enter a message')
throw new Error('User did not enter a message')
}
const attachments: AttachmentInput[] = []
// Add swap logs as an attachment
if (logsState.swapLogs) {
attachments.push({
key: `swap_logs_${inputState.selectedSwap}.txt`,
content: logsToRawString(logsState.swapLogs),
})
}
// Handle daemon logs
if (logsState.daemonLogs) {
attachments.push({
key: 'daemon_logs.txt',
content: logsToRawString(logsState.daemonLogs),
})
}
// Call the updated API function
const feedbackId = await submitFeedbackViaHttp(
inputState.bodyText,
attachments
)
enqueueSnackbar('Feedback submitted successfully', {
variant: 'success',
})
// Dispatch only the ID
store.dispatch(addFeedbackId(feedbackId))
}
return {
input: inputState,
setInputState,
logs: logsState,
error,
clearState,
submitFeedback,
}
}

View file

@ -0,0 +1,35 @@
import { getLogsOfSwap, saveLogFiles } from 'renderer/rpc'
import PromiseInvokeButton from 'renderer/components/PromiseInvokeButton'
import { store } from 'renderer/store/storeRenderer'
import { ButtonProps } from '@material-ui/core'
import { logsToRawString } from 'utils/parseUtils'
interface ExportLogsButtonProps extends ButtonProps {
swap_id: string
}
export default function ExportLogsButton({ swap_id, ...buttonProps }: ExportLogsButtonProps) {
async function handleExportLogs() {
const swapLogs = await getLogsOfSwap(swap_id, false)
const daemonLogs = store.getState().rpc?.logs
const logContent = {
swap_logs: logsToRawString(swapLogs.logs),
daemon_logs: logsToRawString(daemonLogs),
}
await saveLogFiles(
`swap_${swap_id}_logs.zip`,
logContent
)
}
return (
<PromiseInvokeButton
onInvoke={handleExportLogs}
{...buttonProps}
>
Export Logs
</PromiseInvokeButton>
)
}

View file

@ -21,6 +21,7 @@ import {
import { isTestnet } from "store/config";
import { getBitcoinTxExplorerUrl } from "utils/conversionUtils";
import SwapLogFileOpenButton from "./SwapLogFileOpenButton";
import ExportLogsButton from "./ExportLogsButton";
const useStyles = makeStyles((theme) => ({
outer: {
@ -128,6 +129,8 @@ export default function HistoryRowExpanded({
variant="outlined"
size="small"
/>
<ExportLogsButton swap_id={swap.swap_id} variant="outlined"
size="small"/>
</Box>
</Box>
);

View file

@ -313,3 +313,7 @@ export async function getDataDir(): Promise<string> {
export async function resolveApproval(requestId: string, accept: boolean): Promise<void> {
await invoke<ResolveApprovalArgs, ResolveApprovalResponse>("resolve_approval_request", { request_id: requestId, accept });
}
export async function saveLogFiles(zipFileName: string, content: Record<string, string>): Promise<void> {
await invokeUnsafe<void>("save_txt_files", { zipFileName, content });
}

View file

@ -23,6 +23,7 @@ swap = { path = "../swap", features = [ "tauri" ] }
sysinfo = "=0.32.1"
tauri = { version = "^2.0.0", features = [ "config-json5" ] }
tauri-plugin-clipboard-manager = "^2.0.0"
tauri-plugin-dialog = "2.2.2"
tauri-plugin-opener = "^2.0.0"
tauri-plugin-process = "^2.0.0"
tauri-plugin-shell = "^2.0.0"
@ -30,6 +31,7 @@ tauri-plugin-store = "^2.0.0"
tauri-plugin-updater = "^2.0.0"
tracing = "0.1"
uuid = "1.16.0"
zip = "4.0.0"
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
tauri-plugin-cli = "^2.0.0"

View file

@ -1,4 +1,6 @@
use anyhow::Context as AnyhowContext;
use std::collections::HashMap;
use std::io::Write;
use std::result::Result;
use std::sync::Arc;
use swap::cli::{
@ -18,6 +20,8 @@ use swap::cli::{
command::{Bitcoin, Monero},
};
use tauri::{async_runtime::RwLock, Manager, RunEvent};
use tauri_plugin_dialog::DialogExt;
use zip::{write::SimpleFileOptions, ZipWriter};
/// Trait to convert Result<T, E> to Result<T, String>
/// Tauri commands require the error type to be a string
@ -165,6 +169,7 @@ pub fn run() {
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
get_balance,
get_monero_addresses,
@ -186,7 +191,8 @@ pub fn run() {
get_wallet_descriptor,
get_data_dir,
resolve_approval_request,
redact
redact,
save_txt_files
])
.setup(setup)
.build(tauri::generate_context!())
@ -275,6 +281,51 @@ async fn get_data_dir(
.to_string())
}
#[tauri::command]
async fn save_txt_files(
app: tauri::AppHandle,
zip_file_name: String,
content: HashMap<String, String>,
) -> Result<(), String> {
// Step 1: Get the owned PathBuf from the dialog
let path_buf_from_dialog: tauri_plugin_dialog::FilePath = app
.dialog()
.file()
.set_file_name(format!("{}.zip", &zip_file_name).as_str())
.add_filter(&zip_file_name, &["zip"])
.blocking_save_file() // This returns Option<PathBuf>
.ok_or_else(|| "Dialog cancelled or file path not selected".to_string())?; // Converts to Result<PathBuf, String> and unwraps to PathBuf
// Step 2: Now get a &Path reference from the owned PathBuf.
// The user's code structure implied an .as_path().ok_or_else(...) chain which was incorrect for &Path.
// We'll directly use the PathBuf, or if &Path is strictly needed:
let selected_file_path: &std::path::Path = path_buf_from_dialog
.as_path()
.ok_or_else(|| "Could not convert file path".to_string())?;
let zip_file = std::fs::File::create(selected_file_path)
.map_err(|e| format!("Failed to create file: {}", e))?;
let mut zip = ZipWriter::new(zip_file);
for (filename, file_content_str) in content.iter() {
zip.start_file(
format!("{}.txt", filename).as_str(),
SimpleFileOptions::default(),
) // Pass &str to start_file
.map_err(|e| format!("Failed to start file {}: {}", &filename, e))?; // Use &filename
zip.write_all(file_content_str.as_bytes())
.map_err(|e| format!("Failed to write to file {}: {}", &filename, e))?;
// Use &filename
}
zip.finish()
.map_err(|e| format!("Failed to finish zip: {}", e))?;
Ok(())
}
/// Tauri command to initialize the Context
#[tauri::command]
async fn initialize_context(