From b9fa11154b42ca3a22227f449c771f47edece3b3 Mon Sep 17 00:00:00 2001 From: 8144225309 Date: Thu, 28 May 2026 23:06:38 -0400 Subject: [PATCH] feat(factories+connect): Discard button (#150 wallet) + My Join Attempts view (#152 wallet) Combined wallet half for two plugin RPCs that just landed: - factory-forget (superscalar-cln PR #76 / task #150 plugin half) - client-list-outgoing-joins (superscalar-cln PR #77 / task #152 plugin half) #150 wallet half - Discard button on FactoryList - Adds a Discard button next to Hide on each FactoryListItem, only shown when the factory qualifies for the plugin's factory-forget RPC: * lifecycle is FAILED or ABORTED (or ceremony=FAILED as a fallback for the brief window before the plugin auto-transitions the client side), AND * funding_txid has no non-zero hex chars (the plugin's zero-onchain- footprint gate), AND * n_channels === 0. - Wires it to FactoriesService.forgetFactory + a refetch of the factory list on success. Per-item busy state via discardingIds Set so rapid clicks don't double-fire. - Adds FactoryLifecycle.FAILED to the TS enum so PR #74's auto- terminalized factories render correctly (string mapping + bucketOf -> 'incomplete'). #152 wallet half - "My join attempts" view on Connect page - New MyJoinAttemptsCard component below ConnectList. Calls the new client-list-outgoing-joins RPC, renders a status-badged table of every factory-join-request the client has fired (instance_id + lsp_node_id truncated, requested sats, sent block, status, optional reason). - Status badge color: warning (sent/queued/accepted), success (signed), danger (rejected/cancelled/timeout), info (already_member). - Empty state + manual Refresh button. Suppresses the noisy "method not found" before plugin upgrade ships with a clear hint pointing at PR #77. Plumbing: - http.service.ts: new FactoriesService.forgetFactory(instanceId) and FactoriesService.listOutgoingJoins() wrappers. No behavior change for users on plugins that pre-date PRs #76 / #77; the Discard button just stays hidden, and MyJoinAttemptsCard shows the "Plugin does not expose ... yet" hint instead of a noisy crash. --- .../connect/ConnectHome/ConnectHome.tsx | 2 + .../MyJoinAttemptsCard/MyJoinAttemptsCard.tsx | 158 ++++++++++++++++++ .../factories/FactoryList/FactoryList.tsx | 66 +++++++- apps/frontend/src/services/http.service.ts | 36 ++++ apps/frontend/src/types/factories.type.ts | 5 + 5 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/components/connect/MyJoinAttemptsCard/MyJoinAttemptsCard.tsx diff --git a/apps/frontend/src/components/connect/ConnectHome/ConnectHome.tsx b/apps/frontend/src/components/connect/ConnectHome/ConnectHome.tsx index a6caa4a6..c293e8c0 100644 --- a/apps/frontend/src/components/connect/ConnectHome/ConnectHome.tsx +++ b/apps/frontend/src/components/connect/ConnectHome/ConnectHome.tsx @@ -2,6 +2,7 @@ import './ConnectHome.scss'; import { Row, Col } from 'react-bootstrap'; import Header from '../../ui/Header/Header'; import ConnectList from '../ConnectList/ConnectList'; +import MyJoinAttemptsCard from '../MyJoinAttemptsCard/MyJoinAttemptsCard'; import RendezvousSettings from '../RendezvousSettings/RendezvousSettings'; function ConnectHome() { @@ -11,6 +12,7 @@ function ConnectHome() { + diff --git a/apps/frontend/src/components/connect/MyJoinAttemptsCard/MyJoinAttemptsCard.tsx b/apps/frontend/src/components/connect/MyJoinAttemptsCard/MyJoinAttemptsCard.tsx new file mode 100644 index 00000000..92b054d2 --- /dev/null +++ b/apps/frontend/src/components/connect/MyJoinAttemptsCard/MyJoinAttemptsCard.tsx @@ -0,0 +1,158 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Card, Table, Badge, Button, Spinner, Alert } from 'react-bootstrap'; +import { FactoriesService } from '../../../services/http.service'; +import logger from '../../../services/logger.service'; + +/** + * Task #152: surface the client's outgoing factory-join-request attempts + * + current status. Powered by the plugin's client-list-outgoing-joins RPC + * (added in PR #77). Renders on the Connect page so users can see what + * happened to invites they sent without having to grep CLN logs. + */ + +type OutgoingJoin = { + instance_id: string; + lsp_node_id: string; + request_id: string; + contribution_sats: number; + sent_at_block: number; + expected_signing_block: number; + updated_at_block: number; + status: string; + status_code: number; + reason?: string; +}; + +const truncate = (s: string, head = 8, tail = 4) => + !s || s.length <= head + tail + 1 ? s : `${s.slice(0, head)}…${s.slice(-tail)}`; + +const statusBadgeVariant = (status: string): string => { + switch (status) { + case 'sent': + case 'queued': + case 'accepted': + return 'warning'; + case 'signed': + return 'success'; + case 'rejected': + case 'cancelled': + case 'timeout': + return 'danger'; + case 'already_member': + return 'info'; + default: + return 'secondary'; + } +}; + +const statusLabel = (status: string): string => { + switch (status) { + case 'sent': return 'Sent'; + case 'queued': return 'Queued'; + case 'accepted': return 'Accepted'; + case 'signed': return 'Signed'; + case 'rejected': return 'Rejected'; + case 'cancelled': return 'Cancelled'; + case 'timeout': return 'Timed out'; + case 'already_member': return 'Already in'; + default: return status; + } +}; + +const formatSats = (n: number): string => + typeof n === 'number' ? n.toLocaleString() : '—'; + +const MyJoinAttemptsCard = () => { + const [loading, setLoading] = useState(false); + const [joins, setJoins] = useState([]); + const [error, setError] = useState(null); + + const fetchJoins = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await FactoriesService.listOutgoingJoins(); + setJoins(Array.isArray(result?.joins) ? result.joins : []); + } catch (err: any) { + const msg = err?.message || String(err); + // The RPC only exists post #77 deploy. Suppress the noisy "method not + // found" until the plugin upgrade rolls out. + if (/method not found|unknown.*method|client-list-outgoing-joins/i.test(msg)) { + setError('Plugin does not expose client-list-outgoing-joins yet ' + + '(deploy task #152 plugin half / PR #77 to enable).'); + } else { + setError(msg); + } + logger.warn('listOutgoingJoins failed:', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchJoins(); + }, [fetchJoins]); + + return ( + + + My join attempts + + + + {error && ( + + {error} + + )} + {!error && joins.length === 0 && !loading && ( +
+ No outgoing join attempts from this node yet. +
+ )} + {!error && joins.length > 0 && ( + + + + + + + + + + + + {joins.map((j, idx) => ( + + + + + + + + ))} + +
FactoryLSPRequested (sat)Sent atStatus
{truncate(j.instance_id, 10, 4)}{truncate(j.lsp_node_id, 8, 4)}{formatSats(j.contribution_sats)}{j.sent_at_block || '—'} + {statusLabel(j.status)} + {j.reason && ( +
+ {j.reason} +
+ )} +
+ )} +
+
+ ); +}; + +export default MyJoinAttemptsCard; diff --git a/apps/frontend/src/components/factories/FactoryList/FactoryList.tsx b/apps/frontend/src/components/factories/FactoryList/FactoryList.tsx index 62f539b4..3d15c27b 100644 --- a/apps/frontend/src/components/factories/FactoryList/FactoryList.tsx +++ b/apps/frontend/src/components/factories/FactoryList/FactoryList.tsx @@ -6,8 +6,25 @@ import { ActionSVG } from '../../../svgs/Action'; import { useSelector } from 'react-redux'; import { selectIsAuthenticated, selectNodeInfo } from '../../../store/rootSelectors'; import { selectFactories, selectFactoriesLoading, selectFactoriesError, selectRoleCounts } from '../../../store/factoriesSelectors'; +import { FactoriesService } from '../../../services/http.service'; +import logger from '../../../services/logger.service'; import { Factory, FactoryLifecycle, FactoryCeremony } from '../../../types/factories.type'; +// Task #150: a factory qualifies for plugin-side Discard (factory-forget RPC) +// only when it has zero on-chain footprint. Mirrors the plugin's safety gate +// so the button stays disabled rather than producing a server-side reject. +const canDiscard = (f: Factory): boolean => { + if (f.lifecycle !== FactoryLifecycle.ABORTED && f.lifecycle !== FactoryLifecycle.FAILED) { + // Allow a UI Discard for non-FAILED items whose ceremony is failed, since the + // plugin's #149 work auto-transitions those to FAILED on the LSP side; the + // client-side mirror lags a bit. Keep it conservative: only when ceremony=failed. + if (f.ceremony !== FactoryCeremony.FAILED) return false; + } + if (f.n_channels && f.n_channels > 0) return false; + if (f.funding_txid && /[1-9a-f]/i.test(f.funding_txid)) return false; // any non-zero hex = funded + return true; +}; + type RoleFilter = 'all' | 'lsp' | 'client'; type Bucket = 'live' | 'history' | 'incomplete'; @@ -52,7 +69,9 @@ const HISTORY_LIFECYCLES = new Set([ // lifecycle at INIT, so the "did not complete" bucket is also keyed off // ceremony === FAILED until the plugin auto-terminalizes failed drafts (follow-up). const bucketOf = (f: Factory): Bucket => { - if (f.lifecycle === FactoryLifecycle.ABORTED || f.ceremony === FactoryCeremony.FAILED) return 'incomplete'; + if (f.lifecycle === FactoryLifecycle.ABORTED + || f.lifecycle === FactoryLifecycle.FAILED + || f.ceremony === FactoryCeremony.FAILED) return 'incomplete'; if (HISTORY_LIFECYCLES.has(f.lifecycle)) return 'history'; return 'live'; }; @@ -88,11 +107,13 @@ type FactoryListProps = { onFactoryClick: (factory: Factory) => void; }; -const FactoryListItem = ({ factory, onClick, hidden, onToggleHide }: { +const FactoryListItem = ({ factory, onClick, hidden, onToggleHide, onDiscard, discarding }: { factory: Factory; onClick: () => void; hidden: boolean; onToggleHide: (instanceId: string) => void; + onDiscard: (instanceId: string) => void; + discarding: boolean; }) => (
  • {hidden ? 'Unhide' : 'Hide'} + {canDiscard(factory) && ( + + )} @@ -167,6 +201,9 @@ const FactoryList = (props: FactoryListProps) => { const [showIncomplete, setShowIncomplete] = useState(false); const [showHistory, setShowHistory] = useState(false); const [showHidden, setShowHidden] = useState(false); + // Track in-flight factory-forget RPCs so the Discard button shows a busy + // state and we don't double-fire on rapid clicks. + const [discardingIds, setDiscardingIds] = useState>(new Set()); // TEMP: always show the pill until nostr rendezvous lands so single-role // nodes can still preview the Client view. @@ -196,6 +233,29 @@ const FactoryList = (props: FactoryListProps) => { }); }; + // Task #150: hard-delete a factory record via the plugin's factory-forget + // RPC. Safety-gated server-side; canDiscard() mirrors the gate so the + // button stays disabled rather than producing a server-side reject. + const handleDiscard = async (instanceId: string) => { + setDiscardingIds(prev => { + const next = new Set(prev); + next.add(instanceId); + return next; + }); + try { + await FactoriesService.forgetFactory(instanceId); + await FactoriesService.fetchFactoriesData(); + } catch (err) { + logger.error('factory-forget failed:', err); + } finally { + setDiscardingIds(prev => { + const next = new Set(prev); + next.delete(instanceId); + return next; + }); + } + }; + const groups = useMemo(() => { const base = !factories ? [] @@ -228,6 +288,8 @@ const FactoryList = (props: FactoryListProps) => { factory={factory} hidden={hidden.has(factory.instance_id)} onToggleHide={toggleHide} + onDiscard={handleDiscard} + discarding={discardingIds.has(factory.instance_id)} onClick={() => props.onFactoryClick(factory)} /> )); diff --git a/apps/frontend/src/services/http.service.ts b/apps/frontend/src/services/http.service.ts index 4b05ae8c..9b0c3d02 100644 --- a/apps/frontend/src/services/http.service.ts +++ b/apps/frontend/src/services/http.service.ts @@ -701,6 +701,42 @@ export class FactoriesService { return HttpService.clnCall('factory-cancel-join', { request_id: requestId }); } + /** + * Task #150: hard-discard a factory record. Plugin-side gated to refuse + * unless lifecycle is FAILED or ABORTED AND there is zero on-chain + * footprint (no funding TX, no channels). Wired to the Discard button + * in FactoryList; safer than Hide because it actually removes the record. + */ + static async forgetFactory(instanceId: string): Promise<{ + instance_id: string; + lifecycle: string; + previous_lifecycle: number; + }> { + return HttpService.clnCall('factory-forget', { instance_id: instanceId }); + } + + /** + * Task #152: list this client's outgoing join attempts (every + * factory-join-request fired + current status). Powers the "My join + * attempts" view. Plugin RPC: client-list-outgoing-joins. + */ + static async listOutgoingJoins(): Promise<{ + joins: Array<{ + instance_id: string; + lsp_node_id: string; + request_id: string; + contribution_sats: number; + sent_at_block: number; + expected_signing_block: number; + updated_at_block: number; + status: string; + status_code: number; + reason?: string; + }>; + }> { + return HttpService.clnCall('client-list-outgoing-joins'); + } + /** * Client-side: fetch the persisted signing preference thresholds that * the plugin's pre-sign validator checks against. Returns canonical diff --git a/apps/frontend/src/types/factories.type.ts b/apps/frontend/src/types/factories.type.ts index fe924b75..3da8099f 100644 --- a/apps/frontend/src/types/factories.type.ts +++ b/apps/frontend/src/types/factories.type.ts @@ -15,6 +15,11 @@ export enum FactoryLifecycle { CLOSED_BREACHED = 'closed_breached', // Terminal "did not complete" — operator abort or a stalled/failed ceremony. ABORTED = 'aborted', + // Task #149 plugin auto-terminalize: ceremony failed automatically + // (withdraw failure, malformed peer msg, etc.). Same bucket as ABORTED in + // the wallet (Failed / abandoned); distinct semantically so the operator + // can tell auto-vs-manual aborts apart. + FAILED = 'failed', } export enum FactoryCeremony {