Skip to content

Commit

Permalink
feat: Orderbook (#422)
Browse files Browse the repository at this point in the history
  • Loading branch information
theborakompanioni committed Aug 2, 2022
1 parent 81b3e73 commit 2406c04
Show file tree
Hide file tree
Showing 8 changed files with 332 additions and 0 deletions.
17 changes: 17 additions & 0 deletions src/components/Earn.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import SegmentedTabs from './SegmentedTabs'
import { CreateFidelityBond } from './fb/CreateFidelityBond'
import { ExistingFidelityBond } from './fb/ExistingFidelityBond'
import { EarnReportOverlay } from './EarnReport'
import { isFeatureEnabled } from '../constants/features'
import * as Api from '../libs/JmWalletApi'
import styles from './Earn.module.css'
import { OrderbookOverlay } from './Orderbook'

// In order to prevent state mismatch, the 'maker stop' response is delayed shortly.
// Even though the API response suggests that the maker has started or stopped immediately, it seems that this is not always the case.
Expand Down Expand Up @@ -99,6 +101,7 @@ export default function Earn() {
const [isWaitingMakerStart, setIsWaitingMakerStart] = useState(false)
const [isWaitingMakerStop, setIsWaitingMakerStop] = useState(false)
const [isShowReport, setIsShowReport] = useState(false)
const [isShowOrderbook, setIsShowOrderbook] = useState(false)
const [fidelityBonds, setFidelityBonds] = useState([])

const startMakerService = (ordertype, minsize, cjfee_a, cjfee_r) => {
Expand Down Expand Up @@ -518,6 +521,20 @@ export default function Earn() {
</rb.Col>
</rb.Row>
<rb.Row className="mt-5 mb-3">
{isFeatureEnabled('orderbook') && (
<rb.Col className="d-flex justify-content-center">
<OrderbookOverlay show={isShowOrderbook} onHide={() => setIsShowOrderbook(false)} />

<rb.Button
variant="outline-dark"
className="border-0 mb-2 d-inline-flex align-items-center"
onClick={() => setIsShowOrderbook(true)}
>
<Sprite symbol="globe" width="24" height="24" className="me-2" />
{t('earn.button_show_orderbook')}
</rb.Button>
</rb.Col>
)}
<rb.Col className="d-flex justify-content-center">
<EarnReportOverlay show={isShowReport} onHide={() => setIsShowReport(false)} />

Expand Down
10 changes: 10 additions & 0 deletions src/components/Orderbook.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.orderbook-overlay {
width: 100vw !important;
height: 100vh !important;
z-index: 1100 !important;
}

.orderbook-line-placeholder {
height: 2.625rem;
margin: 1px 0;
}
197 changes: 197 additions & 0 deletions src/components/Orderbook.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import React, { useEffect, useState } from 'react'
import * as rb from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import * as ObwatchApi from '../libs/JmObwatchApi'
// @ts-ignore
import { useSettings } from '../context/SettingsContext'
import Balance from './Balance'
import styles from './Orderbook.module.css'

interface OrderbookTableProps {
orders: ObwatchApi.Order[]
maxAmountOfRows?: number
}

type OrderPropName = keyof ObwatchApi.Order

const withTooltip = (node: React.ReactElement, tooltip: string) => {
return (
<rb.OverlayTrigger overlay={(props) => <rb.Tooltip {...props}>{tooltip}</rb.Tooltip>}>{node}</rb.OverlayTrigger>
)
}

const OrderbookTable = ({ orders }: OrderbookTableProps) => {
const { t } = useTranslation()
const settings = useSettings()

const headingMap: { [name in OrderPropName]: { heading: string; render?: (val: string) => React.ReactNode } } = {
type: {
// example: "Native SW Absolute Fee" or "Native SW Relative Fee"
heading: t('orderbook.table.heading_type'),
render: (val) => {
if (val === 'Native SW Absolute Fee') {
return withTooltip(<rb.Badge bg="info">{t('orderbook.text_offer_type_absolute')}</rb.Badge>, val)
}
if (val === 'Native SW Relative Fee') {
return withTooltip(<rb.Badge bg="primary">{t('orderbook.text_offer_type_relative')}</rb.Badge>, val)
}
return <rb.Badge bg="secondary">{val}</rb.Badge>
},
},
counterparty: {
// example: "J5Bv3JSxPFWm2Yjb"
heading: t('orderbook.table.heading_counterparty'),
},
orderId: {
// example: "0" (not unique!)
heading: t('orderbook.table.heading_order_id'),
},
fee: {
// example: "0.00000250" (abs offers) or "0.000100%" (rel offers)
heading: t('orderbook.table.heading_fee'),
render: (val) =>
val.includes('%') ? <>{val}</> : <Balance valueString={val} convertToUnit={settings.unit} showBalance={true} />,
},
minerFeeContribution: {
// example: "0.00000000"
heading: t('orderbook.table.heading_miner_fee_contribution'),
render: (val) => <Balance valueString={val} convertToUnit={settings.unit} showBalance={true} />,
},
minimumSize: {
heading: t('orderbook.table.heading_minimum_size'),
render: (val) => <Balance valueString={val} convertToUnit={settings.unit} showBalance={true} />,
// example: "0.00027300"
},
maximumSize: {
// example: "2374.99972700"
heading: t('orderbook.table.heading_maximum_size'),
render: (val) => <Balance valueString={val} convertToUnit={settings.unit} showBalance={true} />,
},
bondValue: {
// example: "0" (no fb) or "0.0000052877962973"
heading: t('orderbook.table.heading_bond_value'),
},
}

const columns: OrderPropName[] = Object.keys(headingMap) as OrderPropName[]
const counterpartyCount = new Set(orders.map((it) => it.counterparty)).size

return (
<>
{orders.length === 0 ? (
<rb.Alert variant="info">{t('orderbook.alert_empty_orderbook')}</rb.Alert>
) : (
<div>
<div className="mb-2 d-flex justify-content-start">
<small>
{t('orderbook.text_orderbook_summary', {
counterpartyCount,
orderCount: orders.length,
})}
</small>
</div>
<rb.Table striped bordered hover variant={settings.theme} responsive>
<thead>
<tr>
{Object.values(headingMap).map((header, index) => (
<th key={`header_${index}`}>{header.heading}</th>
))}
</tr>
</thead>
<tbody>
{orders.map((order, index) => (
<tr key={`order_${index}_${order.orderId}`}>
{columns.map((propName) => (
<td key={propName}>
{headingMap[propName] && headingMap[propName].render !== undefined
? headingMap[propName].render!(order[propName])
: order[propName]}
</td>
))}
</tr>
))}
</tbody>
</rb.Table>
</div>
)}
</>
)
}

export function Orderbook() {
const { t } = useTranslation()
const [alert, setAlert] = useState<(rb.AlertProps & { message: string }) | null>(null)
const [isInitialized, setIsInitialized] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [orders, setOrders] = useState<ObwatchApi.Order[] | null>(null)

useEffect(() => {
setIsLoading(true)

const abortCtrl = new AbortController()

ObwatchApi.fetchOrderbook({ signal: abortCtrl.signal })
.then((orders) => {
if (abortCtrl.signal.aborted) return
setOrders(orders)
})
.catch((e) => {
if (abortCtrl.signal.aborted) return
const message = t('orderbook.error_loading_orderbook_failed', {
reason: e.message || 'Unknown reason',
})
setAlert({ variant: 'danger', message })
})
.finally(() => {
if (abortCtrl.signal.aborted) return
setIsLoading(false)
setIsInitialized(true)
})

return () => {
abortCtrl.abort()
}
}, [t])

return (
<div>
{!isInitialized && isLoading ? (
Array(5)
.fill('')
.map((_, index) => {
return (
<rb.Placeholder key={index} as="div" animation="wave">
<rb.Placeholder xs={12} className={styles['orderbook-line-placeholder']} />
</rb.Placeholder>
)
})
) : (
<>
{alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
{orders && (
<rb.Row>
<rb.Col className="mb-3">
<OrderbookTable orders={orders} />
</rb.Col>
</rb.Row>
)}
</>
)}
</div>
)
}

export function OrderbookOverlay({ show, onHide }: rb.OffcanvasProps) {
const { t } = useTranslation()

return (
<rb.Offcanvas className={styles['orderbook-overlay']} show={show} onHide={onHide} placement="bottom">
<rb.Offcanvas.Header closeButton>
<rb.Offcanvas.Title>{t('orderbook.title')}</rb.Offcanvas.Title>
</rb.Offcanvas.Header>
<rb.Offcanvas.Body>
<Orderbook />
</rb.Offcanvas.Body>
</rb.Offcanvas>
)
}
2 changes: 2 additions & 0 deletions src/constants/features.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
interface Features {
skipWalletBackupConfirmation: boolean
orderbook: boolean
}

const devMode = process.env.NODE_ENV === 'development'

const features: Features = {
skipWalletBackupConfirmation: devMode,
orderbook: devMode,
}

type Feature = keyof Features
Expand Down
19 changes: 19 additions & 0 deletions src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@
"text_starting": "Starting",
"text_stopping": "Stopping",
"button_show_report": "Show earnings report",
"button_show_orderbook": "Show orderbook",
"report": {
"title": "Earnings Report",
"heading_timestamp": "Timestamp",
Expand Down Expand Up @@ -381,6 +382,24 @@
"description": "Still confused? Dig into the <2>documentation</2>."
}
},
"orderbook": {
"title": "Orderbook",
"text_orderbook_summary": "{{ orderCount }} orders found by {{ counterpartyCount }} counterparties",
"alert_empty_orderbook": "Orderbook is empty",
"error_loading_orderbook_failed": "Error while loading the orderbook. Your current local setup might not support fetching the orderbook. Reason: {{ reason }}",
"text_offer_type_absolute": "absolute",
"text_offer_type_relative": "relative",
"table": {
"heading_type": "Type",
"heading_counterparty": "Counterparty",
"heading_order_id": "Order ID",
"heading_fee": "Fee",
"heading_miner_fee_contribution": "Miner Fee Contribution",
"heading_minimum_size": "Min. Size",
"heading_maximum_size": "Max. Size",
"heading_bond_value": "Bond Value"
}
},
"scheduler": {
"title": "Jam Scheduler (Experimental)",
"subtitle": "Execute multiple transactions using random amounts and time intervals to increase the privacy of yourself and others. Every scheduled transaction is a collaborative transaction. The scheduler will use all available funds that aren't frozen.",
Expand Down
5 changes: 5 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,11 @@ h2 {
background-color: var(--bs-gray-400);
}

.tooltip {
/* allow tooltips in navbar and overlays */
z-index: 1200;
}

/* Wallets Styles */

.wallets a.wallet-name {
Expand Down
73 changes: 73 additions & 0 deletions src/libs/JmObwatchApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Helper as ApiHelper } from '../libs/JmWalletApi'

const basePath = () => `${window.JM.PUBLIC_PATH}/obwatch`

export interface Order {
type: string
counterparty: string
orderId: string
fee: string
minerFeeContribution: string
minimumSize: string
maximumSize: string
bondValue: string
}

const ORDER_KEYS: (keyof Order)[] = [
'type',
'counterparty',
'orderId',
'fee',
'minerFeeContribution',
'minimumSize',
'maximumSize',
'bondValue',
]

const parseOrderbook = (res: Response): Promise<Order[]> => {
if (!res.ok) {
// e.g. error is raised if ob-watcher is not running
return ApiHelper.throwError(res)
}

return res.text().then((html) => {
var parser = new DOMParser()
var doc = parser.parseFromString(html, 'text/html')

const tables = doc.getElementsByTagName('table')
if (tables.length !== 1) {
throw new Error('Cannot find orderbook table')
}
const orderbookTable = tables[0]
const tbodies = [...orderbookTable.children].filter((child) => child.tagName.toLowerCase() === 'tbody')
if (tbodies.length !== 1) {
throw new Error('Cannot find orderbook table body')
}

const tbody = tbodies[0]

const orders: Order[] = [...tbody.children]
.filter((row) => row.tagName.toLowerCase() === 'tr')
.filter((row) => row.children.length > 0)
.map((row) => [...row.children].filter((child) => child.tagName.toLowerCase() === 'td'))
.filter((cols) => cols.length === ORDER_KEYS.length)
.map((cols) => {
const data: unknown = ORDER_KEYS.map((key, index) => ({ [key]: cols[index].innerHTML })).reduce(
(acc, curr) => ({ ...acc, ...curr }),
{}
)
return data as Order
})

return orders
})
}

// TODO: why is "orderbook.json" always empty? -> Parse HTML in the meantime.. ¯\_(ツ)_/¯
const fetchOrderbook = async ({ signal }: { signal: AbortSignal }) => {
return await fetch(`${basePath()}/`, {
signal,
}).then((res) => parseOrderbook(res))
}

export { fetchOrderbook }
9 changes: 9 additions & 0 deletions src/setupProxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,13 @@ module.exports = (app) => {
ws: true,
})
)

app.use(
createProxyMiddleware(`${PUBLIC_URL}/obwatch/`, {
target: 'http://localhost:62601',
pathRewrite: { [`^${PUBLIC_URL}/obwatch/`]: '' },
changeOrigin: true,
secure: false,
})
)
}

0 comments on commit 2406c04

Please sign in to comment.