Wallet transactions: Add label manager (#4796)

* Wallet transactions: Add label manager

* Update BTCPayServer/Views/UIWallets/WalletTransactions.cshtml

Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>

* Add rich label info

* Fixes

* support labels in wallet send

* add labels to tx info page

* Remove noscript parts

* Allow click on transaction label info

* update psbt info labelstyling

* revert red pixel fix as it broke all

---------

Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>
This commit is contained in:
d11n 2023-03-26 13:42:38 +02:00 committed by GitHub
parent 8635fcfe84
commit 95f3e429b4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 453 additions and 227 deletions

View file

@ -1374,11 +1374,11 @@ namespace BTCPayServer.Tests
// Can add a label?
await TestUtils.EventuallyAsync(async () =>
{
s.Driver.WaitForElement(By.CssSelector("div.label-manager input ")).Click();
s.Driver.WaitForElement(By.CssSelector("div.label-manager input")).Click();
await Task.Delay(500);
s.Driver.WaitForElement(By.CssSelector("div.label-manager input ")).SendKeys("test-label" + Keys.Enter);
s.Driver.WaitForElement(By.CssSelector("div.label-manager input")).SendKeys("test-label" + Keys.Enter);
await Task.Delay(500);
s.Driver.WaitForElement(By.CssSelector("div.label-manager input ")).SendKeys("label2" + Keys.Enter);
s.Driver.WaitForElement(By.CssSelector("div.label-manager input")).SendKeys("label2" + Keys.Enter);
});
TestUtils.Eventually(() =>

View file

@ -19,7 +19,7 @@ namespace BTCPayServer
var bg = ColorTranslator.FromHtml(bgColor);
int bgDelta = Convert.ToInt32((bg.R * 0.299) + (bg.G * 0.587) + (bg.B * 0.114));
Color color = (255 - bgDelta < nThreshold) ? Color.Black : Color.White;
return ColorTranslator.ToHtml(color);
return ColorTranslator.ToHtml(color).ToLowerInvariant();
}
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
public static readonly ColorPalette Default = new ColorPalette(new string[] {

View file

@ -1,104 +1,22 @@
@using NBitcoin.DataEncoders
@using NBitcoin
@using BTCPayServer.Abstractions.TagHelpers
@model BTCPayServer.Components.LabelManager.LabelViewModel
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery
@{
var commonCall = Model.ObjectId.Type + Model.ObjectId.Id;
var elementId = "a" + Encoders.Base58.EncodeData(RandomUtils.GetBytes(16));
var fetchUrl = Url.Action("GetLabels", "UIWallets", new {
walletId = Model.WalletObjectId.WalletId,
excludeTypes = Safe.Json(Model.ExcludeTypes)
});
var updateUrl = Model.AutoUpdate? Url.Action("UpdateLabels", "UIWallets", new {
walletId = Model.WalletObjectId.WalletId
}): string.Empty;
}
<link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" rel="stylesheet">
<script src="~/vendor/tom-select/tom-select.complete.min.js"></script>
<script>
const updateUrl = @Safe.Json(Url.Action("UpdateLabels", "UIWallets", new {
Model.ObjectId.WalletId
}));
const getUrl = @Safe.Json(@Url.Action("GetLabels", "UIWallets", new {
walletId = Model.ObjectId.WalletId,
excludeTypes = true
}));
const commonCall = @Safe.Json(commonCall);
const elementId = @Safe.Json(elementId);
if (!window[commonCall]) {
window[commonCall] = fetch(getUrl, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
}).then(response => {
return response.json();
});
}
document.addEventListener("DOMContentLoaded", async () => {
const element = document.querySelector(`#${elementId}`);
if (element) {
const labelsFetchTask = await window[commonCall];
const config = {
create: true,
items: @Safe.Json(Model.SelectedLabels),
options: labelsFetchTask,
valueField: "label",
labelField: "label",
searchField: "label",
allowEmptyOption: false,
closeAfterSelect: false,
persist: true,
render: {
option: function(data, escape) {
return `<div ${data.color? `style='background-color:${data.color}; color:${data.textColor}'`: ""}>${escape(data.label)}</div>`;
},
item: function(data, escape) {
return `<div ${data.color? `style='background-color:${data.color}; color:${data.textColor}'`: ""}>${escape(data.label)}</div>`;
}
},
onItemAdd: (val) => {
window[commonCall] = window[commonCall].then(labels => {
return [...labels, { label: val }]
});
document.dispatchEvent(new CustomEvent(`${commonCall}-option-added`, {
detail: val
}));
},
onChange: async (values) => {
select.lock();
try {
const response = await fetch(updateUrl, {
method: "POST",
credentials: "include",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
address: @Safe.Json(Model.ObjectId.Id),
labels: select.items
})
});
if (!response.ok) {
throw new Error('Network response was not OK');
}
} catch (error) {
console.error('There has been a problem with your fetch operation:', error);
} finally {
select.unlock();
}
}
};
const select = new TomSelect(element, config);
document.addEventListener(`${commonCall}-option-added`, evt => {
if (!(evt.detail in select.options)) {
select.addOption({
label: evt.detail
})
}
})
}
})
</script>
<input id="@elementId" placeholder="Select labels to associate with this object" autocomplete="off" class="form-control label-manager"/>
<input id="@elementId" placeholder="Select labels" autocomplete="off" value="@string.Join(",", Model.SelectedLabels)"
class="only-for-js form-control label-manager ts-wrapper @(Model.DisplayInline ? "ts-inline" : "")"
data-fetch-url="@fetchUrl"
data-update-url="@updateUrl"
data-wallet-id="@Model.WalletObjectId.WalletId"
data-wallet-object-id="@Model.WalletObjectId.Id"
data-wallet-object-type="@Model.WalletObjectId.Type"
data-select-element="@Model.SelectElement"
data-labels='@Safe.Json(Model.RichLabelInfo)' />

