Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add channels cards on mobile #368

Merged
merged 12 commits into from
Jul 31, 2024
37 changes: 35 additions & 2 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
21 changes: 20 additions & 1 deletion api/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"`
}
3 changes: 1 addition & 2 deletions frontend/src/components/SidebarHint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { Button } from "src/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "src/components/ui/card";
Expand Down Expand Up @@ -90,9 +89,9 @@ function SidebarHintCard({
<CardHeader className="p-4">
<Icon className="h-8 w-8 mb-4" />
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-muted-foreground mb-4">{description}</div>
<Link to={buttonLink}>
<Button size="sm" className="w-full">
{buttonText}
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/components/channels/ChannelWarning.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { AlertTriangle } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "src/components/ui/tooltip";
import { Channel } from "src/types";

type ChannelWarningProps = {
channel: Channel;
};

export function ChannelWarning({ channel }: ChannelWarningProps) {
const capacity = channel.localBalance + channel.remoteBalance;
let channelWarning = channel.error;
if (!channelWarning && channel.status === "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 (!channelWarning && channel.status === "offline") {
channelWarning =
"This channel is currently offline and cannot be used to send or receive payments. Please contact Alby Support for more information.";
}
if (!channelWarning && channel.localSpendableBalance > capacity * 0.9) {
channelWarning =
"Receiving capacity low. You may have trouble receiving payments through this channel.";
}

if (!channelWarning) {
return null;
}

return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<AlertTriangle className="w-4 h-4" />
</TooltipTrigger>
<TooltipContent className="max-w-[400px]">
{channelWarning}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
242 changes: 242 additions & 0 deletions frontend/src/components/channels/ChannelsCards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import {
ExternalLinkIcon,
HandCoins,
InfoIcon,
MoreHorizontal,
Trash2,
} from "lucide-react";
import { ChannelWarning } from "src/components/channels/ChannelWarning";
import ExternalLink from "src/components/ExternalLink";
import { Badge } from "src/components/ui/badge.tsx";
import { Button } from "src/components/ui/button.tsx";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "src/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "src/components/ui/dropdown-menu.tsx";
import { Progress } from "src/components/ui/progress.tsx";
import { Separator } from "src/components/ui/separator";
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 ChannelsCardsProps = {
channels?: Channel[];
nodes?: Node[];
closeChannel(
channelId: string,
counterpartyNodeId: string,
isActive: boolean
): void;
editChannel(channel: Channel): void;
};

export function ChannelsCards({
channels,
nodes,
closeChannel,
editChannel,
}: ChannelsCardsProps) {
if (!channels?.length) {
return null;
}

return (
<>
<p className="font-semibold text-lg mt-4">Channels</p>
<div className="flex flex-col gap-4">
{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 (
<Card>
<CardHeader className="w-full pb-4">
<div className="flex flex-col items-start w-full">
<CardTitle className="w-full">
<div className="flex items-center justify-between">
<div className="flex-1 whitespace-nowrap text-ellipsis overflow-hidden">
{alias}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="flex flex-row items-center gap-2 cursor-pointer">
<ExternalLink
to={`https://mempool.space/tx/${channel.fundingTxId}`}
className="w-full flex flex-row items-center gap-2"
>
<ExternalLinkIcon className="w-4 h-4" />
<p>View Funding Transaction</p>
</ExternalLink>
</DropdownMenuItem>
<DropdownMenuItem className="flex flex-row items-center gap-2 cursor-pointer">
<ExternalLink
to={`https://amboss.space/node/${channel.remotePubkey}`}
className="w-full flex flex-row items-center gap-2"
>
<ExternalLinkIcon className="w-4 h-4" />
<p>View Node on amboss.space</p>
</ExternalLink>
</DropdownMenuItem>
{channel.public && (
<DropdownMenuItem
className="flex flex-row items-center gap-2 cursor-pointer"
onClick={() => editChannel(channel)}
>
<HandCoins className="h-4 w-4" />
Set Routing Fee
</DropdownMenuItem>
)}
<DropdownMenuItem
className="flex flex-row items-center gap-2 cursor-pointer"
onClick={() =>
closeChannel(
channel.id,
channel.remotePubkey,
channel.active
)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
Close Channel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardTitle>
<Separator className="mt-5" />
<CardDescription className="w-full flex flex-col gap-4 mt-4">
<div className="flex w-full justify-between items-center">
<p className="text-muted-foreground font-medium">
Status
</p>
{channel.status == "online" ? (
<Badge variant="positive">Online</Badge>
) : channel.status == "opening" ? (
<Badge variant="outline">Opening</Badge>
) : (
<Badge variant="warning">Offline</Badge>
)}
</div>
<div className="flex w-full justify-between items-center">
<p className="text-muted-foreground font-medium">
Type
</p>
<p className="text-foreground">
{channel.public ? "Public" : "Private"}
</p>
</div>
<div className="flex justify-between items-center">
<p className="text-muted-foreground font-medium">
Capacity
</p>
<p className="text-foreground">
{formatAmount(capacity)} sats
</p>
</div>
<div className="flex justify-between items-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="flex flex-row gap-2 items-center">
<p className="text-muted-foreground font-medium">
Reserve
</p>
<InfoIcon className="h-4 w-4 shrink-0" />
</div>
</TooltipTrigger>
<TooltipContent className="w-[400px]">
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.
</TooltipContent>
</Tooltip>
</TooltipProvider>
<p className="text-foreground">
{channel.localBalance <
channel.unspendablePunishmentReserve * 1000 && (
<>
{formatAmount(
Math.min(
channel.localBalance,
channel.unspendablePunishmentReserve * 1000
)
)}{" "}
/{" "}
</>
)}
{formatAmount(
channel.unspendablePunishmentReserve * 1000
)}{" "}
sats
</p>
</div>
</CardDescription>
</div>
</CardHeader>

<CardContent>
<div className="flex justify-between items-center">
<p className="text-muted-foreground font-medium text-sm">
Spending
</p>
<p className="text-muted-foreground font-medium text-sm">
Receiving
</p>
</div>
<div className="flex gap-2 items-center mt-2">
<div className="flex-1 relative">
<Progress
value={(channel.localSpendableBalance / capacity) * 100}
className="h-6 absolute"
/>
<div className="flex flex-row w-full justify-between px-2 text-xs items-center h-6 mix-blend-exclusion text-white">
<span
title={channel.localSpendableBalance / 1000 + " sats"}
>
{formatAmount(channel.localSpendableBalance)} sats
</span>
<span title={channel.remoteBalance / 1000 + " sats"}>
{formatAmount(channel.remoteBalance)} sats
</span>
</div>
</div>
<ChannelWarning channel={channel} />
</div>
</CardContent>
</Card>
);
})}
</div>
</>
);
}
Loading
Loading