diff --git a/.changeset/long-buckets-smash.md b/.changeset/long-buckets-smash.md deleted file mode 100644 index be0c2f226..000000000 --- a/.changeset/long-buckets-smash.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"frontend": patch ---- - -SOV-4468: Show protocol data in BTC and USD only diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore index dd7c0e809..8760e832f 100644 --- a/apps/frontend/.gitignore +++ b/apps/frontend/.gitignore @@ -1,6 +1,8 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies +/public/charting_library +/public/datafeeds /node_modules /.pnp .pnp.js diff --git a/apps/frontend/CHANGELOG.md b/apps/frontend/CHANGELOG.md index 15753c71d..10bc6f5b0 100644 --- a/apps/frontend/CHANGELOG.md +++ b/apps/frontend/CHANGELOG.md @@ -1,5 +1,69 @@ # frontend +## 1.1.29 + +### Patch Changes + +- 08fa89a4: Update spiceInfo.json +- e3c19dae: SOV-4609: fix display ambient liq balance + +## 1.1.28 + +### Patch Changes + +- c760d5c0: Fix Spice multipliers + +## 1.1.27 + +### Patch Changes + +- 58350fd0: Adjust homepage banners + +## 1.1.26 + +### Patch Changes + +- 5dcc907d: chore: fix asset icon styles +- 29852f48: [SOV-4525] - Display LP Fee Rate Without Wallet Connection +- 5dcc907d: SOV-4524: Show USD values on Convert page +- 9579578f: SOV-4537: Add incentives column to market making + +## 1.1.25 + +### Patch Changes + +- d953df39: fix: custom messages on convert page dropdown +- ae74555b: chore: fix asset icon styles +- c426bb82: SOV-4493: Show ecosystem statistics only in BTC and USD +- d3d711eb: SOV-4532: add PUPS pools +- d5079dae: SOV-4462: D2 charts on Convert page +- a5b4f389: Make charts bigger on larger screens +- Updated dependencies [d3d711eb] + - @sovryn/contracts@1.2.4 + - @sovryn/sdk@2.0.4 + +## 1.1.24 + +### Patch Changes + +- 0bc23cc0: chore: update rune symbols +- 52b7c4fe: chore: add new pools +- Updated dependencies [0bc23cc0] +- Updated dependencies [52b7c4fe] + - @sovryn/contracts@1.2.3 + - @sovryn/sdk@2.0.3 + +## 1.1.23 + +### Patch Changes + +- eedbb292: SOV-4445: Add Runes page +- 2028847d: SOV-4468: Show protocol data in BTC and USD only +- 750f7473: SOV-4496: add new pools +- Updated dependencies [750f7473] + - @sovryn/contracts@1.2.2 + - @sovryn/sdk@2.0.2 + ## 1.1.22 ### Patch Changes diff --git a/apps/frontend/package.json b/apps/frontend/package.json index c9ef89951..79e691d80 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "1.1.22", + "version": "1.1.29", "homepage": ".", "private": true, "dependencies": { @@ -11,6 +11,7 @@ "@loadable/component": "5.15.2", "@sovryn-zero/lib-base": "0.2.1", "@sovryn-zero/lib-ethers": "0.2.5", + "@sovryn/charting-library": "2.0.0", "@sovryn/contracts": "*", "@sovryn/ethers-provider": "*", "@sovryn/onboard-bitget": "1.0.1", @@ -61,6 +62,7 @@ "rxjs": "7.5.6", "sanitize-html": "2.11.0", "socket.io-client": "4.5.4", + "storage-factory": "^0.2.1", "utf8": "^3.0.0", "zustand": "^4.5.1" }, @@ -95,16 +97,17 @@ }, "scripts": { "predev": "yarn generate:graphql", - "dev": "craco start", + "dev": "yarn copy-libs && craco start", "prebuild": "yarn generate:graphql", - "build": "craco build", + "build": "yarn copy-libs && craco build", "test": "craco test --watchAll=false --passWithNoTests", "test:staged": "craco test --watchAll=false --passWithNoTests --bail --onlyChanged", "lint": "eslint -c .eslintrc.js ./", "generate:graphql": "graphql-codegen", "generate:graphql:fetch:testnet": "env-cmd -f .env.development.local graphql-codegen -c codegen.fetch.yml", "generate:graphql:fetch:mainnet": "env-cmd -f .env.staging graphql-codegen -c codegen.fetch.yml", - "find-deadcode": "ts-prune -s .generated.tsx | grep -v '(used in module)'" + "find-deadcode": "ts-prune -s .generated.tsx | grep -v '(used in module)'", + "copy-libs": "node scripts/copyLibs.js" }, "browserslist": [ "last 5 chrome version", diff --git a/apps/frontend/scripts/copyLibs.js b/apps/frontend/scripts/copyLibs.js new file mode 100644 index 000000000..116f38d31 --- /dev/null +++ b/apps/frontend/scripts/copyLibs.js @@ -0,0 +1,61 @@ +const fs = require('fs'); +const path = require('path'); +const fsp = fs.promises; + +async function deleteDir(dir) { + if (fs.existsSync(dir)) { + await fsp.rmdir(dir, { recursive: true }); + console.log(`Deleted directory: ${dir}`); + } +} + +async function copyDir(src, dest) { + await fsp.mkdir(dest, { recursive: true }); + const entries = await fsp.readdir(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + await copyDir(srcPath, destPath); + } else { + await fsp.copyFile(srcPath, destPath); + } + } +} + +async function copyLibs() { + const chartingLibrarySrc = path.resolve( + __dirname, + '../../../node_modules/@sovryn/charting-library/public/charting_library', + ); + const chartingLibraryDest = path.resolve( + __dirname, + '../public/charting_library', + ); + + const datafeedsSrc = path.resolve( + __dirname, + '../../../node_modules/@sovryn/charting-library/public/datafeeds', + ); + const datafeedsDest = path.resolve(__dirname, '../public/datafeeds'); + + if (fs.existsSync(chartingLibrarySrc)) { + await deleteDir(chartingLibraryDest); + await copyDir(chartingLibrarySrc, chartingLibraryDest); + console.log('Charting Library copied.'); + } else { + console.error('Charting Library source not found.'); + } + + if (fs.existsSync(datafeedsSrc)) { + await deleteDir(datafeedsDest); + await copyDir(datafeedsSrc, datafeedsDest); + console.log('Datafeeds copied.'); + } else { + console.error('Datafeeds source not found.'); + } +} + +copyLibs(); diff --git a/apps/frontend/src/app/2_molecules/AssetRenderer/AssetRenderer.module.css b/apps/frontend/src/app/2_molecules/AssetRenderer/AssetRenderer.module.css index 5df57554a..fb983a673 100644 --- a/apps/frontend/src/app/2_molecules/AssetRenderer/AssetRenderer.module.css +++ b/apps/frontend/src/app/2_molecules/AssetRenderer/AssetRenderer.module.css @@ -3,7 +3,7 @@ } .assetLogo svg { - @apply mr-2 w-5 h-5; + @apply mr-2 w-5 h-5 bg-gray-80 rounded-full overflow-hidden; } .asset { diff --git a/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.constants.ts b/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.constants.ts new file mode 100644 index 000000000..953fb522b --- /dev/null +++ b/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.constants.ts @@ -0,0 +1,64 @@ +import { storageFactory } from 'storage-factory'; + +import { ResolutionString } from '@sovryn/charting-library/src/charting_library'; + +import { INDEXER_SERVICE } from '../../../constants/infrastructure'; +import { Environments } from '../../../types/global'; +import { CandleDuration } from './dictionary'; + +export const REFRESH_RATE = 15 * 1e3; +export const MAXIMUM_CHUNK_SIZE = 1e3; +export const endTimeCache = new Map(); +export const supportedResolutions = [ + '1', + '5', + '10', + '15', + '30', + '60', + '240', + '720', + '1D', + '3D', + '1W', + '1M', +] as ResolutionString[]; + +export const resolutionMap: { [key: string]: CandleDuration } = { + '1': CandleDuration.M_1, + '5': CandleDuration.M_1, + '10': CandleDuration.M_10, + '15': CandleDuration.M_15, + '30': CandleDuration.M_30, + '60': CandleDuration.H_1, + H: CandleDuration.H_1, + '240': CandleDuration.H_4, + '720': CandleDuration.H_12, + '1440': CandleDuration.D_1, + D: CandleDuration.D_1, + '1D': CandleDuration.D_1, + '3D': CandleDuration.D_3, + W: CandleDuration.W_1, + '1W': CandleDuration.W_1, + M: CandleDuration.D_30, + '1M': CandleDuration.D_30, +}; + +export const local = storageFactory(() => localStorage); + +export const chartStorageKey = 'sovryn.charts'; + +export const config = { + exchanges: [], + symbols_types: [], + supported_resolutions: supportedResolutions, + supports_time: false, +}; + +export const SOVRYN_INDEXER_MAINNET = `${ + INDEXER_SERVICE[Environments.Mainnet] +}chart`; + +export const SOVRYN_INDEXER_TESTNET = `${ + INDEXER_SERVICE[Environments.Testnet] +}chart`; diff --git a/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.tsx b/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.tsx new file mode 100644 index 000000000..95127ec94 --- /dev/null +++ b/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.tsx @@ -0,0 +1,82 @@ +import { useApolloClient } from '@apollo/client'; + +import React, { FC, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +import { + ChartingLibraryWidgetOptions, + IChartingLibraryWidget, + ResolutionString, + widget, +} from '@sovryn/charting-library/src/charting_library'; +import { noop } from '@sovryn/ui'; + +import { SeriesStyle, TradingChartProps } from './TradingChart.types'; +import Datafeed from './datafeed'; + +export const TradingChart: FC = ({ pair }) => { + const chartContainerRef = + useRef() as React.MutableRefObject; + + const [hasCharts, setHasCharts] = useState(false); + const [chart, setChart] = useState(null); + const client = useApolloClient(); + + useEffect(() => { + try { + const widgetOptions: ChartingLibraryWidgetOptions = { + symbol: pair, + datafeed: Datafeed(client), + interval: '1D' as ResolutionString, + container: chartContainerRef.current, + library_path: '/charting_library/', + load_last_chart: true, //last chart layout (if present) + theme: 'dark', + locale: 'en', + disabled_features: ['header_symbol_search', 'header_compare'], + enabled_features: [ + 'study_templates', + 'side_toolbar_in_fullscreen_mode', + ], + charts_storage_url: 'https://saveload.tradingview.com', + charts_storage_api_version: '1.1', + client_id: 'tradingview.com', + user_id: 'public_user_id', + fullscreen: false, + autosize: true, + studies_overrides: {}, + }; + + const myChart = new widget(widgetOptions); + setChart(myChart); + myChart.onChartReady(() => { + setHasCharts(true); + }); + + return () => { + myChart.remove(); + setHasCharts(false); + setChart(null); + }; + } catch (e) { + console.error(e); + setHasCharts(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [client]); + + useLayoutEffect(() => { + if (chart && hasCharts) { + chart.chart().resetData(); + + chart.chart().setChartType(SeriesStyle.Candles as number); + + chart.chart().setSymbol(pair, noop); + } + }, [chart, hasCharts, pair]); + + return ( +
+
+
+ ); +}; diff --git a/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.types.ts b/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.types.ts new file mode 100644 index 000000000..4ebab009e --- /dev/null +++ b/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.types.ts @@ -0,0 +1,80 @@ +export type TradingChartProps = { + pair: string; +}; + +export enum SeriesStyle { + Bars = 0, + Candles = 1, + Line = 2, + Area = 3, + HeikenAshi = 8, + HollowCandles = 9, + Renko = 4, + Kagi = 5, + PointAndFigure = 6, + LineBreak = 7, +} + +export type Bar = { + time: number; + low: number; + high: number; + open: number; + close: number; + volume?: number; +}; + +export type CandleSticksResponse = { + id: string; + open: number; + high: number; + low: number; + close: number; + totalVolume: number; + periodStartUnix: number; +}; + +export type TimestampChunk = { + from: number; + to: number; +}; + +export type Candle = { + open: string; + high: string; + low: string; + close: string; + totalVolume: string; + periodStartUnix: string; +}; + +export type StoredCharts = { + [id: string]: ChartData; +}; + +export type ChartData = { + id: string; + name: string; + symbol: string; + resolution: string; + content: string; + timestamp: number; +}; + +export type StoredTemplates = { + [id: string]: TemplateData; +}; + +export type TemplateData = { + name: string; + content: string; +}; + +export type SubItem = { + symbolInfo: any; + subscribeUID: string; //e.g. SOV/USDT_10 + resolution: string; + lastBar: Bar; + handler: Function; + timer?: number; +}; diff --git a/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.utils.tsx b/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.utils.tsx new file mode 100644 index 000000000..b9e7eb1c0 --- /dev/null +++ b/apps/frontend/src/app/2_molecules/TradingChart/TradingChart.utils.tsx @@ -0,0 +1,66 @@ +import EventEmitter from 'events'; + +import { getContract } from '@sovryn/contracts'; + +import { getCurrentChain } from '../../../hooks/useChainStore'; +import { isMainnet } from '../../../utils/helpers'; +import { + SOVRYN_INDEXER_MAINNET, + SOVRYN_INDEXER_TESTNET, +} from './TradingChart.constants'; +import { Bar } from './TradingChart.types'; +import { CandleDetails } from './dictionary'; + +const pushes: Record = {}; +const hub = new EventEmitter({ captureRejections: true }); + +export const pushPrice = (symbol: string, price: number) => { + pushes[symbol] = price; + hub.emit(symbol, price); +}; + +const indexerBaseUrl = isMainnet() + ? SOVRYN_INDEXER_MAINNET + : SOVRYN_INDEXER_TESTNET; + +export const queryCandles = async ( + chainId: number, + candleDetails: CandleDetails, + baseToken: string, + quoteToken: string, + startTime: number, + endTime: number, +) => { + try { + const fullIndexerUrl = `${indexerBaseUrl}?chainId=${chainId}&base=${baseToken}"e=${quoteToken}&start=${startTime}&end=${endTime}&timeframe=${candleDetails.candleSymbol}`; + + const result = await (await fetch(fullIndexerUrl)).json(); + + const bars: Bar[] = result.data.reverse().map(item => ({ + time: Number(item.date) * 1000, + low: item.low, + high: item.high, + open: item.open, + close: item.close, + })); + + return bars; + } catch (error) { + console.error(error); + throw new Error(`Request error: ${error}`); + } +}; + +const getTokenAddress = async (asset: string) => { + const { address } = await getContract(asset, 'assets', getCurrentChain()); + return address; +}; + +export const getTokensFromSymbol = (symbol: string) => { + let [base, quote, chainId] = symbol.split('/'); + return { + baseToken: getTokenAddress(base), + quoteToken: getTokenAddress(quote), + chainId: Number(chainId), + }; +}; diff --git a/apps/frontend/src/app/2_molecules/TradingChart/datafeed.ts b/apps/frontend/src/app/2_molecules/TradingChart/datafeed.ts new file mode 100644 index 000000000..13b6d8e37 --- /dev/null +++ b/apps/frontend/src/app/2_molecules/TradingChart/datafeed.ts @@ -0,0 +1,166 @@ +import { ApolloClient } from '@apollo/client'; + +import { + ChartingLibraryWidgetOptions, + DatafeedErrorCallback, + HistoryCallback, + LibrarySymbolInfo, + PeriodParams, + ResolutionString, +} from '@sovryn/charting-library/src/charting_library'; + +import { + config, + resolutionMap, + supportedResolutions, +} from './TradingChart.constants'; +import { Bar } from './TradingChart.types'; +import { + getTokensFromSymbol, + pushPrice, + queryCandles, +} from './TradingChart.utils'; +import { CandleDuration, TradingCandleDictionary } from './dictionary'; +import { stream } from './streaming'; + +const newestBarsCache = new Map(); +const oldestBarsCache = new Map(); + +const tradingChartDataFeeds = ( + graphqlClient: ApolloClient, +): ChartingLibraryWidgetOptions['datafeed'] => ({ + onReady: callback => setTimeout(() => callback(config)), + searchSymbols: () => {}, + resolveSymbol: async (symbolName, onSymbolResolvedCallback) => { + const [base, quote] = symbolName.split('/'); + const symbolInfo: LibrarySymbolInfo = { + name: symbolName, + description: '', + type: 'crypto', + exchange: '', + listed_exchange: '', + format: 'price', + volume_precision: 6, + session: '24x7', + timezone: 'Etc/UTC', + ticker: `${base}/${quote}`, + minmov: 1, + pricescale: 10 ** 8, + has_intraday: true, + intraday_multipliers: [ + '1', + '5', + '10', + '15', + '30', + '60', + '240', + '720', + '1440', + '4320', + '10080', + '43200', + ], + supported_resolutions: supportedResolutions, + has_empty_bars: true, + has_daily: true, + has_weekly_and_monthly: true, + data_status: 'streaming', + }; + + setTimeout(() => onSymbolResolvedCallback(symbolInfo)); + }, + getBars: async ( + symbolInfo: LibrarySymbolInfo, + resolution: ResolutionString, + periodParams: PeriodParams, + onResult: HistoryCallback, + onError: DatafeedErrorCallback, + ) => { + const { from, to, firstDataRequest } = periodParams; + const candleDuration: CandleDuration = resolutionMap[resolution]; + const candleDetails = TradingCandleDictionary.get(candleDuration); + + try { + const { baseToken, quoteToken, chainId } = getTokensFromSymbol( + symbolInfo.name, + ); + + let items = await queryCandles( + chainId, + candleDetails, + await baseToken, + await quoteToken, + from, + to, + ); + + if (!items || items.length === 0) { + onResult([], { noData: true }); + return; + } + + if (firstDataRequest) { + newestBarsCache.set(symbolInfo.name, { ...items[items.length - 1] }); + oldestBarsCache.set(symbolInfo.name, { ...items[0] }); + } + + const lastBar = newestBarsCache.get(symbolInfo.name); + const newestBar = items[items.length - 1]; + + if (!lastBar) { + newestBarsCache.set(symbolInfo.name, newestBar); + } + + if (lastBar && newestBar && newestBar?.time >= lastBar.time) { + newestBarsCache.set(symbolInfo.name, newestBar); + pushPrice(`${baseToken}/${quoteToken}`, newestBar.close); + } + + const oldestBar = oldestBarsCache.get(symbolInfo.name); + const currentOldest = items[0]; + + if (!oldestBar) { + oldestBarsCache.set(symbolInfo.name, currentOldest); + } + + if (oldestBar && currentOldest && currentOldest?.time <= oldestBar.time) { + oldestBarsCache.set(symbolInfo.name, currentOldest); + } + + let bars: Bar[] = []; + + items.forEach(bar => { + if (bar.time >= from * 1000 && bar.time < to * 1000) { + bars = [...bars, bar]; + } + }); + + onResult(bars, { noData: false }); + } catch (error) { + console.log('error', error); + onError(error.message); + } + }, + subscribeBars: ( + symbolInfo, + resolution, + onRealtimeCallback, + subscribeUID, + onResetCacheNeededCallback, + ) => { + const newestBar = newestBarsCache.get(symbolInfo.name); + stream.subscribeOnStream( + graphqlClient, + symbolInfo, + resolution, + onRealtimeCallback, + subscribeUID, + onResetCacheNeededCallback, + newestBar, + ); + }, + unsubscribeBars: subscriberUID => stream.unsubscribeFromStream(subscriberUID), +}); + +export default tradingChartDataFeeds; diff --git a/apps/frontend/src/app/2_molecules/TradingChart/dictionary.ts b/apps/frontend/src/app/2_molecules/TradingChart/dictionary.ts new file mode 100644 index 000000000..2baa433e2 --- /dev/null +++ b/apps/frontend/src/app/2_molecules/TradingChart/dictionary.ts @@ -0,0 +1,130 @@ +export enum CandleDuration { + M_1 = 'M_1', + M_10 = 'M_10', + M_15 = 'M_15', + M_30 = 'M_30', + H_1 = 'H_1', + H_4 = 'H_4', + H_12 = 'H_12', + D_1 = 'D_1', + D_3 = 'D_3', + W_1 = 'W_1', + D_30 = 'D_30', +} + +export class CandleDetails { + /** TODO: Add default number of candles or default startTime */ + constructor( + public entityName: string, + public resolutionBack: 'D' | 'M', + public intervalBack: number, + public startDaysFromNow: number, // Number of days back to start from in default query + public candleSeconds: number, + public candleSymbol: string, + ) { + this.entityName = entityName; + this.resolutionBack = resolutionBack; + this.intervalBack = intervalBack; + this.startDaysFromNow = startDaysFromNow; + this.candleSeconds = candleSeconds; + this.candleSymbol = candleSymbol; + } +} + +export class TradingCandleDictionary { + public static candles: Map = new Map< + CandleDuration, + CandleDetails + >([ + [ + CandleDuration.M_1, + new CandleDetails('candleSticksMinutes', 'D', 1, 5, 60, '1m'), + ], + [ + CandleDuration.M_10, + new CandleDetails('candleSticksTenMinutes', 'D', 3, 5, 60 * 10, '10m'), + ], + [ + CandleDuration.M_15, + new CandleDetails( + 'candleSticksFifteenMinutes', + 'D', + 3, + 5, + 60 * 15, + '15m', + ), + ], + [ + CandleDuration.M_30, + new CandleDetails('candleSticksThirtyMinutes', 'D', 3, 5, 60 * 30, '30m'), + ], + [ + CandleDuration.H_1, + new CandleDetails('candleSticksHours', 'D', 5, 5, 60 * 60, '1h'), + ], + [ + CandleDuration.H_4, + new CandleDetails( + 'candleSticksFourHours', + 'D', + 10, + 10, + 60 * 60 * 4, + '4h', + ), + ], + [ + CandleDuration.H_12, + new CandleDetails( + 'candleSticksTwelveHours', + 'D', + 10, + 10, + 60 * 60 * 12, + '12h', + ), + ], + [ + CandleDuration.D_1, + new CandleDetails('candleSticksDays', 'D', 90, 90, 60 * 60 * 24, '1d'), + ], + [ + CandleDuration.D_3, + new CandleDetails( + 'candleSticksThreeDays', + 'D', + 90, + 90, + 60 * 60 * 24 * 3, + '3d', + ), + ], + [ + CandleDuration.W_1, + new CandleDetails( + 'candleSticksOneWeek', + 'D', + 90, + 90, + 60 * 60 * 24 * 7, + '1w', + ), + ], + [ + CandleDuration.D_30, + new CandleDetails( + 'candleSticksOneMonth', + 'D', + 90, + 90, + 60 * 60 * 24 * 30, + '30d', + ), + ], + ]); + + public static get(candle: CandleDuration): CandleDetails { + return this.candles.get(candle) as CandleDetails; + } +} diff --git a/apps/frontend/src/app/2_molecules/TradingChart/streaming.ts b/apps/frontend/src/app/2_molecules/TradingChart/streaming.ts new file mode 100644 index 000000000..d3c204853 --- /dev/null +++ b/apps/frontend/src/app/2_molecules/TradingChart/streaming.ts @@ -0,0 +1,121 @@ +import { ApolloClient } from '@apollo/client'; + +import { REFRESH_RATE, resolutionMap } from './TradingChart.constants'; +import { SubItem } from './TradingChart.types'; +import { + getTokensFromSymbol, + pushPrice, + queryCandles, +} from './TradingChart.utils'; +import { CandleDuration, TradingCandleDictionary } from './dictionary'; + +export class Streaming { + private client: ApolloClient | null = null; + private subscriptions = new Map(); + + private async onUpdate(subscriptionItem: SubItem) { + if (!subscriptionItem?.symbolInfo?.name) { + return; + } + + const candleDuration: CandleDuration = + resolutionMap[subscriptionItem.resolution]; + + const details = TradingCandleDictionary.get(candleDuration); + + const { baseToken, quoteToken, chainId } = getTokensFromSymbol( + subscriptionItem.symbolInfo.name, + ); + + queryCandles( + chainId, + details, + await baseToken, + await quoteToken, + subscriptionItem?.lastBar?.time / 1e3, + Math.ceil(Date.now() / 1e3), + ) + .then(bars => { + bars.reverse().forEach((item, index) => { + let bar; + if ( + !subscriptionItem.lastBar || + item.time > subscriptionItem?.lastBar?.time + ) { + // generate new bar + bar = { + ...item, + time: item.time, + }; + } else if ( + subscriptionItem.lastBar && + item.time === subscriptionItem?.lastBar?.time + ) { + // update last bar + bar = { + ...subscriptionItem.lastBar, + high: Math.max(subscriptionItem.lastBar.high, item.high), + low: Math.min(subscriptionItem.lastBar.low, item.low), + close: item.close, + }; + pushPrice(`${baseToken}/${quoteToken}`, bar.close); + } else { + // do not update + return; + } + + // update last bar cache and execute chart callback + subscriptionItem.lastBar = bar; + subscriptionItem.handler(bar); + }); + }) + .catch(error => { + console.error('Error in onUpdate', error); + }); + } + + private addSubscription(subItem) { + subItem.timer = setInterval(() => this.onUpdate(subItem), REFRESH_RATE); + this.subscriptions.set(subItem.subscribeUID, subItem); + } + + private clearSubscription(subscribeUID) { + const currentSub = this.subscriptions.get(subscribeUID); + if (!currentSub) return; + clearInterval(currentSub.timer); + delete currentSub.timer; + this.subscriptions.delete(subscribeUID); + } + + public subscribeOnStream( + client: ApolloClient, + symbolInfo, + resolution, + onRealtimeCallback, + subscribeUID, + onResetCacheNeededCallback, + lastBar, + ) { + this.client = client; + + let subscriptionItem = this.subscriptions.get(subscribeUID); + if (subscriptionItem) { + return; + } + + subscriptionItem = { + symbolInfo, + subscribeUID, + resolution, + lastBar, + handler: onRealtimeCallback, + }; + this.addSubscription(subscriptionItem); + } + + public unsubscribeFromStream(subscriberUID) { + this.clearSubscription(subscriberUID); + } +} + +export const stream = new Streaming(); diff --git a/apps/frontend/src/app/3_organisms/Header/Header.tsx b/apps/frontend/src/app/3_organisms/Header/Header.tsx index 7615c068d..507b592f5 100644 --- a/apps/frontend/src/app/3_organisms/Header/Header.tsx +++ b/apps/frontend/src/app/3_organisms/Header/Header.tsx @@ -70,12 +70,20 @@ export const Header: FC = () => { ))} {isBobChain(chainId) && ( - +
+ +
- {t(pageTranslations.form.convertFrom)} + {t(pageTranslations.form.convertTo)} - -
+
+
+ -
- - - handleCategorySelect(category, setSourceCategories) - } - onTokenChange={onSourceTokenChange} - dataAttribute="convert-from-asset" - /> +
+ +
+
+ + + handleCategorySelect(category, setDestinationCategories) + } + onTokenChange={onDestinationTokenChange} + dataAttribute="convert-to-asset" + /> +
- {!isValidAmount && ( + setShowAdvancedSettings(!showAdvancedSettings)} + dataAttribute="convert-settings" + > +
+ setSlippageTolerance(e.target.value)} + label={t(pageTranslations.slippageTolerance)} + className="max-w-none w-full" + unit="%" + step="0.01" + decimalPrecision={2} + placeholder="0" + max="100" + /> +
+
+ + {sourceToken && destinationToken && quote ? ( + + + + + ) : null} + + {hasQuoteError && ( )} - -
- -
+