View file

@ -1,3 +1,5 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Mvc;
@ -5,14 +7,25 @@ namespace BTCPayServer.Components.LabelManager
{
public class LabelManager : ViewComponent
{
public IViewComponentResult Invoke(WalletObjectId walletObjectId, string[] selectedLabels)
public IViewComponentResult Invoke(WalletObjectId walletObjectId, string[] selectedLabels, bool excludeTypes = true, bool displayInline = false, Dictionary<string, RichLabelInfo> richLabelInfo = null, bool autoUpdate = true, string selectElement = null)
{
var vm = new LabelViewModel
{
ObjectId = walletObjectId,
SelectedLabels = selectedLabels
ExcludeTypes = excludeTypes,
WalletObjectId = walletObjectId,
SelectedLabels = selectedLabels?? Array.Empty<string>(),
DisplayInline = displayInline,
RichLabelInfo = richLabelInfo,
AutoUpdate = autoUpdate,
SelectElement = selectElement
};
return View(vm);
}
}
public class RichLabelInfo
{
public string Link { get; set; }
public string Tooltip { get; set; }
}
}

View file

@ -1,3 +1,4 @@
using System.Collections.Generic;
using BTCPayServer.Services;
namespace BTCPayServer.Components.LabelManager
@ -5,6 +6,11 @@ namespace BTCPayServer.Components.LabelManager
public class LabelViewModel
{
public string[] SelectedLabels { get; set; }
public WalletObjectId ObjectId { get; set; }
public WalletObjectId WalletObjectId { get; set; }
public bool ExcludeTypes { get; set; }
public bool DisplayInline { get; set; }
public Dictionary<string, RichLabelInfo> RichLabelInfo { get; set; }
public bool AutoUpdate { get; set; }
public string SelectElement { get; set; }
}
}

View file

