diff --git a/src/components/orderbook/OrderbookTable.tsx b/src/components/orderbook/OrderbookTable.tsx
index 13b7cf8f..8463c7ec 100644
--- a/src/components/orderbook/OrderbookTable.tsx
+++ b/src/components/orderbook/OrderbookTable.tsx
@@ -171,7 +171,9 @@ export const OrderbookTable = ({
header: () =>
{t('orderbook.table.heading_fee')}
,
// Custom sorting: absolute before relative, then by fee value
sortingFn: (a, b) => {
- if (a.original.type.isAbsolute !== b.original.type.isAbsolute) return a.original.type.isAbsolute ? -1 : 1
+ if (a.original.type.isAbsolute !== b.original.type.isAbsolute) {
+ return a.original.type.isAbsolute ? -1 : 1
+ }
return a.original.fee.value - b.original.fee.value
},
cell: (info) => {
diff --git a/src/components/wallet/AccountDetailsTabContent.tsx b/src/components/wallet/AccountDetailsTabContent.tsx
index 0aa6a9a3..d436ffa5 100644
--- a/src/components/wallet/AccountDetailsTabContent.tsx
+++ b/src/components/wallet/AccountDetailsTabContent.tsx
@@ -1,9 +1,14 @@
+import { useMemo, useState } from 'react'
+import type { RowModel } from '@tanstack/react-table'
import type { TFunction } from 'i18next'
import { useTranslation } from 'react-i18next'
-import { type AccountMeta } from '@/context/JamWalletInfoContext'
+import type { AccountBranch, AccountMeta } from '@/context/JamWalletInfoContext'
+import { btcToSats, isValidNumber } from '@/lib/utils'
+import type { HdPath } from '@/types/global'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion'
+import { BranchEntryTable, type BranchEntryApiObject, type BranchEntryTableRow } from './BranchEntryTable'
-const HIDE_EMPTY_BRANCHES = false
+const HIDE_EMPTY_BRANCHES = true
const toTypeHeading = (type: string, t: TFunction) => {
if (type === 'external addresses') {
@@ -14,6 +19,54 @@ const toTypeHeading = (type: string, t: TFunction) => {
return type
}
+const toLastHdPathIndex = (hdPath: HdPath): number | null => {
+ const indexOfLastSeparator = hdPath.lastIndexOf('/')
+ if (indexOfLastSeparator === -1 || indexOfLastSeparator === hdPath.length - 1) {
+ return null
+ }
+
+ const stringValue = hdPath.substring(indexOfLastSeparator + 1, hdPath.length)
+ const indexOfFirstColon = stringValue.indexOf(':') // value can be `/76:1777593600`
+
+ const sanitizedStringValue = indexOfFirstColon === -1 ? stringValue : stringValue.substring(0, indexOfFirstColon)
+ const numberValue = parseInt(sanitizedStringValue, 10)
+ return !isValidNumber(numberValue) ? null : numberValue
+}
+
+const branchToTableEntry = (value: BranchEntryApiObject): BranchEntryTableRow => {
+ return {
+ ...value,
+ derivationIndex: toLastHdPathIndex(value.hd_path as HdPath) ?? -1,
+ derivationPath: (value.hd_path !== undefined ? value.hd_path : 'm/-1') as HdPath,
+ address: value.address || '',
+ balance: btcToSats(value.amount || '0'),
+ }
+}
+
+interface BranchAccordionContentProps {
+ value: AccountBranch
+}
+
+const BranchAccordionContent = ({ value }: BranchAccordionContentProps) => {
+ const [_tableRowModel, setTableRowModel] = useState>()
+
+ const tableEntries = useMemo(() => {
+ return (value.__raw.entries || []).map((it) => branchToTableEntry(it))
+ }, [value])
+
+ return (
+ {
+ setTableRowModel(table.getFilteredRowModel())
+ }}
+ />
+ )
+}
+
interface AccountDetailsTabContentProps {
value: AccountMeta
}
@@ -44,18 +97,11 @@ export const AccountDetailsTabContent = ({ value }: AccountDetailsTabContentProp
{typeTitle}
- ({branch.derivation})
+ {branch.derivation}
-
-
-
-
branch:
-
{JSON.stringify(branch, null, 2)}
-
-
-
+
)
diff --git a/src/components/wallet/BranchEntryTable.tsx b/src/components/wallet/BranchEntryTable.tsx
new file mode 100644
index 00000000..7728b739
--- /dev/null
+++ b/src/components/wallet/BranchEntryTable.tsx
@@ -0,0 +1,350 @@
+import { useState, useMemo, useEffect } from 'react'
+import { rankItem } from '@tanstack/match-sorter-utils'
+import {
+ createColumnHelper,
+ flexRender,
+ getCoreRowModel,
+ getSortedRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ type SortingState,
+ type ColumnDef,
+ useReactTable,
+ type RowPinningState,
+ type RowSelectionState,
+ type VisibilityState,
+ type Column,
+ type FilterFn,
+ type FilterFnOption,
+ type Table as TableType,
+} from '@tanstack/react-table'
+import {
+ ArrowUpDownIcon,
+ SortDescIcon,
+ SortAscIcon,
+ ArrowUp01Icon,
+ ArrowDown10Icon,
+ ArrowDownZAIcon,
+ ArrowUpAZIcon,
+} from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { TablePagination } from '@/components/ui/jam/TablePagination'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
+import type { AccountBranch } from '@/context/JamWalletInfoContext'
+import { cn } from '@/lib/utils'
+import type { AmountSats, BitcoinAddress, HdPath } from '@/types/global'
+import { Balance } from '../ui/jam/Balance'
+
+const ITEMS_PER_PAGE = 25
+
+export type BranchEntryApiObject = NonNullable[number]
+
+export type BranchEntryTableRow = BranchEntryApiObject & {
+ derivationIndex: number
+ derivationPath: HdPath
+ address: BitcoinAddress
+ balance: AmountSats
+}
+
+type SortKey = keyof BranchEntryTableRow
+
+const columnHelper = createColumnHelper()
+
+type BranchEntryTableColumnMeta =
+ | {
+ align?: string
+ numeric?: boolean
+ alphabetic?: boolean
+ }
+ | undefined
+
+interface SortIconProps {
+ className?: string
+ sortKey: SortKey
+ column: Column
+}
+const SortIcon = ({ column, className }: SortIconProps) => {
+ const dir = column.getIsSorted()
+ if (!dir) return
+ const meta = column.columnDef.meta as BranchEntryTableColumnMeta
+ if (meta?.numeric === true) {
+ return dir === 'desc' ? :
+ }
+ if (meta?.alphabetic === true) {
+ return dir === 'desc' ? :
+ }
+ return dir === 'desc' ? :
+}
+
+const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => {
+ const itemRank = rankItem(row.getValue(columnId), value)
+ addMeta({ itemRank })
+ return itemRank.passed
+}
+
+interface BranchEntryTableProps {
+ globalFilter?: string
+ tableEntries: BranchEntryTableRow[]
+ selectedEntries: BranchEntryTableRow[]
+ pinnedEntries: BranchEntryTableRow[]
+ onChange?: (table: TableType) => void
+}
+
+export const BranchEntryTable = ({
+ globalFilter,
+ tableEntries,
+ selectedEntries: highlightedEntries,
+ pinnedEntries,
+ onChange,
+}: BranchEntryTableProps) => {
+ const { t } = useTranslation()
+
+ const [currentPage, setCurrentPage] = useState(1)
+ const [itemsPerPage, setItemsPerPage] = useState(ITEMS_PER_PAGE)
+ const [sorting, setSorting] = useState([])
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const columns = useMemo[]>(
+ () => [
+ columnHelper.accessor('derivationIndex', {
+ header: () => ,
+ sortingFn: (a, b) => {
+ return a.original.derivationIndex - b.original.derivationIndex
+ },
+ cell: (info) => (
+
+ …/
+ {info.getValue()}
+
+ ),
+ meta: {
+ align: 'right',
+ numeric: true,
+ },
+ }),
+ columnHelper.accessor('address', {
+ header: () => {t('jar_details.utxo_list.column_title_address')}
,
+ sortingFn: (a, b) => {
+ const val = a.original.address.localeCompare(b.original.address)
+ if (val !== 0) return val
+ // tie-break using derivationIndex
+ return a.original.derivationIndex - b.original.derivationIndex
+ },
+ cell: (info) => {info.getValue()},
+ meta: {
+ alphabetic: true,
+ },
+ }),
+ columnHelper.accessor('balance', {
+ header: () => {t('jar_details.utxo_list.column_title_balance')}
,
+ sortingFn: (a, b) => {
+ const val = a.original.balance - b.original.balance
+ if (val !== 0) return val
+ // tie-break using derivationIndex
+ return a.original.derivationIndex - b.original.derivationIndex
+ },
+ cell: (info) => ,
+ meta: {
+ align: 'right',
+ numeric: true,
+ },
+ }),
+ ],
+ [t],
+ )
+ const [columnVisibility, setColumnVisibility] = useState({
+ minerFeeContribution: false,
+ })
+ const [rowSelection, setRowSelection] = useState({})
+ const [rowPinning, setRowPinning] = useState({
+ top: [],
+ bottom: [],
+ })
+
+ const table = useReactTable({
+ data: tableEntries,
+ columns,
+ filterFns: {
+ fuzzy: fuzzyFilter, //define as a filter function that can be used in column definitions
+ },
+ state: {
+ globalFilter,
+ sorting,
+ pagination: {
+ pageIndex: Math.max(0, currentPage - 1),
+ pageSize: itemsPerPage === -1 ? tableEntries.length || 1 : itemsPerPage,
+ },
+ rowPinning,
+ rowSelection,
+ columnVisibility,
+ },
+ globalFilterFn: 'fuzzy' as FilterFnOption,
+ keepPinnedRows: true,
+ enableRowSelection: true,
+ onSortingChange: setSorting,
+ onRowPinningChange: setRowPinning,
+ onRowSelectionChange: setRowSelection,
+ onColumnVisibilityChange: setColumnVisibility,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ })
+
+ useEffect(() => {
+ table.resetRowPinning(true)
+ table.getRowModel().rows.forEach((row) => {
+ row.pin(pinnedEntries.includes(row.original) ? 'top' : false)
+ })
+ }, [table, pinnedEntries])
+
+ useEffect(() => {
+ table.resetRowSelection(true)
+ table.getRowModel().rows.forEach((row) => {
+ row.toggleSelected(highlightedEntries.includes(row.original))
+ })
+ }, [table, highlightedEntries])
+
+ const totalPages = useMemo(() => {
+ if (itemsPerPage === -1) {
+ return 1
+ }
+ return Math.max(1, Math.ceil(tableEntries.length / itemsPerPage))
+ }, [itemsPerPage, tableEntries.length])
+
+ useEffect(() => {
+ if (currentPage > totalPages) {
+ setCurrentPage(totalPages)
+ table.setPageIndex(Math.max(0, totalPages - 1))
+ }
+ }, [totalPages, currentPage, table])
+
+ const handleSort = (key: SortKey) => {
+ const col = table.getColumn(key)
+ if (col) {
+ col.toggleSorting()
+ }
+ }
+
+ const tableTopRows = () => {
+ try {
+ // pinned offers might not be included in the table data,
+ // and the internal model of the table does not match anymore
+ return table.getTopRows()
+ } catch (e) {
+ console.debug('Error while rendering top table rows', e)
+ return []
+ }
+ }
+
+ useEffect(() => {
+ if (onChange) {
+ onChange(table)
+ }
+ }, [table, onChange])
+
+ return (
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ const canSort = header.column.getCanSort()
+ const key = header.column.id as SortKey
+ const alignCenter = (header.column.columnDef.meta as BranchEntryTableColumnMeta)?.align === 'center'
+ const alignRight = (header.column.columnDef.meta as BranchEntryTableColumnMeta)?.align === 'right'
+ return (
+ handleSort(key) : undefined}
+ >
+ 0 && !header.column.getIsSorted(),
+ })}
+ >
+ {flexRender(header.column.columnDef.header, header.getContext())}
+ {canSort ? : undefined}
+
+
+ )
+ })}
+
+ ))}
+
+
+ {tableTopRows().map((row) => (
+
+ {row.getVisibleCells().map((cell) => {
+ const alignCenter = (cell.column.columnDef.meta as BranchEntryTableColumnMeta)?.align === 'center'
+ const alignRight = (cell.column.columnDef.meta as BranchEntryTableColumnMeta)?.align === 'right'
+ return (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ )
+ })}
+
+ ))}
+ {table.getCenterRows().map((row) => {
+ return (
+
+ {row.getVisibleCells().map((cell) => {
+ const alignCenter = (cell.column.columnDef.meta as BranchEntryTableColumnMeta)?.align === 'center'
+ const alignRight = (cell.column.columnDef.meta as BranchEntryTableColumnMeta)?.align === 'right'
+ return (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ )
+ })}
+
+ )
+ })}
+
+
+
+
+
{
+ setCurrentPage(page)
+ table.setPageIndex(Math.max(0, page - 1))
+ }}
+ onItemsPerPageChange={(newItemsPerPage) => {
+ setItemsPerPage(newItemsPerPage)
+ const size = newItemsPerPage === -1 ? table.getPrePaginationRowModel().rows.length || 1 : newItemsPerPage
+ table.setPageSize(size)
+ setCurrentPage(1)
+ table.setPageIndex(0)
+ }}
+ />
+
+ )
+}
diff --git a/src/components/wallet/JarUtxosTable.tsx b/src/components/wallet/JarUtxosTable.tsx
index d1e298b8..27b7478d 100644
--- a/src/components/wallet/JarUtxosTable.tsx
+++ b/src/components/wallet/JarUtxosTable.tsx
@@ -105,14 +105,14 @@ export const JarUtxosTable = ({
columnHelper.accessor('value', {
header: () => {t('jar_details.utxo_list.column_title_balance')}
,
sortingFn: (a, b) => {
- const val = a.original.address.localeCompare(b.original.address)
+ const val = a.original.value - b.original.value
if (val !== 0) return val
// tie-break using confirmations
const aid = Number(a.original.confirmations)
const bid = Number(b.original.confirmations)
return aid - bid
},
- cell: (info) => ,
+ cell: (info) => ,
meta: {
align: 'right',
numeric: true,
diff --git a/src/components/wallet/WalletJarsDetailsContent.tsx b/src/components/wallet/WalletJarsDetailsContent.tsx
index 5c4c973f..f60ea779 100644
--- a/src/components/wallet/WalletJarsDetailsContent.tsx
+++ b/src/components/wallet/WalletJarsDetailsContent.tsx
@@ -69,7 +69,7 @@ export const UtxosContent = ({ enabled: _enabled, addressSummary, jar }: UtxosCo
-
+
diff --git a/src/context/JamWalletInfoContext.ts b/src/context/JamWalletInfoContext.ts
index 43de8766..3bb0ced3 100644
--- a/src/context/JamWalletInfoContext.ts
+++ b/src/context/JamWalletInfoContext.ts
@@ -4,7 +4,7 @@ import type { AddressInfo } from 'bitcoin-address-validation'
import type { UseQueryDisplayWalletResult } from '@/hooks/useQueryDisplayWallet'
import type { FidelityBondUtxo, UseQueryUtxosResult, Utxo } from '@/hooks/useQueryUtxos'
import type { BalanceSummary } from '@/lib/balanceSummary'
-import type { BitcoinAddress, JarIndex } from '@/types/global'
+import type { BitcoinAddress, HdPath, JarIndex } from '@/types/global'
// Comments for tailwind importer (ADAPT THE COMMENT IF YOU CHANGE THE VALUE)
// "text-[#e2b86a]", "group-hover/jar:text-[#e2b86a]"
@@ -56,8 +56,6 @@ export type AccountBranchType = 'external addresses' | 'internal addresses'
type AccountAbiObj = NonNullable['accounts']>[number]
-type HdPath = `m/${string}`
-
export type AccountBranch = {
type: AccountBranchType | string
derivation: HdPath
diff --git a/src/types/global.d.ts b/src/types/global.d.ts
index 04aa3c6e..e379d849 100644
--- a/src/types/global.d.ts
+++ b/src/types/global.d.ts
@@ -5,6 +5,13 @@ export type JarIndex = number
export type SeedPhrase = string[]
+export type HdPath =
+ | `m/${number}'/${number}'/${number}'` // used internally
+ | `m/${number}'/${number}'/${number}'/${number}` // used by jm
+ | `m/${number}'/${number}'/${number}'/${number}/${number}` // used by jm
+ | `m/${number}'/${number}'/${number}'/${number}/${number}:${number}` // used by jm for Fidelity Bonds
+ | `m/${string}` // catch all
+
export type Milliseconds = number
export type Seconds = number
export type Days = number