diff --git a/api/api.go b/api/api.go index 02320aa9..2520a93c 100644 --- a/api/api.go +++ b/api/api.go @@ -282,11 +282,44 @@ func (api *api) ListApps() ([]App, error) { return apiApps, nil } -func (api *api) ListChannels(ctx context.Context) ([]lnclient.Channel, error) { +func (api *api) ListChannels(ctx context.Context) ([]Channel, error) { if api.svc.GetLNClient() == nil { return nil, errors.New("LNClient not started") } - return api.svc.GetLNClient().ListChannels(ctx) + channels, err := api.svc.GetLNClient().ListChannels(ctx) + if err != nil { + return nil, err + } + + apiChannels := []Channel{} + for _, channel := range channels { + status := "offline" + if channel.Active { + status = "online" + } else if channel.Confirmations != nil && channel.ConfirmationsRequired != nil && *channel.ConfirmationsRequired > *channel.Confirmations { + status = "opening" + } + + apiChannels = append(apiChannels, Channel{ + LocalBalance: channel.LocalBalance, + LocalSpendableBalance: channel.LocalSpendableBalance, + RemoteBalance: channel.RemoteBalance, + Id: channel.Id, + RemotePubkey: channel.RemotePubkey, + FundingTxId: channel.FundingTxId, + Active: channel.Active, + Public: channel.Public, + InternalChannel: channel.InternalChannel, + Confirmations: channel.Confirmations, + ConfirmationsRequired: channel.ConfirmationsRequired, + ForwardingFeeBaseMsat: channel.ForwardingFeeBaseMsat, + UnspendablePunishmentReserve: channel.UnspendablePunishmentReserve, + CounterpartyUnspendablePunishmentReserve: channel.CounterpartyUnspendablePunishmentReserve, + Error: channel.Error, + Status: status, + }) + } + return apiChannels, nil } func (api *api) GetChannelPeerSuggestions(ctx context.Context) ([]alby.ChannelPeerSuggestion, error) { diff --git a/api/models.go b/api/models.go index 8305264e..87a0bc04 100644 --- a/api/models.go +++ b/api/models.go @@ -16,7 +16,7 @@ type API interface { DeleteApp(userApp *db.App) error GetApp(userApp *db.App) *App ListApps() ([]App, error) - ListChannels(ctx context.Context) ([]lnclient.Channel, error) + ListChannels(ctx context.Context) ([]Channel, error) GetChannelPeerSuggestions(ctx context.Context) ([]alby.ChannelPeerSuggestion, error) ResetRouter(key string) error ChangeUnlockPassword(changeUnlockPasswordRequest *ChangeUnlockPasswordRequest) error @@ -285,3 +285,22 @@ type WalletCapabilitiesResponse struct { Methods []string `json:"methods"` NotificationTypes []string `json:"notificationTypes"` } + +type Channel struct { + LocalBalance int64 `json:"localBalance"` + LocalSpendableBalance int64 `json:"localSpendableBalance"` + RemoteBalance int64 `json:"remoteBalance"` + Id string `json:"id"` + RemotePubkey string `json:"remotePubkey"` + FundingTxId string `json:"fundingTxId"` + Active bool `json:"active"` + Public bool `json:"public"` + InternalChannel interface{} `json:"internalChannel"` + Confirmations *uint32 `json:"confirmations"` + ConfirmationsRequired *uint32 `json:"confirmationsRequired"` + ForwardingFeeBaseMsat uint32 `json:"forwardingFeeBaseMsat"` + UnspendablePunishmentReserve uint64 `json:"unspendablePunishmentReserve"` + CounterpartyUnspendablePunishmentReserve uint64 `json:"counterpartyUnspendablePunishmentReserve"` + Error *string `json:"error"` + Status string `json:"status"` +} diff --git a/frontend/src/components/SidebarHint.tsx b/frontend/src/components/SidebarHint.tsx index 188e8484..319d8570 100644 --- a/frontend/src/components/SidebarHint.tsx +++ b/frontend/src/components/SidebarHint.tsx @@ -5,7 +5,6 @@ import { Button } from "src/components/ui/button"; import { Card, CardContent, - CardDescription, CardHeader, CardTitle, } from "src/components/ui/card"; @@ -90,9 +89,9 @@ function SidebarHintCard({ {title} - {description} +
{description}
+ + + + + +

View Funding Transaction

+
+
+ + + +

View Node on amboss.space

+
+
+ {channel.public && ( + editChannel(channel)} + > + + Set Routing Fee + + )} + + closeChannel( + channel.id, + channel.remotePubkey, + channel.active + ) + } + > + + Close Channel + +
+ + + + + +
+

+ Status +

+ {channel.status == "online" ? ( + Online + ) : channel.status == "opening" ? ( + Opening + ) : ( + Offline + )} +
+
+

+ Type +

+

+ {channel.public ? "Public" : "Private"} +

+
+
+

+ Capacity +

+

+ {formatAmount(capacity)} sats +

+
+
+ + + +
+

+ Reserve +

+ +
+
+ + Funds each participant sets aside to discourage + cheating by ensuring each party has something at + stake. This reserve cannot be spent during the + channel's lifetime and typically amounts to 1% of + the channel capacity. + +
+
+

+ {channel.localBalance < + channel.unspendablePunishmentReserve * 1000 && ( + <> + {formatAmount( + Math.min( + channel.localBalance, + channel.unspendablePunishmentReserve * 1000 + ) + )}{" "} + /{" "} + + )} + {formatAmount( + channel.unspendablePunishmentReserve * 1000 + )}{" "} + sats +

+
+
+ + + + +
+

+ Spending +

+

+ Receiving +

+
+
+
+ +
+ + {formatAmount(channel.localSpendableBalance)} sats + + + {formatAmount(channel.remoteBalance)} sats + +
+
+ +
+
+ + ); + })} + + + ); +} diff --git a/frontend/src/components/channels/ChannelsTable.tsx b/frontend/src/components/channels/ChannelsTable.tsx new file mode 100644 index 00000000..aaf9cec2 --- /dev/null +++ b/frontend/src/components/channels/ChannelsTable.tsx @@ -0,0 +1,245 @@ +import { + ExternalLinkIcon, + HandCoins, + InfoIcon, + MoreHorizontal, + Trash2, +} from "lucide-react"; +import { ChannelWarning } from "src/components/channels/ChannelWarning"; +import ExternalLink from "src/components/ExternalLink"; +import Loading from "src/components/Loading.tsx"; +import { Badge } from "src/components/ui/badge.tsx"; +import { Button } from "src/components/ui/button.tsx"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "src/components/ui/dropdown-menu.tsx"; +import { Progress } from "src/components/ui/progress.tsx"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "src/components/ui/table.tsx"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "src/components/ui/tooltip.tsx"; +import { formatAmount } from "src/lib/utils.ts"; +import { Channel, Node } from "src/types"; + +type ChannelsTableProps = { + channels?: Channel[]; + nodes?: Node[]; + closeChannel( + channelId: string, + counterpartyNodeId: string, + isActive: boolean + ): void; + editChannel(channel: Channel): void; +}; + +export function ChannelsTable({ + channels, + nodes, + closeChannel, + editChannel, +}: ChannelsTableProps) { + if (channels && !channels.length) { + return null; + } + + return ( +
+ + + + Status + Node + Capacity + + + + +
+ Reserve + +
+
+ + Funds each participant sets aside to discourage cheating by + ensuring each party has something at stake. This reserve + cannot be spent during the channel's lifetime and typically + amounts to 1% of the channel capacity. + +
+
+
+ +
+
Spending
+
Receiving
+
+
+ + +
+
+ + {channels && channels.length > 0 && ( + <> + {channels + .sort((a, b) => + a.localBalance + a.remoteBalance > + b.localBalance + b.remoteBalance + ? -1 + : 1 + ) + .map((channel) => { + const node = nodes?.find( + (n) => n.public_key === channel.remotePubkey + ); + const alias = node?.alias || "Unknown"; + const capacity = channel.localBalance + channel.remoteBalance; + + return ( + + + {channel.status == "online" ? ( + Online + ) : channel.status == "opening" ? ( + Opening + ) : ( + Offline + )} + + + {alias} + + {channel.public ? "Public" : "Private"} + + + + {formatAmount(capacity)} sats + + + {channel.localBalance < + channel.unspendablePunishmentReserve * 1000 && ( + <> + {formatAmount( + Math.min( + channel.localBalance, + channel.unspendablePunishmentReserve * 1000 + ) + )}{" "} + /{" "} + + )} + {formatAmount( + channel.unspendablePunishmentReserve * 1000 + )}{" "} + sats + + +
+ +
+ + {formatAmount(channel.localSpendableBalance)} sats + + + {formatAmount(channel.remoteBalance)} sats + +
+
+
+ + + + + + + + + + + + +

View Funding Transaction

+
+
+ + + +

View Node on amboss.space

+
+
+ {channel.public && ( + editChannel(channel)} + > + + Set Routing Fee + + )} + + closeChannel( + channel.id, + channel.remotePubkey, + channel.active + ) + } + > + + Close Channel + +
+
+
+
+ ); + })} + + )} + {!channels && ( + + + + + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/icons/AlbyHubLogo.tsx b/frontend/src/components/icons/AlbyHubLogo.tsx index abd42e0b..48a1783c 100644 --- a/frontend/src/components/icons/AlbyHubLogo.tsx +++ b/frontend/src/components/icons/AlbyHubLogo.tsx @@ -10,7 +10,7 @@ export function AlbyHubLogo(props: SVGAttributes) { xmlns="http://www.w3.org/2000/svg" {...props} > - + { - const isDesktop = useMediaQuery(desktop); + const isDesktop = useIsDesktop(); return ( {children} diff --git a/frontend/src/hooks/useMediaQuery.ts b/frontend/src/hooks/useMediaQuery.ts index 48db609e..6c3a6271 100644 --- a/frontend/src/hooks/useMediaQuery.ts +++ b/frontend/src/hooks/useMediaQuery.ts @@ -17,3 +17,8 @@ export function useMediaQuery(query: string) { return value; } + +export function useIsDesktop() { + const desktop = "(min-width: 768px)"; + return useMediaQuery(desktop); +} diff --git a/frontend/src/screens/channels/Channels.tsx b/frontend/src/screens/channels/Channels.tsx index 2bb8424d..4b180b19 100644 --- a/frontend/src/screens/channels/Channels.tsx +++ b/frontend/src/screens/channels/Channels.tsx @@ -5,18 +5,16 @@ import { Bitcoin, ChevronDown, CopyIcon, - ExternalLinkIcon, - HandCoins, Heart, Hotel, InfoIcon, - MoreHorizontal, - Trash2, Unplug, } from "lucide-react"; import React from "react"; import { Link } from "react-router-dom"; import AppHeader from "src/components/AppHeader.tsx"; +import { ChannelsCards } from "src/components/channels/ChannelsCards.tsx"; +import { ChannelsTable } from "src/components/channels/ChannelsTable.tsx"; import EmptyState from "src/components/EmptyState.tsx"; import ExternalLink from "src/components/ExternalLink"; import Loading from "src/components/Loading.tsx"; @@ -25,7 +23,6 @@ import { AlertDescription, AlertTitle, } from "src/components/ui/alert.tsx"; -import { Badge } from "src/components/ui/badge.tsx"; import { Button } from "src/components/ui/button.tsx"; import { Card, @@ -44,15 +41,7 @@ import { DropdownMenuTrigger, } from "src/components/ui/dropdown-menu.tsx"; import { LoadingButton } from "src/components/ui/loading-button.tsx"; -import { CircleProgress, Progress } from "src/components/ui/progress.tsx"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "src/components/ui/table.tsx"; +import { CircleProgress } from "src/components/ui/progress.tsx"; import { Tooltip, TooltipContent, @@ -68,11 +57,12 @@ import { useAlbyBalance } from "src/hooks/useAlbyBalance.ts"; import { useBalances } from "src/hooks/useBalances.ts"; import { useChannels } from "src/hooks/useChannels"; import { useInfo } from "src/hooks/useInfo"; +import { useIsDesktop } from "src/hooks/useMediaQuery.ts"; import { useNodeConnectionInfo } from "src/hooks/useNodeConnectionInfo.ts"; import { useRedeemOnchainFunds } from "src/hooks/useRedeemOnchainFunds.ts"; import { useSyncWallet } from "src/hooks/useSyncWallet.ts"; import { copyToClipboard } from "src/lib/clipboard.ts"; -import { cn, formatAmount } from "src/lib/utils.ts"; +import { cn } from "src/lib/utils.ts"; import { Channel, CloseChannelResponse, @@ -95,6 +85,7 @@ export default function Channels() { const { toast } = useToast(); const [drainingAlbySharedFunds, setDrainingAlbySharedFunds] = React.useState(false); + const isDesktop = useIsDesktop(); const nodeHealth = channels ? getNodeHealth(channels) : 0; @@ -656,247 +647,21 @@ export default function Channels() { /> )} - {!channels || - (channels.length > 0 && ( -
- - - - Status - Node - Capacity - - - - -
- Reserve - -
-
- - Funds each participant sets aside to discourage - cheating by ensuring each party has something at - stake. This reserve cannot be spent during the - channel's lifetime and typically amounts to 1% of the - channel capacity. - -
-
-
- -
-
Spending
-
Receiving
-
-
- - -
-
- - {channels && channels.length > 0 && ( - <> - {channels - .sort((a, b) => - a.localBalance + a.remoteBalance > - b.localBalance + b.remoteBalance - ? -1 - : 1 - ) - .map((channel) => { - const node = nodes.find( - (n) => n.public_key === channel.remotePubkey - ); - const alias = node?.alias || "Unknown"; - const capacity = - channel.localBalance + channel.remoteBalance; - - let channelWarning = ""; - if (channel.error) { - channelWarning = channel.error; - } else { - if (channel.localSpendableBalance < capacity * 0.1) { - channelWarning = - "Spending balance low. You may have trouble sending payments through this channel."; - } - if (channel.localSpendableBalance > capacity * 0.9) { - channelWarning = - "Receiving capacity low. You may have trouble receiving payments through this channel."; - } - } - - const channelStatus = channel.active - ? "online" - : channel.confirmationsRequired !== undefined && - channel.confirmations !== undefined && - channel.confirmationsRequired > - channel.confirmations - ? "opening" - : "offline"; - if (channelStatus === "opening") { - channelWarning = `Channel is currently being opened (${channel.confirmations} of ${channel.confirmationsRequired} confirmations). Once the required confirmation are reached, you will be able to send and receive on this channel.`; - } - if (channelStatus === "offline") { - channelWarning = - "This channel is currently offline and cannot be used to send or receive payments. Please contact Alby Support for more information."; - } - - return ( - - - {channelStatus == "online" ? ( - Online - ) : channelStatus == "opening" ? ( - Opening - ) : ( - Offline - )} - - - - - - - {channel.public ? "Public" : "Private"} - - - - {formatAmount(capacity)} sats - - - {channel.localBalance < - channel.unspendablePunishmentReserve * 1000 && ( - <> - {formatAmount( - Math.min( - channel.localBalance, - channel.unspendablePunishmentReserve * - 1000 - ) - )}{" "} - /{" "} - - )} - {formatAmount( - channel.unspendablePunishmentReserve * 1000 - )}{" "} - sats - - -
- -
- - {formatAmount( - channel.localSpendableBalance - )}{" "} - sats - - - {formatAmount(channel.remoteBalance)} sats - -
-
-
- - {channelWarning ? ( - - - - - - - {channelWarning} - - - - ) : null} - - - - - - - - - - -

View Funding Transaction

-
-
- {channel.public && ( - editChannel(channel)} - > - - Set Routing Fee - - )} - - closeChannel( - channel.id, - channel.remotePubkey, - channel.active - ) - } - > - - Close Channel - -
-
-
-
- ); - })} - - )} - {!channels && ( - - - - - - )} -
-
-
- ))} + {isDesktop ? ( + + ) : ( + + )} ); } diff --git a/frontend/src/themes/alby.css b/frontend/src/themes/alby.css index db52a422..117da5ad 100644 --- a/frontend/src/themes/alby.css +++ b/frontend/src/themes/alby.css @@ -1,23 +1,35 @@ .theme-alby { --background: 0 0% 100%; --foreground: 0 0% 5%; + --card: 0 0% 100%; --card-foreground: 0 0% 5%; + --popover: 0 0% 100%; --popover-foreground: 0 0% 5%; + --primary: 47 100% 72%; --primary-foreground: 0 0% 2%; + --secondary: 0 0% 96%; --secondary-foreground: 0 0% 5%; + --muted: 0 0% 96%; --muted-foreground: 0 0% 45%; + --accent: 0 0% 96%; --accent-foreground: 0 0% 5%; + --destructive: 0 84% 60%; --destructive-foreground: 0 0% 98%; + + --positive: 138, 68%, 96%; + --positive-foreground: 142 76% 36%; + --border: 0 0% 92%; --input: 0 0% 85%; --ring: 0 0% 76%; + --radius: 0.5rem; } @@ -46,6 +58,9 @@ --destructive: 0 84% 60%; --destructive-foreground: 0 0% 98%; + --warning: 33 90% 96%; + --warning-foreground: 21 90% 48%; + --border: 0 0% 15%; --input: 0 0% 15%; --ring: 47 100% 40%; diff --git a/frontend/src/themes/bitcoin.css b/frontend/src/themes/bitcoin.css index eb7cd837..8799dab1 100644 --- a/frontend/src/themes/bitcoin.css +++ b/frontend/src/themes/bitcoin.css @@ -23,6 +23,12 @@ --destructive: 0 84% 60%; --destructive-foreground: 0 0% 98%; + --positive: 138, 68%, 96%; + --positive-foreground: 142 76% 36%; + + --warning: 33, 90%, 96%; + --warning-foreground: 21, 90%, 48%; + --border: 0 0% 92%; --input: 0 0% 85%; --ring: 0 0% 76%; diff --git a/frontend/src/themes/default.css b/frontend/src/themes/default.css index 64a4a1bd..cef648a0 100644 --- a/frontend/src/themes/default.css +++ b/frontend/src/themes/default.css @@ -1,25 +1,37 @@ .theme-default { --background: 0 0% 100%; --foreground: 240 10% 3.9%; + --card: 0 0% 100%; --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; + + --positive: 138, 68%, 96%; + --positive-foreground: 142 76% 36%; + + --warning: 33, 90%, 96%; + --warning-foreground: 21, 90%, 48%; + --border: 240 5.9% 90%; --input: 240 5.9% 90%; --ring: 240 5.9% 10%; - --positive: 138, 68%, 96%; - --positive-foreground: 142 76% 36%; --radius: 0.5rem; } diff --git a/frontend/src/themes/nostr.css b/frontend/src/themes/nostr.css index 01b9c732..fbe5734a 100644 --- a/frontend/src/themes/nostr.css +++ b/frontend/src/themes/nostr.css @@ -23,6 +23,12 @@ --destructive: 0 84% 60%; --destructive-foreground: 0 0% 98%; + --positive: 138, 68%, 96%; + --positive-foreground: 142 76% 36%; + + --warning: 33, 90%, 96%; + --warning-foreground: 21, 90%, 48%; + --border: 270 9% 87%; --input: 267 9% 81%; --ring: 273 36% 72%; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b26c4d51..a3eb7962 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -195,6 +195,7 @@ export type Channel = { unspendablePunishmentReserve: number; counterpartyUnspendablePunishmentReserve: number; error?: string; + status: "online" | "opening" | "offline"; }; export type UpdateChannelRequest = { diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index afa21e7a..f2835e2a 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -61,6 +61,10 @@ module.exports = { DEFAULT: "hsl(var(--positive))", foreground: "hsl(var(--positive-foreground))", }, + warning: { + DEFAULT: "hsl(var(--warning))", + foreground: "hsl(var(--warning-foreground))", + }, }, borderRadius: { lg: "var(--radius)", diff --git a/lnclient/ldk/ldk.go b/lnclient/ldk/ldk.go index 35bea447..0146f87e 100644 --- a/lnclient/ldk/ldk.go +++ b/lnclient/ldk/ldk.go @@ -263,6 +263,7 @@ func NewLDKService(ctx context.Context, cfg config.Config, eventPublisher events case <-time.After(MIN_SYNC_INTERVAL): ls.syncing = true // always update fee rates to avoid differences in fee rates with channel partners + logger.Logger.Info("Updating fee estimates") err = node.UpdateFeeEstimates() if err != nil { logger.Logger.WithError(err).Error("Failed to update fee estimates") diff --git a/lnclient/models.go b/lnclient/models.go index 8ff75a12..513bc49a 100644 --- a/lnclient/models.go +++ b/lnclient/models.go @@ -79,21 +79,21 @@ type LNClient interface { } type Channel struct { - LocalBalance int64 `json:"localBalance"` - LocalSpendableBalance int64 `json:"localSpendableBalance"` - RemoteBalance int64 `json:"remoteBalance"` - Id string `json:"id"` - RemotePubkey string `json:"remotePubkey"` - FundingTxId string `json:"fundingTxId"` - Active bool `json:"active"` - Public bool `json:"public"` - InternalChannel interface{} `json:"internalChannel"` - Confirmations *uint32 `json:"confirmations"` - ConfirmationsRequired *uint32 `json:"confirmationsRequired"` - ForwardingFeeBaseMsat uint32 `json:"forwardingFeeBaseMsat"` - UnspendablePunishmentReserve uint64 `json:"unspendablePunishmentReserve"` - CounterpartyUnspendablePunishmentReserve uint64 `json:"counterpartyUnspendablePunishmentReserve"` - Error *string `json:"error"` + LocalBalance int64 + LocalSpendableBalance int64 + RemoteBalance int64 + Id string + RemotePubkey string + FundingTxId string + Active bool + Public bool + InternalChannel interface{} + Confirmations *uint32 + ConfirmationsRequired *uint32 + ForwardingFeeBaseMsat uint32 + UnspendablePunishmentReserve uint64 + CounterpartyUnspendablePunishmentReserve uint64 + Error *string } type NodeStatus struct {