@ -7,6 +7,7 @@ using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.BIP78.Sender;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.ModelBinders;
using BTCPayServer.Models;
@ -266,7 +267,7 @@ namespace BTCPayServer.Controllers
ModelState.Remove(nameof(vm.PSBT));
ModelState.Remove(nameof(vm.FileName));
ModelState.Remove(nameof(vm.UploadedPSBTFile));
await FetchTransactionDetails(derivationSchemeSettings, vm, network);
await FetchTransactionDetails(walletId,derivationSchemeSettings, vm, network);
return View("WalletPSBTDecoded", vm);
case "save-psbt":
@ -320,7 +321,7 @@ namespace BTCPayServer.Controllers
return await _payjoinClient.RequestPayjoin(bip21, new PayjoinWallet(derivationSchemeSettings), psbt, cts.Token);
}
private async Task FetchTransactionDetails(DerivationSchemeSettings derivationSchemeSettings, WalletPSBTReadyViewModel vm, BTCPayNetwork network)
private async Task FetchTransactionDetails(WalletId walletId, DerivationSchemeSettings derivationSchemeSettings, WalletPSBTReadyViewModel vm, BTCPayNetwork network)
{
var psbtObject = PSBT.Parse(vm.SigningContext.PSBT, network.NBitcoinNetwork);
if (!psbtObject.IsAllFinalized())
@ -371,17 +372,29 @@ namespace BTCPayServer.Controllers
vm.Positive = balanceChange >= Money.Zero;
}
vm.Inputs = new List<WalletPSBTReadyViewModel.InputViewModel>();
var inputToObjects = new Dictionary<uint, ObjectTypeId[]>();
var outputToObjects = new Dictionary<string, ObjectTypeId>();
foreach (var input in psbtObject.Inputs)
{
var inputVm = new WalletPSBTReadyViewModel.InputViewModel();
vm.Inputs.Add(inputVm);
var txOut = input.GetTxOut();
var mine = input.HDKeysFor(derivationSchemeSettings.AccountDerivation, signingKey, signingKeyPath).Any();
var balanceChange2 = input.GetTxOut()?.Value ?? Money.Zero;
var balanceChange2 = txOut?.Value ?? Money.Zero;
if (mine)
balanceChange2 = -balanceChange2;
inputVm.BalanceChange = ValueToString(balanceChange2, network);
inputVm.Positive = balanceChange2 >= Money.Zero;
inputVm.Index = (int)input.Index;
var walletObjectIds = new List<ObjectTypeId>();
walletObjectIds.Add(new ObjectTypeId(WalletObjectData.Types.Utxo, input.PrevOut.ToString()));
walletObjectIds.Add(new ObjectTypeId(WalletObjectData.Types.Tx, input.PrevOut.Hash.ToString()));
var address = txOut?.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString();
if(address != null)
walletObjectIds.Add(new ObjectTypeId(WalletObjectData.Types.Address, address));
inputToObjects.Add(input.Index, walletObjectIds.ToArray());
}
vm.Destinations = new List<WalletPSBTReadyViewModel.DestinationViewModel>();
foreach (var output in psbtObject.Outputs)
@ -395,6 +408,10 @@ namespace BTCPayServer.Controllers
dest.Balance = ValueToString(balanceChange2, network);
dest.Positive = balanceChange2 >= Money.Zero;
dest.Destination = output.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString() ?? output.ScriptPubKey.ToString();
var address = output.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString();
if(address != null)
outputToObjects.Add(dest.Destination, new ObjectTypeId(WalletObjectData.Types.Address, address));
}
if (psbtObject.TryGetFee(out var fee))
@ -420,6 +437,38 @@ namespace BTCPayServer.Controllers
{
vm.SetErrors(errors);
}
var combinedTypeIds = inputToObjects.Values.SelectMany(ids => ids).Concat(outputToObjects.Values)
.DistinctBy(id => $"{id.Type}:{id.Id}").ToArray();
var labelInfo = await WalletRepository.GetWalletTransactionsInfo(walletId, combinedTypeIds);
foreach (KeyValuePair<uint,ObjectTypeId[]> inputToObject in inputToObjects)
{
var keys = inputToObject.Value.Select(id => id.Id).ToArray();
WalletTransactionInfo ix = null;
foreach (var key in keys)
{
if (!labelInfo.TryGetValue(key, out var i)) continue;
if (ix is null)
{
ix = i;
}
else
{
ix.Merge(i);
}
}
if (ix is null) continue;
var input = vm.Inputs.First(model => model.Index == inputToObject.Key);
input.Labels = ix.LabelColors;
}
foreach (var outputToObject in outputToObjects)
{
if (!labelInfo.TryGetValue(outputToObject.Value.Id, out var ix)) continue;
var destination = vm.Destinations.First(model => model.Destination == outputToObject.Key);
destination.Labels = ix.LabelColors;
}
}
[HttpPost("{walletId}/psbt/ready")]
@ -439,7 +488,7 @@ namespace BTCPayServer.Controllers
if (derivationSchemeSettings == null)
return NotFound();
await FetchTransactionDetails(derivationSchemeSettings, vm, network);
await FetchTransactionDetails(walletId,derivationSchemeSettings, vm, network);
switch (command)
{
@ -570,7 +619,7 @@ namespace BTCPayServer.Controllers
BackUrl = vm.BackUrl
});
case "decode":
await FetchTransactionDetails(derivationSchemeSettings, vm, network);
await FetchTransactionDetails(walletId,derivationSchemeSettings, vm, network);
return View("WalletPSBTDecoded", vm);
default:
vm.Errors.Add("Unknown command");

View file

