diff --git a/src/client/src/App.tsx b/src/client/src/App.tsx index e48c5083..8a6932ac 100644 --- a/src/client/src/App.tsx +++ b/src/client/src/App.tsx @@ -10,6 +10,7 @@ import { Toaster } from 'react-hot-toast'; import { TooltipProvider } from './components/ui/tooltip'; import { useListener } from './hooks/UseListener'; import { SseProvider } from './context/SseContext'; +import { EventLogProvider } from './context/EventLogContext'; import { config } from './config/thunderhubConfig'; import { LoadingCard } from './components/loading/LoadingCard'; import { useGetNodeInfoQuery } from './graphql/queries/__generated__/getNodeInfo.generated'; @@ -162,11 +163,13 @@ export default function App() { - - - - - + + + + + + + diff --git a/src/client/src/context/EventLogContext.tsx b/src/client/src/context/EventLogContext.tsx new file mode 100644 index 00000000..4c41675f --- /dev/null +++ b/src/client/src/context/EventLogContext.tsx @@ -0,0 +1,123 @@ +import { + FC, + ReactNode, + createContext, + useContext, + useReducer, + useCallback, +} from 'react'; + +const MAX_ENTRIES = 100; + +export type EventField = { label: string; value: ReactNode }; + +export type EventLogEntry = { + id: string; + type: string; + title: string; + summary: EventField[]; + details: EventField[]; + timestamp: number; + status: 'success' | 'error'; +}; + +type State = { + entries: EventLogEntry[]; +}; + +type Action = + | { type: 'add'; entry: Omit } + | { type: 'dismiss'; id: string } + | { type: 'clear' }; + +type Dispatch = (action: Action) => void; + +const StateContext = createContext(undefined); +const DispatchContext = createContext(undefined); + +let nextId = 0; + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'add': { + const entry: EventLogEntry = { + ...action.entry, + id: String(++nextId), + timestamp: Date.now(), + }; + return { + entries: [entry, ...state.entries].slice(0, MAX_ENTRIES), + }; + } + case 'dismiss': + return { + entries: state.entries.filter(e => e.id !== action.id), + }; + case 'clear': + return { entries: [] }; + default: + return state; + } +}; + +export const EventLogProvider: FC<{ children?: ReactNode }> = ({ + children, +}) => { + const [state, dispatch] = useReducer(reducer, { entries: [] }); + + return ( + + {children} + + ); +}; + +export const useEventLogState = () => { + const context = useContext(StateContext); + if (context === undefined) { + throw new Error('useEventLogState must be used within an EventLogProvider'); + } + return context; +}; + +export const useEventLogDispatch = () => { + const context = useContext(DispatchContext); + if (context === undefined) { + throw new Error( + 'useEventLogDispatch must be used within an EventLogProvider' + ); + } + return context; +}; + +export const useEventLog = () => { + const dispatch = useEventLogDispatch(); + + const addEvent = useCallback( + (event: { + title: string; + summary: EventField[]; + details?: EventField[]; + status?: 'success' | 'error'; + type?: string; + }) => { + dispatch({ + type: 'add', + entry: { + title: event.title, + summary: event.summary, + details: event.details ?? [], + status: event.status ?? 'success', + type: event.type ?? 'event', + }, + }); + }, + [dispatch] + ); + + const clearEvents = useCallback(() => { + dispatch({ type: 'clear' }); + }, [dispatch]); + + return { addEvent, clearEvents }; +}; diff --git a/src/client/src/hooks/UseListener.tsx b/src/client/src/hooks/UseListener.tsx index feba079d..62b5b221 100644 --- a/src/client/src/hooks/UseListener.tsx +++ b/src/client/src/hooks/UseListener.tsx @@ -1,12 +1,8 @@ import { useApolloClient } from '@apollo/client'; -import { FC, useCallback, useEffect, useMemo, useRef } from 'react'; -import toast from 'react-hot-toast'; -import { - getNodeLink, - getTransactionLink, - renderLine, -} from '../components/generic/helpers'; +import { FC, useCallback, useEffect, useRef } from 'react'; +import { getNodeLink, getTransactionLink } from '../components/generic/helpers'; import { useNotificationState } from '../context/NotificationContext'; +import { useEventLog, EventField } from '../context/EventLogContext'; import { formatSats } from '../utils/helpers'; import { useChannelInfo } from './UseChannelInfo'; import { useNodeDetails } from './UseNodeDetails'; @@ -14,19 +10,6 @@ import { useSse, useSseEvent } from './useSse'; const refetchTimeMs = 1000 * 1; -const renderToast = ( - title: string, - content: JSX.Element | null | string | number -) => { - return ( -
- {title} -
- {content} -
- ); -}; - const PeerAlias: FC<{ pubkey: string }> = ({ pubkey }) => { const { alias } = useNodeDetails(pubkey); @@ -42,12 +25,10 @@ const ChannelPeerAlias: FC<{ id: string }> = ({ id }) => { }; export const useListener = (disabled?: boolean) => { - const { channels, forwardAttempts, forwards, invoices, payments, autoClose } = + const { channels, forwardAttempts, forwards, invoices, payments } = useNotificationState(); - const options: { duration?: number } = useMemo(() => { - return autoClose ? {} : { duration: Infinity }; - }, [autoClose]); + const { addEvent } = useEventLog(); const refetchQueryTimeout: { current: ReturnType | null } = useRef(null); @@ -87,33 +68,27 @@ export const useListener = (disabled?: boolean) => { message; if (is_confirmed) { - toast.success( - renderToast( - 'Invoice Paid', - <> - {renderLine('Description', description)} - {renderLine('Description Hash', description_hash)} - {renderLine('Amount', formatSats(received))} - - ), - options - ); + addEvent({ + title: 'Invoice Paid', + summary: [{ label: 'Amount', value: formatSats(received) }], + details: [ + { label: 'Description', value: description }, + { label: 'Description Hash', value: description_hash }, + ], + }); } else { - toast.success( - renderToast( - 'New Invoice Created', - <> - {renderLine('Description', description)} - {renderLine('Description Hash', description_hash)} - {renderLine('Amount', formatSats(tokens))} - - ), - options - ); + addEvent({ + title: 'New Invoice Created', + summary: [{ label: 'Amount', value: formatSats(tokens) }], + details: [ + { label: 'Description', value: description }, + { label: 'Description Hash', value: description_hash }, + ], + }); } handleRefetchQueries(['GetInvoices']); }, - [handleRefetchQueries, invoices, options] + [handleRefetchQueries, invoices, addEvent] ); const handlePayment = useCallback( @@ -122,25 +97,25 @@ export const useListener = (disabled?: boolean) => { const { hops, fee, destination, tokens } = message; - const hopLines = hops.map((h: any, index: number) => - renderLine(`Hop ${index + 1}`, h.channel) - ); + const hopFields: EventField[] = hops.map((h: any, index: number) => ({ + label: `Hop ${index + 1}`, + value: h.channel, + })); - toast.success( - renderToast( - 'New Payment', - <> - {renderLine('Destination', )} - {renderLine('Amount', formatSats(tokens))} - {renderLine('Fee', fee ? formatSats(fee) : null)} - {hopLines} - - ), - options - ); + addEvent({ + title: 'New Payment', + summary: [ + { label: 'Amount', value: formatSats(tokens) }, + { label: 'Destination', value: }, + ], + details: [ + ...(fee ? [{ label: 'Fee', value: formatSats(fee) }] : []), + ...hopFields, + ], + }); handleRefetchQueries(['GetPayments']); }, - [handleRefetchQueries, payments, options] + [handleRefetchQueries, payments, addEvent] ); const handleForward = useCallback( @@ -158,40 +133,35 @@ export const useListener = (disabled?: boolean) => { if (is_send || is_receive) return; if (!is_confirmed && forwardAttempts) { - toast.error( - renderToast( - 'Forward Attempt', - <> - {renderLine('In Peer', )} - {renderLine('Out Peer', )} - {renderLine('In Channel', in_channel)} - {renderLine('Out Channel', out_channel)} - {renderLine('Tokens', formatSats(tokens))} - {renderLine('Fee', fee ? formatSats(fee) : null)} - - ), - options - ); + addEvent({ + title: 'Forward Attempt', + summary: [{ label: 'Tokens', value: formatSats(tokens) }], + details: [ + { label: 'In Peer', value: }, + { label: 'Out Peer', value: }, + { label: 'In Channel', value: in_channel }, + { label: 'Out Channel', value: out_channel }, + ...(fee ? [{ label: 'Fee', value: formatSats(fee) }] : []), + ], + status: 'error', + }); } if (is_confirmed && forwards) { - toast.success( - renderToast( - 'Successful Forward', - <> - {renderLine('In Peer', )} - {renderLine('Out Peer', )} - {renderLine('In Channel', in_channel)} - {renderLine('Out Channel', out_channel)} - {renderLine('Fee', fee ? formatSats(fee) : null)} - - ), - options - ); + addEvent({ + title: 'Successful Forward', + summary: fee ? [{ label: 'Fee', value: formatSats(fee) }] : [], + details: [ + { label: 'In Peer', value: }, + { label: 'Out Peer', value: }, + { label: 'In Channel', value: in_channel }, + { label: 'Out Channel', value: out_channel }, + ], + }); handleRefetchQueries(['GetForwards']); } }, - [handleRefetchQueries, forwardAttempts, forwards, options] + [handleRefetchQueries, forwardAttempts, forwards, addEvent] ); const handleClosed = useCallback( @@ -233,28 +203,31 @@ export const useListener = (disabled?: boolean) => { return types.join(', '); }; - toast.success( - renderToast( - 'Channel Closed', - <> - {renderLine('Reason', getCloseType())} - {renderLine('Capacity', formatSats(capacity))} - {renderLine('Id', id)} - {renderLine('Peer', )} - {renderLine( - 'Tx', - transaction_id ? getTransactionLink(transaction_id) : null - )} - {renderLine( - 'Closing Tx', - close_transaction_id - ? getTransactionLink(close_transaction_id) - : null - )} - - ), - options - ); + addEvent({ + title: 'Channel Closed', + summary: [ + { label: 'Reason', value: getCloseType() }, + { label: 'Capacity', value: formatSats(capacity) }, + ], + details: [ + { label: 'Id', value: id }, + { + label: 'Peer', + value: , + }, + ...(transaction_id + ? [{ label: 'Tx', value: getTransactionLink(transaction_id) }] + : []), + ...(close_transaction_id + ? [ + { + label: 'Closing Tx', + value: getTransactionLink(close_transaction_id), + }, + ] + : []), + ], + }); handleRefetchQueries([ 'GetChannels', @@ -262,7 +235,7 @@ export const useListener = (disabled?: boolean) => { 'GetClosedChannels', ]); }, - [handleRefetchQueries, channels, options] + [handleRefetchQueries, channels, addEvent] ); const handleOpen = useCallback( @@ -279,43 +252,47 @@ export const useListener = (disabled?: boolean) => { is_private, } = message; - toast.success( - renderToast( - 'Channel Opened', - <> - {renderLine( - 'Initiated By', - is_partner_initiated ? 'Your Peer' : 'You' - )} - {renderLine('Id', id)} - {renderLine('Peer', )} - {renderLine('Private', is_private ? 'Yes' : 'No')} - {renderLine('Capacity', formatSats(capacity))} - {renderLine('Local', formatSats(local_balance))} - {renderLine('Remote', formatSats(remote_balance))} - - ), - options - ); + addEvent({ + title: 'Channel Opened', + summary: [ + { label: 'Capacity', value: formatSats(capacity) }, + { + label: 'Initiated By', + value: is_partner_initiated ? 'Your Peer' : 'You', + }, + ], + details: [ + { label: 'Id', value: id }, + { + label: 'Peer', + value: , + }, + { label: 'Private', value: is_private ? 'Yes' : 'No' }, + { label: 'Local', value: formatSats(local_balance) }, + { label: 'Remote', value: formatSats(remote_balance) }, + ], + }); handleRefetchQueries(['GetChannels', 'GetPendingChannels']); }, - [handleRefetchQueries, channels, options] + [handleRefetchQueries, channels, addEvent] ); const handleOpening = useCallback( (message: any) => { if (!channels) return; - toast.success( - renderToast( - 'Channel Opening', - renderLine('Transaction', getTransactionLink(message.transaction_id)) - ), - options - ); + addEvent({ + title: 'Channel Opening', + summary: [ + { + label: 'Transaction', + value: getTransactionLink(message.transaction_id), + }, + ], + }); handleRefetchQueries(['GetChannels', 'GetPendingChannels']); }, - [handleRefetchQueries, channels, options] + [handleRefetchQueries, channels, addEvent] ); useSseEvent('invoice_updated', handleInvoice); diff --git a/src/client/src/layouts/footer/Footer.tsx b/src/client/src/layouts/footer/Footer.tsx index 97db7650..7943874e 100644 --- a/src/client/src/layouts/footer/Footer.tsx +++ b/src/client/src/layouts/footer/Footer.tsx @@ -9,7 +9,7 @@ export const Footer = () => { return (
-
+
{
-
-
+
+
Liquidity
-
+
+ {expanded && ( +
+ {entry.details.map((f, i) => ( + + ))} +
+ )} + + )} +
+
+
+ ); +}; + +const NotificationToggles: FC = () => { + const { invoices, payments, channels, forwards, forwardAttempts } = + useNotificationState(); + const dispatch = useNotificationDispatch(); + + const items = [ + { label: 'Invoices', property: 'invoices', value: invoices }, + { label: 'Payments', property: 'payments', value: payments }, + { label: 'Channels', property: 'channels', value: channels }, + { label: 'Forwards', property: 'forwards', value: forwards }, + { + label: 'Forward Attempts', + property: 'forwardAttempts', + value: forwardAttempts, + }, + ]; + + return ( +
+
+ Enable event types +
+ {items.map(item => ( +
+ {item.label} + + dispatch({ type: 'change', [item.property]: checked }) + } + /> +
+ ))} +
+ ); +}; + +export const EventLog: FC = () => { + const { entries } = useEventLogState(); + const dispatch = useEventLogDispatch(); + const { addEvent } = useEventLog(); + const [collapsed, setCollapsed] = useState(false); + const [ping, setPing] = useState(false); + const prevCount = useRef(entries.length); + + useEffect(() => { + if (entries.length > prevCount.current) { + setPing(true); + const t = setTimeout(() => setPing(false), 2000); + return () => clearTimeout(t); + } + prevCount.current = entries.length; + }, [entries.length]); + + useEffect(() => { + prevCount.current = entries.length; + }, [entries.length]); + + const { invoices, payments, channels, forwards, forwardAttempts } = + useNotificationState(); + const anyEnabled = + invoices || payments || channels || forwards || forwardAttempts; + + const pushFakeEvent = () => { + const event = fakeEvents[Math.floor(Math.random() * fakeEvents.length)]; + addEvent(event); + }; + + return ( +
+
+ +
+ + + + + + + + + {entries.length > 0 && ( + + )} + {import.meta.env.DEV && ( + + )} +
+
+ {!collapsed && ( +
+ {!anyEnabled ? ( +
+ + No events enabled +
+ ) : entries.length === 0 ? ( +
+ + No events yet +
+ ) : ( + entries.map((entry, i) => ( + + )) + )} +
+ )} +
+ ); +}; diff --git a/src/client/src/layouts/sidebar/RightSidebar.tsx b/src/client/src/layouts/sidebar/RightSidebar.tsx index 5fe4afe5..988981c5 100644 --- a/src/client/src/layouts/sidebar/RightSidebar.tsx +++ b/src/client/src/layouts/sidebar/RightSidebar.tsx @@ -1,5 +1,6 @@ import { useConfigState } from '../../context/ConfigContext'; import { BalancesContent } from './BalancesContent'; +import { EventLog } from './EventLog'; export const RightSidebar = () => { const { rightSidebar } = useConfigState(); @@ -8,10 +9,13 @@ export const RightSidebar = () => { return (
-
-
+
+
+
+ +
); diff --git a/src/client/src/layouts/sidebar/fakeEvents.tsx b/src/client/src/layouts/sidebar/fakeEvents.tsx new file mode 100644 index 00000000..0e434f02 --- /dev/null +++ b/src/client/src/layouts/sidebar/fakeEvents.tsx @@ -0,0 +1,76 @@ +import { EventField } from '../../context/EventLogContext'; + +export const fakeEvents: { + title: string; + summary: EventField[]; + details?: EventField[]; + status?: 'success' | 'error'; +}[] = [ + { + title: 'Invoice Paid', + summary: [{ label: 'Amount', value: '50,000 sats' }], + details: [ + { label: 'Description', value: 'Monthly VPN subscription' }, + { label: 'Description Hash', value: 'a1b2c3...d4e5f6' }, + ], + }, + { + title: 'New Payment', + summary: [ + { label: 'Amount', value: '125,000 sats' }, + { label: 'Destination', value: 'ACINQ' }, + ], + details: [ + { label: 'Fee', value: '12 sats' }, + { label: 'Hop 1', value: '824333x2100x1' }, + { label: 'Hop 2', value: '710421x1500x0' }, + ], + }, + { + title: 'Successful Forward', + summary: [{ label: 'Fee', value: '3 sats' }], + details: [ + { label: 'In Peer', value: 'WalletOfSatoshi' }, + { label: 'Out Peer', value: 'River Financial' }, + { label: 'In Channel', value: '810111x2300x1' }, + { label: 'Out Channel', value: '799000x1800x0' }, + ], + }, + { + title: 'Forward Attempt', + summary: [{ label: 'Tokens', value: '2,000,000 sats' }], + details: [ + { label: 'In Peer', value: 'Kraken' }, + { label: 'Out Peer', value: 'Bitfinex' }, + { label: 'In Channel', value: '800100x500x2' }, + { label: 'Out Channel', value: '800200x600x1' }, + { label: 'Fee', value: '150 sats' }, + ], + status: 'error', + }, + { + title: 'Channel Closed', + summary: [ + { label: 'Reason', value: 'Cooperative' }, + { label: 'Capacity', value: '5,000,000 sats' }, + ], + details: [ + { label: 'Id', value: '750000x1200x0' }, + { label: 'Peer', value: 'LNBig' }, + ], + }, + { + title: 'Channel Opened', + summary: [ + { label: 'Capacity', value: '10,000,000 sats' }, + { label: 'Initiated By', value: 'Your Peer' }, + ], + details: [ + { label: 'Id', value: '812000x900x1' }, + { label: 'Peer', value: 'Voltage' }, + { label: 'Private', value: 'No' }, + { label: 'Local', value: '0 sats' }, + { label: 'Remote', value: '10,000,000 sats' }, + ], + }, +];