feat(gui): Display developer responses to feedback (#302)

This commit is contained in:
Mohan 2025-04-28 13:12:43 +02:00 committed by GitHub
parent f1e5cdfbfe
commit 53a994e6dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1216 additions and 45 deletions

View file

@ -26,3 +26,49 @@ export interface Alert {
body: string;
severity: "info" | "warning" | "error";
}
// Define the correct 9-element tuple type for PrimitiveDateTime
export type PrimitiveDateTimeString = [
number, // Year
number, // Day of Year
number, // Hour
number, // Minute
number, // Second
number, // Nanosecond
number, // Offset Hour
number, // Offset Minute
number // Offset Second
];
export interface Feedback {
id: string;
created_at: PrimitiveDateTimeString;
}
export interface Attachment {
id: number;
message_id: number;
key: string;
content: string;
created_at: PrimitiveDateTimeString;
}
export interface Message {
id: number;
feedback_id: string;
is_from_staff: boolean;
content: string;
created_at: PrimitiveDateTimeString;
attachments?: Attachment[];
}
export interface MessageWithAttachments {
message: Message;
attachments: Attachment[];
}
// Define type for Attachment data in request body
export interface AttachmentInput {
key: string;
content: string;
}

View file

@ -5,13 +5,14 @@
// - and to submit feedback
// - fetch currency rates from CoinGecko
import { Alert, ExtendedMakerStatus } from "models/apiModel";
import { Alert, Attachment, AttachmentInput, ExtendedMakerStatus, Feedback, Message, MessageWithAttachments, PrimitiveDateTimeString } from "models/apiModel";
import { store } from "./store/storeRenderer";
import { setBtcPrice, setXmrBtcRate, setXmrPrice } from "store/features/ratesSlice";
import { FiatCurrency } from "store/features/settingsSlice";
import { setAlerts } from "store/features/alertsSlice";
import { registryConnectionFailed, setRegistryMakers } from "store/features/makersSlice";
import logger from "utils/logger";
import { setConversation } from "store/features/conversationsSlice";
const PUBLIC_REGISTRY_API_BASE_URL = "https://api.unstoppableswap.net";
@ -28,11 +29,14 @@ async function fetchAlertsViaHttp(): Promise<Alert[]> {
}
export async function submitFeedbackViaHttp(
body: string,
attachedData: string,
content: string,
attachments?: AttachmentInput[]
): Promise<string> {
type Response = {
feedbackId: string;
type Response = string;
const requestPayload = {
body: content,
attachments: attachments || [], // Ensure attachments is always an array
};
const response = await fetch(`${PUBLIC_REGISTRY_API_BASE_URL}/api/submit-feedback`, {
@ -40,16 +44,56 @@ export async function submitFeedbackViaHttp(
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ body, attachedData }),
body: JSON.stringify(requestPayload), // Send the corrected structure
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
const errorBody = await response.text();
throw new Error(`Failed to submit feedback. Status: ${response.status}. Body: ${errorBody}`);
}
const responseBody = (await response.json()) as Response;
return responseBody;
}
return responseBody.feedbackId;
export async function fetchFeedbackMessagesViaHttp(feedbackId: string): Promise<Message[]> {
const response = await fetch(`${PUBLIC_REGISTRY_API_BASE_URL}/api/feedback/${feedbackId}/messages`);
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Failed to fetch messages for feedback ${feedbackId}. Status: ${response.status}. Body: ${errorBody}`);
}
// Assuming the response is directly the Message[] array including attachments
return (await response.json()) as Message[];
}
export async function appendFeedbackMessageViaHttp(
feedbackId: string,
content: string,
attachments?: AttachmentInput[]
): Promise<number> {
type Response = number;
const body = {
feedback_id: feedbackId,
content,
attachments: attachments || [], // Ensure attachments is always an array
};
const response = await fetch(`${PUBLIC_REGISTRY_API_BASE_URL}/api/append-feedback-message`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body), // Send new structure
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Failed to append message for feedback ${feedbackId}. Status: ${response.status}. Body: ${errorBody}`);
}
const responseBody = (await response.json()) as Response;
return responseBody;
}
async function fetchCurrencyPrice(currency: string, fiatCurrency: FiatCurrency): Promise<number> {
@ -74,7 +118,6 @@ async function fetchXmrBtcRate(): Promise<number> {
return lastTradePrice;
}
function fetchBtcPrice(fiatCurrency: FiatCurrency): Promise<number> {
return fetchCurrencyPrice("bitcoin", fiatCurrency);
}
@ -108,7 +151,6 @@ export async function updateRates(): Promise<void> {
}
}
/**
* Update public registry
*/
@ -127,4 +169,25 @@ export async function updatePublicRegistry(): Promise<void> {
} catch (error) {
logger.error(error, "Error fetching alerts");
}
}
/**
* Fetch all conversations
* Goes through all feedback ids and fetches all the messages for each feedback id
*/
export async function fetchAllConversations(): Promise<void> {
const feedbackIds = store.getState().conversations.knownFeedbackIds;
console.log("Fetching all conversations", feedbackIds);
for (const feedbackId of feedbackIds) {
try {
console.log("Fetching messages for feedback id", feedbackId);
const messages = await fetchFeedbackMessagesViaHttp(feedbackId);
console.log("Fetched messages for feedback id", feedbackId, messages);
store.dispatch(setConversation({ feedbackId, messages }));
} catch (error) {
logger.error(error, "Error fetching messages for feedback id", feedbackId);
}
}
}