@ -235,8 +235,7 @@ namespace BTCPayServer.Controllers
var model = new ListTransactionsViewModel { Skip = skip, Count = count };
model.Labels.AddRange(
(await WalletRepository.GetWalletLabels(walletId))
.Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color)))
);
.Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color))));
if (labelFilter != null)
{
@ -733,6 +732,18 @@ namespace BTCPayServer.Controllers
if (!ModelState.IsValid)
return View(vm);
foreach (var transactionOutput in vm.Outputs.Where(output => output.Labels?.Any() is true))
{
var labels = transactionOutput.Labels.Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
var walletObjectAddress = new WalletObjectId(walletId, WalletObjectData.Types.Address, transactionOutput.DestinationAddress.ToLowerInvariant());
var obj = await WalletRepository.GetWalletObject(walletObjectAddress);
if (obj is null)
{
await WalletRepository.EnsureWalletObject(walletObjectAddress);
}
await WalletRepository.AddWalletObjectLabels(walletObjectAddress, labels);
}
var derivationScheme = GetDerivationSchemeSettings(walletId);
if (derivationScheme is null)
return NotFound();
@ -1324,18 +1335,21 @@ namespace BTCPayServer.Controllers
public class UpdateLabelsRequest
{
public string? Address { get; set; }
public string? Id { get; set; }
public string? Type { get; set; }
public string[]? Labels { get; set; }
}
[HttpPost("{walletId}/update-labels")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> UpdateLabels([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, [FromBody] UpdateLabelsRequest request)
public async Task<IActionResult> UpdateLabels(
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
[FromBody] UpdateLabelsRequest request)
{
if (string.IsNullOrEmpty(request.Address) || request.Labels is null)
if (string.IsNullOrEmpty(request.Type) || string.IsNullOrEmpty(request.Id) || request.Labels is null)
return BadRequest();
var objid = new WalletObjectId(walletId, WalletObjectData.Types.Address, request.Address);
var objid = new WalletObjectId(walletId, request.Type, request.Id);
var obj = await WalletRepository.GetWalletObject(objid);
if (obj is null)
{
@ -1353,17 +1367,26 @@ namespace BTCPayServer.Controllers
[HttpGet("{walletId}/labels")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> GetLabels( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, bool excludeTypes)
public async Task<IActionResult> GetLabels(
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
bool excludeTypes,
string? type = null,
string? id = null)
{
return Ok(( await WalletRepository.GetWalletLabels(walletId))
.Where(l => !excludeTypes || !WalletObjectData.Types.AllTypes.Contains(l.Label))
.Select(tuple => new
{
label = tuple.Label,
color = tuple.Color,
textColor = ColorPalette.Default.TextColor(tuple.Color)
}));
var walletObjectId = !string.IsNullOrEmpty(type) && !string.IsNullOrEmpty(id)
? new WalletObjectId(walletId, type, id)
: null;
var labels = walletObjectId == null
? await WalletRepository.GetWalletLabels(walletId)
: await WalletRepository.GetWalletLabels(walletObjectId);
return Ok(labels
.Where(l => !excludeTypes || !WalletObjectData.Types.AllTypes.Contains(l.Label))
.Select(tuple => new
{
label = tuple.Label,
color = tuple.Color,
textColor = ColorPalette.Default.TextColor(tuple.Color)
}));
}
private string GetImage(PaymentMethodId paymentMethodId, BTCPayNetwork network)

View file

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Services.Labels;
namespace BTCPayServer.Models.WalletViewModels
{
@ -15,10 +14,10 @@ namespace BTCPayServer.Models.WalletViewModels
public string Link { get; set; }
public bool Positive { get; set; }
public string Balance { get; set; }
public HashSet<TransactionTagModel> Tags { get; set; } = new HashSet<TransactionTagModel>();
public HashSet<TransactionTagModel> Tags { get; set; } = new ();
}
public HashSet<(string Text, string Color, string TextColor)> Labels { get; set; } = new HashSet<(string Text, string Color, string TextColor)>();
public List<TransactionViewModel> Transactions { get; set; } = new List<TransactionViewModel>();
public HashSet<(string Text, string Color, string TextColor)> Labels { get; set; } = new ();
public List<TransactionViewModel> Transactions { get; set; } = new ();
public override int CurrentPageCount => Transactions.Count;
public string CryptoCode { get; set; }
}

View file

@ -16,6 +16,7 @@ namespace BTCPayServer.Models.WalletViewModels
public bool Positive { get; set; }
public string Destination { get; set; }
public string Balance { get; set; }
public Dictionary<string, string> Labels { get; set; } = new();
}
public class InputViewModel
@ -24,6 +25,7 @@ namespace BTCPayServer.Models.WalletViewModels
public string Error { get; set; }
public bool Positive { get; set; }
public string BalanceChange { get; set; }
public Dictionary<string, string> Labels { get; set; } = new();
}
public bool HasErrors => Inputs.Count == 0 || Inputs.Any(i => !string.IsNullOrEmpty(i.Error));
public string BalanceChange { get; set; }

View file

@ -34,6 +34,8 @@ namespace BTCPayServer.Models.WalletViewModels
public bool SubtractFeesFromOutput { get; set; }
public string PayoutId { get; set; }
public string[] Labels { get; set; } = Array.Empty<string>();
}
public decimal CurrentBalance { get; set; }
public decimal ImmatureBalance { get; set; }

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
@ -309,21 +310,33 @@ namespace BTCPayServer.Services
#nullable enable
public async Task<(string Label, string Color)[]> GetWalletLabels(WalletId walletId)
{
return await GetWalletLabels(w =>
w.WalletId == walletId.ToString() &&
w.Type == WalletObjectData.Types.Label);
}
public async Task<(string Label, string Color)[]> GetWalletLabels(WalletObjectId objectId)
{
return await GetWalletLabels(w =>
w.WalletId == objectId.WalletId.ToString() &&
w.Type == objectId.Type &&
w.Id == objectId.Id);
}
private async Task<(string Label, string Color)[]> GetWalletLabels(Expression<Func<WalletObjectData, bool>> predicate)
{
await using var ctx = _ContextFactory.CreateContext();
return (await
ctx.WalletObjects.AsNoTracking().Where(w => w.WalletId == walletId.ToString() && w.Type == WalletObjectData.Types.Label)
.ToArrayAsync())
.Select(o =>
{
if (o.Data is null)
{
return (o.Id, ColorPalette.Default.DeterministicColor(o.Id));
}
return (o.Id,
JObject.Parse(o.Data)["color"]?.Value<string>() ??
ColorPalette.Default.DeterministicColor(o.Id));
}).ToArray();
return (await ctx.WalletObjects.AsNoTracking().Where(predicate).ToArrayAsync())
.Select(FormatToLabel).ToArray();
}
private (string Label, string Color) FormatToLabel(WalletObjectData o)
{
return o.Data is null
? (o.Id, ColorPalette.Default.DeterministicColor(o.Id))
: (o.Id,
JObject.Parse(o.Data)["color"]?.Value<string>() ?? ColorPalette.Default.DeterministicColor(o.Id));
}
public async Task<bool> RemoveWalletObjects(WalletObjectId walletObjectId)

View file

@ -13,6 +13,8 @@
@section PageHeadContent
{
<link href="~/main/qrcode.css" rel="stylesheet" asp-append-version="true"/>
<link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" asp-append-version="true" rel="stylesheet">
<script src="~/vendor/tom-select/tom-select.complete.min.js" asp-append-version="true"></script>
}
@section Navbar {

View file

@ -1,6 +1,10 @@
@inject BTCPayServer.Security.ContentSecurityPolicies csp
@using Microsoft.AspNetCore.Mvc.ModelBinding
@using BTCPayServer.Controllers
@using BTCPayServer.Services
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Components.LabelManager
@using BTCPayServer.Components.UIExtensionPoint
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model WalletSendModel
@{
@ -35,6 +39,8 @@
border-bottom-right-radius: .2rem !important;
}
</style>
<link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" asp-append-version="true" rel="stylesheet">
<script src="~/vendor/tom-select/tom-select.complete.min.js" asp-append-version="true"></script>
}
@section PageFootContent
@ -115,6 +121,21 @@
}
</div>
</div>
<div class="form-group">
<label class="form-label">Labels</label>
<select asp-for="Outputs[0].Labels" class="d-none">
@foreach (var t in Model.Outputs[0].Labels)
{
<option value="@t" selected></option>
}
</select>
<vc:label-manager
selected-labels="Model.Outputs[0].Labels"
exclude-types="true"
select-element="Outputs_0__Labels"
wallet-object-id="new WalletObjectId(WalletId.Parse(walletId), WalletObjectData.Types.Address, Model.Outputs[0].DestinationAddress)"
auto-update="false" />
</div>
}
else
{
@ -158,6 +179,21 @@
<label asp-for="Outputs[index].SubtractFeesFromOutput" class="form-check-label"></label>
<span asp-validation-for="Outputs[index].SubtractFeesFromOutput" class="text-danger"></span>
</div>
<div class="form-group">
<label class="form-label">Labels</label>
<select asp-for="Outputs[index].Labels" class="d-none">
@foreach (var t in Model.Outputs[index].Labels)
{
<option value="@t" selected></option>
}
</select>
<vc:label-manager
selected-labels="Model.Outputs[index].Labels"
exclude-types="true"
select-element="Outputs_@(index)__Labels"
wallet-object-id="new WalletObjectId(WalletId.Parse(walletId), WalletObjectData.Types.Address, Model.Outputs[index].DestinationAddress)"
auto-update="false" />
</div>
</div>
}
</div>