View file

@ -3,7 +3,7 @@ import { TauriSwapProgressEventWrapper, TauriContextStatusEvent, TauriLogEvent,
import { contextStatusEventReceived, receivedCliLog, rpcSetBalance, timelockChangeEventReceived, rpcSetBackgroundRefundState, approvalEventReceived } from "store/features/rpcSlice";
import { swapProgressEventReceived } from "store/features/swapSlice";
import logger from "utils/logger";
import { updatePublicRegistry, updateRates } from "./api";
import { fetchAllConversations, updatePublicRegistry, updateRates } from "./api";
import { checkContextAvailability, getSwapInfo, initializeContext, updateAllNodeStatuses } from "./rpc";
import { store } from "./store/storeRenderer";
@ -16,6 +16,9 @@ const STATUS_UPDATE_INTERVAL = 2 * 60 * 1_000;
// Update the exchange rate every 5 minutes
const UPDATE_RATE_INTERVAL = 5 * 60 * 1_000;
// Fetch all conversations every 10 minutes
const FETCH_CONVERSATIONS_INTERVAL = 10 * 60 * 1_000;
function setIntervalImmediate(callback: () => void, interval: number): void {
callback();
setInterval(callback, interval);
@ -26,6 +29,7 @@ export async function setupBackgroundTasks(): Promise<void> {
setIntervalImmediate(updatePublicRegistry, PROVIDER_UPDATE_INTERVAL);
setIntervalImmediate(updateAllNodeStatuses, STATUS_UPDATE_INTERVAL);
setIntervalImmediate(updateRates, UPDATE_RATE_INTERVAL);
setIntervalImmediate(fetchAllConversations, FETCH_CONVERSATIONS_INTERVAL);
// // Setup Tauri event listeners

View file

@ -3,7 +3,7 @@ import { ThemeProvider } from "@material-ui/core/styles";
import "@tauri-apps/plugin-shell";
import { Route, MemoryRouter as Router, Routes } from "react-router-dom";
import Navigation, { drawerWidth } from "./navigation/Navigation";
import HelpPage from "./pages/help/HelpPage";
import SettingsPage from "./pages/help/SettingsPage";
import HistoryPage from "./pages/history/HistoryPage";
import SwapPage from "./pages/swap/SwapPage";
import WalletPage from "./pages/wallet/WalletPage";
@ -14,6 +14,7 @@ import { themes } from "./theme";
import { useEffect } from "react";
import { setupBackgroundTasks } from "renderer/background";
import "@fontsource/roboto";
import FeedbackPage from "./pages/feedback/FeedbackPage";
const useStyles = makeStyles((theme) => ({
innerContent: {
@ -54,7 +55,8 @@ function InnerContent() {
<Route path="/swap" element={<SwapPage />} />
<Route path="/history" element={<HistoryPage />} />
<Route path="/wallet" element={<WalletPage />} />
<Route path="/help" element={<HelpPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/feedback" element={<FeedbackPage />} />
<Route path="/" element={<SwapPage />} />
</Routes>
</Box>

View file

@ -23,7 +23,7 @@ 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 } from "../../../api";
import { submitFeedbackViaHttp, AttachmentInput } from "../../../api";
import LoadingButton from "../../other/LoadingButton";
import { PiconeroAmount } from "../../other/Units";
import { getLogsOfSwap, redactLogs } from "renderer/rpc";
@ -31,26 +31,58 @@ 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) {
let attachedBody = "";
const attachments: AttachmentInput[] = [];
if (swapId !== null) {
const swapInfo = store.getState().rpc.state.swapInfos[swapId];
if (swapInfo === undefined) {
throw new Error(`Swap with id ${swapId} not found`);
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." });
}
attachedBody = `${JSON.stringify(swapInfo, null, 4)}\n\nLogs: ${swapLogs ?? ""}`;
// Add swap logs as an attachment
if (swapLogs) {
attachments.push({
key: `swap_logs_${swapId}.txt`,
content: swapLogs,
});
}
}
// Handle daemon logs
if (daemonLogs !== null) {
attachedBody += `\n\nDaemon Logs: ${daemonLogs ?? ""}`;
attachments.push({
key: "daemon_logs.txt",
content: daemonLogs,
});
}
console.log(`Sending feedback with attachement: \`\n${attachedBody}\``)
await submitFeedbackViaHttp(body, attachedBody);
// Call the updated API function
const feedbackId = await submitFeedbackViaHttp(body, attachments);
// Dispatch only the ID
store.dispatch(addFeedbackId(feedbackId));
}
/*
@ -152,7 +184,12 @@ export default function FeedbackDialog({
try {
setPending(true);
await submitFeedback(bodyText, selectedSwap, logsToRawString(swapLogs), logsToRawString(daemonLogs));
await submitFeedback(
bodyText,
selectedSwap,
logsToRawString(swapLogs ?? []),
logsToRawString(daemonLogs ?? [])
);
enqueueSnackbar("Feedback submitted successfully!", {
variant: "success",
});

View file

@ -1,12 +1,16 @@
import { Box, List } from "@material-ui/core";
import { Box, List, Badge } from "@material-ui/core";
import AccountBalanceWalletIcon from "@material-ui/icons/AccountBalanceWallet";
import HelpOutlineIcon from "@material-ui/icons/HelpOutline";
import HistoryOutlinedIcon from "@material-ui/icons/HistoryOutlined";
import SwapHorizOutlinedIcon from "@material-ui/icons/SwapHorizOutlined";
import FeedbackOutlinedIcon from '@material-ui/icons/FeedbackOutlined';
import RouteListItemIconButton from "./RouteListItemIconButton";
import UnfinishedSwapsBadge from "./UnfinishedSwapsCountBadge";
import { useTotalUnreadMessagesCount } from "store/hooks";
import SettingsIcon from '@material-ui/icons/Settings';
export default function NavigationHeader() {
const totalUnreadCount = useTotalUnreadMessagesCount();
return (
<Box>
<List>
@ -21,8 +25,18 @@ export default function NavigationHeader() {
<RouteListItemIconButton name="Wallet" route="/wallet">
<AccountBalanceWalletIcon />
</RouteListItemIconButton>
<RouteListItemIconButton name="Help & Settings" route="/help">
<HelpOutlineIcon />
<RouteListItemIconButton name="Feedback" route="/feedback">
<Badge
badgeContent={totalUnreadCount}
color="primary"
overlap="rectangular"
invisible={totalUnreadCount === 0}
>
<FeedbackOutlinedIcon />
</Badge>
</RouteListItemIconButton>
<RouteListItemIconButton name="Settings" route="/settings">
<SettingsIcon />
</RouteListItemIconButton>
</List>
</Box>

View file

@ -0,0 +1,25 @@
import { Box, makeStyles } from "@material-ui/core";
import FeedbackInfoBox from "../help/FeedbackInfoBox";
import ConversationsBox from "../help/ConversationsBox";
import ContactInfoBox from "../help/ContactInfoBox";
const useStyles = makeStyles((theme) => ({
outer: {
display: "flex",
gap: theme.spacing(2),
flexDirection: "column",
paddingBottom: theme.spacing(2),
},
}));
export default function FeedbackPage() {
const classes = useStyles();
return (
<Box className={classes.outer}>
<FeedbackInfoBox />
<ConversationsBox />
<ContactInfoBox />
</Box>
);
}

View file

@ -0,0 +1,346 @@
import { useState, useEffect, useMemo } from "react";
import {
Box,
Typography,
makeStyles,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
IconButton,
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
Paper,
Badge,
TextField,
CircularProgress,
InputAdornment,
Tooltip,
List,
ListItem,
ListItemIcon,
Link,
} from "@material-ui/core";
import ChatIcon from '@material-ui/icons/Chat';
import SendIcon from '@material-ui/icons/Send';
import InfoBox from "renderer/components/modal/swap/InfoBox";
import TruncatedText from "renderer/components/other/TruncatedText";
import clsx from 'clsx';
import { useAppSelector, useAppDispatch, useUnreadMessagesCount } from "store/hooks";
import { markMessagesAsSeen } from "store/features/conversationsSlice";
import { appendFeedbackMessageViaHttp, fetchAllConversations } from "renderer/api";
import { useSnackbar } from "notistack";
import logger from "utils/logger";
import AttachmentIcon from '@material-ui/icons/Attachment';
import { Message, PrimitiveDateTimeString } from "models/apiModel";
import { formatDateTime } from "utils/conversionUtils";
// Styles
const useStyles = makeStyles((theme) => ({
content: { display: "flex", flexDirection: "column", alignItems: "flex-start", gap: theme.spacing(2) },
tableContainer: { maxHeight: 300 },
dialogContent: { display: 'flex', flexDirection: 'column' },
messagesContainer: { flexGrow: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: theme.spacing(1), maxHeight: 400, padding: theme.spacing(1) },
messageRow: { display: 'flex', marginTop: theme.spacing(1) },
staffRow: { justifyContent: 'flex-start' },
userRow: { justifyContent: 'flex-end' },
messageBubble: { padding: theme.spacing(1.5), borderRadius: theme.shape.borderRadius * 2, maxWidth: '75%', wordBreak: 'break-word', boxShadow: theme.shadows[1] },
staffBubble: { border: `1px solid ${theme.palette.divider}`, color: theme.palette.text.primary, borderRadius: theme.spacing(2) },
userBubble: { backgroundColor: theme.palette.primary.main, color: theme.palette.primary.contrastText, borderRadius: theme.spacing(2) },
timestamp: { marginTop: theme.spacing(0.5), fontSize: '0.75rem', opacity: 0.7, textAlign: 'right' },
inputArea: { flexShrink: 0, marginTop: theme.spacing(2) },
attachmentList: {
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
paddingLeft: theme.spacing(2),
},
attachmentItem: {
paddingTop: theme.spacing(0.5),
paddingBottom: theme.spacing(0.5),
}
}));
// Hook: sorted feedback IDs by latest activity, then unread
function useSortedFeedbackIds() {
const ids = useAppSelector((s) => s.conversations.knownFeedbackIds || []);
const conv = useAppSelector((s) => s.conversations.conversations);
const seen = useAppSelector((s) => new Set(s.conversations.seenMessages));
return useMemo(() => {
const arr = ids.map((id) => {
const msgs = conv[id] || [];
const unread = msgs.filter((m) => m.is_from_staff && !seen.has(m.id.toString())).length;
const latest = msgs.reduce((d, m) => {
try {
const formattedDate = formatDateTime(m.created_at);
if (formattedDate.startsWith("Invalid")) return d;
const t = new Date(formattedDate).getTime();
return isNaN(t) ? d : Math.max(d, t);
} catch(e) { return d; }
}, 0);
return { id, unread, latest };
});
arr.sort((a, b) => b.latest - a.latest || (b.unread > 0 ? 1 : 0) - (a.unread > 0 ? 1 : 0));
return arr.map((x) => x.id);
}, [ids, conv, seen]);
}
// Main component
export default function ConversationsBox() {
const classes = useStyles();
const sortedIds = useSortedFeedbackIds();
const [openId, setOpenId] = useState<string | null>(null);
useEffect(() => {
// Fetch conversations via API function (handles its own dispatch)
fetchAllConversations();
}, []);
return (
<InfoBox
title="Developer Responses"
icon={null}
loading={false}
mainContent={
<Box className={classes.content}>
<Typography variant="subtitle2">
View your past feedback submissions and any replies from the development team.
</Typography>
{sortedIds.length === 0 ? (
<Typography variant="body2">No feedback submitted yet.</Typography>
) : (
<TableContainer component={Paper} className={classes.tableContainer}>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell style={{ width: '25%' }}>Last Message</TableCell>
<TableCell style={{ width: '60%' }}>Preview</TableCell>
<TableCell align="right" style={{ width: '15%' }} />
</TableRow>
</TableHead>
<TableBody>
{sortedIds.map((id) => (
<ConversationRow key={id} feedbackId={id} onOpen={setOpenId} />
))}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
}
additionalContent={
openId && (
<ConversationModal
open={!!openId}
onClose={() => setOpenId(null)}
feedbackId={openId}
/>
)
}
/>
);
}
// Single row
function ConversationRow({ feedbackId, onOpen }: { feedbackId: string, onOpen: (id: string) => void }) {
const msgs = useAppSelector((s) => s.conversations.conversations[feedbackId] || []);
const unread = useUnreadMessagesCount(feedbackId);
const sorted = useMemo(
() =>
[...msgs].sort((a, b) => {
try {
const formattedDateA = formatDateTime(a.created_at);
const formattedDateB = formatDateTime(b.created_at);
if (formattedDateA.startsWith("Invalid")) return 1;
if (formattedDateB.startsWith("Invalid")) return -1;
const dateA = new Date(formattedDateA).getTime();
const dateB = new Date(formattedDateB).getTime();
if (isNaN(dateA)) return 1;
if (isNaN(dateB)) return -1;
return dateB - dateA;
} catch (e) { return 0; }
}),
[msgs]
);
const lastMsg = sorted[0];
const time = lastMsg ? formatDateTime(lastMsg.created_at) : '-';
const content = lastMsg ? lastMsg.content : 'No messages yet';
const preview = (() => {
return content;
})();
const hasStaff = useMemo(() => msgs.some((m) => m.is_from_staff), [msgs]);
return (
<TableRow>
<TableCell style={{ width: '25%' }}>{time}</TableCell>
<TableCell style={{ width: '60%' }}>
"<TruncatedText limit={30}>{preview}</TruncatedText>"
</TableCell>
<TableCell align="right" style={{ width: '15%' }}>
<Badge badgeContent={unread} color="primary" overlap="rectangular">
<Tooltip title={hasStaff ? 'Open Conversation' : 'No developer has responded'} arrow>
<span>
<IconButton size="small" onClick={() => onOpen(feedbackId)} disabled={!hasStaff}>
<ChatIcon />
</IconButton>
</span>
</Tooltip>
</Badge>
</TableCell>
</TableRow>
);
}
// Modal
function ConversationModal({ open, onClose, feedbackId }: { open: boolean, onClose: () => void, feedbackId: string }) {
const classes = useStyles();
const dispatch = useAppDispatch();
const { enqueueSnackbar } = useSnackbar();
const msgs = useAppSelector((s): Message[] => s.conversations.conversations[feedbackId] || []);
const seen = useAppSelector((s) => new Set(s.conversations.seenMessages));
const [text, setText] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
const unseen = msgs.filter((m) => !seen.has(m.id.toString()));
if (unseen.length) dispatch(markMessagesAsSeen(unseen));
}
}, [open, msgs, seen, dispatch]);
const sorted = useMemo(
() =>
[...msgs].sort((a, b) => {
try {
const formattedDateA = formatDateTime(a.created_at);
const formattedDateB = formatDateTime(b.created_at);
if (formattedDateA.startsWith("Invalid")) return 1;
if (formattedDateB.startsWith("Invalid")) return -1;
const dateA = new Date(formattedDateA).getTime();
const dateB = new Date(formattedDateB).getTime();
if (isNaN(dateA)) return 1;
if (isNaN(dateB)) return -1;
return dateB - dateA;
} catch(e) { return 0; }
}),
[msgs]
);
const sendMessage = async () => {
if (!text.trim()) return;
setLoading(true);
try {
await appendFeedbackMessageViaHttp(feedbackId, text);
setText('');
enqueueSnackbar('Message sent successfully!', { variant: 'success' });
fetchAllConversations();
} catch (e) {
logger.error(e, 'Send failed');
enqueueSnackbar('Failed to send message. Please try again.', { variant: 'error' });
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth scroll="paper">
<DialogTitle>Conversation <TruncatedText limit={8} truncateMiddle>{feedbackId}</TruncatedText></DialogTitle>
<DialogContent dividers className={classes.dialogContent}>
{sorted.length === 0 ? (
<Typography variant="body2">No messages in this conversation.</Typography>
) : (
<Box className={classes.messagesContainer}>
{sorted.map((m: Message) => {
const raw = m.content;
return (
<Box
key={m.id}
className={clsx(
classes.messageRow,
m.is_from_staff ? classes.staffRow : classes.userRow
)}
>
<Box className={clsx(
classes.messageBubble,
m.is_from_staff ? classes.staffBubble : classes.userBubble
)}
>
<Typography variant="body1" style={{ whiteSpace: 'pre-wrap' }}>
{raw}
</Typography>
{m.attachments && m.attachments.length > 0 && (
<List dense disablePadding className={classes.attachmentList}>
{m.attachments.map((att) => (
<ListItem key={att.id} className={classes.attachmentItem}>
<ListItemIcon style={{ minWidth: 'auto', marginRight: '8px' }}>
<AttachmentIcon fontSize="small" />
</ListItemIcon>
<Link
href="#"
onClick={(e) => {
e.preventDefault();
alert(`Attachment Key: ${att.key}\n\nContent:\n${att.content}`);
}}
variant="body2"
color="inherit"
underline="none"
>
{att.key}
</Link>
</ListItem>
))}
</List>
)}
<Typography variant="caption" className={classes.timestamp}>
{m.is_from_staff ? 'Developer' : 'You'} · {formatDateTime(m.created_at)}
</Typography>
</Box>
</Box>
);
})}
</Box>
)}
<Box className={classes.inputArea}>
<TextField
variant="outlined"
fullWidth
placeholder="Type your message..."
value={text}
onChange={(e) => setText(e.target.value)}
disabled={loading}
multiline
rowsMax={4}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
color="primary"
onClick={sendMessage}
disabled={!text.trim() || loading}
>
{loading ? <CircularProgress size={24} /> : <SendIcon />}
</IconButton>
</InputAdornment>
),
}}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} variant="outlined">
Close
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -1,12 +1,12 @@
import { Box, makeStyles } from "@material-ui/core";
import ContactInfoBox from "./ContactInfoBox";
import DonateInfoBox from "./DonateInfoBox";
import FeedbackInfoBox from "./FeedbackInfoBox";
import DaemonControlBox from "./DaemonControlBox";
import SettingsBox from "./SettingsBox";
import ExportDataBox from "./ExportDataBox";
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
const useStyles = makeStyles((theme) => ({
outer: {
display: "flex",
@ -16,7 +16,7 @@ const useStyles = makeStyles((theme) => ({
},
}));
export default function HelpPage() {
export default function SettingsPage() {
const classes = useStyles();
const location = useLocation();
@ -29,11 +29,9 @@ export default function HelpPage() {
return (
<Box className={classes.outer}>
<FeedbackInfoBox />
<SettingsBox />
<ExportDataBox />
<DaemonControlBox />
<ContactInfoBox />
<DonateInfoBox />
</Box>
);

View file

@ -1,9 +1,8 @@
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import { combineReducers, configureStore, StoreEnhancer } from "@reduxjs/toolkit";
import { persistReducer, persistStore } from "redux-persist";
import sessionStorage from "redux-persist/lib/storage/session";
import { reducers } from "store/combinedReducer";
import { createMainListeners } from "store/middleware/storeListener";
import { getNetworkName } from "store/config";
import { LazyStore } from "@tauri-apps/plugin-store";
// Goal: Maintain application state across page reloads while allowing a clean slate on application restart
@ -14,20 +13,42 @@ import { LazyStore } from "@tauri-apps/plugin-store";
const rootPersistConfig = {
key: "gui-global-state-store",
storage: sessionStorage,
blacklist: ["settings"],
blacklist: ["settings", "conversations"],
};
// Use Tauri's store plugin for persistent settings
const tauriStore = new LazyStore("settings.bin");
// Helper to adapt Tauri storage to redux-persist (expects stringified JSON)
const createTauriStorage = () => ({
getItem: async (key: string): Promise<string | null> => {
const value = await tauriStore.get<unknown>(key); // Use generic get
return value == null ? null : JSON.stringify(value);
},
setItem: async (key: string, value: string): Promise<void> => {
try {
await tauriStore.set(key, JSON.parse(value));
await tauriStore.save();
} catch (err) {
console.error(`Error parsing or setting item "${key}" in Tauri store:`, err);
}
},
removeItem: async (key: string): Promise<void> => {
await tauriStore.delete(key);
await tauriStore.save();
},
});
// Configure how settings are stored and retrieved using Tauri's storage
const settingsPersistConfig = {
key: "settings",
storage: {
getItem: async (key: string) => tauriStore.get(key),
setItem: async (key: string, value: unknown) => tauriStore.set(key, value),
removeItem: async (key: string) => tauriStore.delete(key),
},
storage: createTauriStorage(),
};
// Persist conversations across application restarts
const conversationsPersistConfig = {
key: "conversations",
storage: createTauriStorage(),
};
// Create a persisted version of the settings reducer
@ -36,23 +57,55 @@ const persistedSettingsReducer = persistReducer(
reducers.settings,
);
// Create a persisted version of the conversations reducer
const persistedConversationsReducer = persistReducer(
conversationsPersistConfig,
reducers.conversations,
);
// Combine all reducers, using the persisted settings reducer
const rootReducer = combineReducers({
...reducers,
settings: persistedSettingsReducer,
conversations: persistedConversationsReducer,
});
// Enable persistence for the entire application state
const persistedReducer = persistReducer(rootPersistConfig, rootReducer);
// Set up the Redux store with persistence and custom middleware
// Add DevTools Enhancer logic
let remoteDevToolsEnhancer: StoreEnhancer | undefined;
if (import.meta.env.DEV) {
console.log('Development mode detected, attempting to enable Redux DevTools Remote...');
try {
const { devToolsEnhancer } = await import('@redux-devtools/remote');
remoteDevToolsEnhancer = devToolsEnhancer({
name: 'UnstoppableSwap_RemoteInstance',
realtime: true,
hostname: 'localhost',
port: 8098,
});
console.log('Redux DevTools Remote enhancer is ready.');
} catch (e) {
console.warn('Could not enable Redux DevTools Remote.', e);
remoteDevToolsEnhancer = undefined;
}
}
// Set up the Redux store with persistence, middleware, and remote DevTools
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
// Disable serializable to silence warnings about non-serializable actions
serializableCheck: false,
}).prepend(createMainListeners().middleware),
}).prepend(createMainListeners().middleware),
enhancers: (getDefaultEnhancers) => {
const defaultEnhancers = getDefaultEnhancers();
return remoteDevToolsEnhancer
? defaultEnhancers.concat(remoteDevToolsEnhancer)
: defaultEnhancers;
},
});
// Create a persistor to manage the persisted store

View file

@ -6,6 +6,7 @@ import swapReducer from "./features/swapSlice";
import torSlice from "./features/torSlice";
import settingsSlice from "./features/settingsSlice";
import nodesSlice from "./features/nodesSlice";
import conversationsSlice from "./features/conversationsSlice";
export const reducers = {
swap: swapReducer,
@ -16,4 +17,5 @@ export const reducers = {
rates: ratesSlice,
settings: settingsSlice,
nodes: nodesSlice,
conversations: conversationsSlice,
};

View file

@ -0,0 +1,54 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Message } from "../../models/apiModel";
export interface ConversationsSlice {
// List of feedback IDs we know of
knownFeedbackIds: string[];
// Maps feedback IDs to conversations using the updated Message type
conversations: {
[key: string]: Message[]; // Use the imported Message type
};
// Stores IDs for Messages that have been seen by the user
seenMessages: string[];
}
const initialState: ConversationsSlice = {
knownFeedbackIds: [],
conversations: {},
seenMessages: [],
};
const conversationsSlice = createSlice({
name: "conversations",
initialState,
reducers: {
addFeedbackId(slice, action: PayloadAction<string>) {
// Only add if not already present
if (!slice.knownFeedbackIds.includes(action.payload)) {
slice.knownFeedbackIds.push(action.payload);
}
},
// Removes a feedback id from the list of known ones
// Also removes the conversation from the store
removeFeedback(slice, action: PayloadAction<string>) {
slice.knownFeedbackIds = slice.knownFeedbackIds.filter(
(id) => id !== action.payload,
);
delete slice.conversations[action.payload];
},
// Sets the conversations for a given feedback id (Payload uses the correct Message type)
setConversation(slice, action: PayloadAction<{feedbackId: string, messages: Message[]}>) {
slice.conversations[action.payload.feedbackId] = action.payload.messages;
},
// Sets the seen messages for a given feedback id (Payload uses the correct Message type)
markMessagesAsSeen(slice, action: PayloadAction<Message[]>) {
const newSeenIds = action.payload
.map((msg) => msg.id.toString())
.filter(id => !slice.seenMessages.includes(id)); // Avoid duplicates
slice.seenMessages.push(...newSeenIds);
},
},
});
export const { addFeedbackId, removeFeedback, setConversation, markMessagesAsSeen } = conversationsSlice.actions;
export default conversationsSlice.reducer;

View file

@ -146,4 +146,47 @@ export function usePendingApprovals(): PendingApprovalRequest[] {
export function usePendingLockBitcoinApproval(): PendingLockBitcoinApprovalRequest[] {
const approvals = usePendingApprovals();
return approvals.filter((c) => c.content.details.type === "LockBitcoin");
}
/**
* Calculates the number of unread messages from staff for a specific feedback conversation.
* @param feedbackId The ID of the feedback conversation.
* @returns The number of unread staff messages.
*/
export function useUnreadMessagesCount(feedbackId: string): number {
const { conversationsMap, seenMessagesSet } = useAppSelector((state) => ({
conversationsMap: state.conversations.conversations,
// Convert seenMessages array to a Set for efficient lookup
seenMessagesSet: new Set(state.conversations.seenMessages),
}));
const messages = conversationsMap[feedbackId] || [];
const unreadStaffMessages = messages.filter(
(msg) => msg.is_from_staff && !seenMessagesSet.has(msg.id.toString()),
);
return unreadStaffMessages.length;
}
/**
* Calculates the total number of unread messages from staff across all feedback conversations.
* @returns The total number of unread staff messages.
*/
export function useTotalUnreadMessagesCount(): number {
const { conversationsMap, seenMessagesSet } = useAppSelector((state) => ({
conversationsMap: state.conversations.conversations,
seenMessagesSet: new Set(state.conversations.seenMessages),
}));
let totalUnreadCount = 0;
for (const feedbackId in conversationsMap) {
const messages = conversationsMap[feedbackId] || [];
const unreadStaffMessages = messages.filter(
(msg) => msg.is_from_staff && !seenMessagesSet.has(msg.id.toString()),
);
totalUnreadCount += unreadStaffMessages.length;
}
return totalUnreadCount;
}

View file

@ -3,9 +3,10 @@ import { getAllSwapInfos, checkBitcoinBalance, updateAllNodeStatuses, fetchSelle
import logger from "utils/logger";
import { contextStatusEventReceived } from "store/features/rpcSlice";
import { addNode, setFetchFiatPrices, setFiatCurrency } from "store/features/settingsSlice";
import { updateRates } from "renderer/api";
import { fetchFeedbackMessagesViaHttp, updateRates } from "renderer/api";
import { store } from "renderer/store/storeRenderer";
import { swapProgressEventReceived } from "store/features/swapSlice";
import { addFeedbackId, setConversation } from "store/features/conversationsSlice";
export function createMainListeners() {
const listener = createListenerMiddleware();
@ -78,5 +79,15 @@ export function createMainListeners() {
},
});
// Listener for when a feedback id is added
listener.startListening({
actionCreator: addFeedbackId,
effect: async (action) => {
// Whenever a new feedback id is added, fetch the messages and store them in the Redux store
const messages = await fetchFeedbackMessagesViaHttp(action.payload);
store.dispatch(setConversation({ feedbackId: action.payload, messages }));
},
});
return listener;
}

View file

@ -74,4 +74,61 @@ export function bytesToMb(bytes: number): number {
/// Get the markup of a maker's exchange rate compared to the market rate in percent
export function getMarkup(makerPrice: number, marketPrice: number): number {
return (makerPrice - marketPrice) / marketPrice * 100;
}
// Updated function to parse 9-element tuple and format it
export function formatDateTime(dateTime: [number, number, number, number, number, number, number, number, number] | null | undefined): string {
if (!dateTime || !Array.isArray(dateTime) || dateTime.length !== 9) {
// Basic validation for null, undefined, or incorrect structure
return "Invalid Date Input";
}
try {
const [year, dayOfYear, hour, minute, second, nanoseconds, offsetH, offsetM, offsetS] = dateTime;
// More robust validation (example)
if (year < 1970 || dayOfYear < 1 || dayOfYear > 366 || hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59 || nanoseconds < 0 || nanoseconds > 999999999) {
return "Invalid Date Components";
}
// Calculate total offset in seconds (handle potential non-zero offsets)
const totalOffsetSeconds = (offsetH * 3600) + (offsetM * 60) + offsetS;
// Calculate milliseconds from nanoseconds
const milliseconds = Math.floor(nanoseconds / 1_000_000);
// Create Date object for the start of the year *in UTC*
const date = new Date(Date.UTC(year, 0, 1)); // Month is 0-indexed (January)
// Add (dayOfYear - 1) days to get the correct date *in UTC*
date.setUTCDate(date.getUTCDate() + dayOfYear - 1);
// Set the time components *in UTC*
date.setUTCHours(hour);
date.setUTCMinutes(minute);
date.setUTCSeconds(second);
date.setUTCMilliseconds(milliseconds);
// Adjust for the timezone offset to get the correct UTC time
// Subtract the offset because Date.UTC assumes UTC, but the components might be for a different offset
date.setTime(date.getTime() - totalOffsetSeconds * 1000);
// Final validation
if (isNaN(date.getTime())) {
return "Invalid Calculated Date";
}
// Format to a readable string (e.g., "YYYY-MM-DD HH:MM:SS UTC")
const yyyy = date.getUTCFullYear();
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
const HH = String(date.getUTCHours()).padStart(2, '0');
const MM = String(date.getUTCMinutes()).padStart(2, '0');
const SS = String(date.getUTCSeconds()).padStart(2, '0');
return `${yyyy}-${mm}-${dd} ${HH}:${MM}:${SS} UTC`;
} catch (e) {
return "Invalid Date Format";
}
}