View file

@ -9,6 +9,8 @@
}
@section PageHeadContent {
<script src="~/vendor/tom-select/tom-select.complete.min.js" asp-append-version="true"></script>
<link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" asp-append-version="true" rel="stylesheet">
<style>
.smMaxWidth {
max-width: 125px;
@ -45,7 +47,6 @@
@section PageFootContent {
@*Without async, somehow selenium do not manage to click on links in this page*@
<script src="~/modal/btcpay.js" asp-append-version="true" async></script>
@* Custom Range Modal *@
<script>
let observer = null;
@ -92,7 +93,7 @@
if (response.ok) {
const html = await response.text();
$list.innerHTML += html;
$list.insertAdjacentHTML('beforeend', html);
skip = skipNext;
if ($loadMore) {
@ -122,6 +123,7 @@
$indicator.classList.add('d-none');
formatDateTimes(document.getElementById('switchTimeFormat').dataset.mode);
initLabelManagers();
}
</script>
}

View file

@ -1,5 +1,7 @@
@model WalletPSBTReadyViewModel
<script src="~/js/wallet/wallet-camera-scanner.js" asp-append-version="true"></script>
<script src="~/js/wallet/WalletSend.js" asp-append-version="true"></script>
@if (Model.CanCalculateBalance)
{
<p class="lead text-center text-secondary">
@ -15,6 +17,7 @@
<thead>
<tr>
<th>Index</th>
<th>Labels</th>
<th class="text-end">Amount</th>
</tr>
</thead>
@ -31,8 +34,14 @@
else
{
<td>@input.Index</td>
}
}<td>
@foreach (var label in input.Labels)
{
<div class="transaction-label" style="--label-bg:@label.Value;--label-fg:@ColorPalette.Default.TextColor(label.Value)">@label.Key</div>
}
</td>
<td class="text-end text-@(input.Positive ? "success" : "danger")">@input.BalanceChange</td>
</tr>
}
</tbody>
@ -45,6 +54,7 @@
<thead>
<tr>
<th>Destination</th>
<th>Labels</th>
<th class="text-end">Amount</th>
</tr>
</thead>
@ -53,7 +63,15 @@
{
<tr>
<td class="text-break">@destination.Destination</td>
<td>
@foreach (var label in destination.Labels)
{
<div class="transaction-label" style="--label-bg:@label.Value;--label-fg:@ColorPalette.Default.TextColor(label.Value)">@label.Key</div>
}
</td>
<td class="text-end text-@(destination.Positive ? "success" : "danger")">@destination.Balance</td>
</tr>
}
</tbody>

View file

@ -1,5 +1,10 @@
@using BTCPayServer.Services
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Components.LabelManager
@model ListTransactionsViewModel
@{
var walletId = Context.GetRouteValue("walletId").ToString();
}
@foreach (var transaction in Model.Transactions)
{
<tr>
@ -10,33 +15,12 @@
@transaction.Timestamp.ToBrowserDate()
</td>
<td class="text-start">
@if (transaction.Tags.Any())
{
<div class="d-flex flex-wrap gap-2 align-items-center">
@foreach (var label in transaction.Tags)
{
<div class="transaction-label" style="--label-bg:@label.Color;--label-fg:@label.TextColor;">
<a asp-route-labelFilter="@label.Text">@label.Text</a>
<form method="post" asp-action="ModifyTransaction" asp-route-walletId="@Context.GetRouteValue("walletId")">
<input type="hidden" name="transactionId" value="@transaction.Id" />
<button name="removelabel" type="submit" value="@label.Text" class="transaction-label-action">
<vc:icon symbol="remove"/>
</button>
</form>
@if (!string.IsNullOrEmpty(label.Link))
{
<a href="@label.Link" target="_blank" rel="noreferrer noopener" class="transaction-label-info transaction-details-icon"
title="@label.Tooltip"
data-bs-html="true"
data-bs-toggle="tooltip"
data-bs-custom-class="transaction-label-tooltip">
<vc:icon symbol="info"/>
</a>
}
</div>
}
</div>
}
<vc:label-manager
wallet-object-id="new WalletObjectId(WalletId.Parse(walletId), WalletObjectData.Types.Tx, transaction.Id)"
selected-labels="transaction.Tags.Select(t => t.Text).ToArray()"
rich-label-info="transaction.Tags.Where(t=> !string.IsNullOrEmpty(t.Link)).ToDictionary(t => t.Text, t => new RichLabelInfo { Link = t.Link, Tooltip = t.Tooltip })"
exclude-types="false"
display-inline="true"/>
</td>
<td class="smMaxWidth text-truncate@(transaction.IsConfirmed ? "" : " unconf")">
<a href="@transaction.Link" target="_blank" rel="noreferrer noopener">
@ -52,35 +36,6 @@
<td class="text-end text-danger">@transaction.Balance</td>
}
<td class="text-end">
<div class="dropstart d-inline-block me-2">
<span class="fa fa-tags cursor-pointer" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
<div class="dropdown-menu">
<form asp-action="ModifyTransaction" method="post" style="width:260px;" asp-route-walletId="@Context.GetRouteValue("walletId")">
<input type="hidden" name="transactionId" value="@transaction.Id" />
<div class="input-group input-group-sm p-2">
<input name="addlabel" placeholder="Label name" maxlength="20" type="text" class="form-control form-control-sm" />
<button type="submit" class="btn btn-primary btn-sm"><span class="fa fa-plus"></span></button>
</div>
@if (Model.Labels.Count > 0)
{
<div class="dropdown-divider"></div>
<div class="py-2 px-3 d-flex flex-wrap gap-2">
@foreach (var label in Model.Labels)
{
var isActive = transaction.Tags.Any(l => l.Text == label.Text);
<button name="@(isActive ? "removelabel" : "addlabelclick")" type="submit" value="@label.Text"
class="transaction-label@(isActive ? " active" : string.Empty)" style="--label-bg:@label.Color;--label-fg:@label.TextColor">
<span>@label.Text</span>
<span class="transaction-label-action">
<vc:icon symbol="@(isActive ? "remove" : "new")"/>
</span>
</button>
}
</div>
}
</form>
</div>
</div>
<div class="dropstart d-inline-block">
@if (string.IsNullOrEmpty(transaction.Comment))
{

View file

@ -698,23 +698,6 @@ a.store-powered-by:hover .logo-brand-dark {
color: var(--btcpay-brand-tertiary);
}
/* Tom Select */
.ts-wrapper.form-control .ts-control {
padding: .5rem .75rem .2rem .5rem !important;
}
.ts-wrapper.form-control .ts-control > .item {
margin: 0 .3rem .3rem 0 !important;
}
.ts-wrapper.form-control .ts-control > input {
margin: 0 0 .3rem .5rem !important;
}
.form-floating .ts-wrapper {
margin-top: 1.85rem;
}
/* Payment Box */
.payment-box {
--qr-size: 256px;
@ -773,18 +756,89 @@ a.store-powered-by:hover .logo-brand-dark {
font-weight: var(--btcpay-font-weight-semibold);
}
/* Tom Select */
.ts-control, .ts-control input, .ts-dropdown {
color: var(--btcpay-body-text-muted) !important;
}
.ts-wrapper.form-control .ts-control {
gap: .5rem;
outline: 0 !important;
}
.ts-wrapper.form-control:not(.ts-inline) .ts-control {
padding: .5rem !important;
border-radius: var(--btcpay-border-radius) !important;
}
.ts-wrapper.form-control.focus:not(.ts-inline) .ts-control {
border-color: var(--btcpay-form-border-focus);
box-shadow: 0 0 0 1px var(--btcpay-form-border-focus) inset;
}
.ts-wrapper.form-control.ts-inline,
.ts-wrapper.form-control.ts-inline .ts-control {
padding: 0 !important;
border: none !important;
box-shadow: none !important;
min-height: auto !important;
background: transparent !important;
}
.form-floating .ts-wrapper {
margin-top: 1.85rem;
}
.form-floating input.ts-wrapper.form-control:not(.ts-hidden-accessible) {
padding: .5rem .75rem !important;
height: 39px;
}
.form-floating input.label-manager.form-control ~ label {
opacity: 0.65;
transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem);
}
input.ts-wrapper.form-control:not(.ts-hidden-accessible,.ts-inline) {
padding: .5rem .75rem !important;
height: 46px;
}
.ts-dropdown.dropdown-menu,
.ts-dropdown.dropdown-menu.form-control,
.ts-dropdown.dropdown-menu.form-select {
color: var(--btcpay-dropdown-color);
background: var(--btcpay-dropdown-bg);
border: var(--btcpay-dropdown-border-width) solid var(--btcpay-dropdown-border-color);
border-radius: var(--btcpay-dropdown-border-radius);
padding: var(--btcpay-dropdown-padding-y) var(--btcpay-dropdown-padding-x);
min-width: var(--btcpay-dropdown-min-width);
}
.ts-dropdown.dropdown-menu .ts-dropdown-content {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--btcpay-space-s);
padding: 0 var(--btcpay-space-s);
}
/* Transaction Labels */
.transaction-label {
.transaction-label,
.ts-dropdown.dropdown-menu .transaction-label,
.ts-dropdown.dropdown-menu .create.transaction-label,
.ts-wrapper.form-control .ts-control > .item.transaction-label {
display: inline-flex;
flex-wrap: nowrap;
align-items: center;
gap: var(--btcpay-space-xs);
color: var(--btcpay-body-text);
background: transparent;
border: 1px solid var(--label-bg);
border: 1px solid var(--label-bg, var(--btcpay-neutral-300));
border-radius: var(--btcpay-border-radius-l);
font-size: var(--btcpay-font-size-s);
padding: var(--btcpay-space-xs) var(--btcpay-space-s);
margin: 0;
padding: 1px var(--btcpay-space-s);
text-align: left;
}
@ -796,9 +850,14 @@ a.store-powered-by:hover .logo-brand-dark {
.transaction-label:focus,
.transaction-label:hover,
.transaction-label.active {
color: var(--label-fg);
background: var(--label-bg);
.transaction-label.active,
.ts-dropdown.dropdown-menu .transaction-label:focus,
.ts-dropdown.dropdown-menu .transaction-label:hover,
.ts-wrapper.multi .ts-control > .item.transaction-label:focus,
.ts-wrapper.multi .ts-control > .item.transaction-label:hover,
.ts-wrapper.multi .ts-control > .item.transaction-label.active {
color: var(--label-fg, var(--btcpay-body-text));
background: var(--label-bg, var(--btcpay-neutral-300));
}
.transaction-label:focus a,

View file

@ -24,6 +24,133 @@ const switchTimeFormat = event => {
event.target.dataset.mode = mode;
};
async function initLabelManager (elementId) {
const element = document.getElementById(elementId);
const labelStyle = data =>
data && data.color && data.textColor
? `--label-bg:${data.color};--label-fg:${data.textColor}`
: '--label-bg:var(--btcpay-neutral-300);--label-fg:var(--btcpay-neutral-800)'
if (element) {
const { fetchUrl, updateUrl, walletId, walletObjectType, walletObjectId, labels,selectElement } = element.dataset;
const commonCallId = `walletLabels-${walletId}`;
if (!window[commonCallId]) {
window[commonCallId] = fetch(fetchUrl, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
}).then(res => res.json());
}
const selectElementI = document.getElementById(selectElement);
const items = element.value.split(',').filter(x => !!x);
const options = await window[commonCallId].then(labels => {
const newItems = items.filter(item => !labels.find(label => label.label === item));
labels = [...labels, ...newItems.map(item => ({ label: item }))];
return labels;
});
const richInfo = labels ? JSON.parse(labels) : {};
const config = {
options,
items,
valueField: "label",
labelField: "label",
searchField: "label",
create: true,
persist: true,
allowEmptyOption: false,
closeAfterSelect: false,
render: {
dropdown (){
return '<div class="dropdown-menu"></div>';
},
option_create: function(data, escape) {
return `<div class="transaction-label create" style="${labelStyle(null)}">Add <strong>${escape(data.input)}</strong>&hellip;</div>`;
},
option (data, escape) {
return `<div class="transaction-label" style="${labelStyle(data)}"><span>${escape(data.label)}</span></div>`;
},
item (data, escape) {
const info = richInfo && richInfo[data.label];
const additionalInfo = info
? `<a href="${info.link}" target="_blank" rel="noreferrer noopener" class="transaction-label-info transaction-details-icon" title="${info.tooltip}" data-bs-html="true"
data-bs-toggle="tooltip" data-bs-custom-class="transaction-label-tooltip"><svg role="img" class="icon icon-info"><use href="/img/icon-sprite.svg#info"></use></svg></a>`
: '';
const inner = `<span>${escape(data.label)}</span>${additionalInfo}`;
return `<div class="transaction-label" style="${labelStyle(data)}">${inner}</div>`;
}
},
onItemAdd (val) {
window[commonCallId] = window[commonCallId].then(labels => {
return [...labels, { label: val }]
});
document.dispatchEvent(new CustomEvent(`${commonCallId}-option-added`, {
detail: val
}));
},
async onChange (values) {
if(selectElementI){
while (selectElementI.options.length > 0) {
selectElementI.remove(0);
}
select.items.forEach((item) => {
selectElementI.add(new Option(item, item, true, true));
})
}
if(!updateUrl)
return;
select.lock();
try {
const response = await fetch(updateUrl, {
method: "POST",
credentials: "include",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: walletObjectId,
type: walletObjectType,
labels: select.items
})
});
if (!response.ok) {
throw new Error('Network response was not OK');
}
} catch (error) {
console.error('There has been a problem with your fetch operation:', error);
} finally {
select.unlock();
}
}
};
const select = new TomSelect(element, config);
element.parentElement.querySelectorAll('.ts-control .transaction-label a').forEach(lbl => {
lbl.addEventListener('click', e => {
e.stopPropagation()
})
})
document.addEventListener(`${commonCallId}-option-added`, evt => {
if (!(evt.detail in select.options)) {
select.addOption({
label: evt.detail
})
}
})
}
}
const initLabelManagers = () => {
// select only elements which haven't been initialized before, those without data-localized
document.querySelectorAll("input.label-manager:not(.tomselected)").forEach($el => {
initLabelManager($el.id)
});
}
document.addEventListener("DOMContentLoaded", () => {
// sticky header
const stickyHeader = document.querySelector('.sticky-header-setup + .sticky-header');
@ -37,6 +164,8 @@ document.addEventListener("DOMContentLoaded", () => {
// localize all elements that have localizeDate class
formatDateTimes();
initLabelManagers();
function updateTimeAgo(){
var timeagoElements = $("[data-timeago-unixms]");