Merge branch 'master' into knorrium/update_node_matrix
This commit is contained in:
		
						commit
						77f8999f1e
					
				
							
								
								
									
										14
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @ -16,6 +16,20 @@ updates: | ||||
|   - package-ecosystem: npm | ||||
|     directory: "/frontend" | ||||
|     versioning-strategy: increase | ||||
|     groups: | ||||
|       frontend-angular-dependencies: | ||||
|         patterns: | ||||
|           - "@angular*" | ||||
|           - "@ng-*" | ||||
|           - "ngx-*" | ||||
|       frontend-jest-dependencies: | ||||
|         patterns: | ||||
|           - "@types/jest" | ||||
|           - "jest" | ||||
|       frontend-eslint-dependencies: | ||||
|         patterns: | ||||
|           - "@typescript-eslint*" | ||||
|           - "eslint" | ||||
|     schedule: | ||||
|       interval: daily | ||||
|     open-pull-requests-limit: 10 | ||||
|  | ||||
							
								
								
									
										13
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										13
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @ -27,8 +27,17 @@ jobs: | ||||
|           node-version: ${{ matrix.node }} | ||||
|           registry-url: "https://registry.npmjs.org" | ||||
| 
 | ||||
|       - name: Install 1.70.x Rust toolchain | ||||
|         uses: dtolnay/rust-toolchain@1.70 | ||||
|       - name: Read rust-toolchain file from repository | ||||
|         id: gettoolchain | ||||
|         run: echo "::set-output name=toolchain::$(cat rust-toolchain)" | ||||
|         working-directory: ${{ matrix.node }}/${{ matrix.flavor }} | ||||
| 
 | ||||
|       - name: Install ${{ steps.gettoolchain.outputs.toolchain }} Rust toolchain | ||||
|         # Latest version available on this commit is 1.71.1 | ||||
|         # Commit date is Aug 3, 2023 | ||||
|         uses: dtolnay/rust-toolchain@f361669954a8ecfc00a3443f35f9ac8e610ffc06 | ||||
|         with: | ||||
|           toolchain: ${{ steps.gettoolchain.outputs.toolchain }} | ||||
| 
 | ||||
|       - name: Install | ||||
|         if: ${{ matrix.flavor == 'dev'}} | ||||
|  | ||||
							
								
								
									
										19
									
								
								.github/workflows/get_backend_hash.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								.github/workflows/get_backend_hash.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,19 @@ | ||||
| name: 'Print backend hashes' | ||||
| 
 | ||||
| on: [workflow_dispatch] | ||||
| 
 | ||||
| jobs: | ||||
|   print-backend-sha: | ||||
|     runs-on: 'ubuntu-latest' | ||||
|     name: Print backend hashes | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v3 | ||||
|         with: | ||||
|           path: repo | ||||
| 
 | ||||
|       - name: Run script | ||||
|         working-directory: repo | ||||
|         run: | | ||||
|           chmod +x ./scripts/get_backend_hash.sh | ||||
|           sh ./scripts/get_backend_hash.sh | ||||
							
								
								
									
										8
									
								
								.github/workflows/on-tag.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.github/workflows/on-tag.yml
									
									
									
									
										vendored
									
									
								
							| @ -68,17 +68,17 @@ jobs: | ||||
|         run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin | ||||
| 
 | ||||
|       - name: Checkout project | ||||
|         uses: actions/checkout@v3 | ||||
|         uses: actions/checkout@v4 | ||||
| 
 | ||||
|       - name: Init repo for Dockerization | ||||
|         run: docker/init.sh "$TAG" | ||||
| 
 | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v2 | ||||
|         uses: docker/setup-qemu-action@v3 | ||||
|         id: qemu | ||||
| 
 | ||||
|       - name: Setup Docker buildx action | ||||
|         uses: docker/setup-buildx-action@v2 | ||||
|         uses: docker/setup-buildx-action@v3 | ||||
|         id: buildx | ||||
| 
 | ||||
|       - name: Available platforms | ||||
| @ -98,7 +98,7 @@ jobs: | ||||
|           docker buildx build \ | ||||
|           --cache-from "type=local,src=/tmp/.buildx-cache" \ | ||||
|           --cache-to "type=local,dest=/tmp/.buildx-cache" \ | ||||
|           --platform linux/amd64,linux/arm64,linux/arm/v7 \ | ||||
|           --platform linux/amd64,linux/arm64 \ | ||||
|           --tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:$TAG \ | ||||
|           --tag ${{ secrets.DOCKER_HUB_USER }}/${{ matrix.service }}:latest \ | ||||
|           --output "type=registry" ./${{ matrix.service }}/ \ | ||||
|  | ||||
							
								
								
									
										47
									
								
								GNUmakefile
									
									
									
									
									
								
							
							
						
						
									
										47
									
								
								GNUmakefile
									
									
									
									
									
								
							| @ -1,47 +0,0 @@ | ||||
| # If you see pwd_unknown showing up check permissions
 | ||||
| PWD ?= pwd_unknown | ||||
| 
 | ||||
| # DATABASE DEPLOY FOLDER CONFIG - default ./data
 | ||||
| ifeq ($(data),) | ||||
| DATA := data | ||||
| export DATA | ||||
| else | ||||
| DATA := $(data) | ||||
| export DATA | ||||
| endif | ||||
| 
 | ||||
| .PHONY: help | ||||
| help: | ||||
| 	@echo '' | ||||
| 	@echo '' | ||||
| 	@echo '	Usage: make [COMMAND]' | ||||
| 	@echo '' | ||||
| 	@echo '		make all		# build init mempool and electrs' | ||||
| 	@echo '		make init		# setup some useful configs' | ||||
| 	@echo '		make mempool		# build q dockerized mempool.space' | ||||
| 	@echo '		make electrs		# build a docker electrs image' | ||||
| 	@echo '' | ||||
| 
 | ||||
| .PHONY: init | ||||
| init: | ||||
| 	@echo '' | ||||
| 	mkdir -p   $(DATA) $(DATA)/mysql $(DATA)/mysql/data  | ||||
| 	#REF: https://github.com/mempool/mempool/blob/master/docker/README.md | ||||
| 	cat docker/docker-compose.yml > docker-compose.yml | ||||
| 	cat backend/mempool-config.sample.json > backend/mempool-config.json | ||||
| .PHONY: mempool | ||||
| mempool: init | ||||
| 	@echo '' | ||||
| 	docker-compose up --force-recreate --always-recreate-deps | ||||
| 	@echo '' | ||||
| .PHONY: electrs | ||||
| electrum: | ||||
| 	#REF: https://hub.docker.com/r/beli/electrum | ||||
| 	@echo '' | ||||
| 	docker build -f docker/electrum/Dockerfile . | ||||
| 	@echo '' | ||||
| .PHONY: all | ||||
| all: init | ||||
| 	make mempool | ||||
| #######################
 | ||||
| -include Makefile | ||||
| @ -85,7 +85,7 @@ Install dependencies with `npm` and build the backend: | ||||
| 
 | ||||
| ``` | ||||
| cd backend | ||||
| npm install | ||||
| npm install --no-install-links # npm@9.4.2 and later can omit the --no-install-links | ||||
| npm run build | ||||
| ``` | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										14
									
								
								backend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										14
									
								
								backend/package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -12,7 +12,7 @@ | ||||
|         "@babel/core": "^7.21.3", | ||||
|         "@mempool/electrum-client": "1.1.9", | ||||
|         "@types/node": "^18.15.3", | ||||
|         "axios": "~1.4.0", | ||||
|         "axios": "~1.5.0", | ||||
|         "bitcoinjs-lib": "~6.1.3", | ||||
|         "crypto-js": "~4.1.1", | ||||
|         "express": "~4.18.2", | ||||
| @ -2321,9 +2321,9 @@ | ||||
|       "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" | ||||
|     }, | ||||
|     "node_modules/axios": { | ||||
|       "version": "1.4.0", | ||||
|       "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", | ||||
|       "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", | ||||
|       "version": "1.5.0", | ||||
|       "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.0.tgz", | ||||
|       "integrity": "sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ==", | ||||
|       "dependencies": { | ||||
|         "follow-redirects": "^1.15.0", | ||||
|         "form-data": "^4.0.0", | ||||
| @ -9397,9 +9397,9 @@ | ||||
|       "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" | ||||
|     }, | ||||
|     "axios": { | ||||
|       "version": "1.4.0", | ||||
|       "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", | ||||
|       "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", | ||||
|       "version": "1.5.0", | ||||
|       "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.0.tgz", | ||||
|       "integrity": "sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ==", | ||||
|       "requires": { | ||||
|         "follow-redirects": "^1.15.0", | ||||
|         "form-data": "^4.0.0", | ||||
|  | ||||
| @ -41,7 +41,7 @@ | ||||
|     "@babel/core": "^7.21.3", | ||||
|     "@mempool/electrum-client": "1.1.9", | ||||
|     "@types/node": "^18.15.3", | ||||
|     "axios": "~1.4.0", | ||||
|     "axios": "~1.5.0", | ||||
|     "bitcoinjs-lib": "~6.1.3", | ||||
|     "crypto-js": "~4.1.1", | ||||
|     "express": "~4.18.2", | ||||
|  | ||||
| @ -335,13 +335,15 @@ fn set_relatives(txid: u32, audit_pool: &mut AuditPool) { | ||||
|     let mut total_sigops: u32 = 0; | ||||
| 
 | ||||
|     for ancestor_id in &ancestors { | ||||
|         let Some(ancestor) = audit_pool | ||||
|         if let Some(ancestor) = audit_pool | ||||
|             .get(*ancestor_id as usize) | ||||
|             .expect("audit_pool contains all ancestors") else { todo!() }; | ||||
|         total_fee += ancestor.fee; | ||||
|         total_sigop_adjusted_weight += ancestor.sigop_adjusted_weight; | ||||
|         total_sigop_adjusted_vsize += ancestor.sigop_adjusted_vsize; | ||||
|         total_sigops += ancestor.sigops; | ||||
|             .expect("audit_pool contains all ancestors") | ||||
|         { | ||||
|             total_fee += ancestor.fee; | ||||
|             total_sigop_adjusted_weight += ancestor.sigop_adjusted_weight; | ||||
|             total_sigop_adjusted_vsize += ancestor.sigop_adjusted_vsize; | ||||
|             total_sigops += ancestor.sigops; | ||||
|         } else { todo!() }; | ||||
|     } | ||||
| 
 | ||||
|     if let Some(Some(tx)) = audit_pool.get_mut(txid as usize) { | ||||
|  | ||||
| @ -191,30 +191,31 @@ describe('Mempool Backend Config', () => { | ||||
|             } | ||||
|             continue; | ||||
|           } | ||||
|           switch (typeof value) { | ||||
|             case 'object': { | ||||
|               if (Array.isArray(value)) { | ||||
|                 continue; | ||||
|               } else { | ||||
|                 parseJson(value, key); | ||||
|               } | ||||
|               break; | ||||
|             } | ||||
|             default: { | ||||
| 
 | ||||
|           if (root) { | ||||
|               //The flattened string, i.e, __MEMPOOL_ENABLED__
 | ||||
|               const replaceStr = `${root ? '__' + root + '_' : '__'}${key}__`; | ||||
| 
 | ||||
|               //The string used as the environment variable, i.e, MEMPOOL_ENABLED
 | ||||
|               const envVarStr = `${root ? root : ''}_${key}`; | ||||
| 
 | ||||
|               let defaultEntry; | ||||
|               //The string used as the default value, to be checked as a regex, i.e, __MEMPOOL_ENABLED__=${MEMPOOL_ENABLED:=(.*)}
 | ||||
|               const defaultEntry = replaceStr + '=' + '\\${' + envVarStr + ':=(.*)' + '}'; | ||||
| 
 | ||||
|               if (process.env.CI) { | ||||
|                 console.log(`looking for ${defaultEntry} in the start.sh script`); | ||||
|               if (Array.isArray(value)) { | ||||
|                 defaultEntry = `${replaceStr}=\${${envVarStr}:=[]}`; | ||||
|                 if (process.env.CI) { | ||||
|                   console.log(`looking for ${defaultEntry} in the start.sh script`); | ||||
|                 } | ||||
|                 //Regex matching does not work with the array values
 | ||||
|                 expect(startSh).toContain(defaultEntry); | ||||
|               } else { | ||||
|                  defaultEntry = replaceStr + '=' + '\\${' + envVarStr + ':=(.*)' + '}'; | ||||
|                  if (process.env.CI) { | ||||
|                   console.log(`looking for ${defaultEntry} in the start.sh script`); | ||||
|                 } | ||||
|                 const re = new RegExp(defaultEntry); | ||||
|                 expect(startSh).toMatch(re); | ||||
|               } | ||||
|               const re = new RegExp(defaultEntry); | ||||
|               expect(startSh).toMatch(re); | ||||
| 
 | ||||
|               //The string that actually replaces the values in the config file
 | ||||
|               const sedStr = 'sed -i "s!' + replaceStr + '!${' + replaceStr + '}!g" mempool-config.json'; | ||||
| @ -222,11 +223,13 @@ describe('Mempool Backend Config', () => { | ||||
|                 console.log(`looking for ${sedStr} in the start.sh script`); | ||||
|               } | ||||
|               expect(startSh).toContain(sedStr); | ||||
|               break; | ||||
|             } | ||||
|           else { | ||||
|             parseJson(value, key); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       parseJson(fixture); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
| @ -75,9 +75,9 @@ class FailoverRouter { | ||||
| 
 | ||||
|     const results = await Promise.allSettled(this.hosts.map(async (host) => { | ||||
|       if (host.socket) { | ||||
|         return this.pollConnection.get<number>('/blocks/tip/height', { socketPath: host.host, timeout: 2000 }); | ||||
|         return this.pollConnection.get<number>('/blocks/tip/height', { socketPath: host.host, timeout: 5000 }); | ||||
|       } else { | ||||
|         return this.pollConnection.get<number>(host.host + '/blocks/tip/height', { timeout: 2000 }); | ||||
|         return this.pollConnection.get<number>(host.host + '/blocks/tip/height', { timeout: 5000 }); | ||||
|       } | ||||
|     })); | ||||
|     const maxHeight = results.reduce((max, result) => Math.max(max, result.status === 'fulfilled' ? result.value?.data || 0 : 0), 0); | ||||
|  | ||||
| @ -32,13 +32,13 @@ export interface DifficultyAdjustment { | ||||
| export function calcBitsDifference(oldBits: number, newBits: number): number { | ||||
|   // Must be
 | ||||
|   // - integer
 | ||||
|   // - highest exponent is 0x1f, so max value (as integer) is 0x1f0000ff
 | ||||
|   // - highest exponent is 0x20, so max value (as integer) is 0x207fffff
 | ||||
|   // - min value is 1 (exponent = 0)
 | ||||
|   // - highest bit of the number-part is +- sign, it must not be 1
 | ||||
|   const verifyBits = (bits: number): void => { | ||||
|     if ( | ||||
|       Math.floor(bits) !== bits || | ||||
|       bits > 0x1f0000ff || | ||||
|       bits > 0x207fffff || | ||||
|       bits < 1 || | ||||
|       (bits & 0x00800000) !== 0 || | ||||
|       (bits & 0x007fffff) === 0 | ||||
|  | ||||
| @ -42,6 +42,12 @@ class NodesRoutes { | ||||
|       switch (config.MEMPOOL.NETWORK) { | ||||
|         case 'testnet': | ||||
|           nodesList = [ | ||||
|             '0259db43b4e4ac0ff12a805f2d81e521253ba2317f6739bc611d8e2fa156d64256', | ||||
|             '0352b9944b9a52bd2116c91f1ba70c4ef851ac5ba27e1b20f1d92da3ade010dd10', | ||||
|             '03424f5a7601eaa47482cb17100b31a84a04d14fb44b83a57eeceffd8e299878e3', | ||||
|             '032850492ee61a5f7006a2fda6925e4b4ec3782f2b6de2ff0e439ef5a38c3b2470', | ||||
|             '022c80bace98831c44c32fb69755f2b353434e0ee9e7fbda29507f7ef8abea1421', | ||||
|             '02c3559c833e6f99f9ca05fe503e0b4e7524dea9121344edfd3e811101e0c28680', | ||||
|             '032c7c7819276c4f706a04df1a0f1e10a5495994a7be4c1d3d28ca766e5a2b957b', | ||||
|             '025a7e38c2834dd843591a4d23d5f09cdeb77ddca85f673c2d944a14220ff14cf7', | ||||
|             '0395e2731a1673ef21d7a16a727c4fc4d4c35a861c428ce2c819c53d2b81c8bd55', | ||||
| @ -64,6 +70,12 @@ class NodesRoutes { | ||||
|           break; | ||||
|         case 'signet': | ||||
|           nodesList = [ | ||||
|             '029fe3621fc0c6e08056a14b868f8fb9acca1aa28a129512f6cea0f0d7654d9f92', | ||||
|             '02f60cd7a3a4f1c953dd9554a6ebd51a34f8b10b8124b7fc43a0b381139b55c883', | ||||
|             '03cbbf581774700865eebd1be42d022bc004ba30881274ab304e088a25d70e773d', | ||||
|             '0243348cb3741cfe2d8485fa8375c29c7bc7cbb67577c363cb6987a5e5fd0052cc', | ||||
|             '02cb73e631af44bee600d80f8488a9194c9dc5c7590e575c421a070d1be05bc8e9', | ||||
|             '0306f55ee631aa1e2cd4d9b2bfcbc14404faec5c541cef8b2e6f779061029d09c4', | ||||
|             '03ddab321b760433cbf561b615ef62ac7d318630c5f51d523aaf5395b90b751956', | ||||
|             '033d92c7bfd213ef1b34c90e985fb5dc77f9ec2409d391492484e57a44c4aca1de', | ||||
|             '02ad010dda54253c1eb9efe38b0760657a3b43ecad62198c359c051c9d99d45781', | ||||
| @ -86,6 +98,12 @@ class NodesRoutes { | ||||
|           break; | ||||
|         default: | ||||
|           nodesList = [ | ||||
|             '02b12b889fe3c943cb05645921040ef13d6d397a2e7a4ad000e28500c505ff26d6', | ||||
|             '0302240ac9d71b39617cbde2764837ec3d6198bd6074b15b75d2ff33108e89d2e1', | ||||
|             '03364a8ace313376e5e4b68c954e287c6388e16df9e9fdbaf0363ecac41105cbf6', | ||||
|             '03229ab4b7f692753e094b93df90530150680f86b535b5183b0cffd75b3df583fc', | ||||
|             '03a696eb7acde991c1be97a58a9daef416659539ae462b897f5e9ae361f990228e', | ||||
|             '0248bf26cf3a63ab8870f34dc0ec9e6c8c6288cdba96ba3f026f34ec0f13ac4055', | ||||
|             '03fbc17549ec667bccf397ababbcb4cdc0e3394345e4773079ab2774612ec9be61', | ||||
|             '03da9a8623241ccf95f19cd645c6cecd4019ac91570e976eb0a128bebbc4d8a437', | ||||
|             '03ca5340cf85cb2e7cf076e489f785410838de174e40be62723e8a60972ad75144', | ||||
|  | ||||
| @ -451,6 +451,7 @@ class MempoolBlocks { | ||||
|   private processBlockTemplates(mempool: { [txid: string]: MempoolTransactionExtended }, blocks: string[][], blockWeights: number[] | null, rates: [string, number][], clusters: string[][], accelerations, accelerationPool, saveResults): MempoolBlockWithTransactions[] { | ||||
|     for (const [txid, rate] of rates) { | ||||
|       if (txid in mempool) { | ||||
|         mempool[txid].cpfpDirty = (rate !== mempool[txid].effectiveFeePerVsize); | ||||
|         mempool[txid].effectiveFeePerVsize = rate; | ||||
|         mempool[txid].cpfpChecked = false; | ||||
|       } | ||||
| @ -494,6 +495,9 @@ class MempoolBlocks { | ||||
|               } | ||||
|             } | ||||
|           }); | ||||
|           if (mempoolTx.ancestors?.length !== ancestors.length || mempoolTx.descendants?.length !== descendants.length) { | ||||
|             mempoolTx.cpfpDirty = true; | ||||
|           } | ||||
|           Object.assign(mempoolTx, {ancestors, descendants, bestDescendant: null, cpfpChecked: true}); | ||||
|         } | ||||
|       } | ||||
| @ -531,12 +535,21 @@ class MempoolBlocks { | ||||
| 
 | ||||
|           const acceleration = accelerations[txid]; | ||||
|           if (isAccelerated[txid] || (acceleration && (!accelerationPool || acceleration.pools.includes(accelerationPool)))) { | ||||
|             if (!mempoolTx.acceleration) { | ||||
|               mempoolTx.cpfpDirty = true; | ||||
|             } | ||||
|             mempoolTx.acceleration = true; | ||||
|             for (const ancestor of mempoolTx.ancestors || []) { | ||||
|               if (!mempool[ancestor.txid].acceleration) { | ||||
|                 mempool[ancestor.txid].cpfpDirty = true; | ||||
|               } | ||||
|               mempool[ancestor.txid].acceleration = true; | ||||
|               isAccelerated[ancestor.txid] = true; | ||||
|             } | ||||
|           } else { | ||||
|             if (mempoolTx.acceleration) { | ||||
|               mempoolTx.cpfpDirty = true; | ||||
|             } | ||||
|             delete mempoolTx.acceleration; | ||||
|           } | ||||
| 
 | ||||
|  | ||||
| @ -171,6 +171,7 @@ class StatisticsApi { | ||||
|   private getQueryForDaysAvg(div: number, interval: string) { | ||||
|     return `SELECT
 | ||||
|       UNIX_TIMESTAMP(added) as added, | ||||
|       CAST(avg(unconfirmed_transactions) as DOUBLE) as unconfirmed_transactions, | ||||
|       CAST(avg(vbytes_per_second) as DOUBLE) as vbytes_per_second, | ||||
|       CAST(avg(vsize_1) as DOUBLE) as vsize_1, | ||||
|       CAST(avg(vsize_2) as DOUBLE) as vsize_2, | ||||
| @ -219,6 +220,7 @@ class StatisticsApi { | ||||
|   private getQueryForDays(div: number, interval: string) { | ||||
|     return `SELECT
 | ||||
|       UNIX_TIMESTAMP(added) as added, | ||||
|       CAST(avg(unconfirmed_transactions) as DOUBLE) as unconfirmed_transactions, | ||||
|       CAST(avg(vbytes_per_second) as DOUBLE) as vbytes_per_second, | ||||
|       vsize_1, | ||||
|       vsize_2, | ||||
| @ -401,6 +403,7 @@ class StatisticsApi { | ||||
|     return statistic.map((s) => { | ||||
|       return { | ||||
|         added: s.added, | ||||
|         count: s.unconfirmed_transactions, | ||||
|         vbytes_per_second: s.vbytes_per_second, | ||||
|         mempool_byte_weight: s.mempool_byte_weight, | ||||
|         total_fee: s.total_fee, | ||||
|  | ||||
| @ -5,6 +5,7 @@ import bitcoinApi, { bitcoinCoreApi } from './bitcoin/bitcoin-api-factory'; | ||||
| import * as bitcoinjs from 'bitcoinjs-lib'; | ||||
| import logger from '../logger'; | ||||
| import config from '../config'; | ||||
| import pLimit from '../utils/p-limit'; | ||||
| 
 | ||||
| class TransactionUtils { | ||||
|   constructor() { } | ||||
| @ -74,8 +75,12 @@ class TransactionUtils { | ||||
| 
 | ||||
|   public async $getMempoolTransactionsExtended(txids: string[], addPrevouts = false, lazyPrevouts = false, forceCore = false): Promise<MempoolTransactionExtended[]> { | ||||
|     if (forceCore || config.MEMPOOL.BACKEND !== 'esplora') { | ||||
|       const results = await Promise.allSettled(txids.map(txid => this.$getTransactionExtended(txid, addPrevouts, lazyPrevouts, forceCore, true))); | ||||
|       return (results.filter(r => r.status === 'fulfilled') as PromiseFulfilledResult<MempoolTransactionExtended>[]).map(r => r.value); | ||||
|       const limiter = pLimit(8); // Run 8 requests at a time
 | ||||
|       const results = await Promise.allSettled(txids.map( | ||||
|         txid => limiter(() => this.$getMempoolTransactionExtended(txid, addPrevouts, lazyPrevouts, forceCore)) | ||||
|       )); | ||||
|       return results.filter(reply => reply.status === 'fulfilled') | ||||
|         .map(r => (r as PromiseFulfilledResult<MempoolTransactionExtended>).value); | ||||
|     } else { | ||||
|       const transactions = await bitcoinApi.$getMempoolTransactions(txids); | ||||
|       return transactions.map(transaction => { | ||||
|  | ||||
| @ -486,6 +486,7 @@ class WebsocketHandler { | ||||
| 
 | ||||
|     // pre-compute address transactions
 | ||||
|     const addressCache = this.makeAddressCache(newTransactions); | ||||
|     const removedAddressCache = this.makeAddressCache(deletedTransactions); | ||||
| 
 | ||||
|     this.wss.clients.forEach(async (client) => { | ||||
|       if (client.readyState !== WebSocket.OPEN) { | ||||
| @ -526,11 +527,15 @@ class WebsocketHandler { | ||||
|       } | ||||
| 
 | ||||
|       if (client['track-address']) { | ||||
|         const foundTransactions = Array.from(addressCache[client['track-address']]?.values() || []); | ||||
|         const newTransactions = Array.from(addressCache[client['track-address']]?.values() || []); | ||||
|         const removedTransactions = Array.from(removedAddressCache[client['track-address']]?.values() || []); | ||||
|         // txs may be missing prevouts in non-esplora backends
 | ||||
|         // so fetch the full transactions now
 | ||||
|         const fullTransactions = (config.MEMPOOL.BACKEND !== 'esplora') ? await this.getFullTransactions(foundTransactions) : foundTransactions; | ||||
|         const fullTransactions = (config.MEMPOOL.BACKEND !== 'esplora') ? await this.getFullTransactions(newTransactions) : newTransactions; | ||||
| 
 | ||||
|         if (removedTransactions.length) { | ||||
|           response['address-removed-transactions'] = JSON.stringify(removedTransactions); | ||||
|         } | ||||
|         if (fullTransactions.length) { | ||||
|           response['address-transactions'] = JSON.stringify(fullTransactions); | ||||
|         } | ||||
| @ -586,13 +591,25 @@ class WebsocketHandler { | ||||
| 
 | ||||
|         const mempoolTx = newMempool[trackTxid]; | ||||
|         if (mempoolTx && mempoolTx.position) { | ||||
|           response['txPosition'] = JSON.stringify({ | ||||
|           const positionData = { | ||||
|             txid: trackTxid, | ||||
|             position: { | ||||
|               ...mempoolTx.position, | ||||
|               accelerated: mempoolTx.acceleration || undefined, | ||||
|             } | ||||
|           }); | ||||
|           }; | ||||
|           if (mempoolTx.cpfpDirty) { | ||||
|             positionData['cpfp'] = { | ||||
|               ancestors: mempoolTx.ancestors, | ||||
|               bestDescendant: mempoolTx.bestDescendant || null, | ||||
|               descendants: mempoolTx.descendants || null, | ||||
|               effectiveFeePerVsize: mempoolTx.effectiveFeePerVsize || null, | ||||
|               sigops: mempoolTx.sigops, | ||||
|               adjustedVsize: mempoolTx.adjustedVsize, | ||||
|               acceleration: mempoolTx.acceleration | ||||
|             }; | ||||
|           } | ||||
|           response['txPosition'] = JSON.stringify(positionData); | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|  | ||||
| @ -104,6 +104,7 @@ export interface MempoolTransactionExtended extends TransactionExtended { | ||||
|   adjustedFeePerVsize: number; | ||||
|   inputs?: number[]; | ||||
|   lastBoosted?: number; | ||||
|   cpfpDirty?: boolean; | ||||
| } | ||||
| 
 | ||||
| export interface AuditTransaction { | ||||
|  | ||||
							
								
								
									
										179
									
								
								backend/src/utils/p-limit.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								backend/src/utils/p-limit.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,179 @@ | ||||
| /* | ||||
| MIT License | ||||
| 
 | ||||
| Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
 | ||||
| 
 | ||||
| Permission is hereby granted, free of charge, to any person obtaining a copy of this | ||||
| software and associated documentation files (the "Software"), to deal in the Software | ||||
| without restriction, including without limitation the rights to use, copy, modify, | ||||
| merge, publish, distribute, sublicense, and/or sell copies of the Software, and to | ||||
| permit persons to whom the Software is furnished to do so, subject to the following | ||||
| conditions: | ||||
| 
 | ||||
| The above copyright notice and this permission notice shall be included in all copies | ||||
| or substantial portions of the Software. | ||||
| 
 | ||||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, | ||||
| INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A | ||||
| PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT | ||||
| HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF | ||||
| CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE | ||||
| OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | ||||
| */ | ||||
| 
 | ||||
| /* | ||||
| How it works: | ||||
| `this._head` is an instance of `Node` which keeps track of its current value and nests | ||||
| another instance of `Node` that keeps the value that comes after it. When a value is | ||||
| provided to `.enqueue()`, the code needs to iterate through `this._head`, going deeper | ||||
| and deeper to find the last value. However, iterating through every single item is slow. | ||||
| This problem is solved by saving a reference to the last value as `this._tail` so that | ||||
| it can reference it to add a new value. | ||||
| */ | ||||
| 
 | ||||
| class Node { | ||||
|   value; | ||||
|   next; | ||||
| 
 | ||||
|   constructor(value) { | ||||
|     this.value = value; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| class Queue { | ||||
|   private _head; | ||||
|   private _tail; | ||||
|   private _size; | ||||
| 
 | ||||
|   constructor() { | ||||
|     this.clear(); | ||||
|   } | ||||
| 
 | ||||
|   enqueue(value) { | ||||
|     const node = new Node(value); | ||||
| 
 | ||||
|     if (this._head) { | ||||
|       this._tail.next = node; | ||||
|       this._tail = node; | ||||
|     } else { | ||||
|       this._head = node; | ||||
|       this._tail = node; | ||||
|     } | ||||
| 
 | ||||
|     this._size++; | ||||
|   } | ||||
| 
 | ||||
|   dequeue() { | ||||
|     const current = this._head; | ||||
|     if (!current) { | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     this._head = this._head.next; | ||||
|     this._size--; | ||||
|     return current.value; | ||||
|   } | ||||
| 
 | ||||
|   clear() { | ||||
|     this._head = undefined; | ||||
|     this._tail = undefined; | ||||
|     this._size = 0; | ||||
|   } | ||||
| 
 | ||||
|   get size() { | ||||
|     return this._size; | ||||
|   } | ||||
| 
 | ||||
|   *[Symbol.iterator]() { | ||||
|     let current = this._head; | ||||
| 
 | ||||
|     while (current) { | ||||
|       yield current.value; | ||||
|       current = current.next; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| interface LimitFunction { | ||||
|   readonly activeCount: number; | ||||
|   readonly pendingCount: number; | ||||
|   clearQueue: () => void; | ||||
|   <Arguments extends unknown[], ReturnType>( | ||||
|     fn: (...args: Arguments) => PromiseLike<ReturnType> | ReturnType, | ||||
|     ...args: Arguments | ||||
|   ): Promise<ReturnType>; | ||||
| } | ||||
| 
 | ||||
| export default function pLimit(concurrency: number): LimitFunction { | ||||
|   if ( | ||||
|     !( | ||||
|       (Number.isInteger(concurrency) || | ||||
|         concurrency === Number.POSITIVE_INFINITY) && | ||||
|       concurrency > 0 | ||||
|     ) | ||||
|   ) { | ||||
|     throw new TypeError('Expected `concurrency` to be a number from 1 and up'); | ||||
|   } | ||||
| 
 | ||||
|   const queue = new Queue(); | ||||
|   let activeCount = 0; | ||||
| 
 | ||||
|   const next = () => { | ||||
|     activeCount--; | ||||
| 
 | ||||
|     if (queue.size > 0) { | ||||
|       queue.dequeue()(); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const run = async (fn, resolve, args) => { | ||||
|     activeCount++; | ||||
| 
 | ||||
|     const result = (async () => fn(...args))(); | ||||
| 
 | ||||
|     resolve(result); | ||||
| 
 | ||||
|     try { | ||||
|       await result; | ||||
|     } catch {} | ||||
| 
 | ||||
|     next(); | ||||
|   }; | ||||
| 
 | ||||
|   const enqueue = (fn, resolve, args) => { | ||||
|     queue.enqueue(run.bind(undefined, fn, resolve, args)); | ||||
| 
 | ||||
|     (async () => { | ||||
|       // This function needs to wait until the next microtask before comparing
 | ||||
|       // `activeCount` to `concurrency`, because `activeCount` is updated asynchronously
 | ||||
|       // when the run function is dequeued and called. The comparison in the if-statement
 | ||||
|       // needs to happen asynchronously as well to get an up-to-date value for `activeCount`.
 | ||||
|       await Promise.resolve(); | ||||
| 
 | ||||
|       if (activeCount < concurrency && queue.size > 0) { | ||||
|         queue.dequeue()(); | ||||
|       } | ||||
|     })(); | ||||
|   }; | ||||
| 
 | ||||
|   const generator = (fn, ...args) => | ||||
|     new Promise((resolve) => { | ||||
|       enqueue(fn, resolve, args); | ||||
|     }); | ||||
| 
 | ||||
|   Object.defineProperties(generator, { | ||||
|     activeCount: { | ||||
|       get: () => activeCount, | ||||
|     }, | ||||
|     pendingCount: { | ||||
|       get: () => queue.size, | ||||
|     }, | ||||
|     clearQueue: { | ||||
|       value: () => { | ||||
|         queue.clear(); | ||||
|       }, | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|   return generator as any; | ||||
| } | ||||
							
								
								
									
										3
									
								
								contributors/TheBlueMatt.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								contributors/TheBlueMatt.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | ||||
| I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file with sha256 hash c80c5ee4c71c5a76a1f6cd35339bd0c45b25b491933ea7b02a66470e9f43a6fd. | ||||
| 
 | ||||
| Signed: TheBlueMatt | ||||
							
								
								
									
										3
									
								
								contributors/orangesurf.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								contributors/orangesurf.txt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | ||||
| I hereby accept the terms of the Contributor License Agreement in the CONTRIBUTING.md file of the mempool/mempool git repository as of September 12, 2023. | ||||
| 
 | ||||
| Signed: orange surf | ||||
| @ -1,4 +1,4 @@ | ||||
| FROM node:16.16.0-buster-slim AS builder | ||||
| FROM node:20.8.0-buster-slim AS builder | ||||
| 
 | ||||
| ARG commitHash | ||||
| ENV MEMPOOL_COMMIT_HASH=${commitHash} | ||||
| @ -17,7 +17,7 @@ ENV PATH="/root/.cargo/bin:$PATH" | ||||
| RUN npm install --omit=dev --omit=optional | ||||
| RUN npm run package | ||||
| 
 | ||||
| FROM node:16.16.0-buster-slim | ||||
| FROM node:20.8.0-buster-slim | ||||
| 
 | ||||
| WORKDIR /backend | ||||
| 
 | ||||
|  | ||||
| @ -52,7 +52,7 @@ | ||||
|     "REST_API_URL": "__ESPLORA_REST_API_URL__", | ||||
|     "UNIX_SOCKET_PATH": "__ESPLORA_UNIX_SOCKET_PATH__", | ||||
|     "RETRY_UNIX_SOCKET_AFTER": __ESPLORA_RETRY_UNIX_SOCKET_AFTER__, | ||||
|     "FALLBACK": __ESPLORA_FALLBACK__, | ||||
|     "FALLBACK": __ESPLORA_FALLBACK__ | ||||
|   }, | ||||
|   "SECOND_CORE_RPC": { | ||||
|     "HOST": "__SECOND_CORE_RPC_HOST__", | ||||
|  | ||||
| @ -53,6 +53,7 @@ __ELECTRUM_TLS_ENABLED__=${ELECTRUM_TLS_ENABLED:=false} | ||||
| __ESPLORA_REST_API_URL__=${ESPLORA_REST_API_URL:=http://127.0.0.1:3000} | ||||
| __ESPLORA_UNIX_SOCKET_PATH__=${ESPLORA_UNIX_SOCKET_PATH:="null"} | ||||
| __ESPLORA_RETRY_UNIX_SOCKET_AFTER__=${ESPLORA_RETRY_UNIX_SOCKET_AFTER:=30000} | ||||
| __ESPLORA_FALLBACK__=${ESPLORA_FALLBACK:=[]} | ||||
| 
 | ||||
| # SECOND_CORE_RPC | ||||
| __SECOND_CORE_RPC_HOST__=${SECOND_CORE_RPC_HOST:=127.0.0.1} | ||||
| @ -138,7 +139,7 @@ __MEMPOOL_SERVICES_API__=${MEMPOOL_SERVICES_API:=""} | ||||
| __MEMPOOL_SERVICES_ACCELERATIONS__=${MEMPOOL_SERVICES_ACCELERATIONS:=false} | ||||
| 
 | ||||
| # REDIS | ||||
| __REDIS_ENABLED__=${REDIS_ENABLED:=true} | ||||
| __REDIS_ENABLED__=${REDIS_ENABLED:=false} | ||||
| __REDIS_UNIX_SOCKET_PATH__=${REDIS_UNIX_SOCKET_PATH:=true} | ||||
| 
 | ||||
| mkdir -p "${__MEMPOOL_CACHE_DIR__}" | ||||
| @ -192,6 +193,7 @@ sed -i "s!__ELECTRUM_TLS_ENABLED__!${__ELECTRUM_TLS_ENABLED__}!g" mempool-config | ||||
| sed -i "s!__ESPLORA_REST_API_URL__!${__ESPLORA_REST_API_URL__}!g" mempool-config.json | ||||
| sed -i "s!__ESPLORA_UNIX_SOCKET_PATH__!${__ESPLORA_UNIX_SOCKET_PATH__}!g" mempool-config.json | ||||
| sed -i "s!__ESPLORA_RETRY_UNIX_SOCKET_AFTER__!${__ESPLORA_RETRY_UNIX_SOCKET_AFTER__}!g" mempool-config.json | ||||
| sed -i "s!__ESPLORA_FALLBACK__!${__ESPLORA_FALLBACK__}!g" mempool-config.json | ||||
| 
 | ||||
| sed -i "s!__SECOND_CORE_RPC_HOST__!${__SECOND_CORE_RPC_HOST__}!g" mempool-config.json | ||||
| sed -i "s!__SECOND_CORE_RPC_PORT__!${__SECOND_CORE_RPC_PORT__}!g" mempool-config.json | ||||
|  | ||||
| @ -38,7 +38,7 @@ services: | ||||
|       MYSQL_USER: "mempool" | ||||
|       MYSQL_PASSWORD: "mempool" | ||||
|       MYSQL_ROOT_PASSWORD: "admin" | ||||
|     image: mariadb:10.5.8 | ||||
|     image: mariadb:10.5.21 | ||||
|     user: "1000:1000" | ||||
|     restart: on-failure | ||||
|     stop_grace_period: 1m | ||||
|  | ||||
| @ -1,32 +0,0 @@ | ||||
| FROM ubuntu:18.04 | ||||
| MAINTAINER mempool.space developers | ||||
| EXPOSE 50002 | ||||
| 
 | ||||
| # runs as UID 1000 GID 1000 inside the container | ||||
| 
 | ||||
| ENV VERSION 4.0.9 | ||||
| RUN set -x \ | ||||
|         && apt-get update \ | ||||
| 	&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends gpg gpg-agent dirmngr \ | ||||
| 	&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends wget xpra python3-pyqt5 python3-wheel python3-pip python3-setuptools libsecp256k1-0 libsecp256k1-dev python3-numpy python3-dev build-essential \ | ||||
| 	&& wget -O /tmp/Electrum-${VERSION}.tar.gz https://download.electrum.org/${VERSION}/Electrum-${VERSION}.tar.gz \ | ||||
| 	&& wget -O /tmp/Electrum-${VERSION}.tar.gz.asc https://download.electrum.org/${VERSION}/Electrum-${VERSION}.tar.gz.asc \ | ||||
| 	&& gpg --keyserver keys.gnupg.net --recv-keys 6694D8DE7BE8EE5631BED9502BD5824B7F9470E6 \ | ||||
| 	&& gpg --verify /tmp/Electrum-${VERSION}.tar.gz.asc /tmp/Electrum-${VERSION}.tar.gz \ | ||||
| 	&& pip3 install /tmp/Electrum-${VERSION}.tar.gz \ | ||||
| 	&& test -f /usr/local/bin/electrum \ | ||||
| 	&& rm -vrf /tmp/Electrum-${VERSION}.tar.gz /tmp/Electrum-${VERSION}.tar.gz.asc ${HOME}/.gnupg \ | ||||
| 	&& apt-get purge --autoremove -y python3-wheel python3-pip python3-setuptools python3-dev build-essential libsecp256k1-dev curl gpg gpg-agent dirmngr \ | ||||
| 	&& apt-get clean && rm -rf /var/lib/apt/lists/* \ | ||||
| 	&& useradd -d /home/mempool -m mempool \ | ||||
| 	&& mkdir /electrum \ | ||||
| 	&& ln -s /electrum /home/mempool/.electrum \ | ||||
| 	&& chown mempool:mempool /electrum | ||||
| 
 | ||||
| USER mempool | ||||
| ENV HOME /home/mempool | ||||
| WORKDIR /home/mempool | ||||
| VOLUME /electrum | ||||
| 
 | ||||
| CMD ["/usr/bin/xpra", "start", ":100", "--start-child=/usr/local/bin/electrum", "--bind-tcp=0.0.0.0:50002","--daemon=yes", "--notifications=no", "--mdns=no", "--pulseaudio=no", "--html=off", "--speaker=disabled", "--microphone=disabled", "--webcam=no", "--printing=no", "--dbus-launch=", "--exit-with-children"] | ||||
| ENTRYPOINT ["electrum"] | ||||
| @ -1,4 +1,4 @@ | ||||
| FROM node:16.16.0-buster-slim AS builder | ||||
| FROM node:20.8.0-buster-slim AS builder | ||||
| 
 | ||||
| ARG commitHash | ||||
| ENV DOCKER_COMMIT_HASH=${commitHash} | ||||
| @ -13,7 +13,7 @@ RUN npm install --omit=dev --omit=optional | ||||
| 
 | ||||
| RUN npm run build | ||||
| 
 | ||||
| FROM nginx:1.17.8-alpine | ||||
| FROM nginx:1.24.0-alpine | ||||
| 
 | ||||
| WORKDIR /patch | ||||
| 
 | ||||
|  | ||||
| @ -39,6 +39,7 @@ __AUDIT__=${AUDIT:=false} | ||||
| __MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0} | ||||
| __TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0} | ||||
| __SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0} | ||||
| __ACCELERATOR__=${ACCELERATOR:=false} | ||||
| __HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true} | ||||
| 
 | ||||
| # Export as environment variables to be used by envsubst | ||||
| @ -65,6 +66,7 @@ export __AUDIT__ | ||||
| export __MAINNET_BLOCK_AUDIT_START_HEIGHT__ | ||||
| export __TESTNET_BLOCK_AUDIT_START_HEIGHT__ | ||||
| export __SIGNET_BLOCK_AUDIT_START_HEIGHT__ | ||||
| export __ACCELERATOR__ | ||||
| export __HISTORICAL_PRICE__ | ||||
| 
 | ||||
| folder=$(find /var/www/mempool -name "config.js" | xargs dirname) | ||||
|  | ||||
							
								
								
									
										3478
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3478
									
								
								frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -35,7 +35,7 @@ | ||||
|     "start:local-staging": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c local-staging", | ||||
|     "start:mixed": "npm run generate-config && npm run sync-assets-dev && npm run ng -- serve -c mixed", | ||||
|     "build": "npm run generate-config && npm run ng -- build --configuration production --localize && npm run sync-assets && npm run build-mempool.js", | ||||
|     "sync-assets": "rsync -av ./src/resources ./dist/mempool/browser && node sync-assets.js 'dist/mempool/browser/resources'", | ||||
|     "sync-assets": "rsync -av ./src/resources ./dist/mempool/browser && node sync-assets.js 'dist/mempool/browser/resources/'", | ||||
|     "sync-assets-dev": "node sync-assets.js 'src/resources/'", | ||||
|     "generate-config": "node generate-config.js", | ||||
|     "build-mempool.js": "npm run build-mempool-js && npm run build-mempool-liquid-js && npm run build-mempool-bisq-js", | ||||
| @ -61,18 +61,18 @@ | ||||
|     "cypress:run:ci:staging": "node update-config.js TESTNET_ENABLED=true SIGNET_ENABLED=true LIQUID_ENABLED=true BISQ_ENABLED=true ITEMS_PER_PAGE=25 && npm run generate-config && start-server-and-test serve:local-staging 4200 cypress:run:record" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@angular-devkit/build-angular": "^16.1.4", | ||||
|     "@angular/animations": "^16.1.5", | ||||
|     "@angular/cli": "^16.1.4", | ||||
|     "@angular/common": "^16.1.5", | ||||
|     "@angular/compiler": "^16.1.5", | ||||
|     "@angular/core": "^16.1.5", | ||||
|     "@angular/forms": "^16.1.5", | ||||
|     "@angular/localize": "^16.1.5", | ||||
|     "@angular/platform-browser": "^16.1.5", | ||||
|     "@angular/platform-browser-dynamic": "^16.1.5", | ||||
|     "@angular/platform-server": "^16.1.5", | ||||
|     "@angular/router": "^16.1.5", | ||||
|     "@angular-devkit/build-angular": "^16.2.0", | ||||
|     "@angular/animations": "^16.2.2", | ||||
|     "@angular/cli": "^16.2.0", | ||||
|     "@angular/common": "^16.2.2", | ||||
|     "@angular/compiler": "^16.2.2", | ||||
|     "@angular/core": "^16.2.2", | ||||
|     "@angular/forms": "^16.2.2", | ||||
|     "@angular/localize": "^16.2.2", | ||||
|     "@angular/platform-browser": "^16.2.2", | ||||
|     "@angular/platform-browser-dynamic": "^16.2.2", | ||||
|     "@angular/platform-server": "^16.2.2", | ||||
|     "@angular/router": "^16.2.2", | ||||
|     "@fortawesome/angular-fontawesome": "~0.13.0", | ||||
|     "@fortawesome/fontawesome-common-types": "~6.4.0", | ||||
|     "@fortawesome/fontawesome-svg-core": "~6.4.0", | ||||
| @ -111,9 +111,9 @@ | ||||
|   "optionalDependencies": { | ||||
|     "@cypress/schematic": "^2.5.0", | ||||
|     "@types/cypress": "^1.1.3", | ||||
|     "cypress": "^12.17.2", | ||||
|     "cypress-fail-on-console-error": "~4.0.3", | ||||
|     "cypress-wait-until": "^2.0.0", | ||||
|     "cypress": "^13.2.0", | ||||
|     "cypress-fail-on-console-error": "~5.0.0", | ||||
|     "cypress-wait-until": "^2.0.1", | ||||
|     "mock-socket": "~9.2.1", | ||||
|     "start-server-and-test": "~2.0.0" | ||||
|   }, | ||||
|  | ||||
| @ -112,6 +112,14 @@ PROXY_CONFIG.push(...[ | ||||
|         "^/testnet": "" | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     context: ['/api/v1/services/**'], | ||||
|     target: `http://localhost:9000`, | ||||
|     secure: false, | ||||
|     ws: true, | ||||
|     changeOrigin: true, | ||||
|     proxyTimeout: 30000, | ||||
|   }, | ||||
|   { | ||||
|     context: ['/api/v1/**'], | ||||
|     target: `http://127.0.0.1:8999`, | ||||
|  | ||||
| @ -112,6 +112,14 @@ PROXY_CONFIG.push(...[ | ||||
|         "^/testnet": "" | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     context: ['/api/v1/services/**'], | ||||
|     target: `http://localhost:9000`, | ||||
|     secure: false, | ||||
|     ws: true, | ||||
|     changeOrigin: true, | ||||
|     proxyTimeout: 30000, | ||||
|   }, | ||||
|   { | ||||
|     context: ['/api/v1/**'], | ||||
|     target: `http://localhost:8999`, | ||||
|  | ||||
| @ -95,6 +95,14 @@ if (configContent && configContent.BASE_MODULE === 'bisq') { | ||||
| } | ||||
| 
 | ||||
| PROXY_CONFIG.push(...[ | ||||
|   { | ||||
|     context: ['/api/v1/services/**'], | ||||
|     target: `http://localhost:9000`, | ||||
|     secure: false, | ||||
|     ws: true, | ||||
|     changeOrigin: true, | ||||
|     proxyTimeout: 30000, | ||||
|   }, | ||||
|   { | ||||
|     context: ['/api/v1/**'], | ||||
|     target: `http://localhost:8999`, | ||||
|  | ||||
| @ -41,6 +41,7 @@ export class BisqAddressComponent implements OnInit, OnDestroy { | ||||
|           document.body.scrollTo(0, 0); | ||||
|           this.addressString = params.get('id') || ''; | ||||
|           this.seoService.setTitle($localize`:@@bisq-address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`); | ||||
|           this.seoService.setDescription($localize`:@@meta.description.bisq.address:See current balance, pending transactions, and history of confirmed transactions for BSQ address ${this.addressString}:INTERPOLATION:.`); | ||||
| 
 | ||||
|           return this.bisqApiService.getAddress$(this.addressString) | ||||
|             .pipe( | ||||
|  | ||||
| @ -69,6 +69,7 @@ export class BisqBlockComponent implements OnInit, OnDestroy { | ||||
|                   this.location.replaceState( | ||||
|                     this.router.createUrlTree(['/bisq/block/', hash]).toString() | ||||
|                   ); | ||||
|                   this.seoService.updateCanonical(this.location.path()); | ||||
|                   return this.bisqApiService.getBlock$(this.blockHash) | ||||
|                     .pipe(catchError(this.caughtHttpError.bind(this))); | ||||
|                 }), | ||||
| @ -88,6 +89,7 @@ export class BisqBlockComponent implements OnInit, OnDestroy { | ||||
|         this.isLoading = false; | ||||
|         this.blockHeight = block.height; | ||||
|         this.seoService.setTitle($localize`:@@bisq-block.component.browser-title:Block ${block.height}:BLOCK_HEIGHT:: ${block.hash}:BLOCK_HASH:`); | ||||
|         this.seoService.setDescription($localize`:@@meta.description.bisq.block:See all BSQ transactions in Bitcoin block ${block.height}:BLOCK_HEIGHT: (block hash ${block.hash}:BLOCK_HASH:).`); | ||||
|         this.block = block; | ||||
|       }); | ||||
|   } | ||||
|  | ||||
| @ -36,6 +36,7 @@ export class BisqBlocksComponent implements OnInit { | ||||
|   ngOnInit(): void { | ||||
|     this.websocketService.want(['blocks']); | ||||
|     this.seoService.setTitle($localize`:@@8a7b4bd44c0ac71b2e72de0398b303257f7d2f54:Blocks`); | ||||
|     this.seoService.setDescription($localize`:@@meta.description.bisq.blocks:See a list of recent Bitcoin blocks with BSQ transactions, total BSQ sent per block, and more.`); | ||||
|     this.itemsPerPage = Math.max(Math.round(this.contentSpace / this.fiveItemsPxSize) * 5, 10); | ||||
|     this.loadingItems = Array(this.itemsPerPage); | ||||
|     if (document.body.clientWidth < 670) { | ||||
|  | ||||
| @ -29,7 +29,8 @@ export class BisqDashboardComponent implements OnInit { | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.seoService.setTitle(`Markets`); | ||||
|     this.seoService.setTitle($localize`:@@meta.title.bisq.markets:Markets`); | ||||
|     this.seoService.setDescription($localize`:@@meta.description.bisq.markets:Explore the full Bitcoin ecosystem with The Mempool Open Project™. See Bisq market prices, trading activity, and more.`); | ||||
|     this.websocketService.want(['blocks']); | ||||
| 
 | ||||
|     this.volumes$ = this.bisqApiService.getAllVolumesDay$() | ||||
|  | ||||
| @ -34,6 +34,7 @@ export class BisqMainDashboardComponent implements OnInit { | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.seoService.resetTitle(); | ||||
|     this.seoService.resetDescription(); | ||||
|     this.websocketService.want(['blocks']); | ||||
| 
 | ||||
|     this.usdPrice$ = this.stateService.conversions$.asObservable().pipe( | ||||
|  | ||||
| @ -48,7 +48,8 @@ export class BisqMarketComponent implements OnInit, OnDestroy { | ||||
|         map(([markets, routeParams]) => { | ||||
|           const pair = routeParams.get('pair'); | ||||
|           const pairUpperCase = pair.replace('_', '/').toUpperCase(); | ||||
|           this.seoService.setTitle(`Bisq market: ${pairUpperCase}`); | ||||
|           this.seoService.setTitle($localize`:@@meta.title.bisq.market:Bisq market: ${pairUpperCase}`); | ||||
|           this.seoService.setDescription($localize`:@@meta.description.bisq.market:See price history, current buy/sell offers, and latest trades for the ${pairUpperCase} market on Bisq.`); | ||||
| 
 | ||||
|           return { | ||||
|             pair: pairUpperCase, | ||||
|  | ||||
| @ -26,6 +26,7 @@ export class BisqStatsComponent implements OnInit { | ||||
|     this.websocketService.want(['blocks']); | ||||
| 
 | ||||
|     this.seoService.setTitle($localize`:@@2a30a4cdb123a03facc5ab8c5b3e6d8b8dbbc3d4:BSQ statistics`); | ||||
|     this.seoService.setDescription($localize`:@@meta.description.bisq.stats:See high-level stats on the BSQ economy: supply metrics, number of addresses, BSQ price, market cap, and more.`); | ||||
|     this.stateService.bsqPrice$ | ||||
|       .subscribe((bsqPrice) => { | ||||
|         this.price = bsqPrice; | ||||
|  | ||||
| @ -48,6 +48,7 @@ export class BisqTransactionComponent implements OnInit, OnDestroy { | ||||
|         document.body.scrollTo(0, 0); | ||||
|         this.txId = params.get('id') || ''; | ||||
|         this.seoService.setTitle($localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`); | ||||
|         this.seoService.setDescription($localize`:@@meta.description.bisq.transaction:See inputs, outputs, transaction type, burnt amount, and more for transaction with txid ${this.txId}:INTERPOLATION:.`); | ||||
|         if (history.state.data) { | ||||
|           return of(history.state.data); | ||||
|         } | ||||
|  | ||||
| @ -79,6 +79,7 @@ export class BisqTransactionsComponent implements OnInit, OnDestroy { | ||||
|   ngOnInit(): void { | ||||
|     this.websocketService.want(['blocks']); | ||||
|     this.seoService.setTitle($localize`:@@add4cd82e3e38a3110fe67b3c7df56e9602644ee:Transactions`); | ||||
|     this.seoService.setDescription($localize`:@@meta.description.bisq.transactions:See recent BSQ transactions: amount, txid, associated Bitcoin block, transaction type, and more.`); | ||||
| 
 | ||||
|     this.radioGroupForm = this.formBuilder.group({ | ||||
|       txTypes: [this.txTypesDefaultChecked], | ||||
|  | ||||
| @ -4,12 +4,13 @@ | ||||
|     <span style="margin-left: auto; margin-right: -20px; margin-bottom: -20px">®</span> | ||||
|     <img class="logo" src="/resources/mempool-logo-bigger.png" /> | ||||
|     <div class="version"> | ||||
|       v{{ packetJsonVersion }} [<a href="https://github.com/mempool/mempool/commit/{{ frontendGitCommitHash }}">{{ frontendGitCommitHash }}</a>] | ||||
|       <span>v{{ packetJsonVersion }} [<a href="https://github.com/mempool/mempool/commit/{{ frontendGitCommitHash }}">{{ frontendGitCommitHash }}</a>]</span> | ||||
|       <span *ngIf="stateService.env.GIT_COMMIT_HASH_MEMPOOL_SPACE">[{{ stateService.env.GIT_COMMIT_HASH_MEMPOOL_SPACE }}]</span> | ||||
|     </div> | ||||
|   </div> | ||||
| 
 | ||||
|   <div class="about-text"> | ||||
|     <h5><ng-container i18n="about.about-the-project">The Mempool Open Source Project</ng-container><ng-template [ngIf]="locale.substr(0, 2) === 'en'"> ™</ng-template></h5> | ||||
|     <h5><ng-container i18n="about.about-the-project">The Mempool Open Source Project</ng-container><ng-template [ngIf]="locale.substr(0, 2) === 'en'"> ®</ng-template></h5> | ||||
|     <p i18n>Our mempool and blockchain explorer for the Bitcoin community, focusing on the transaction fee market and multi-layer ecosystem, completely self-hosted without any trusted third-parties.</p> | ||||
|   </div> | ||||
| 
 | ||||
| @ -242,7 +243,7 @@ | ||||
|         <img class="image" src="/resources/profile/ronindojo.png" /> | ||||
|         <span>RoninDojo</span> | ||||
|       </a> | ||||
|       <a href="https://github.com/runcitadel/core" target="_blank" title="Citadel"> | ||||
|       <a href="https://github.com/runcitadel" target="_blank" title="Citadel"> | ||||
|         <img class="image" src="/resources/profile/runcitadel.svg" /> | ||||
|         <span>Citadel</span> | ||||
|       </a> | ||||
|  | ||||
| @ -22,6 +22,7 @@ | ||||
| 
 | ||||
|   .intro { | ||||
|     margin: 25px auto 30px; | ||||
|     margin-top: 25px; | ||||
|     width: 250px; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|  | ||||
| @ -43,6 +43,7 @@ export class AboutComponent implements OnInit { | ||||
|   ngOnInit() { | ||||
|     this.backendInfo$ = this.stateService.backendInfo$; | ||||
|     this.seoService.setTitle($localize`:@@004b222ff9ef9dd4771b777950ca1d0e4cd4348a:About`); | ||||
|     this.seoService.setDescription($localize`:@@meta.description.about:Learn more about The Mempool Open Source Project®\: enterprise sponsors, individual sponsors, integrations, who contributes, FOSS licensing, and more.`); | ||||
|     this.websocketService.want(['blocks']); | ||||
| 
 | ||||
|     this.profiles$ = this.apiService.getAboutPageProfiles$().pipe( | ||||
|  | ||||
| @ -0,0 +1,21 @@ | ||||
| <div class="fee-graph" *ngIf="tx && estimate"> | ||||
|   <div class="column"> | ||||
|     <ng-container *ngFor="let bar of bars"> | ||||
|       <div class="bar {{ bar.class }}" [class.active]="bar.active" [style]="bar.style" (click)="onClick($event, bar);"> | ||||
|         <div class="fill"></div> | ||||
|         <div class="line"> | ||||
|           <p class="fee-rate"> | ||||
|             <span class="label">{{ bar.label }}</span> | ||||
|             <span class="rate"> | ||||
|               <app-fee-rate [fee]="bar.rate"></app-fee-rate> | ||||
|             </span> | ||||
|           </p> | ||||
|         </div> | ||||
|         <div class="spacer"></div> | ||||
|         <span class="fee">{{ bar.class === 'tx' ? '' : '+' }} {{ bar.fee | number }} <span class="symbol" i18n="shared.sat|sat">sat</span></span> | ||||
|         <div class="spacer"></div> | ||||
|         <div class="spacer"></div> | ||||
|       </div> | ||||
|     </ng-container> | ||||
|   </div> | ||||
| </div> | ||||
| @ -0,0 +1,157 @@ | ||||
| .fee-graph { | ||||
|   height: 100%; | ||||
|   min-width: 120px; | ||||
|   width: 120px; | ||||
|   max-height: 90vh; | ||||
|   margin-left: 4em; | ||||
|   margin-right: 1.5em; | ||||
|   padding-bottom: 63px; | ||||
| 
 | ||||
|   .column { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     position: relative; | ||||
|     background: #181b2d; | ||||
| 
 | ||||
|     .bar { | ||||
|       position: absolute; | ||||
|       bottom: 0; | ||||
|       left: 0; | ||||
|       right: 0; | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       justify-content: center; | ||||
|       align-items: center; | ||||
| 
 | ||||
|       .fill { | ||||
|         position: absolute; | ||||
|         left: 0; | ||||
|         right: 0; | ||||
|         top: 0; | ||||
|         bottom: 0; | ||||
|         opacity: 0.75; | ||||
|         pointer-events: none; | ||||
|       } | ||||
| 
 | ||||
|       .fee { | ||||
|         font-size: 0.9em; | ||||
|         opacity: 0; | ||||
|         pointer-events: none; | ||||
|       } | ||||
| 
 | ||||
|       .spacer { | ||||
|         width: 100%; | ||||
|         height: 1px; | ||||
|         flex-grow: 1; | ||||
|         pointer-events: none; | ||||
|       } | ||||
| 
 | ||||
|       .line { | ||||
|         position: absolute; | ||||
|         right: 0; | ||||
|         top: 0; | ||||
|         left: -4.5em; | ||||
|         border-top: dashed white 1.5px; | ||||
| 
 | ||||
|         .fee-rate { | ||||
|           width: 100%; | ||||
|           position: absolute; | ||||
|           left: 0; | ||||
|           right: 0.2em; | ||||
|           font-size: 0.8em; | ||||
|           display: flex; | ||||
|           flex-direction: row-reverse; | ||||
|           justify-content: space-between; | ||||
|           margin: 0; | ||||
| 
 | ||||
|           .label { | ||||
|             margin-right: .2em; | ||||
|           } | ||||
| 
 | ||||
|           .rate .symbol { | ||||
|             color: white; | ||||
|           } | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       &.tx { | ||||
|         .fill { | ||||
|           background: #3bcc49; | ||||
|         } | ||||
|         .line { | ||||
|           .fee-rate { | ||||
|             top: 0; | ||||
|           } | ||||
|         } | ||||
|         .fee { | ||||
|           position: absolute; | ||||
|           opacity: 1; | ||||
|           z-index: 11; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       &.target { | ||||
|         .fill { | ||||
|           background: #653b9c; | ||||
|         } | ||||
|         .fee { | ||||
|           position: absolute; | ||||
|           opacity: 1; | ||||
|           z-index: 11; | ||||
|         } | ||||
|         .line .fee-rate { | ||||
|           bottom: 2px; | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       &.max { | ||||
|         cursor: pointer; | ||||
|         .line .fee-rate { | ||||
|           .label { | ||||
|             opacity: 0; | ||||
|           } | ||||
|           bottom: 2px; | ||||
|         } | ||||
|         &.active, &:hover { | ||||
|           .fill { | ||||
|             background: #105fb0; | ||||
|           } | ||||
|           .line { | ||||
|             .fee-rate .label { | ||||
|               opacity: 1; | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       } | ||||
| 
 | ||||
|       &:hover { | ||||
|         .fill { | ||||
|           z-index: 10; | ||||
|         } | ||||
|         .line { | ||||
|           z-index: 11; | ||||
|         } | ||||
|         .fee { | ||||
|           opacity: 1; | ||||
|           z-index: 12; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     &:hover > .bar:not(:hover) { | ||||
|       &.target, &.max { | ||||
|         .fee { | ||||
|           opacity: 0; | ||||
|         } | ||||
|         .line .fee-rate .label { | ||||
|           opacity: 0; | ||||
|         } | ||||
|       } | ||||
|       &.max { | ||||
|         .fill { | ||||
|           background: none; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,96 @@ | ||||
| import { Component, OnInit, Input, Output, OnChanges, EventEmitter, HostListener, Inject, LOCALE_ID } from '@angular/core'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { Outspend, Transaction, Vin, Vout } from '../../interfaces/electrs.interface'; | ||||
| import { Router } from '@angular/router'; | ||||
| import { ReplaySubject, merge, Subscription, of } from 'rxjs'; | ||||
| import { tap, switchMap } from 'rxjs/operators'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { AccelerationEstimate, RateOption } from './accelerate-preview.component'; | ||||
| 
 | ||||
| interface GraphBar { | ||||
|   rate: number; | ||||
|   style: any; | ||||
|   class: 'tx' | 'target' | 'max'; | ||||
|   label: string; | ||||
|   active?: boolean; | ||||
|   rateIndex?: number; | ||||
|   fee?: number; | ||||
| } | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-accelerate-fee-graph', | ||||
|   templateUrl: './accelerate-fee-graph.component.html', | ||||
|   styleUrls: ['./accelerate-fee-graph.component.scss'], | ||||
| }) | ||||
| export class AccelerateFeeGraphComponent implements OnInit, OnChanges { | ||||
|   @Input() tx: Transaction; | ||||
|   @Input() estimate: AccelerationEstimate; | ||||
|   @Input() maxRateOptions: RateOption[] = []; | ||||
|   @Input() maxRateIndex: number = 0; | ||||
|   @Output() setUserBid = new EventEmitter<{ fee: number, index: number }>(); | ||||
| 
 | ||||
|   bars: GraphBar[] = []; | ||||
|   tooltipPosition = { x: 0, y: 0 }; | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.initGraph(); | ||||
|   } | ||||
| 
 | ||||
|   ngOnChanges(): void { | ||||
|     this.initGraph(); | ||||
|   } | ||||
| 
 | ||||
|   initGraph(): void { | ||||
|     if (!this.tx || !this.estimate) { | ||||
|       return; | ||||
|     } | ||||
|     const maxRate = Math.max(...this.maxRateOptions.map(option => option.rate)); | ||||
|     const baseRate = this.estimate.txSummary.effectiveFee / this.estimate.txSummary.effectiveVsize; | ||||
|     const baseHeight = baseRate / maxRate; | ||||
|     const bars: GraphBar[] = this.maxRateOptions.slice().reverse().map(option => { | ||||
|       return { | ||||
|         rate: option.rate, | ||||
|         style: this.getStyle(option.rate, maxRate, baseHeight), | ||||
|         class: 'max', | ||||
|         label: 'maximum', | ||||
|         active: option.index === this.maxRateIndex, | ||||
|         rateIndex: option.index, | ||||
|         fee: option.fee, | ||||
|       } | ||||
|     }); | ||||
|     bars.push({ | ||||
|       rate: this.estimate.targetFeeRate, | ||||
|       style: this.getStyle(this.estimate.targetFeeRate, maxRate, baseHeight), | ||||
|       class: 'target', | ||||
|       label: 'next block', | ||||
|       fee: this.estimate.nextBlockFee - this.estimate.txSummary.effectiveFee | ||||
|     }); | ||||
|     bars.push({ | ||||
|       rate: baseRate, | ||||
|       style: this.getStyle(baseRate, maxRate, 0), | ||||
|       class: 'tx', | ||||
|       label: '', | ||||
|       fee: this.estimate.txSummary.effectiveFee, | ||||
|     }); | ||||
|     this.bars = bars; | ||||
|   } | ||||
| 
 | ||||
|   getStyle(rate, maxRate, base) { | ||||
|     const top = (rate / maxRate); | ||||
|     return { | ||||
|       height: `${(top - base) * 100}%`, | ||||
|       bottom: base ? `${base * 100}%` : '0', | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onClick(event, bar): void { | ||||
|     if (bar.rateIndex != null) { | ||||
|       this.setUserBid.emit({ fee: bar.fee, index: bar.rateIndex }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('pointermove', ['$event']) | ||||
|   onPointerMove(event) { | ||||
|     this.tooltipPosition = { x: event.offsetX, y: event.offsetY }; | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,262 @@ | ||||
| <div class="row" *ngIf="showSuccess"> | ||||
|   <div class="col" id="successAlert"> | ||||
|     <div class="alert alert-success"> | ||||
|       Transaction has now been submitted to mining pools for acceleration. You can track the progress <a class="alert-link" routerLink="/services/accelerator/history">here</a>. | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
| 
 | ||||
| <div class="row" *ngIf="error"> | ||||
|   <div class="col" id="mempoolError"> | ||||
|     <app-mempool-error [error]="error"></app-mempool-error> | ||||
|   </div> | ||||
| </div> | ||||
| 
 | ||||
| <div class="accelerate-cols"> | ||||
|   <ng-container *ngIf="!isMobile"> | ||||
|     <app-accelerate-fee-graph | ||||
|       [tx]="tx" | ||||
|       [estimate]="estimate" | ||||
|       [maxRateOptions]="maxRateOptions" | ||||
|       [maxRateIndex]="selectFeeRateIndex" | ||||
|       (setUserBid)="setUserBid($event)" | ||||
|     ></app-accelerate-fee-graph> | ||||
|   </ng-container> | ||||
| 
 | ||||
|   <ng-container *ngIf="estimate"> | ||||
|     <div [class]="{estimateDisabled: error}"> | ||||
|       <h5>Your transaction</h5> | ||||
|       <div class="row"> | ||||
|         <div class="col"> | ||||
|           <small *ngIf="hasAncestors" class="form-text text-muted mb-2"> | ||||
|             Plus {{ estimate.txSummary.ancestorCount - 1 }} unconfirmed ancestor{{ estimate.txSummary.ancestorCount > 2 ? 's' : ''}}. | ||||
|           </small> | ||||
|           <table class="table table-borderless table-border table-dark table-accelerator"> | ||||
|             <tbody> | ||||
|               <tr class="group-first"> | ||||
|                 <td class="item"> | ||||
|                   Virtual size | ||||
|                 </td> | ||||
|                 <td class="units" [innerHTML]="'‎' + (estimate.txSummary.effectiveVsize | vbytes: 2)"></td> | ||||
|               </tr> | ||||
|               <tr class="info"> | ||||
|                 <td class="info"> | ||||
|                   <i><small>Size in vbytes of this transaction<span *ngIf="hasAncestors"> and its unconfirmed ancestors</span></small></i> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 <td class="item"> | ||||
|                   In-band fees | ||||
|                 </td> | ||||
|                 <td class="units"> | ||||
|                   {{ estimate.txSummary.effectiveFee | number : '1.0-0' }} <span class="symbol" i18n="shared.sats|sats">sats</span> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr class="info group-last"> | ||||
|                 <td class="info"> | ||||
|                   <i><small>Fees already paid by this transaction<span *ngIf="hasAncestors"> and its unconfirmed ancestors</span></small></i> | ||||
|                 </td> | ||||
|               </tr> | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
|       </div> | ||||
|       <br> | ||||
|       <h5>How much more are you willing to pay?</h5> | ||||
|       <div class="row"> | ||||
|         <div class="col"> | ||||
|           <small class="form-text text-muted mb-2"> | ||||
|             Choose the maximum extra transaction fee you're willing to pay to get into the next block.<br> | ||||
|             If the estimated next block rate rises beyond this limit, we will automatically cancel your acceleration request. | ||||
|           </small> | ||||
|           <div class="form-group"> | ||||
|             <div class="fee-card"> | ||||
|               <div class="d-flex mb-0"> | ||||
|                 <ng-container *ngFor="let option of maxRateOptions"> | ||||
|                   <button type="button" class="btn btn-primary flex-grow-1 btn-border btn-sm feerate" [class]="{active: selectFeeRateIndex === option.index}" (click)="setUserBid(option)"> | ||||
|                     <span class="fee">{{ option.fee | number }} <span class="symbol" i18n="shared.sats|sats">sats</span></span> | ||||
|                     <span class="rate">~ <app-fee-rate [fee]="option.rate" rounding="1.0-0"></app-fee-rate></span> | ||||
|                   </button> | ||||
|                 </ng-container> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|    | ||||
|       <h5>Acceleration summary</h5> | ||||
|       <div class="row mb-3"> | ||||
|         <div class="col"> | ||||
|           <div class="table-toggle btn-group btn-group-toggle"> | ||||
|             <div class="btn btn-primary btn-sm" [class.active]="showTable === 'estimated'" (click)="showTable = 'estimated'"> | ||||
|               <span>Estimated cost</span> | ||||
|             </div> | ||||
|             <div class="btn btn-primary btn-sm" [class.active]="showTable === 'maximum'" (click)="showTable = 'maximum'"> | ||||
|               <span>Maximum cost</span> | ||||
|             </div> | ||||
|           </div> | ||||
|           <table class="table table-borderless table-border table-dark table-accelerator"> | ||||
|             <tbody> | ||||
|               <!-- ESTIMATED FEE --> | ||||
|               <ng-container *ngIf="showTable === 'estimated'"> | ||||
|                 <tr class="group-first"> | ||||
|                   <td class="item"> | ||||
|                     Next block market rate | ||||
|                   </td> | ||||
|                   <td class="amt" style="font-size: 20px"> | ||||
|                     {{ estimate.targetFeeRate | number : '1.0-0' }} | ||||
|                   </td> | ||||
|                   <td class="units"><span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></td> | ||||
|                 </tr> | ||||
|                 <tr class="info"> | ||||
|                   <td class="info"> | ||||
|                     <i><small>Estimated extra fee required</small></i> | ||||
|                   </td> | ||||
|                   <td class="amt"> | ||||
|                     {{ math.max(0, estimate.nextBlockFee - estimate.txSummary.effectiveFee) | number }} | ||||
|                   </td> | ||||
|                   <td class="units"> | ||||
|                     <span class="symbol" i18n="shared.sats|sats">sats</span> | ||||
|                     <span class="fiat"><app-fiat [value]="math.max(0, estimate.nextBlockFee - estimate.txSummary.effectiveFee)"></app-fiat></span> | ||||
|                   </td> | ||||
|                 </tr> | ||||
|               </ng-container> | ||||
|               <!-- USER MAX BID --> | ||||
|               <ng-container *ngIf="showTable === 'maximum'"> | ||||
|                 <tr class="group-first"> | ||||
|                   <td class="item"> | ||||
|                     Your maximum | ||||
|                   </td> | ||||
|                   <td class="amt" style="width: 45%; font-size: 20px"> | ||||
|                     ~{{ ((estimate.txSummary.effectiveFee + userBid) / estimate.txSummary.effectiveVsize) | number : '1.0-0' }} | ||||
|                   </td> | ||||
|                   <td class="units"><span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></td> | ||||
|                 </tr> | ||||
|                 <tr class="info"> | ||||
|                   <td class="info"> | ||||
|                     <i><small>The maximum extra transaction fee you could pay</small></i> | ||||
|                   </td> | ||||
|                   <td class="amt"> | ||||
|                     <span> | ||||
|                       {{ userBid | number }} | ||||
|                     </span> | ||||
|                   </td> | ||||
|                   <td class="units"> | ||||
|                     <span class="symbol" i18n="shared.sats|sats">sats</span> | ||||
|                     <span class="fiat"><app-fiat [value]="userBid"></app-fiat></span> | ||||
|                   </td> | ||||
|                 </tr> | ||||
|               </ng-container> | ||||
|    | ||||
|               <!-- MEMPOOL BASE FEE --> | ||||
|               <tr> | ||||
|                 <td class="item"> | ||||
|                   Mempool Accelerator™ fees | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr class="info"> | ||||
|                 <td class="info"> | ||||
|                   <i><small>mempool.space fee</small></i> | ||||
|                 </td> | ||||
|                 <td class="amt"> | ||||
|                   +{{ estimate.mempoolBaseFee | number }} | ||||
|                 </td> | ||||
|                 <td class="units"> | ||||
|                   <span class="symbol" i18n="shared.sats|sats">sats</span> | ||||
|                   <span class="fiat"><app-fiat [value]="estimate.mempoolBaseFee"></app-fiat></span> | ||||
|                 </td> | ||||
|               </tr> | ||||
|               <tr class="info group-last" style="border-bottom: 1px solid lightgrey"> | ||||
|                 <td class="info"> | ||||
|                   <i><small>Transaction vsize fee</small></i> | ||||
|                 </td> | ||||
|                 <td class="amt"> | ||||
|                   +{{ estimate.vsizeFee | number }} | ||||
|                 </td> | ||||
|                 <td class="units"> | ||||
|                   <span class="symbol" i18n="shared.sats|sats">sats</span> | ||||
|                   <span class="fiat"><app-fiat [value]="estimate.vsizeFee"></app-fiat></span> | ||||
|                 </td> | ||||
|               </tr> | ||||
| 
 | ||||
|               <!-- NEXT BLOCK ESTIMATE --> | ||||
|               <ng-container *ngIf="showTable === 'estimated'"> | ||||
|                 <tr class="group-first"> | ||||
|                   <td class="item"> | ||||
|                     <b style="background-color: #5E35B1" class="p-1 pl-0">Estimated acceleration cost</b> | ||||
|                   </td> | ||||
|                   <td class="amt"> | ||||
|                     <span style="background-color: #5E35B1" class="p-1 pl-0"> | ||||
|                       {{ estimate.cost + estimate.mempoolBaseFee + estimate.vsizeFee | number }} | ||||
|                     </span> | ||||
|                   </td> | ||||
|                   <td class="units"> | ||||
|                     <span class="symbol" i18n="shared.sats|sats">sats</span> | ||||
|                     <span class="fiat"><app-fiat [value]="estimate.cost + estimate.mempoolBaseFee + estimate.vsizeFee"></app-fiat></span> | ||||
|                   </td> | ||||
|                 </tr> | ||||
|                 <tr class="info group-last"> | ||||
|                   <td class="info"> | ||||
|                     <i><small>If your tx is accelerated to </small><small>{{ estimate.targetFeeRate | number : '1.0-0' }} <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></small></i> | ||||
|                   </td> | ||||
|                 </tr> | ||||
|               </ng-container> | ||||
|    | ||||
|               <!-- MAX COST --> | ||||
|               <ng-container *ngIf="showTable === 'maximum'"> | ||||
|                 <tr class="group-first"> | ||||
|                   <td class="item"> | ||||
|                     <b style="background-color: #105fb0;" class="p-1 pl-0">Maximum acceleration cost</b> | ||||
|                   </td> | ||||
|                   <td class="amt"> | ||||
|                     <span style="background-color: #105fb0" class="p-1 pl-0"> | ||||
|                       {{ maxCost | number }} | ||||
|                     </span> | ||||
|                   </td> | ||||
|                   <td class="units"> | ||||
|                     <span class="symbol" i18n="shared.sats|sats">sats</span> | ||||
|                     <span class="fiat"> | ||||
|                       <app-fiat [value]="maxCost" [colorClass]="estimate.userBalance < maxCost ? 'red-color' : 'green-color'"></app-fiat> | ||||
|                     </span> | ||||
|                   </td> | ||||
|                 </tr> | ||||
|                 <tr class="info group-last"> | ||||
|                   <td class="info"> | ||||
|                     <i><small>If your tx is accelerated to </small><small>~{{ ((estimate.txSummary.effectiveFee + userBid) / estimate.txSummary.effectiveVsize) | number : '1.0-0' }}  <span class="symbol" i18n="shared.sat-vbyte|sat/vB">sat/vB</span></small></i> | ||||
|                   </td> | ||||
|                 </tr> | ||||
|               </ng-container> | ||||
|    | ||||
|               <!-- USER BALANCE --> | ||||
|               <ng-container *ngIf="estimate.userBalance < maxCost"> | ||||
|                 <tr class="group-first group-last" style="border-top: 1px dashed grey"> | ||||
|                   <td class="item"> | ||||
|                     Available balance | ||||
|                   </td> | ||||
|                   <td class="amt"> | ||||
|                     {{ estimate.userBalance | number }} | ||||
|                   </td> | ||||
|                   <td class="units"> | ||||
|                     <span class="symbol" i18n="shared.sats|sats">sats</span> | ||||
|                     <span class="fiat"> | ||||
|                       <app-fiat [value]="estimate.userBalance" [colorClass]="estimate.userBalance < maxCost ? 'red-color' : 'green-color'"></app-fiat> | ||||
|                     </span> | ||||
|                   </td> | ||||
|                 </tr> | ||||
|               </ng-container> | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
|       </div> | ||||
|    | ||||
|       <div class="row mb-3" *ngIf="isLoggedIn()"> | ||||
|         <div class="col"> | ||||
|           <div class="d-flex justify-content-end"> | ||||
|             <button class="btn btn-sm btn-primary btn-success" style="width: 150px" (click)="accelerate()">Accelerate</button> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|    | ||||
|     </div> | ||||
|   </ng-container> | ||||
| </div> | ||||
| @ -0,0 +1,88 @@ | ||||
| .fee-card { | ||||
|   padding: 15px; | ||||
|   background-color: #1d1f31; | ||||
| 
 | ||||
|   .feerate { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
| 
 | ||||
|     .fee { | ||||
|       font-size: 1.2em; | ||||
|     } | ||||
|     .rate { | ||||
|       font-size: 0.9em; | ||||
|       .symbol { | ||||
|         color: white; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .btn-border { | ||||
|   border: solid 1px black; | ||||
|   background-color: #0c4a87; | ||||
| } | ||||
| 
 | ||||
| .feerate.active { | ||||
|   background-color: #105fb0 !important; | ||||
|   opacity: 1; | ||||
|   border: 1px solid white !important; | ||||
| } | ||||
| 
 | ||||
| .estimateDisabled { | ||||
|   opacity: 0.5; | ||||
|   pointer-events: none; | ||||
| } | ||||
| 
 | ||||
| .table-toggle { | ||||
|   width: 100%; | ||||
|   margin-top: 0.5em; | ||||
| } | ||||
| 
 | ||||
| .table-accelerator { | ||||
|   tr { | ||||
|     text-wrap: wrap; | ||||
| 
 | ||||
|     td { | ||||
|       padding-top: 0; | ||||
|       padding-bottom: 0; | ||||
|       vertical-align: baseline; | ||||
|     } | ||||
| 
 | ||||
|     &.group-first { | ||||
|       td { | ||||
|         padding-top: 0.75rem; | ||||
|       } | ||||
|     } | ||||
|     &.group-last { | ||||
|       td { | ||||
|         padding-bottom: 0.75rem; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   td { | ||||
|     &:first-child { | ||||
|       width: 100vw; | ||||
|     } | ||||
|     &.info { | ||||
|       color: #6c757d; | ||||
|     } | ||||
|     &.amt { | ||||
|       text-align: right; | ||||
|       padding-right: 0.2em; | ||||
|     } | ||||
|     &.units { | ||||
|       padding-left: 0.2em; | ||||
|       white-space: nowrap; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .accelerate-cols { | ||||
|   display: flex; | ||||
|   flex-direction: row; | ||||
|   align-items: stretch; | ||||
|   margin-top: 1em; | ||||
| } | ||||
| @ -0,0 +1,205 @@ | ||||
| import { Component, OnInit, Input, OnDestroy, OnChanges, SimpleChanges, HostListener } from '@angular/core'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { Subscription, catchError, of, tap } from 'rxjs'; | ||||
| import { StorageService } from '../../services/storage.service'; | ||||
| import { Transaction } from '../../interfaces/electrs.interface'; | ||||
| import { nextRoundNumber } from '../../shared/common.utils'; | ||||
| 
 | ||||
| export type AccelerationEstimate = { | ||||
|   txSummary: TxSummary; | ||||
|   nextBlockFee: number; | ||||
|   targetFeeRate: number; | ||||
|   userBalance: number; | ||||
|   enoughBalance: boolean; | ||||
|   cost: number; | ||||
|   mempoolBaseFee: number; | ||||
|   vsizeFee: number; | ||||
| } | ||||
| export type TxSummary = { | ||||
|   txid: string; // txid of the current transaction
 | ||||
|   effectiveVsize: number; // Total vsize of the dependency tree
 | ||||
|   effectiveFee: number;  // Total fee of the dependency tree in sats
 | ||||
|   ancestorCount: number; // Number of ancestors
 | ||||
| } | ||||
| 
 | ||||
| export interface RateOption { | ||||
|   fee: number; | ||||
|   rate: number; | ||||
|   index: number; | ||||
| } | ||||
| 
 | ||||
| export const MIN_BID_RATIO = 1; | ||||
| export const DEFAULT_BID_RATIO = 2; | ||||
| export const MAX_BID_RATIO = 4; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-accelerate-preview', | ||||
|   templateUrl: 'accelerate-preview.component.html', | ||||
|   styleUrls: ['accelerate-preview.component.scss'] | ||||
| }) | ||||
| export class AcceleratePreviewComponent implements OnInit, OnDestroy, OnChanges { | ||||
|   @Input() tx: Transaction | undefined; | ||||
|   @Input() scrollEvent: boolean; | ||||
| 
 | ||||
|   math = Math; | ||||
|   error = ''; | ||||
|   showSuccess = false; | ||||
|   estimateSubscription: Subscription; | ||||
|   accelerationSubscription: Subscription; | ||||
|   estimate: any; | ||||
|   hasAncestors: boolean = false; | ||||
|   minExtraCost = 0; | ||||
|   minBidAllowed = 0; | ||||
|   maxBidAllowed = 0; | ||||
|   defaultBid = 0; | ||||
|   maxCost = 0; | ||||
|   userBid = 0; | ||||
|   selectFeeRateIndex = 1; | ||||
|   showTable: 'estimated' | 'maximum' = 'maximum'; | ||||
|   isMobile: boolean = window.innerWidth <= 767.98; | ||||
| 
 | ||||
|   maxRateOptions: RateOption[] = []; | ||||
| 
 | ||||
|   constructor( | ||||
|     private apiService: ApiService, | ||||
|     private storageService: StorageService | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnDestroy(): void { | ||||
|     if (this.estimateSubscription) { | ||||
|       this.estimateSubscription.unsubscribe(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   ngOnChanges(changes: SimpleChanges): void { | ||||
|     if (changes.scrollEvent) { | ||||
|       this.scrollToPreview('acceleratePreviewAnchor', 'center'); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit() { | ||||
|     this.estimateSubscription = this.apiService.estimate$(this.tx.txid).pipe( | ||||
|       tap((response) => { | ||||
|         if (response.status === 204) { | ||||
|           this.estimate = undefined; | ||||
|           this.error = `cannot_accelerate_tx`; | ||||
|           this.scrollToPreviewWithTimeout('mempoolError', 'center'); | ||||
|           this.estimateSubscription.unsubscribe(); | ||||
|         } else { | ||||
|           this.estimate = response.body; | ||||
|           if (!this.estimate) { | ||||
|             this.error = `cannot_accelerate_tx`; | ||||
|             this.scrollToPreviewWithTimeout('mempoolError', 'center'); | ||||
|             this.estimateSubscription.unsubscribe(); | ||||
|           } | ||||
| 
 | ||||
|           if (this.estimate.userBalance <= 0) { | ||||
|             if (this.isLoggedIn()) { | ||||
|               this.error = `not_enough_balance`; | ||||
|               this.scrollToPreviewWithTimeout('mempoolError', 'center'); | ||||
|             } | ||||
|           } | ||||
| 
 | ||||
|           this.hasAncestors = this.estimate.txSummary.ancestorCount > 1; | ||||
|            | ||||
|           // Make min extra fee at least 50% of the current tx fee
 | ||||
|           this.minExtraCost = nextRoundNumber(Math.max(this.estimate.cost * 2, this.estimate.txSummary.effectiveFee)); | ||||
| 
 | ||||
|           this.maxRateOptions = [1, 2, 4].map((multiplier, index) => { | ||||
|             return { | ||||
|               fee: this.minExtraCost * multiplier, | ||||
|               rate: (this.estimate.txSummary.effectiveFee + (this.minExtraCost * multiplier)) / this.estimate.txSummary.effectiveVsize, | ||||
|               index, | ||||
|             }; | ||||
|           }); | ||||
| 
 | ||||
|           this.minBidAllowed = this.minExtraCost * MIN_BID_RATIO; | ||||
|           this.defaultBid = this.minExtraCost * DEFAULT_BID_RATIO; | ||||
|           this.maxBidAllowed = this.minExtraCost * MAX_BID_RATIO; | ||||
| 
 | ||||
|           this.userBid = this.defaultBid; | ||||
|           if (this.userBid < this.minBidAllowed) { | ||||
|             this.userBid = this.minBidAllowed; | ||||
|           } else if (this.userBid > this.maxBidAllowed) { | ||||
|             this.userBid = this.maxBidAllowed; | ||||
|           }             | ||||
|           this.maxCost = this.userBid + this.estimate.mempoolBaseFee + this.estimate.vsizeFee; | ||||
| 
 | ||||
|           if (!this.error) { | ||||
|             this.scrollToPreview('acceleratePreviewAnchor', 'center'); | ||||
|           } | ||||
|         } | ||||
|       }), | ||||
|       catchError((response) => { | ||||
|         this.estimate = undefined; | ||||
|         this.error = response.error; | ||||
|         this.scrollToPreviewWithTimeout('mempoolError', 'center'); | ||||
|         this.estimateSubscription.unsubscribe(); | ||||
|         return of(null); | ||||
|       }) | ||||
|     ).subscribe(); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * User changed his bid | ||||
|    */ | ||||
|   setUserBid({ fee, index }: { fee: number, index: number}) { | ||||
|     if (this.estimate) { | ||||
|       this.selectFeeRateIndex = index; | ||||
|       this.userBid = Math.max(0, fee); | ||||
|       this.maxCost = this.userBid + this.estimate.mempoolBaseFee + this.estimate.vsizeFee; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Scroll to element id with or without setTimeout | ||||
|    */ | ||||
|   scrollToPreviewWithTimeout(id: string, position: ScrollLogicalPosition) { | ||||
|     setTimeout(() => { | ||||
|       this.scrollToPreview(id, position); | ||||
|     }, 100); | ||||
|   } | ||||
|   scrollToPreview(id: string, position: ScrollLogicalPosition) { | ||||
|     const acceleratePreviewAnchor = document.getElementById(id); | ||||
|     if (acceleratePreviewAnchor) { | ||||
|       acceleratePreviewAnchor.scrollIntoView({ | ||||
|         behavior: 'smooth', | ||||
|         inline: position, | ||||
|         block: position, | ||||
|       }); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|   /** | ||||
|    * Send acceleration request | ||||
|    */ | ||||
|   accelerate() { | ||||
|     if (this.accelerationSubscription) { | ||||
|       this.accelerationSubscription.unsubscribe(); | ||||
|     } | ||||
|     this.accelerationSubscription = this.apiService.accelerate$( | ||||
|       this.tx.txid, | ||||
|       this.userBid | ||||
|     ).subscribe({ | ||||
|       next: () => { | ||||
|         this.showSuccess = true; | ||||
|         this.scrollToPreviewWithTimeout('successAlert', 'center'); | ||||
|         this.estimateSubscription.unsubscribe(); | ||||
|       }, | ||||
|       error: (response) => { | ||||
|         this.error = response.error; | ||||
|         this.scrollToPreviewWithTimeout('mempoolError', 'center'); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   isLoggedIn() { | ||||
|     const auth = this.storageService.getAuth(); | ||||
|     return auth !== null; | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('window:resize', ['$event']) | ||||
|   onResize(): void { | ||||
|     this.isMobile = window.innerWidth <= 767.98; | ||||
|   } | ||||
| } | ||||
| @ -9,6 +9,7 @@ import { AudioService } from '../../services/audio.service'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { of, merge, Subscription, Observable } from 'rxjs'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||
| import { AddressInformation } from '../../interfaces/node-api.interface'; | ||||
| 
 | ||||
| @Component({ | ||||
| @ -68,6 +69,7 @@ export class AddressPreviewComponent implements OnInit, OnDestroy { | ||||
|             this.addressString = this.addressString.toLowerCase(); | ||||
|           } | ||||
|           this.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`); | ||||
|           this.seoService.setDescription($localize`:@@meta.description.bitcoin.address:See mempool transactions, confirmed transactions, balance, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} address ${this.addressString}:INTERPOLATION:.`); | ||||
| 
 | ||||
|           return (this.addressString.match(/04[a-fA-F0-9]{128}|(02|03)[a-fA-F0-9]{64}/) | ||||
|               ? this.electrsApiService.getPubKeyAddress$(this.addressString) | ||||
|  | ||||
| @ -9,6 +9,7 @@ import { AudioService } from '../../services/audio.service'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { of, merge, Subscription, Observable } from 'rxjs'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||
| import { AddressInformation } from '../../interfaces/node-api.interface'; | ||||
| 
 | ||||
| @Component({ | ||||
| @ -76,6 +77,7 @@ export class AddressComponent implements OnInit, OnDestroy { | ||||
|             this.addressString = this.addressString.toLowerCase(); | ||||
|           } | ||||
|           this.seoService.setTitle($localize`:@@address.component.browser-title:Address: ${this.addressString}:INTERPOLATION:`); | ||||
|           this.seoService.setDescription($localize`:@@meta.description.bitcoin.address:See mempool transactions, confirmed transactions, balance, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} address ${this.addressString}:INTERPOLATION:.`); | ||||
| 
 | ||||
|           return merge( | ||||
|             of(true), | ||||
| @ -172,6 +174,11 @@ export class AddressComponent implements OnInit, OnDestroy { | ||||
|         this.addTransaction(tx); | ||||
|       }); | ||||
| 
 | ||||
|     this.stateService.mempoolRemovedTransactions$ | ||||
|       .subscribe(tx => { | ||||
|         this.removeTransaction(tx); | ||||
|       }); | ||||
| 
 | ||||
|     this.stateService.blockTransactions$ | ||||
|       .subscribe((transaction) => { | ||||
|         const tx = this.transactions.find((t) => t.txid === transaction.txid); | ||||
| @ -220,6 +227,30 @@ export class AddressComponent implements OnInit, OnDestroy { | ||||
|     return true; | ||||
|   } | ||||
| 
 | ||||
|   removeTransaction(transaction: Transaction): boolean { | ||||
|     const index = this.transactions.findIndex(((tx) => tx.txid === transaction.txid)); | ||||
|     if (index === -1) { | ||||
|       return false; | ||||
|     } | ||||
| 
 | ||||
|     this.transactions.splice(index, 1); | ||||
|     this.transactions = this.transactions.slice(); | ||||
|     this.txCount--; | ||||
| 
 | ||||
|     transaction.vin.forEach((vin) => { | ||||
|       if (vin?.prevout?.scriptpubkey_address === this.address.address) { | ||||
|         this.sent -= vin.prevout.value; | ||||
|       } | ||||
|     }); | ||||
|     transaction.vout.forEach((vout) => { | ||||
|       if (vout?.scriptpubkey_address === this.address.address) { | ||||
|         this.received -= vout.value; | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     return true; | ||||
|   } | ||||
| 
 | ||||
|   loadMore() { | ||||
|     if (this.isLoadingTransactions || !this.totalConfirmedTxCount || this.loadedConfirmedTxCount >= this.totalConfirmedTxCount) { | ||||
|       return; | ||||
|  | ||||
| @ -40,6 +40,7 @@ export class AssetsNavComponent implements OnInit { | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.seoService.setTitle($localize`:@@ee8f8008bae6ce3a49840c4e1d39b4af23d4c263:Assets`); | ||||
|     this.seoService.setDescription($localize`:@@meta.description.liquid.assets:Explore all the assets issued on the Liquid network like L-BTC, L-CAD, USDT, and more.`); | ||||
|     this.typeaheadSearchFn = this.typeaheadSearch; | ||||
| 
 | ||||
|     this.searchForm = this.formBuilder.group({ | ||||
|  | ||||
| @ -64,6 +64,7 @@ export class BlockFeeRatesGraphComponent implements OnInit { | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.seoService.setTitle($localize`:@@ed8e33059967f554ff06b4f5b6049c465b92d9b3:Block Fee Rates`); | ||||
|     this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-fee-rates:See Bitcoin feerates visualized over time, including minimum and maximum feerates per block along with feerates at various percentiles.`); | ||||
|     this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); | ||||
|     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); | ||||
|     this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); | ||||
|  | ||||
| @ -65,6 +65,7 @@ export class BlockFeesGraphComponent implements OnInit { | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.seoService.setTitle($localize`:@@6c453b11fd7bd159ae30bc381f367bc736d86909:Block Fees`); | ||||
|     this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-fees:See the average mining fees earned per Bitcoin block visualized in BTC and USD over time.`); | ||||
|     this.miningWindowPreference = this.miningService.getDefaultTimespan('1m'); | ||||
|     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); | ||||
|     this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); | ||||
| @ -192,7 +193,7 @@ export class BlockFeesGraphComponent implements OnInit { | ||||
|           { | ||||
|             name: 'Fees ' + this.currency, | ||||
|             inactiveColor: 'rgb(110, 112, 121)', | ||||
|             textStyle: {   | ||||
|             textStyle: { | ||||
|               color: 'white', | ||||
|             }, | ||||
|             icon: 'roundRect', | ||||
|  | ||||
| @ -61,6 +61,7 @@ export class BlockHealthGraphComponent implements OnInit { | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.seoService.setTitle($localize`:@@d7d5fcf50179ad70c938491c517efb82de2c8146:Block Health`); | ||||
|     this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-health:See Bitcoin block health visualized over time. Block health is a measure of how many expected transactions were included in an actual mined block. Expected transactions are determined using Mempool's re-implementation of Bitcoin Core's transaction selection algorithm.`); | ||||
|     this.miningWindowPreference = '24h';//this.miningService.getDefaultTimespan('24h');
 | ||||
|     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); | ||||
|     this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); | ||||
|  | ||||
| @ -70,9 +70,11 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|     this.canvas.nativeElement.addEventListener('webglcontextlost', this.handleContextLost, false); | ||||
|     this.canvas.nativeElement.addEventListener('webglcontextrestored', this.handleContextRestored, false); | ||||
|     this.gl = this.canvas.nativeElement.getContext('webgl'); | ||||
|     this.initCanvas(); | ||||
| 
 | ||||
|     this.resizeCanvas(); | ||||
|     if (this.gl) { | ||||
|       this.initCanvas(); | ||||
|       this.resizeCanvas(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   ngOnChanges(changes): void { | ||||
| @ -195,10 +197,16 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|     cancelAnimationFrame(this.animationFrameRequest); | ||||
|     this.animationFrameRequest = null; | ||||
|     this.running = false; | ||||
|     this.gl = null; | ||||
|   } | ||||
| 
 | ||||
|   handleContextRestored(event): void { | ||||
|     this.initCanvas(); | ||||
|     if (this.canvas?.nativeElement) { | ||||
|       this.gl = this.canvas.nativeElement.getContext('webgl'); | ||||
|       if (this.gl) { | ||||
|         this.initCanvas(); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('window:resize', ['$event']) | ||||
| @ -224,6 +232,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|   } | ||||
| 
 | ||||
|   compileShader(src, type): WebGLShader { | ||||
|     if (!this.gl) { | ||||
|       return; | ||||
|     } | ||||
|     const shader = this.gl.createShader(type); | ||||
| 
 | ||||
|     this.gl.shaderSource(shader, src); | ||||
| @ -237,6 +248,9 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|   } | ||||
| 
 | ||||
|   buildShaderProgram(shaderInfo): WebGLProgram { | ||||
|     if (!this.gl) { | ||||
|       return; | ||||
|     } | ||||
|     const program = this.gl.createProgram(); | ||||
| 
 | ||||
|     shaderInfo.forEach((desc) => { | ||||
| @ -273,7 +287,7 @@ export class BlockOverviewGraphComponent implements AfterViewInit, OnDestroy, On | ||||
|       now = performance.now(); | ||||
|     } | ||||
|     // skip re-render if there's no change to the scene
 | ||||
|     if (this.scene) { | ||||
|     if (this.scene && this.gl) { | ||||
|       /* SET UP SHADER UNIFORMS */ | ||||
|       // screen dimensions
 | ||||
|       this.gl.uniform2f(this.gl.getUniformLocation(this.shaderProgram, 'screenSize'), this.displayWidth, this.displayHeight); | ||||
|  | ||||
| @ -63,6 +63,7 @@ export class BlockRewardsGraphComponent implements OnInit { | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.seoService.setTitle($localize`:@@8ba8fe810458280a83df7fdf4c614dfc1a826445:Block Rewards`); | ||||
|     this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-rewards:See Bitcoin block rewards in BTC and USD visualized over time. Block rewards are the total funds miners earn from the block subsidy and fees.`); | ||||
|     this.miningWindowPreference = this.miningService.getDefaultTimespan('3m'); | ||||
|     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); | ||||
|     this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); | ||||
| @ -191,7 +192,7 @@ export class BlockRewardsGraphComponent implements OnInit { | ||||
|           { | ||||
|             name: 'Rewards ' + this.currency, | ||||
|             inactiveColor: 'rgb(110, 112, 121)', | ||||
|             textStyle: {   | ||||
|             textStyle: { | ||||
|               color: 'white', | ||||
|             }, | ||||
|             icon: 'roundRect', | ||||
|  | ||||
| @ -60,6 +60,7 @@ export class BlockSizesWeightsGraphComponent implements OnInit { | ||||
|     let firstRun = true; | ||||
| 
 | ||||
|     this.seoService.setTitle($localize`:@@56fa1cd221491b6478998679cba2dc8d55ba330d:Block Sizes and Weights`); | ||||
|     this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.block-sizes:See Bitcoin block sizes (MB) and block weights (weight units) visualized over time.`); | ||||
|     this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); | ||||
|     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); | ||||
|     this.radioGroupForm.controls.dateSpan.setValue(this.miningWindowPreference); | ||||
|  | ||||
| @ -8,6 +8,7 @@ import { SeoService } from '../../services/seo.service'; | ||||
| import { OpenGraphService } from '../../services/opengraph.service'; | ||||
| import { BlockExtended, TransactionStripped } from '../../interfaces/node-api.interface'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||
| import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; | ||||
| 
 | ||||
| @Component({ | ||||
| @ -97,6 +98,11 @@ export class BlockPreviewComponent implements OnInit, OnDestroy { | ||||
|         this.blockHeight = block.height; | ||||
| 
 | ||||
|         this.seoService.setTitle($localize`:@@block.component.browser-title:Block ${block.height}:BLOCK_HEIGHT:: ${block.id}:BLOCK_ID:`); | ||||
|         if( this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet' ) { | ||||
|           this.seoService.setDescription($localize`:@@meta.description.liquid.block:See size, weight, fee range, included transactions, and more for Liquid${seoDescriptionNetwork(this.stateService.network)} block ${block.height}:BLOCK_HEIGHT: (${block.id}:BLOCK_ID:).`); | ||||
|         } else { | ||||
|           this.seoService.setDescription($localize`:@@meta.description.bitcoin.block:See size, weight, fee range, included transactions, audit (expected v actual), and more for Bitcoin${seoDescriptionNetwork(this.stateService.network)} block ${block.height}:BLOCK_HEIGHT: (${block.id}:BLOCK_ID:).`); | ||||
|         } | ||||
|         this.isLoadingBlock = false; | ||||
|         this.setBlockSubsidy(); | ||||
|         if (block?.extras?.reward !== undefined) { | ||||
|  | ||||
| @ -13,6 +13,7 @@ import { BlockAudit, BlockExtended, TransactionStripped } from '../../interfaces | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { BlockOverviewGraphComponent } from '../../components/block-overview-graph/block-overview-graph.component'; | ||||
| import { detectWebGL } from '../../shared/graphs.utils'; | ||||
| import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||
| import { PriceService, Price } from '../../services/price.service'; | ||||
| import { CacheService } from '../../services/cache.service'; | ||||
| 
 | ||||
| @ -165,7 +166,6 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|         this.page = 1; | ||||
|         this.error = undefined; | ||||
|         this.fees = undefined; | ||||
|         this.stateService.markBlock$.next({}); | ||||
| 
 | ||||
|         if (history.state.data && history.state.data.blockHeight) { | ||||
|           this.blockHeight = history.state.data.blockHeight; | ||||
| @ -175,6 +175,7 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|         let isBlockHeight = false; | ||||
|         if (/^[0-9]+$/.test(blockHash)) { | ||||
|           isBlockHeight = true; | ||||
|           this.stateService.markBlock$.next({ blockHeight: parseInt(blockHash, 10)}); | ||||
|         } else { | ||||
|           this.blockHash = blockHash; | ||||
|         } | ||||
| @ -201,6 +202,7 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|                   this.location.replaceState( | ||||
|                     this.router.createUrlTree([(this.network ? '/' + this.network : '') + '/block/', hash]).toString() | ||||
|                   ); | ||||
|                   this.seoService.updateCanonical(this.location.path()); | ||||
|                   return this.apiService.getBlock$(hash).pipe( | ||||
|                     catchError((err) => { | ||||
|                       this.error = err; | ||||
| @ -261,6 +263,11 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|         this.setNextAndPreviousBlockLink(); | ||||
| 
 | ||||
|         this.seoService.setTitle($localize`:@@block.component.browser-title:Block ${block.height}:BLOCK_HEIGHT:: ${block.id}:BLOCK_ID:`); | ||||
|         if( this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet' ) { | ||||
|           this.seoService.setDescription($localize`:@@meta.description.liquid.block:See size, weight, fee range, included transactions, and more for Liquid${seoDescriptionNetwork(this.stateService.network)} block ${block.height}:BLOCK_HEIGHT: (${block.id}:BLOCK_ID:).`); | ||||
|         } else { | ||||
|           this.seoService.setDescription($localize`:@@meta.description.bitcoin.block:See size, weight, fee range, included transactions, audit (expected v actual), and more for Bitcoin${seoDescriptionNetwork(this.stateService.network)} block ${block.height}:BLOCK_HEIGHT: (${block.id}:BLOCK_ID:).`); | ||||
|         } | ||||
|         this.isLoadingBlock = false; | ||||
|         this.setBlockSubsidy(); | ||||
|         if (block?.extras?.reward !== undefined) { | ||||
| @ -325,7 +332,7 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|         ]); | ||||
|       }) | ||||
|     ) | ||||
|     .subscribe(([transactions, blockAudit]) => {       | ||||
|     .subscribe(([transactions, blockAudit]) => { | ||||
|       if (transactions) { | ||||
|         this.strippedTransactions = transactions; | ||||
|       } else { | ||||
| @ -680,7 +687,7 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|       this.setAuditAvailable(false); | ||||
|     } | ||||
|   } | ||||
|    | ||||
| 
 | ||||
|   isAuditAvailableFromBlockHeight(blockHeight: number): boolean { | ||||
|     if (!this.auditSupported) { | ||||
|       return false; | ||||
| @ -729,4 +736,4 @@ export class BlockComponent implements OnInit, OnDestroy { | ||||
|       this.block.canonical = block.id; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| } | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| <div class="text-center" class="blockchain-wrapper" [class.time-ltr]="timeLtr" [class.ltr-transition]="ltrTransitionEnabled" #container> | ||||
|   <div class="position-container" [ngClass]="network ? network : ''" [style.--divider-offset]="dividerOffset + 'px'" [style.--mempool-offset]="mempoolOffset + 'px'"> | ||||
|   <div #positionContainer class="position-container" [ngClass]="network ? network : ''" [style]="positionStyle"> | ||||
|     <span> | ||||
|       <div class="blocks-wrapper"> | ||||
|         <div class="scroll-spacer" *ngIf="minScrollWidth" [style.left]="minScrollWidth + 'px'"></div> | ||||
|  | ||||
| @ -26,15 +26,7 @@ | ||||
|   position: absolute; | ||||
|   left: 0; | ||||
|   top: 75px; | ||||
|   --divider-offset: 50vw; | ||||
|   --mempool-offset: 0px; | ||||
|   transform: translateX(calc(var(--divider-offset) + var(--mempool-offset))); | ||||
| } | ||||
| 
 | ||||
| .blockchain-wrapper.time-ltr { | ||||
|   .position-container { | ||||
|     transform: translateX(calc(100vw - var(--divider-offset) - var(--mempool-offset))); | ||||
|   } | ||||
|   transform: translateX(1280px); | ||||
| } | ||||
| 
 | ||||
| .black-background { | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, Output, EventEmitter, HostListener, ChangeDetectorRef } from '@angular/core'; | ||||
| import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, Input, Output, EventEmitter, ChangeDetectorRef, OnChanges, SimpleChanges } from '@angular/core'; | ||||
| import { firstValueFrom, Subscription } from 'rxjs'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| 
 | ||||
| @ -8,12 +8,13 @@ import { StateService } from '../../services/state.service'; | ||||
|   styleUrls: ['./blockchain.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class BlockchainComponent implements OnInit, OnDestroy { | ||||
| export class BlockchainComponent implements OnInit, OnDestroy, OnChanges { | ||||
|   @Input() pages: any[] = []; | ||||
|   @Input() pageIndex: number; | ||||
|   @Input() blocksPerPage: number = 8; | ||||
|   @Input() minScrollWidth: number = 0; | ||||
|   @Input() scrollableMempool: boolean = false; | ||||
|   @Input() containerWidth: number; | ||||
| 
 | ||||
|   @Output() mempoolOffsetChange: EventEmitter<number> = new EventEmitter(); | ||||
| 
 | ||||
| @ -26,8 +27,11 @@ export class BlockchainComponent implements OnInit, OnDestroy { | ||||
|   loadingTip: boolean = true; | ||||
|   connected: boolean = true; | ||||
| 
 | ||||
|   dividerOffset: number = 0; | ||||
|   mempoolOffset: number = 0; | ||||
|   dividerOffset: number | null = null; | ||||
|   mempoolOffset: number | null = null; | ||||
|   positionStyle = { | ||||
|     transform: "translateX(1280px)", | ||||
|   }; | ||||
| 
 | ||||
|   constructor( | ||||
|     public stateService: StateService, | ||||
| @ -39,6 +43,7 @@ export class BlockchainComponent implements OnInit, OnDestroy { | ||||
|     this.network = this.stateService.network; | ||||
|     this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => { | ||||
|       this.timeLtr = !!ltr; | ||||
|       this.updateStyle(); | ||||
|     }); | ||||
|     this.connectionStateSubscription = this.stateService.connectionState$.subscribe(state => { | ||||
|       this.connected = (state === 2); | ||||
| @ -62,44 +67,68 @@ export class BlockchainComponent implements OnInit, OnDestroy { | ||||
|     const prevOffset = this.mempoolOffset; | ||||
|     this.mempoolOffset = 0; | ||||
|     this.mempoolOffsetChange.emit(0); | ||||
|     this.updateStyle(); | ||||
|     setTimeout(() => { | ||||
|       this.ltrTransitionEnabled = true; | ||||
|       this.flipping = true; | ||||
|       this.stateService.timeLtr.next(!this.timeLtr); | ||||
|       this.cd.markForCheck(); | ||||
|       setTimeout(() => { | ||||
|         this.ltrTransitionEnabled = false; | ||||
|         this.flipping = false; | ||||
|         this.mempoolOffset = prevOffset; | ||||
|         this.mempoolOffsetChange.emit(this.mempoolOffset); | ||||
|         this.mempoolOffsetChange.emit((this.mempoolOffset || 0)); | ||||
|         this.updateStyle(); | ||||
|         this.cd.markForCheck(); | ||||
|       },  1000); | ||||
|     }, 0); | ||||
|     this.cd.markForCheck(); | ||||
|   } | ||||
| 
 | ||||
|   onMempoolWidthChange(width): void { | ||||
|     if (this.flipping) { | ||||
|       return; | ||||
|     } | ||||
|     this.mempoolOffset = Math.max(0, width - this.dividerOffset); | ||||
|     this.cd.markForCheck(); | ||||
|     this.mempoolOffset = Math.max(0, width - (this.dividerOffset || 0)); | ||||
|     this.updateStyle(); | ||||
|     this.mempoolOffsetChange.emit(this.mempoolOffset); | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('window:resize', ['$event']) | ||||
|   updateStyle(): void { | ||||
|     if (this.dividerOffset == null || this.mempoolOffset == null) { | ||||
|       return; | ||||
|     } | ||||
|     const oldTransform = this.positionStyle.transform; | ||||
|     this.positionStyle = this.timeLtr ? { | ||||
|       transform: `translateX(calc(100vw - ${this.dividerOffset + this.mempoolOffset}px)`, | ||||
|     } : { | ||||
|       transform: `translateX(${this.dividerOffset + this.mempoolOffset}px)`, | ||||
|     }; | ||||
|     if (oldTransform !== this.positionStyle.transform) { | ||||
|       this.cd.detectChanges(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   ngOnChanges(changes: SimpleChanges): void { | ||||
|     if (changes.containerWidth) { | ||||
|       this.onResize(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onResize(): void { | ||||
|     if (window.innerWidth >= 768) { | ||||
|     const width = this.containerWidth || window.innerWidth; | ||||
|     if (width >= 768) { | ||||
|       if (this.stateService.isLiquid()) { | ||||
|         this.dividerOffset = 420; | ||||
|       } else { | ||||
|         this.dividerOffset = window.innerWidth * 0.5; | ||||
|         this.dividerOffset = width * 0.5; | ||||
|       } | ||||
|     } else { | ||||
|       if (this.stateService.isLiquid()) { | ||||
|         this.dividerOffset = window.innerWidth * 0.5; | ||||
|         this.dividerOffset = width * 0.5; | ||||
|       } else { | ||||
|         this.dividerOffset = window.innerWidth * 0.95; | ||||
|         this.dividerOffset = width * 0.95; | ||||
|       } | ||||
|     } | ||||
|     this.cd.markForCheck(); | ||||
|     this.updateStyle(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -5,6 +5,8 @@ import { BlockExtended } from '../../interfaces/node-api.interface'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { WebsocketService } from '../../services/websocket.service'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-blocks-list', | ||||
| @ -35,6 +37,7 @@ export class BlocksList implements OnInit { | ||||
|     private websocketService: WebsocketService, | ||||
|     public stateService: StateService, | ||||
|     private cd: ChangeDetectorRef, | ||||
|     private seoService: SeoService, | ||||
|   ) { | ||||
|   } | ||||
| 
 | ||||
| @ -50,6 +53,14 @@ export class BlocksList implements OnInit { | ||||
|     this.skeletonLines = this.widget === true ? [...Array(6).keys()] : [...Array(15).keys()]; | ||||
|     this.paginationMaxSize = window.matchMedia('(max-width: 670px)').matches ? 3 : 5; | ||||
| 
 | ||||
|     this.seoService.setTitle($localize`:@@meta.title.blocks-list:Blocks`); | ||||
|     if( this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet' ) { | ||||
|       this.seoService.setDescription($localize`:@@meta.description.liquid.blocks:See the most recent Liquid${seoDescriptionNetwork(this.stateService.network)} blocks along with basic stats such as block height, block size, and more.`); | ||||
|     } else { | ||||
|       this.seoService.setDescription($localize`:@@meta.description.bitcoin.blocks:See the most recent Bitcoin${seoDescriptionNetwork(this.stateService.network)} blocks along with basic stats such as block height, block reward, block size, and more.`); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     this.blocks$ = combineLatest([ | ||||
|       this.fromHeightSubject.pipe( | ||||
|         switchMap((fromBlockHeight) => { | ||||
| @ -129,4 +140,4 @@ export class BlocksList implements OnInit { | ||||
|   isEllipsisActive(e): boolean { | ||||
|     return (e.offsetWidth < e.scrollWidth); | ||||
|   } | ||||
| } | ||||
| } | ||||
|  | ||||
| @ -194,7 +194,7 @@ export class DifficultyComponent implements OnInit { | ||||
| 
 | ||||
|   @HostListener('pointerdown', ['$event']) | ||||
|   onPointerDown(event): void { | ||||
|     if (this.epochSvgElement.nativeElement?.contains(event.target)) { | ||||
|     if (this.epochSvgElement?.nativeElement?.contains(event.target)) { | ||||
|       this.onPointerMove(event); | ||||
|       event.preventDefault(); | ||||
|     } | ||||
| @ -202,7 +202,7 @@ export class DifficultyComponent implements OnInit { | ||||
| 
 | ||||
|   @HostListener('pointermove', ['$event']) | ||||
|   onPointerMove(event): void { | ||||
|     if (this.epochSvgElement.nativeElement?.contains(event.target)) { | ||||
|     if (this.epochSvgElement?.nativeElement?.contains(event.target)) { | ||||
|       this.tooltipPosition = { x: event.clientX, y: event.clientY }; | ||||
|       this.cd.markForCheck(); | ||||
|     } | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| <div [formGroup]="fiatForm" class="text-small text-center"> | ||||
|     <select formControlName="fiat" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 150px;" (change)="changeFiat()"> | ||||
|         <option *ngFor="let currency of currencies" [value]="currency[1].code">{{ currency[1].name + " (" + currency[1].code + ")" }}</option> | ||||
|     <select formControlName="fiat" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 85px;" (change)="changeFiat()"> | ||||
|         <option *ngFor="let currency of currencies" [value]="currency[1].code">{{ currency[1].code }}</option> | ||||
|     </select> | ||||
| </div> | ||||
|  | ||||
| @ -12,6 +12,7 @@ import { MiningService } from '../../services/mining.service'; | ||||
| import { download } from '../../shared/graphs.utils'; | ||||
| import { ActivatedRoute } from '@angular/router'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-hashrate-chart', | ||||
| @ -71,6 +72,7 @@ export class HashrateChartComponent implements OnInit { | ||||
|       this.miningWindowPreference = '1y'; | ||||
|     } else { | ||||
|       this.seoService.setTitle($localize`:@@3510fc6daa1d975f331e3a717bdf1a34efa06dff:Hashrate & Difficulty`); | ||||
|       this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.hashrate:See hashrate and difficulty for the Bitcoin${seoDescriptionNetwork(this.network)} network visualized over time.`); | ||||
|       this.miningWindowPreference = this.miningService.getDefaultTimespan('3m'); | ||||
|     } | ||||
|     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); | ||||
| @ -256,7 +258,7 @@ export class HashrateChartComponent implements OnInit { | ||||
|               let difficultyPowerOfTen = hashratePowerOfTen; | ||||
|               let difficulty = tick.data[1]; | ||||
|               if (difficulty === null) { | ||||
|                 difficultyString = `${tick.marker} ${tick.seriesName}: No data<br>`;   | ||||
|                 difficultyString = `${tick.marker} ${tick.seriesName}: No data<br>`; | ||||
|               } else { | ||||
|                 if (this.isMobile()) { | ||||
|                   difficultyPowerOfTen = selectPowerOfTen(tick.data[1]); | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| <div [formGroup]="languageForm" class="text-small text-center"> | ||||
|     <select formControlName="language" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 100px;" (change)="changeLanguage()"> | ||||
|     <select formControlName="language" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 95px;" (change)="changeLanguage()"> | ||||
|         <option *ngFor="let lang of languages" [value]="lang.code">{{ lang.name }}</option> | ||||
|     </select> | ||||
| </div> | ||||
|  | ||||
| @ -1,6 +1,20 @@ | ||||
| <ng-container *ngIf="{ val: network$ | async } as network"> | ||||
| <header *ngIf="headerVisible"> | ||||
| <header *ngIf="headerVisible" class="sticky-header"> | ||||
| 
 | ||||
|   <nav class="navbar navbar-expand-md navbar-dark bg-dark"> | ||||
|   <!-- Hamburger --> | ||||
|   <ng-container *ngIf="servicesEnabled"> | ||||
|     <div *ngIf="user" class="profile_image_container" [class]="{'anon': !user.imageMd5}" (click)="hamburgerClick($event)"> | ||||
|       <img *ngIf="user.imageMd5" [src]="'/api/v1/services/account/image/' + user.username + '?md5=' + user.imageMd5" class="profile_image"> | ||||
|       <app-svg-images style="color: lightgrey; fill: lightgray" *ngIf="!user.imageMd5" name="anon"></app-svg-images> | ||||
|     </div> | ||||
|     <div *ngIf="false && user === null" class="profile_image_container" (click)="hamburgerClick($event)"> | ||||
|       <app-svg-images name="hamburger" height="40"></app-svg-images> | ||||
|     </div> | ||||
|     <!-- Empty placeholder --> | ||||
|     <div *ngIf="user === undefined" class="profile_image_container"></div> | ||||
|   </ng-container> | ||||
| 
 | ||||
|   <a class="navbar-brand" [ngClass]="{'dual-logos': subdomain}" [routerLink]="['/' | relativeUrl]" (click)="brandClick($event)"> | ||||
|   <ng-template [ngIf]="subdomain"> | ||||
|     <div class="subdomain_container"> | ||||
| @ -62,11 +76,19 @@ | ||||
| </nav> | ||||
| </header> | ||||
| 
 | ||||
| <app-testnet-alert *ngIf="network.val === 'testnet' || network.val === 'signet'"></app-testnet-alert> | ||||
| <div class="d-flex" style="overflow: clip"> | ||||
|   <app-menu *ngIf="servicesEnabled" [navOpen]="menuOpen" (loggedOut)="onLoggedOut()" (menuToggled)="menuToggled($event)"></app-menu> | ||||
| 
 | ||||
| <main> | ||||
|   <router-outlet></router-outlet> | ||||
| </main> | ||||
|   <div class="flex-grow-1 d-flex flex-column"> | ||||
|     <app-testnet-alert *ngIf="network.val === 'testnet' || network.val === 'signet'"></app-testnet-alert> | ||||
| 
 | ||||
|     <main style="min-width: 375px" [style]="menuOpen ? 'max-width: calc(100vw - 225px)' : 'max-width: 100vw'"> | ||||
|       <router-outlet></router-outlet> | ||||
|     </main> | ||||
| 
 | ||||
|     <div class="flex-grow-1"></div> | ||||
|     <app-global-footer *ngIf="footerVisible"></app-global-footer> | ||||
|   </div> | ||||
| </div> | ||||
| 
 | ||||
| <app-global-footer *ngIf="footerVisible"></app-global-footer> | ||||
| </ng-container> | ||||
|  | ||||
| @ -1,3 +1,11 @@ | ||||
| .sticky-header { | ||||
|   position: sticky; | ||||
|   position: -webkit-sticky; | ||||
|   top: 0; | ||||
|   width: 100%; | ||||
|   z-index: 100; | ||||
| } | ||||
| 
 | ||||
| li.nav-item.active { | ||||
|   background-color: #653b9c; | ||||
| } | ||||
| @ -86,7 +94,6 @@ li.nav-item { | ||||
| 
 | ||||
| .navbar-brand { | ||||
|   position: relative; | ||||
|   height: 65px; | ||||
| } | ||||
| 
 | ||||
| .navbar-brand.dual-logos { | ||||
| @ -102,7 +109,7 @@ nav { | ||||
| 
 | ||||
| .connection-badge { | ||||
|   position: absolute; | ||||
|   top: 22px; | ||||
|   top: 12px; | ||||
|   width: 100%; | ||||
| } | ||||
| 
 | ||||
| @ -209,4 +216,26 @@ nav { | ||||
|     margin-left: 5px; | ||||
|     margin-right: 0px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .profile_image_container { | ||||
|   width: 35px; | ||||
|   margin-right: 15px; | ||||
|   text-align: center; | ||||
|   align-self: center; | ||||
|   cursor: pointer; | ||||
|   &.anon { | ||||
|     border: 1.5px solid lightgrey; | ||||
|     color: lightgrey; | ||||
|     border-radius: 5px; | ||||
|   } | ||||
| } | ||||
| .profile_image { | ||||
|   height: 35px; | ||||
|   border-radius: 5px; | ||||
| } | ||||
| 
 | ||||
| main { | ||||
|   transition: 0.2s; | ||||
|   transition-property: max-width; | ||||
| } | ||||
| @ -1,9 +1,13 @@ | ||||
| import { Component, OnInit, Input } from '@angular/core'; | ||||
| import { Component, OnInit, Input, ViewChild } from '@angular/core'; | ||||
| import { Router } from '@angular/router'; | ||||
| import { Env, StateService } from '../../services/state.service'; | ||||
| import { Observable, merge, of } from 'rxjs'; | ||||
| import { LanguageService } from '../../services/language.service'; | ||||
| import { EnterpriseService } from '../../services/enterprise.service'; | ||||
| import { NavigationService } from '../../services/navigation.service'; | ||||
| import { MenuComponent } from '../menu/menu.component'; | ||||
| import { StorageService } from '../../services/storage.service'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-master-page', | ||||
| @ -25,12 +29,21 @@ export class MasterPageComponent implements OnInit { | ||||
|   networkPaths: { [network: string]: string }; | ||||
|   networkPaths$: Observable<Record<string, string>>; | ||||
|   footerVisible = true; | ||||
|   user: any = undefined; | ||||
|   servicesEnabled = false; | ||||
|   menuOpen = false; | ||||
| 
 | ||||
|   @ViewChild(MenuComponent) | ||||
|   public menuComponent!: MenuComponent; | ||||
| 
 | ||||
|   constructor( | ||||
|     public stateService: StateService, | ||||
|     private languageService: LanguageService, | ||||
|     private enterpriseService: EnterpriseService, | ||||
|     private navigationService: NavigationService, | ||||
|     private storageService: StorageService, | ||||
|     private apiService: ApiService, | ||||
|     private router: Router, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
| @ -51,17 +64,47 @@ export class MasterPageComponent implements OnInit { | ||||
|         this.footerVisible = this.footerVisibleOverride; | ||||
|       } | ||||
|     }); | ||||
|      | ||||
|     this.servicesEnabled = this.officialMempoolSpace && this.stateService.env.ACCELERATOR === true && this.stateService.network === ''; | ||||
|     this.refreshAuth(); | ||||
| 
 | ||||
|     const isServicesPage = this.router.url.includes('/services/'); | ||||
|     this.menuOpen = isServicesPage && !this.isSmallScreen(); | ||||
|   } | ||||
| 
 | ||||
|   collapse(): void { | ||||
|     this.navCollapsed = !this.navCollapsed; | ||||
|   } | ||||
| 
 | ||||
|   isSmallScreen() { | ||||
|     return window.innerWidth <= 767.98; | ||||
|   } | ||||
| 
 | ||||
|   onResize(): void { | ||||
|     this.isMobile = window.innerWidth <= 767.98; | ||||
|     this.isMobile = this.isSmallScreen(); | ||||
|   } | ||||
| 
 | ||||
|   brandClick(e): void { | ||||
|     this.stateService.resetScroll$.next(true); | ||||
|   } | ||||
| 
 | ||||
|   onLoggedOut(): void { | ||||
|     this.refreshAuth(); | ||||
|   } | ||||
| 
 | ||||
|   refreshAuth(): void { | ||||
|     this.user = this.storageService.getAuth()?.user ?? null; | ||||
|   } | ||||
| 
 | ||||
|   hamburgerClick(event): void { | ||||
|     if (this.menuComponent) { | ||||
|       this.menuComponent.hamburgerClick(); | ||||
|       this.menuOpen = this.menuComponent.navOpen; | ||||
|       event.stopPropagation(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   menuToggled(isOpen: boolean): void { | ||||
|     this.menuOpen = isOpen; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -5,6 +5,7 @@ import { switchMap, map, tap, filter } from 'rxjs/operators'; | ||||
| import { MempoolBlock, TransactionStripped } from '../../interfaces/websocket.interface'; | ||||
| import { Observable, BehaviorSubject } from 'rxjs'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||
| import { WebsocketService } from '../../services/websocket.service'; | ||||
| 
 | ||||
| @Component({ | ||||
| @ -54,6 +55,7 @@ export class MempoolBlockComponent implements OnInit, OnDestroy { | ||||
|                 const ordinal = this.getOrdinal(mempoolBlocks[this.mempoolBlockIndex]); | ||||
|                 this.ordinal$.next(ordinal); | ||||
|                 this.seoService.setTitle(ordinal); | ||||
|                 this.seoService.setDescription($localize`:@@meta.description.mempool-block:See stats for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} transactions in the mempool: fee range, aggregate size, and more. Mempool blocks are updated in real-time as the network receives new transactions.`); | ||||
|                 mempoolBlocks[this.mempoolBlockIndex].isStack = mempoolBlocks[this.mempoolBlockIndex].blockVSize > this.stateService.blockVSize; | ||||
|                 return mempoolBlocks[this.mempoolBlockIndex]; | ||||
|               }) | ||||
|  | ||||
| @ -31,6 +31,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | ||||
|   @Input() spotlight: number = 0; | ||||
|   @Input() getHref?: (index) => string = (index) => `/mempool-block/${index}`; | ||||
|   @Input() allBlocks: boolean = false; | ||||
|   @Input() forceRtl: boolean = false; | ||||
| 
 | ||||
|   mempoolWidth: number = 0; | ||||
|   @Output() widthChange: EventEmitter<number> = new EventEmitter(); | ||||
| @ -96,13 +97,17 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | ||||
|   ngOnInit() { | ||||
|     this.chainTip = this.stateService.latestBlockHeight; | ||||
| 
 | ||||
|     const width = this.containerOffset + (this.stateService.env.MEMPOOL_BLOCKS_AMOUNT) * this.blockOffset; | ||||
|     this.mempoolWidth = width; | ||||
|     this.widthChange.emit(this.mempoolWidth); | ||||
| 
 | ||||
|     if (['', 'testnet', 'signet'].includes(this.stateService.network)) { | ||||
|       this.enabledMiningInfoIfNeeded(this.location.path()); | ||||
|       this.location.onUrlChange((url) => this.enabledMiningInfoIfNeeded(url)); | ||||
|     } | ||||
| 
 | ||||
|     this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => { | ||||
|       this.timeLtr = !!ltr; | ||||
|       this.timeLtr = !this.forceRtl && !!ltr; | ||||
|       this.cd.markForCheck(); | ||||
|     }); | ||||
| 
 | ||||
| @ -114,11 +119,6 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | ||||
|     }); | ||||
|     this.reduceEmptyBlocksToFitScreen(this.mempoolEmptyBlocks); | ||||
| 
 | ||||
|     this.mempoolBlocks.map(() => { | ||||
|       this.updateMempoolBlockStyles(); | ||||
|       this.calculateTransactionPosition(); | ||||
|     }); | ||||
|     this.reduceMempoolBlocksToFitScreen(this.mempoolBlocks); | ||||
|     this.isTabHiddenSubscription = this.stateService.isTabHidden$.subscribe((tabHidden) => this.tabHidden = tabHidden); | ||||
|     this.loadingBlocks$ = combineLatest([ | ||||
|       this.stateService.isLoadingWebSocket$, | ||||
| @ -165,11 +165,11 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | ||||
|           return this.mempoolBlocks; | ||||
|         }), | ||||
|         tap(() => { | ||||
|           this.cd.markForCheck(); | ||||
|           const width = this.containerOffset + this.mempoolBlocks.length * this.blockOffset; | ||||
|           if (this.mempoolWidth !== width) { | ||||
|             this.mempoolWidth = width; | ||||
|             this.widthChange.emit(this.mempoolWidth); | ||||
|             this.cd.markForCheck(); | ||||
|           } | ||||
|         }) | ||||
|       ); | ||||
| @ -206,21 +206,26 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | ||||
|         if (!block) { | ||||
|           return; | ||||
|         } | ||||
| 
 | ||||
|         const isNewBlock = block.height > this.chainTip; | ||||
| 
 | ||||
|         if (this.chainTip === -1) { | ||||
|           this.animateEntry = block.height === this.stateService.latestBlockHeight; | ||||
|         } else { | ||||
|           this.animateEntry = block.height > this.chainTip; | ||||
|           this.animateEntry = isNewBlock; | ||||
|         } | ||||
| 
 | ||||
|         this.chainTip = this.stateService.latestBlockHeight; | ||||
|         if ((block?.extras?.similarity == null || block?.extras?.similarity > 0.5) && !this.tabHidden) { | ||||
|         if (isNewBlock && (block?.extras?.similarity == null || block?.extras?.similarity > 0.5) && !this.tabHidden) { | ||||
|           this.blockIndex++; | ||||
|         } | ||||
|         this.cd.markForCheck(); | ||||
|       }); | ||||
| 
 | ||||
|     this.chainTipSubscription = this.stateService.chainTip$.subscribe((height) => { | ||||
|       if (this.chainTip === -1) { | ||||
|         this.chainTip = height; | ||||
|         this.cd.markForCheck(); | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
| @ -258,6 +263,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | ||||
|       this.blockPadding = 0.24 * this.blockWidth; | ||||
|       this.containerOffset = 0.32 * this.blockWidth; | ||||
|       this.blockOffset = this.blockWidth + this.blockPadding; | ||||
|       this.cd.markForCheck(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
| @ -276,6 +282,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | ||||
|   onResize(): void { | ||||
|     this.animateEntry = false; | ||||
|     this.reduceEmptyBlocksToFitScreen(this.mempoolEmptyBlocks); | ||||
|     this.cd.markForCheck(); | ||||
|   } | ||||
| 
 | ||||
|   trackByFn(index: number, block: MempoolBlock) { | ||||
| @ -283,7 +290,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | ||||
|   } | ||||
| 
 | ||||
|   reduceEmptyBlocksToFitScreen(blocks: MempoolBlock[]): MempoolBlock[] { | ||||
|     const innerWidth = this.stateService.env.BASE_MODULE !== 'liquid' && window.innerWidth <= 767.98 ? window.innerWidth : window.innerWidth / 2; | ||||
|     const innerWidth = this.containerWidth || (this.stateService.env.BASE_MODULE !== 'liquid' && window.innerWidth <= 767.98 ? window.innerWidth : window.innerWidth / 2); | ||||
|     let blocksAmount = this.stateService.env.MEMPOOL_BLOCKS_AMOUNT; | ||||
|     if (!this.allBlocks) { | ||||
|       blocksAmount = Math.min(this.stateService.env.MEMPOOL_BLOCKS_AMOUNT, Math.floor(innerWidth / (this.blockWidth + this.blockPadding))); | ||||
| @ -306,7 +313,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | ||||
|   } | ||||
| 
 | ||||
|   reduceMempoolBlocksToFitScreen(blocks: MempoolBlock[]): MempoolBlock[] { | ||||
|     const innerWidth = this.stateService.env.BASE_MODULE !== 'liquid' && window.innerWidth <= 767.98 ? window.innerWidth : window.innerWidth / 2; | ||||
|     const innerWidth = this.containerWidth || (this.stateService.env.BASE_MODULE !== 'liquid' && window.innerWidth <= 767.98 ? window.innerWidth : window.innerWidth / 2); | ||||
|     let blocksAmount = this.stateService.env.MEMPOOL_BLOCKS_AMOUNT; | ||||
|     if (this.count) { | ||||
|       blocksAmount = 8; | ||||
| @ -316,7 +323,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | ||||
|     while (blocks.length > blocksAmount) { | ||||
|       const block = blocks.pop(); | ||||
|       if (!this.count) { | ||||
|         const lastBlock = blocks[0]; | ||||
|         const lastBlock = blocks[blocks.length - 1]; | ||||
|         lastBlock.blockSize += block.blockSize; | ||||
|         lastBlock.blockVSize += block.blockVSize; | ||||
|         lastBlock.nTx += block.nTx; | ||||
| @ -327,7 +334,7 @@ export class MempoolBlocksComponent implements OnInit, OnChanges, OnDestroy { | ||||
|       } | ||||
|     } | ||||
|     if (blocks.length) { | ||||
|       blocks[0].isStack = blocks[0].blockVSize > this.stateService.blockVSize; | ||||
|       blocks[blocks.length - 1].isStack = blocks[blocks.length - 1].blockVSize > this.stateService.blockVSize; | ||||
|     } | ||||
|     return blocks; | ||||
|   } | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| import { Component, OnInit, Input, Inject, LOCALE_ID, ChangeDetectionStrategy, OnChanges } from '@angular/core'; | ||||
| import { VbytesPipe } from '../../shared/pipes/bytes-pipe/vbytes.pipe'; | ||||
| import { WuBytesPipe } from '../../shared/pipes/bytes-pipe/wubytes.pipe'; | ||||
| import { AmountShortenerPipe } from '../../shared/pipes/amount-shortener.pipe'; | ||||
| import { formatNumber } from '@angular/common'; | ||||
| import { OptimizedMempoolStats } from '../../interfaces/node-api.interface'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| @ -26,6 +27,7 @@ export class MempoolGraphComponent implements OnInit, OnChanges { | ||||
|   @Input() data: any[]; | ||||
|   @Input() filterSize = 100000; | ||||
|   @Input() limitFilterFee = 1; | ||||
|   @Input() hideCount: boolean = false; | ||||
|   @Input() height: number | string = 200; | ||||
|   @Input() top: number | string = 20; | ||||
|   @Input() right: number | string = 10; | ||||
| @ -50,10 +52,13 @@ export class MempoolGraphComponent implements OnInit, OnChanges { | ||||
|   inverted: boolean; | ||||
|   chartInstance: any = undefined; | ||||
|   weightMode: boolean = false; | ||||
|   isWidget: boolean = false; | ||||
|   showCount: boolean = true; | ||||
| 
 | ||||
|   constructor( | ||||
|     private vbytesPipe: VbytesPipe, | ||||
|     private wubytesPipe: WuBytesPipe, | ||||
|     private amountShortenerPipe: AmountShortenerPipe, | ||||
|     private stateService: StateService, | ||||
|     private storageService: StorageService, | ||||
|     @Inject(LOCALE_ID) private locale: string, | ||||
| @ -62,12 +67,16 @@ export class MempoolGraphComponent implements OnInit, OnChanges { | ||||
|   ngOnInit(): void { | ||||
|     this.isLoading = true; | ||||
|     this.inverted = this.storageService.getValue('inverted-graph') === 'true'; | ||||
|     this.isWidget = this.template === 'widget'; | ||||
|     this.showCount = !this.isWidget && !this.hideCount; | ||||
|   } | ||||
| 
 | ||||
|   ngOnChanges() { | ||||
|   ngOnChanges(changes) { | ||||
|     if (!this.data) { | ||||
|       return; | ||||
|     } | ||||
|     this.isWidget = this.template === 'widget'; | ||||
|     this.showCount = !this.isWidget && !this.hideCount; | ||||
|     this.windowPreference = this.windowPreferenceOverride ? this.windowPreferenceOverride : this.storageService.getValue('graphWindowPreference'); | ||||
|     this.mempoolVsizeFeesData = this.handleNewMempoolData(this.data.concat([])); | ||||
|     this.mountFeeChart(); | ||||
| @ -96,10 +105,12 @@ export class MempoolGraphComponent implements OnInit, OnChanges { | ||||
|     mempoolStats.reverse(); | ||||
|     const labels = mempoolStats.map(stats => stats.added); | ||||
|     const finalArrayVByte = this.generateArray(mempoolStats); | ||||
|     const finalArrayCount = this.generateCountArray(mempoolStats); | ||||
| 
 | ||||
|     return { | ||||
|       labels: labels, | ||||
|       series: finalArrayVByte | ||||
|       series: finalArrayVByte, | ||||
|       countSeries: finalArrayCount, | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
| @ -124,9 +135,13 @@ export class MempoolGraphComponent implements OnInit, OnChanges { | ||||
|     return finalArray; | ||||
|   } | ||||
| 
 | ||||
|   generateCountArray(mempoolStats: OptimizedMempoolStats[]) { | ||||
|     return mempoolStats.filter(stats => stats.count > 0).map(stats => [stats.added * 1000, stats.count]); | ||||
|   } | ||||
| 
 | ||||
|   mountFeeChart() { | ||||
|     this.orderLevels(); | ||||
|     const { series } = this.mempoolVsizeFeesData; | ||||
|     const { series, countSeries } = this.mempoolVsizeFeesData; | ||||
| 
 | ||||
|     const seriesGraph = []; | ||||
|     const newColors = []; | ||||
| @ -178,6 +193,29 @@ export class MempoolGraphComponent implements OnInit, OnChanges { | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|     if (this.showCount) { | ||||
|       newColors.push('white'); | ||||
|       seriesGraph.push({ | ||||
|         zlevel: 1, | ||||
|         yAxisIndex: 1, | ||||
|         name: 'count', | ||||
|         type: 'line', | ||||
|         stack: 'count', | ||||
|         smooth: false, | ||||
|         markPoint: false, | ||||
|         lineStyle: { | ||||
|           width: 2, | ||||
|           opacity: 1, | ||||
|         }, | ||||
|         symbol: 'none', | ||||
|         silent: true, | ||||
|         areaStyle: { | ||||
|           color: null, | ||||
|           opacity: 0, | ||||
|         }, | ||||
|         data: countSeries, | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     this.mempoolVsizeFeesOptions = { | ||||
|       series: this.inverted ? [...seriesGraph].reverse() : seriesGraph, | ||||
| @ -201,7 +239,11 @@ export class MempoolGraphComponent implements OnInit, OnChanges { | ||||
|           label: { | ||||
|             formatter: (params: any) => { | ||||
|               if (params.axisDimension === 'y') { | ||||
|                 return this.vbytesPipe.transform(params.value, 2, 'vB', 'MvB', true) | ||||
|                 if (params.axisIndex === 0) { | ||||
|                   return this.vbytesPipe.transform(params.value, 2, 'vB', 'MvB', true); | ||||
|                 } else { | ||||
|                   return this.amountShortenerPipe.transform(params.value, 2, undefined, true); | ||||
|                 } | ||||
|               } else { | ||||
|                 return formatterXAxis(this.locale, this.windowPreference, params.value); | ||||
|               } | ||||
| @ -214,7 +256,11 @@ export class MempoolGraphComponent implements OnInit, OnChanges { | ||||
|           const itemFormatted = []; | ||||
|           let totalParcial = 0; | ||||
|           let progressPercentageText = ''; | ||||
|           const items = this.inverted ? [...params].reverse() : params; | ||||
|           let countItem; | ||||
|           let items = this.inverted ? [...params].reverse() : params; | ||||
|           if (items[items.length - 1].seriesName === 'count') { | ||||
|             countItem = items.pop(); | ||||
|           } | ||||
|           items.map((item: any, index: number) => { | ||||
|             totalParcial += item.value[1]; | ||||
|             const progressPercentage = (item.value[1] / totalValue) * 100; | ||||
| @ -276,6 +322,7 @@ export class MempoolGraphComponent implements OnInit, OnChanges { | ||||
|             </tr>`);
 | ||||
|           }); | ||||
|           const classActive = (this.template === 'advanced') ? 'fees-wrapper-tooltip-chart-advanced' : ''; | ||||
|           const titleCount = $localize`Count`; | ||||
|           const titleRange = $localize`Range`; | ||||
|           const titleSize = $localize`:@@7faaaa08f56427999f3be41df1093ce4089bbd75:Size`; | ||||
|           const titleSum = $localize`Sum`; | ||||
| @ -286,6 +333,25 @@ export class MempoolGraphComponent implements OnInit, OnChanges { | ||||
|                 ${this.vbytesPipe.transform(totalValue, 2, 'vB', 'MvB', false)} | ||||
|               </span> | ||||
|             </div> | ||||
|             ` +
 | ||||
|               (this.showCount && countItem ? ` | ||||
|                 <table class="count"> | ||||
|                   <tbody> | ||||
|                     <tr class="item"> | ||||
|                       <td class="indicator-container"> | ||||
|                         <span class="indicator" style="background-color: white"></span> | ||||
|                         <span> | ||||
|                           ${titleCount} | ||||
|                         </span> | ||||
|                       </td> | ||||
|                       <td style="text-align: right;"> | ||||
|                         <span>${this.amountShortenerPipe.transform(countItem.value[1], 2, undefined, true)}</span> | ||||
|                       </td> | ||||
|                     </tr> | ||||
|                   </tbody> | ||||
|                 </table> | ||||
|               ` : '')
 | ||||
|             + ` | ||||
|             <table> | ||||
|               <thead> | ||||
|                 <tr> | ||||
| @ -305,12 +371,12 @@ export class MempoolGraphComponent implements OnInit, OnChanges { | ||||
|           </div>`;
 | ||||
|         } | ||||
|       }, | ||||
|       dataZoom: (this.template === 'widget' && this.isMobile()) ? null : [{ | ||||
|       dataZoom: (this.isWidget && this.isMobile()) ? null : [{ | ||||
|         type: 'inside', | ||||
|         realtime: true, | ||||
|         zoomLock: (this.template === 'widget') ? true : false, | ||||
|         zoomLock: (this.isWidget) ? true : false, | ||||
|         zoomOnMouseWheel: (this.template === 'advanced') ? true : false, | ||||
|         moveOnMouseMove: (this.template === 'widget') ? true : false, | ||||
|         moveOnMouseMove: (this.isWidget) ? true : false, | ||||
|         maxSpan: 100, | ||||
|         minSpan: 10, | ||||
|       }, { | ||||
| @ -339,7 +405,7 @@ export class MempoolGraphComponent implements OnInit, OnChanges { | ||||
|       }, | ||||
|       xAxis: [ | ||||
|         { | ||||
|           name: this.template === 'widget' ? '' : formatterXAxisLabel(this.locale, this.windowPreference), | ||||
|           name: this.isWidget ? '' : formatterXAxisLabel(this.locale, this.windowPreference), | ||||
|           nameLocation: 'middle', | ||||
|           nameTextStyle: { | ||||
|             padding: [20, 0, 0, 0], | ||||
| @ -357,7 +423,7 @@ export class MempoolGraphComponent implements OnInit, OnChanges { | ||||
|           }, | ||||
|         } | ||||
|       ], | ||||
|       yAxis: { | ||||
|       yAxis: [{ | ||||
|         type: 'value', | ||||
|         axisLine: { onZero: false }, | ||||
|         axisLabel: { | ||||
| @ -371,7 +437,17 @@ export class MempoolGraphComponent implements OnInit, OnChanges { | ||||
|             opacity: 0.25, | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
|       }, this.showCount ? { | ||||
|         type: 'value', | ||||
|         position: 'right', | ||||
|         axisLine: { onZero: false }, | ||||
|         axisLabel: { | ||||
|           formatter: (value: number) => (`${this.amountShortenerPipe.transform(value, 2, undefined, true)}`), | ||||
|         }, | ||||
|         splitLine: { | ||||
|           show: false, | ||||
|         } | ||||
|       } : null], | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										31
									
								
								frontend/src/app/components/menu/menu.component.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								frontend/src/app/components/menu/menu.component.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,31 @@ | ||||
| <div class="sidenav menu-click" [class]="navOpen ? 'open': ''"> | ||||
|   <div class="d-flex menu-click"> | ||||
| 
 | ||||
|     <nav class="scrollable menu-click"> | ||||
|       <span *ngIf="userAuth" class="menu-click"> | ||||
|         <strong class="menu-click">@ {{ userAuth.user.username }}</strong> | ||||
|       </span> | ||||
|       <a *ngIf="!userAuth" class="d-flex justify-content-center align-items-center nav-link m-0  menu-click" routerLink="/login" role="tab" (click)="onLinkClick('/login')"> | ||||
|         <fa-icon class="menu-click" [icon]="['fas', 'user-circle']" [fixedWidth]="true" style="font-size: 25px;margin-right: 15px;"></fa-icon> | ||||
|         <span class="menu-click" style="font-size: 20px;">Sign in</span> | ||||
|       </a> | ||||
| 
 | ||||
|       <ng-container *ngIf="userMenuGroups$ | async as menuGroups"> | ||||
|         <div class="menu-click" *ngFor="let group of menuGroups" style="height: max-content;"> | ||||
|           <h6 class="d-flex justify-content-between align-items-center mt-4 mb-2 text-uppercase menu-click"> | ||||
|             <span class="menu-click">{{ group.title }}</span> | ||||
|           </h6> | ||||
|           <ul class="nav flex-column menu-click" *ngFor="let item of group.items" (click)="onLinkClick(item.link)"> | ||||
|             <li class="nav-item d-flex justify-content-start align-items-center menu-click"> | ||||
|               <fa-icon class="menu-click" [icon]="['fas', item.faIcon]" [fixedWidth]="true"></fa-icon> | ||||
|               <button *ngIf="item.link === 'logout'" class="btn nav-link menu-click" role="tab" (click)="logout()">{{ item.title }}</button> | ||||
|               <a *ngIf="item.title !== 'Logout'" class="nav-link menu-click" [routerLink]="[item.link]" role="tab">{{ item.title }}</a> | ||||
|             </li> | ||||
|           </ul> | ||||
|         </div> | ||||
|       </ng-container> | ||||
|     </nav> | ||||
| 
 | ||||
|   </div> | ||||
| 
 | ||||
| </div> | ||||
							
								
								
									
										48
									
								
								frontend/src/app/components/menu/menu.component.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								frontend/src/app/components/menu/menu.component.scss
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,48 @@ | ||||
| .sidenav { | ||||
|   z-index: 1; | ||||
|   background-color: transparent; | ||||
|   width: 225px; | ||||
|   height: calc(100vh - 65px); | ||||
|   position: sticky; | ||||
|   top: 65px; | ||||
|   transition: 0.25s; | ||||
|   margin-left: -250px; | ||||
|   box-shadow: 5px 0px 30px 0px #000; | ||||
|   padding-bottom: 20px; | ||||
| } | ||||
| 
 | ||||
| .scrollable { | ||||
|   overflow-x: hidden; | ||||
|   overflow-y: scroll; | ||||
| } | ||||
| 
 | ||||
| .sidenav.open { | ||||
|   margin-left: 0px; | ||||
|   left: 0px; | ||||
|   display: block; | ||||
| } | ||||
| 
 | ||||
| .sidenav a, button{ | ||||
|   text-decoration: none; | ||||
|   color: lightgray; | ||||
|   margin-left: 20px; | ||||
| } | ||||
| .sidenav a:hover { | ||||
|   color: white; | ||||
| } | ||||
| .sidenav nav { | ||||
|   width: 100%; | ||||
|   height: calc(100vh - 65px); | ||||
|   background-color: #1d1f31; | ||||
|   padding-left: 20px; | ||||
|   padding-right: 20px; | ||||
|   padding-top: 20px; | ||||
|   padding-bottom: 20px; | ||||
|   @media (max-width: 991px) { | ||||
|     padding-bottom: 200px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @media screen and (max-height: 450px) { | ||||
|   .sidenav a {font-size: 18px;} | ||||
| } | ||||
							
								
								
									
										101
									
								
								frontend/src/app/components/menu/menu.component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								frontend/src/app/components/menu/menu.component.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,101 @@ | ||||
| import { Component, OnInit, Input, Output, EventEmitter, HostListener, OnDestroy } from '@angular/core'; | ||||
| import { Observable } from 'rxjs'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { MenuGroup } from '../../interfaces/services.interface'; | ||||
| import { StorageService } from '../../services/storage.service'; | ||||
| import { Router, NavigationStart } from '@angular/router'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-menu', | ||||
|   templateUrl: './menu.component.html', | ||||
|   styleUrls: ['./menu.component.scss'] | ||||
| }) | ||||
| 
 | ||||
| export class MenuComponent implements OnInit, OnDestroy { | ||||
|   @Input() navOpen: boolean = false; | ||||
|   @Output() loggedOut = new EventEmitter<boolean>(); | ||||
|   @Output() menuToggled = new EventEmitter<boolean>(); | ||||
|    | ||||
|   userMenuGroups$: Observable<MenuGroup[]> | undefined; | ||||
|   userAuth: any | undefined; | ||||
|   isServicesPage = false; | ||||
| 
 | ||||
|   constructor( | ||||
|     private apiService: ApiService, | ||||
|     private storageService: StorageService, | ||||
|     private router: Router, | ||||
|     private stateService: StateService | ||||
|   ) {} | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.userAuth = this.storageService.getAuth(); | ||||
|      | ||||
|     if (this.stateService.env.GIT_COMMIT_HASH_MEMPOOL_SPACE) { | ||||
|       this.userMenuGroups$ = this.apiService.getUserMenuGroups$(); | ||||
|     } | ||||
| 
 | ||||
|     this.isServicesPage = this.router.url.includes('/services/'); | ||||
|     this.router.events.subscribe((event) => { | ||||
|       if (event instanceof NavigationStart) { | ||||
|         if (!this.isServicesPage) { | ||||
|           this.toggleMenu(false); | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   toggleMenu(toggled: boolean) { | ||||
|     this.navOpen = toggled; | ||||
|     this.menuToggled.emit(toggled); | ||||
|   } | ||||
| 
 | ||||
|   isSmallScreen() { | ||||
|     return window.innerWidth <= 767.98; | ||||
|   } | ||||
| 
 | ||||
|   logout(): void { | ||||
|     this.apiService.logout$().subscribe(() => { | ||||
|       this.loggedOut.emit(true); | ||||
|       if (this.stateService.env.GIT_COMMIT_HASH_MEMPOOL_SPACE) { | ||||
|         this.userMenuGroups$ = this.apiService.getUserMenuGroups$(); | ||||
|         this.router.navigateByUrl('/'); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   onLinkClick(link) { | ||||
|     if (!this.isServicesPage || this.isSmallScreen()) { | ||||
|       this.toggleMenu(false); | ||||
|     } | ||||
|     this.router.navigateByUrl(link); | ||||
|   } | ||||
| 
 | ||||
|   hamburgerClick() { | ||||
|     this.toggleMenu(!this.navOpen); | ||||
|     this.stateService.menuOpen$.next(this.navOpen); | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('window:click', ['$event']) | ||||
|   onClick(event) { | ||||
|     const isServicesPageOnMobile = this.isServicesPage && this.isSmallScreen(); | ||||
|     const cssClasses = event.target.className; | ||||
| 
 | ||||
|     if (!cssClasses.indexOf) { // Click on chart or non html thingy, close the menu
 | ||||
|       if (!this.isServicesPage || isServicesPageOnMobile) { | ||||
|         this.toggleMenu(false); | ||||
|       } | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     const isHamburger = cssClasses.indexOf('profile_image') !== -1; | ||||
|     const isMenu = cssClasses.indexOf('menu-click') !== -1; | ||||
|     if (!isHamburger && !isMenu && (!this.isServicesPage || isServicesPageOnMobile)) { | ||||
|       this.toggleMenu(false); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   ngOnDestroy(): void { | ||||
|     this.stateService.menuOpen$.next(false); | ||||
|   } | ||||
| } | ||||
| @ -18,6 +18,7 @@ export class MiningDashboardComponent implements OnInit, AfterViewInit { | ||||
|     private router: Router | ||||
|   ) { | ||||
|     this.seoService.setTitle($localize`:@@a681a4e2011bb28157689dbaa387de0dd0aa0c11:Mining Dashboard`); | ||||
|     this.seoService.setDescription($localize`:@@meta.description.mining.dashboard:Get real-time Bitcoin mining stats like hashrate, difficulty adjustment, block rewards, pool dominance, and more.`); | ||||
|   } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
| @ -29,7 +30,7 @@ export class MiningDashboardComponent implements OnInit, AfterViewInit { | ||||
|     this.router.events.subscribe((e: NavigationStart) => { | ||||
|       if (e.type === EventType.NavigationStart) { | ||||
|         if (e.url.indexOf('graphs') === -1) { // The mining dashboard and the graph component are part of the same module so we can't use ngAfterViewInit in graphs.component.ts to blur the input
 | ||||
|           this.stateService.focusSearchInputDesktop();  | ||||
|           this.stateService.focusSearchInputDesktop(); | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
|  | ||||
| @ -56,6 +56,7 @@ export class PoolRankingComponent implements OnInit { | ||||
|       this.miningWindowPreference = '1w'; | ||||
|     } else { | ||||
|       this.seoService.setTitle($localize`:@@mining.mining-pools:Mining Pools`); | ||||
|       this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.pool-ranking:See the top Bitcoin mining pools ranked by number of blocks mined, over your desired timeframe.`); | ||||
|       this.miningWindowPreference = this.miningService.getDefaultTimespan('24h'); | ||||
|     } | ||||
|     this.radioGroupForm = this.formBuilder.group({ dateSpan: this.miningWindowPreference }); | ||||
| @ -116,7 +117,7 @@ export class PoolRankingComponent implements OnInit { | ||||
|     } else if (this.widget) { | ||||
|       poolShareThreshold = 1; | ||||
|     } | ||||
|      | ||||
| 
 | ||||
|     const data: object[] = []; | ||||
|     let totalShareOther = 0; | ||||
|     let totalBlockOther = 0; | ||||
|  | ||||
| @ -83,6 +83,7 @@ export class PoolPreviewComponent implements OnInit { | ||||
|           } | ||||
| 
 | ||||
|           this.seoService.setTitle(poolStats.pool.name); | ||||
|           this.seoService.setDescription($localize`:@@meta.description.mining.pool:See mining pool stats for ${poolStats.pool.name}\: most recent mined blocks, hashrate over time, total block reward to date, known coinbase addresses, and more.`); | ||||
|           let regexes = '"'; | ||||
|           for (const regex of poolStats.pool.regexes) { | ||||
|             regexes += regex + '", "'; | ||||
|  | ||||
| @ -83,6 +83,7 @@ export class PoolComponent implements OnInit { | ||||
|         }), | ||||
|         map((poolStats) => { | ||||
|           this.seoService.setTitle(poolStats.pool.name); | ||||
|           this.seoService.setDescription($localize`:@@meta.description.mining.pool:See mining pool stats for ${poolStats.pool.name}\: most recent mined blocks, hashrate over time, total block reward to date, known coinbase addresses, and more.`); | ||||
|           let regexes = '"'; | ||||
|           for (const regex of poolStats.pool.regexes) { | ||||
|             regexes += regex + '", "'; | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| import { Component } from '@angular/core'; | ||||
| import { Env, StateService } from '../../services/state.service'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-privacy-policy', | ||||
| @ -11,5 +12,11 @@ export class PrivacyPolicyComponent { | ||||
| 
 | ||||
|   constructor( | ||||
|     private stateService: StateService, | ||||
|     private seoService: SeoService, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.seoService.setTitle('Privacy Policy'); | ||||
|     this.seoService.setDescription('Trusted third parties are security holes, as are trusted first parties...you should only trust your own self-hosted instance of The Mempool Open Source Project®.'); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -1,6 +1,9 @@ | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-push-transaction', | ||||
| @ -16,12 +19,17 @@ export class PushTransactionComponent implements OnInit { | ||||
|   constructor( | ||||
|     private formBuilder: UntypedFormBuilder, | ||||
|     private apiService: ApiService, | ||||
|     public stateService: StateService, | ||||
|     private seoService: SeoService, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.pushTxForm = this.formBuilder.group({ | ||||
|       txHash: ['', Validators.required], | ||||
|     }); | ||||
| 
 | ||||
|     this.seoService.setTitle($localize`:@@meta.title.push-tx:Broadcast Transaction`); | ||||
|     this.seoService.setDescription($localize`:@@meta.description.push-tx:Broadcast a transaction to the ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} network using the transaction's hash.`); | ||||
|   } | ||||
| 
 | ||||
|   postTx() { | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| <div [formGroup]="rateUnitForm" class="text-small text-center"> | ||||
|     <select formControlName="rateUnits" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 100px;" (change)="changeUnits()"> | ||||
|     <select formControlName="rateUnits" class="custom-select custom-select-sm form-control-secondary form-control mx-auto" style="width: 95px;" (change)="changeUnits()"> | ||||
|         <option *ngFor="let unit of units" [value]="unit.name">{{ unit.label }}</option> | ||||
|     </select> | ||||
| </div> | ||||
|  | ||||
| @ -6,6 +6,8 @@ import { WebsocketService } from '../../services/websocket.service'; | ||||
| import { RbfTree } from '../../interfaces/node-api.interface'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { StateService } from '../../services/state.service'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-rbf-list', | ||||
| @ -26,6 +28,7 @@ export class RbfList implements OnInit, OnDestroy { | ||||
|     private apiService: ApiService, | ||||
|     public stateService: StateService, | ||||
|     private websocketService: WebsocketService, | ||||
|     private seoService: SeoService, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
| @ -51,9 +54,12 @@ export class RbfList implements OnInit, OnDestroy { | ||||
|         this.isLoading = false; | ||||
|       }) | ||||
|     ); | ||||
| 
 | ||||
|     this.seoService.setTitle($localize`:@@meta.title.rbf-list:RBF Replacements`); | ||||
|     this.seoService.setDescription($localize`:@@meta.description.rbf-list:See the most recent RBF replacements on the Bitcoin${seoDescriptionNetwork(this.stateService.network)} network, updated in real-time.`); | ||||
|   } | ||||
| 
 | ||||
|   ngOnDestroy(): void { | ||||
|     this.websocketService.stopTrackRbf(); | ||||
|   } | ||||
| } | ||||
| } | ||||
|  | ||||
| @ -10,15 +10,26 @@ | ||||
| 
 | ||||
| <div *ngIf="countdown > 0" class="warning-label">{{ eventName }} in {{ countdown | number }} block{{ countdown === 1 ? '' : 's' }}!</div> | ||||
| 
 | ||||
| <div class="blockchain-wrapper" [class.time-ltr]="timeLtr" [class.time-rtl]="!timeLtr"> | ||||
| <div class="blockchain-wrapper" [class.time-ltr]="timeLtr" [class.time-rtl]="!timeLtr" #blockchainWrapper> | ||||
|   <div id="blockchain-container" [dir]="timeLtr ? 'rtl' : 'ltr'" #blockchainContainer | ||||
|     [class.menu-open]="menuOpen" | ||||
|     [class.menu-closing]="menuSliding && !menuOpen" | ||||
|     [class.with-menu]="hasMenu" | ||||
|     (mousedown)="onMouseDown($event)" | ||||
|     (pointerdown)="onPointerDown($event)" | ||||
|     (touchmove)="onTouchMove($event)" | ||||
|     (dragstart)="onDragStart($event)" | ||||
|     (scroll)="onScroll($event)" | ||||
|   > | ||||
|     <app-blockchain [pageIndex]="pageIndex" [pages]="pages" [blocksPerPage]="blocksPerPage" [minScrollWidth]="minScrollWidth" [scrollableMempool]="true" (mempoolOffsetChange)="onMempoolOffsetChange($event)"></app-blockchain> | ||||
|     <app-blockchain | ||||
|       [containerWidth]="chainWidth" | ||||
|       [pageIndex]="pageIndex" | ||||
|       [pages]="pages" | ||||
|       [blocksPerPage]="blocksPerPage" | ||||
|       [minScrollWidth]="minScrollWidth" | ||||
|       [scrollableMempool]="true" | ||||
|       (mempoolOffsetChange)="onMempoolOffsetChange($event)" | ||||
|     ></app-blockchain> | ||||
|   </div> | ||||
|   <div class="reset-scroll" [class.hidden]="pageIndex === 0" (click)="resetScroll()"> | ||||
|     <fa-icon [icon]="['fas', 'circle-left']" [fixedWidth]="true"></fa-icon> | ||||
|  | ||||
| @ -6,6 +6,24 @@ | ||||
|   overflow-y: hidden; | ||||
|   scrollbar-width: none; | ||||
|   -ms-overflow-style: none; | ||||
|   width: 100%; | ||||
| 
 | ||||
|   transform: translateX(0px); | ||||
|   transition: transform 0; | ||||
| 
 | ||||
|   &.menu-open { | ||||
|     transform: translateX(-112.5px); | ||||
|     transition: transform 0.25s; | ||||
|   } | ||||
| 
 | ||||
|   &.menu-closing { | ||||
|     transform: translateX(0px); | ||||
|     transition: transform 0.25s; | ||||
|   } | ||||
| 
 | ||||
|   &.with-menu { | ||||
|     width: calc(100% + 120px); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| #blockchain-container::-webkit-scrollbar { | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| import { Component, ElementRef, HostListener, OnInit, OnDestroy, ViewChild, Input, DoCheck } from '@angular/core'; | ||||
| import { Component, ElementRef, HostListener, OnInit, OnDestroy, ViewChild, Input, ChangeDetectorRef, ChangeDetectionStrategy, AfterViewChecked } from '@angular/core'; | ||||
| import { Subscription } from 'rxjs'; | ||||
| import { MarkBlockState, StateService } from '../../services/state.service'; | ||||
| import { specialBlocks } from '../../app.constants'; | ||||
| @ -8,8 +8,9 @@ import { BlockExtended } from '../../interfaces/node-api.interface'; | ||||
|   selector: 'app-start', | ||||
|   templateUrl: './start.component.html', | ||||
|   styleUrls: ['./start.component.scss'], | ||||
|   changeDetection: ChangeDetectionStrategy.OnPush | ||||
| }) | ||||
| export class StartComponent implements OnInit, OnDestroy, DoCheck { | ||||
| export class StartComponent implements OnInit, AfterViewChecked, OnDestroy { | ||||
|   @Input() showLoadingIndicator = false; | ||||
| 
 | ||||
|   interval = 60; | ||||
| @ -23,13 +24,15 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | ||||
|   timeLtrSubscription: Subscription; | ||||
|   timeLtr: boolean = this.stateService.timeLtr.value; | ||||
|   chainTipSubscription: Subscription; | ||||
|   chainTip: number = -1; | ||||
|   chainTip: number = 100; | ||||
|   tipIsSet: boolean = false; | ||||
|   lastMark: MarkBlockState; | ||||
|   markBlockSubscription: Subscription; | ||||
|   blockCounterSubscription: Subscription; | ||||
|   @ViewChild('blockchainWrapper', { static: true }) blockchainWrapper: ElementRef; | ||||
|   @ViewChild('blockchainContainer') blockchainContainer: ElementRef; | ||||
|   resetScrollSubscription: Subscription;  | ||||
|   resetScrollSubscription: Subscription; | ||||
|   menuSubscription: Subscription; | ||||
| 
 | ||||
|   isMobile: boolean = false; | ||||
|   isiOS: boolean = false; | ||||
| @ -39,7 +42,8 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | ||||
|   blocksPerPage: number = 1; | ||||
|   pageWidth: number; | ||||
|   firstPageWidth: number; | ||||
|   minScrollWidth: number; | ||||
|   minScrollWidth: number = 40 + (155 * (8 + (2 * Math.ceil(window.innerWidth / 155)))); | ||||
|   currentScrollWidth: number = null; | ||||
|   pageIndex: number = 0; | ||||
|   pages: any[] = []; | ||||
|   pendingMark: number | null = null; | ||||
| @ -47,19 +51,24 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | ||||
|   lastUpdate: number = 0; | ||||
|   lastMouseX: number; | ||||
|   velocity: number = 0; | ||||
|   mempoolOffset: number = 0; | ||||
|   mempoolOffset: number = null; | ||||
|   mempoolWidth: number = 0; | ||||
|   scrollLeft: number = null; | ||||
| 
 | ||||
|   chainWidth: number = window.innerWidth; | ||||
|   menuOpen: boolean = false; | ||||
|   menuSliding: boolean = false; | ||||
|   menuTimeout: number; | ||||
| 
 | ||||
|   hasMenu = false; | ||||
| 
 | ||||
|   constructor( | ||||
|     private stateService: StateService, | ||||
|     private cd: ChangeDetectorRef, | ||||
|   ) { | ||||
|     this.isiOS = ['iPhone','iPod','iPad'].includes((navigator as any)?.userAgentData?.platform || navigator.platform); | ||||
|   } | ||||
| 
 | ||||
|   ngDoCheck(): void { | ||||
|     if (this.pendingOffset != null) { | ||||
|       const offset = this.pendingOffset; | ||||
|       this.pendingOffset = null; | ||||
|       this.addConvertedScrollOffset(offset); | ||||
|     if (this.stateService.network === '') { | ||||
|       this.hasMenu = true; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
| @ -69,6 +78,7 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | ||||
|       this.blockCount = blocks.length; | ||||
|       this.dynamicBlocksAmount = Math.min(this.blockCount, this.stateService.env.KEEP_BLOCKS_AMOUNT, 8); | ||||
|       this.firstPageWidth = 40 + (this.blockWidth * this.dynamicBlocksAmount); | ||||
|       this.minScrollWidth = 40 + (8 * this.blockWidth) + (this.pageWidth * 2); | ||||
|       if (this.blockCount <= Math.min(8, this.stateService.env.KEEP_BLOCKS_AMOUNT)) { | ||||
|         this.onResize(); | ||||
|       } | ||||
| @ -114,7 +124,7 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | ||||
|             this.scrollToBlock(scrollToHeight); | ||||
|           } | ||||
|         } | ||||
|         if (!this.tipIsSet || (blockHeight < 0 && !this.mempoolOffset)) { | ||||
|         if (!this.tipIsSet || (blockHeight < 0 && this.mempoolOffset == null)) { | ||||
|           this.pendingMark = blockHeight; | ||||
|         } | ||||
|       } | ||||
| @ -151,17 +161,56 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | ||||
|         this.stateService.resetScroll$.next(false); | ||||
|       }  | ||||
|     }); | ||||
| 
 | ||||
|     this.menuSubscription = this.stateService.menuOpen$.subscribe((open) => { | ||||
|       if (this.menuOpen !== open) { | ||||
|         this.menuOpen = open; | ||||
|         this.applyMenuScroll(this.menuOpen); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   ngAfterViewChecked(): void { | ||||
|     if (this.currentScrollWidth !== this.blockchainContainer?.nativeElement?.scrollWidth) { | ||||
|       this.currentScrollWidth = this.blockchainContainer?.nativeElement?.scrollWidth; | ||||
|       if (this.pendingOffset != null) { | ||||
|         const delta = this.pendingOffset - (this.mempoolOffset || 0); | ||||
|         this.mempoolOffset = this.pendingOffset; | ||||
|         this.currentScrollWidth = this.blockchainContainer?.nativeElement?.scrollWidth; | ||||
|         this.pendingOffset = null; | ||||
|         this.addConvertedScrollOffset(delta); | ||||
|         this.applyPendingMarkArrow(); | ||||
|       } else { | ||||
|         this.applyScrollLeft(); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onMempoolOffsetChange(offset): void { | ||||
|     const delta = offset - this.mempoolOffset; | ||||
|     this.addConvertedScrollOffset(delta); | ||||
|     this.mempoolOffset = offset; | ||||
|     this.applyPendingMarkArrow(); | ||||
|     if (offset !== this.mempoolOffset) { | ||||
|       this.pendingOffset = offset; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   applyScrollLeft(): void { | ||||
|     if (this.blockchainContainer?.nativeElement?.scrollWidth) { | ||||
|       let lastScrollLeft = null; | ||||
|       while (this.scrollLeft < 0 && this.shiftPagesForward() && lastScrollLeft !== this.scrollLeft) { | ||||
|         lastScrollLeft = this.scrollLeft; | ||||
|         this.scrollLeft += this.pageWidth; | ||||
|       } | ||||
|       lastScrollLeft = null; | ||||
|       while (this.scrollLeft > this.blockchainContainer.nativeElement.scrollWidth && this.shiftPagesBack() && lastScrollLeft !== this.scrollLeft) { | ||||
|         lastScrollLeft = this.scrollLeft; | ||||
|         this.scrollLeft -= this.pageWidth; | ||||
|       } | ||||
|       this.blockchainContainer.nativeElement.scrollLeft = this.scrollLeft; | ||||
|     } | ||||
|     this.cd.detectChanges(); | ||||
|   } | ||||
| 
 | ||||
|   applyPendingMarkArrow(): void { | ||||
|     if (this.pendingMark != null) { | ||||
|     if (this.pendingMark != null && this.pendingMark <= this.chainTip) { | ||||
|       if (this.pendingMark < 0) { | ||||
|         this.scrollToBlock(this.chainTip - this.pendingMark); | ||||
|       } else { | ||||
| @ -171,39 +220,48 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   applyMenuScroll(opening: boolean): void { | ||||
|     this.menuSliding = true; | ||||
|     window.clearTimeout(this.menuTimeout); | ||||
|     this.menuTimeout = window.setTimeout(() => { | ||||
|       this.menuSliding = false; | ||||
|       this.cd.markForCheck(); | ||||
|     }, 300); | ||||
|   } | ||||
| 
 | ||||
|   @HostListener('window:resize', ['$event']) | ||||
|   onResize(): void { | ||||
|     this.isMobile = window.innerWidth <= 767.98; | ||||
|     this.chainWidth = window.innerWidth; | ||||
|     this.isMobile = this.chainWidth <= 767.98; | ||||
|     let firstVisibleBlock; | ||||
|     let offset; | ||||
|     if (this.blockchainContainer?.nativeElement != null) { | ||||
|       this.pages.forEach(page => { | ||||
|         const left = page.offset - this.getConvertedScrollOffset(); | ||||
|         const right = left + this.pageWidth; | ||||
|         if (left <= 0 && right > 0) { | ||||
|           const blockIndex = Math.max(0, Math.floor(left / -this.blockWidth)); | ||||
|           firstVisibleBlock = page.height - blockIndex; | ||||
|           offset = left + (blockIndex * this.blockWidth); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|     this.pages.forEach(page => { | ||||
|       const left = page.offset - this.getConvertedScrollOffset(this.scrollLeft); | ||||
|       const right = left + this.pageWidth; | ||||
|       if (left <= 0 && right > 0) { | ||||
|         const blockIndex = Math.max(0, Math.floor(left / -this.blockWidth)); | ||||
|         firstVisibleBlock = page.height - blockIndex; | ||||
|         offset = left + (blockIndex * this.blockWidth); | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     this.blocksPerPage = Math.ceil(window.innerWidth / this.blockWidth); | ||||
|     this.blocksPerPage = Math.ceil(this.chainWidth / this.blockWidth); | ||||
|     this.pageWidth = this.blocksPerPage * this.blockWidth; | ||||
|     this.minScrollWidth = this.firstPageWidth + (this.pageWidth * 2); | ||||
|     this.minScrollWidth = 40 + (8 * this.blockWidth) + (this.pageWidth * 2); | ||||
| 
 | ||||
|     if (firstVisibleBlock != null) { | ||||
|       this.scrollToBlock(firstVisibleBlock, offset + (this.isMobile ? this.blockWidth : 0)); | ||||
|       this.scrollToBlock(firstVisibleBlock, offset); | ||||
|     } else { | ||||
|       this.updatePages(); | ||||
|     } | ||||
|     this.cd.markForCheck(); | ||||
|   } | ||||
| 
 | ||||
|   onMouseDown(event: MouseEvent) { | ||||
|     if (!(event.which > 1 || event.button > 0)) { | ||||
|       this.mouseDragStartX = event.clientX; | ||||
|       this.resetMomentum(event.clientX); | ||||
|       this.blockchainScrollLeftInit = this.blockchainContainer.nativeElement.scrollLeft; | ||||
|       this.blockchainScrollLeftInit = this.scrollLeft; | ||||
|     } | ||||
|   } | ||||
|   onPointerDown(event: PointerEvent) { | ||||
| @ -229,8 +287,8 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | ||||
|     if (this.mouseDragStartX != null) { | ||||
|       this.updateVelocity(event.clientX); | ||||
|       this.stateService.setBlockScrollingInProgress(true); | ||||
|       this.blockchainContainer.nativeElement.scrollLeft = | ||||
|         this.blockchainScrollLeftInit + this.mouseDragStartX - event.clientX; | ||||
|       this.scrollLeft = this.blockchainScrollLeftInit + this.mouseDragStartX - event.clientX; | ||||
|       this.applyScrollLeft(); | ||||
|     } | ||||
|   } | ||||
|   @HostListener('document:mouseup', []) | ||||
| @ -286,25 +344,31 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | ||||
|         } else { | ||||
|           this.velocity += dv; | ||||
|         } | ||||
|         this.blockchainContainer.nativeElement.scrollLeft -= displacement; | ||||
|         this.scrollLeft -= displacement; | ||||
|         this.applyScrollLeft(); | ||||
|         this.animateMomentum(); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   onScroll(e) { | ||||
|     if (this.blockchainContainer?.nativeElement?.scrollLeft == null) { | ||||
|       return; | ||||
|     } | ||||
|     this.scrollLeft = this.blockchainContainer?.nativeElement?.scrollLeft; | ||||
|     const middlePage = this.pageIndex === 0 ? this.pages[0] : this.pages[1]; | ||||
|     // compensate for css transform
 | ||||
|     const translation = (this.isMobile ? window.innerWidth * 0.95 : window.innerWidth * 0.5); | ||||
|     const translation = (this.isMobile ? this.chainWidth * 0.95 : this.chainWidth * 0.5); | ||||
|     const backThreshold = middlePage.offset + (this.pageWidth * 0.5) + translation; | ||||
|     const forwardThreshold = middlePage.offset - (this.pageWidth * 0.5) + translation; | ||||
|     const scrollLeft = this.getConvertedScrollOffset(); | ||||
|     if (scrollLeft > backThreshold) { | ||||
|     this.scrollLeft = this.blockchainContainer.nativeElement.scrollLeft; | ||||
|     const offsetScroll = this.getConvertedScrollOffset(this.scrollLeft); | ||||
|     if (offsetScroll > backThreshold) { | ||||
|       if (this.shiftPagesBack()) { | ||||
|         this.addConvertedScrollOffset(-this.pageWidth); | ||||
|         this.blockchainScrollLeftInit -= this.pageWidth; | ||||
|       } | ||||
|     } else if (scrollLeft < forwardThreshold) { | ||||
|     } else if (offsetScroll < forwardThreshold) { | ||||
|       if (this.shiftPagesForward()) { | ||||
|         this.addConvertedScrollOffset(this.pageWidth); | ||||
|         this.blockchainScrollLeftInit += this.pageWidth; | ||||
| @ -313,10 +377,6 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | ||||
|   } | ||||
| 
 | ||||
|   scrollToBlock(height, blockOffset = 0) { | ||||
|     if (!this.blockchainContainer?.nativeElement) { | ||||
|       setTimeout(() => { this.scrollToBlock(height, blockOffset); }, 50); | ||||
|       return; | ||||
|     } | ||||
|     if (this.isMobile) { | ||||
|       blockOffset -= this.blockWidth; | ||||
|     } | ||||
| @ -324,15 +384,15 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | ||||
|     const pages = []; | ||||
|     this.pageIndex = Math.max(viewingPageIndex - 1, 0); | ||||
|     let viewingPage = this.getPageAt(viewingPageIndex); | ||||
|     const isLastPage = viewingPage.height < this.blocksPerPage; | ||||
|     const isLastPage = viewingPage.height <= 0; | ||||
|     if (isLastPage) { | ||||
|       this.pageIndex = Math.max(viewingPageIndex - 2, 0); | ||||
|       viewingPage = this.getPageAt(viewingPageIndex); | ||||
|     } | ||||
|     const left = viewingPage.offset - this.getConvertedScrollOffset(); | ||||
|     const left = viewingPage.offset - this.getConvertedScrollOffset(this.scrollLeft); | ||||
|     const blockIndex = viewingPage.height - height; | ||||
|     const targetOffset = (this.blockWidth * blockIndex) + left; | ||||
|     let deltaOffset = targetOffset - blockOffset; | ||||
|     const deltaOffset = targetOffset - blockOffset; | ||||
| 
 | ||||
|     if (isLastPage) { | ||||
|       pages.push(this.getPageAt(viewingPageIndex - 2)); | ||||
| @ -362,6 +422,7 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | ||||
|     pages.push(this.getPageAt(this.pageIndex + 1)); | ||||
|     pages.push(this.getPageAt(this.pageIndex + 2)); | ||||
|     this.pages = pages; | ||||
|     this.cd.markForCheck(); | ||||
|   } | ||||
| 
 | ||||
|   shiftPagesBack(): boolean { | ||||
| @ -414,49 +475,46 @@ export class StartComponent implements OnInit, OnDestroy, DoCheck { | ||||
| 
 | ||||
|   blockInViewport(height: number): boolean { | ||||
|     const firstHeight = this.pages[0].height; | ||||
|     const translation = (this.isMobile ? window.innerWidth * 0.95 : window.innerWidth * 0.5); | ||||
|     const firstX = this.pages[0].offset - this.getConvertedScrollOffset() + translation; | ||||
|     const translation = (this.isMobile ? this.chainWidth * 0.95 : this.chainWidth * 0.5); | ||||
|     const firstX = this.pages[0].offset - this.getConvertedScrollOffset(this.scrollLeft) + translation; | ||||
|     const xPos = firstX + ((firstHeight - height) * 155); | ||||
|     return xPos > -55 && xPos < (window.innerWidth - 100); | ||||
|     return xPos > -55 && xPos < (this.chainWidth - 100); | ||||
|   } | ||||
| 
 | ||||
|   getConvertedScrollOffset(): number { | ||||
|   getConvertedScrollOffset(scrollLeft): number { | ||||
|     if (this.timeLtr) { | ||||
|       return -(this.blockchainContainer?.nativeElement?.scrollLeft || 0) - this.mempoolOffset; | ||||
|       return -(scrollLeft || 0) - (this.mempoolOffset || 0); | ||||
|     } else { | ||||
|       return (this.blockchainContainer?.nativeElement?.scrollLeft || 0) - this.mempoolOffset; | ||||
|       return (scrollLeft || 0) - (this.mempoolOffset || 0); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   setScrollLeft(offset: number): void { | ||||
|     if (this.timeLtr) { | ||||
|       this.blockchainContainer.nativeElement.scrollLeft = offset - this.mempoolOffset; | ||||
|       this.scrollLeft = offset - (this.mempoolOffset || 0); | ||||
|     } else { | ||||
|       this.blockchainContainer.nativeElement.scrollLeft = offset + this.mempoolOffset; | ||||
|       this.scrollLeft = offset + (this.mempoolOffset || 0); | ||||
|     } | ||||
|     this.applyScrollLeft(); | ||||
|   } | ||||
| 
 | ||||
|   addConvertedScrollOffset(offset: number): void { | ||||
|     if (!this.blockchainContainer?.nativeElement) { | ||||
|       this.pendingOffset = offset; | ||||
|       return; | ||||
|     } | ||||
|     if (this.timeLtr) { | ||||
|       this.blockchainContainer.nativeElement.scrollLeft -= offset; | ||||
|       this.scrollLeft -= offset; | ||||
|     } else { | ||||
|       this.blockchainContainer.nativeElement.scrollLeft += offset; | ||||
|       this.scrollLeft += offset; | ||||
|     } | ||||
|     this.applyScrollLeft(); | ||||
|   } | ||||
| 
 | ||||
|   ngOnDestroy() { | ||||
|     if (this.blockchainContainer?.nativeElement) { | ||||
|       // clean up scroll position to prevent caching wrong scroll in Firefox
 | ||||
|       this.setScrollLeft(0); | ||||
|     } | ||||
|     // clean up scroll position to prevent caching wrong scroll in Firefox
 | ||||
|     this.setScrollLeft(0); | ||||
|     this.timeLtrSubscription.unsubscribe(); | ||||
|     this.chainTipSubscription.unsubscribe(); | ||||
|     this.markBlockSubscription.unsubscribe(); | ||||
|     this.blockCounterSubscription.unsubscribe(); | ||||
|     this.resetScrollSubscription.unsubscribe(); | ||||
|     this.menuSubscription.unsubscribe(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -69,6 +69,12 @@ | ||||
|                 </button> | ||||
|                 <div class="dropdown-fees" ngbDropdownMenu aria-labelledby="dropdownFees"> | ||||
|                   <ul> | ||||
|                     <li (click)="this.showCount = !this.showCount" | ||||
|                       [class]="this.showCount ? '' : 'inactive'"> | ||||
|                       <span class="square" [ngStyle]="{'backgroundColor': 'white'}"></span> | ||||
|                       <span class="fee-text">{{ titleCount }}</span> | ||||
|                     </li> | ||||
|                     <hr style="margin: 4px;"> | ||||
|                     <ng-template ngFor let-feeData let-i="index" [ngForOf]="feeLevelDropdownData"> | ||||
|                       <ng-template [ngIf]="feeData.fee <= (feeLevels[maxFeeIndex])"> | ||||
|                         <li (click)="filterFeeIndex = feeData.fee" | ||||
| @ -92,8 +98,8 @@ | ||||
|         </div> | ||||
|         <div class="card-body"> | ||||
|           <div class="incoming-transactions-graph"> | ||||
|             <app-mempool-graph #mempoolgraph dir="ltr" [template]="'advanced'" | ||||
|               [limitFilterFee]="filterFeeIndex" [height]="500" [left]="65" [right]="10" | ||||
|             <app-mempool-graph #mempoolgraph dir="ltr" [template]="'advanced'" [hideCount]="!showCount" | ||||
|               [limitFilterFee]="filterFeeIndex" [height]="500" [left]="65" [right]="showCount ? 50 : 10" | ||||
|               [data]="mempoolStats && mempoolStats.length ? mempoolStats : null"></app-mempool-graph> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
| @ -32,6 +32,7 @@ export class StatisticsComponent implements OnInit { | ||||
|   chartColors = chartColors; | ||||
|   filterSize = 100000; | ||||
|   filterFeeIndex = 1; | ||||
|   showCount = true; | ||||
|   maxFeeIndex: number; | ||||
|   dropDownOpen = false; | ||||
| 
 | ||||
| @ -46,6 +47,7 @@ export class StatisticsComponent implements OnInit { | ||||
|   inverted: boolean; | ||||
|   feeLevelDropdownData = []; | ||||
|   timespan = ''; | ||||
|   titleCount = $localize`Count`; | ||||
| 
 | ||||
|   constructor( | ||||
|     @Inject(LOCALE_ID) private locale: string, | ||||
| @ -62,6 +64,7 @@ export class StatisticsComponent implements OnInit { | ||||
|     this.inverted = this.storageService.getValue('inverted-graph') === 'true'; | ||||
|     this.setFeeLevelDropdownData(); | ||||
|     this.seoService.setTitle($localize`:@@5d4f792f048fcaa6df5948575d7cb325c9393383:Graphs`); | ||||
|     this.seoService.setDescription($localize`:@@meta.description.bitcoin.graphs.mempool:See mempool size (in MvB) and transactions per second (in vB/s) visualized over time.`); | ||||
|     this.stateService.networkChanged$.subscribe((network) => this.network = network); | ||||
|     this.graphWindowPreference = this.storageService.getValue('graphWindowPreference') ? this.storageService.getValue('graphWindowPreference').trim() : '2h'; | ||||
| 
 | ||||
|  | ||||
| @ -74,6 +74,16 @@ | ||||
|       <path fill="#FFFFFF" d="M128 768h256v64H128v-64z m320-384H128v64h320v-64z m128 192V448L384 640l192 192V704h320V576H576z m-288-64H128v64h160v-64zM128 704h160v-64H128v64z m576 64h64v128c-1 18-7 33-19 45s-27 18-45 19H64c-35 0-64-29-64-64V192c0-35 29-64 64-64h192C256 57 313 0 384 0s128 57 128 128h192c35 0 64 29 64 64v320h-64V320H64v576h640V768zM128 256h512c0-35-29-64-64-64h-64c-35 0-64-29-64-64s-29-64-64-64-64 29-64 64-29 64-64 64h-64c-35 0-64 29-64 64z" /> | ||||
|     </svg> | ||||
|   </ng-container> | ||||
|   <ng-container *ngSwitchCase="'hamburger'"> | ||||
|     <svg [attr.width]="width" [attr.height]="height" viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg" stroke="currentColor"> | ||||
|       <path stroke-width="0.5" stroke-linecap="round" d="M0.5 2.5 H7 M0.5 5 H5.5 M0.5 7.5 H7"></path> | ||||
|     </svg> | ||||
|   </ng-container> | ||||
|   <ng-container *ngSwitchCase="'anon'"> | ||||
|     <svg [attr.width]="width" [attr.height]="height" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" stroke="currentColor"> | ||||
|       <path stroke-linecap="round" stroke-linejoin="round" d="M9 9.5v-2a3 3 0 116 0v2c0 1.11-.603 2.08-1.5 2.599v1.224a1 1 0 00.629.928l2.05.82A3.693 3.693 0 0118.5 18.5h-13c0-1.51.92-2.868 2.321-3.428l2.05-.82a1 1 0 00.629-.929v-1.224A2.999 2.999 0 019 9.5z"></path> | ||||
|     </svg> | ||||
|   </ng-container> | ||||
| </ng-container> | ||||
| 
 | ||||
| <ng-template #bitcoinLogo let-color let-width="width" let-height="height" let-viewBox="viewBox"> | ||||
|  | ||||
| @ -37,6 +37,7 @@ export class TelevisionComponent implements OnInit, OnDestroy { | ||||
| 
 | ||||
|   ngOnInit() { | ||||
|     this.seoService.setTitle($localize`:@@46ce8155c9ab953edeec97e8950b5a21e67d7c4e:TV view`); | ||||
|     this.seoService.setDescription($localize`:@@meta.description.tv:See Bitcoin blocks and mempool congestion in real-time in a simplified format perfect for a TV.`); | ||||
|     this.websocketService.want(['blocks', 'live-2h-chart', 'mempool-blocks']); | ||||
| 
 | ||||
|     this.timeLtrSubscription = this.stateService.timeLtr.subscribe((ltr) => { | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| import { Component } from '@angular/core'; | ||||
| import { Env, StateService } from '../../services/state.service'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-terms-of-service', | ||||
| @ -10,5 +11,11 @@ export class TermsOfServiceComponent { | ||||
| 
 | ||||
|   constructor( | ||||
|     private stateService: StateService, | ||||
|     private seoService: SeoService, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.seoService.setTitle('Terms of Service'); | ||||
|     this.seoService.setDescription('Out of respect for the Bitcoin community, the mempool.space website is Bitcoin Only and does not display any advertising.'); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| import { Component } from '@angular/core'; | ||||
| import { Env, StateService } from '../../services/state.service'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'app-trademark-policy', | ||||
| @ -11,5 +12,11 @@ export class TrademarkPolicyComponent { | ||||
| 
 | ||||
|   constructor( | ||||
|     private stateService: StateService, | ||||
|     private seoService: SeoService, | ||||
|   ) { } | ||||
| 
 | ||||
|   ngOnInit(): void { | ||||
|     this.seoService.setTitle('Trademark Policy'); | ||||
|     this.seoService.setDescription('An overview of the trademarks registered by Mempool Space K.K. and The Mempool Open Source Project® and what we consider to be lawful usage of those trademarks.'); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -15,6 +15,7 @@ import { CacheService } from '../../services/cache.service'; | ||||
| import { OpenGraphService } from '../../services/opengraph.service'; | ||||
| import { ApiService } from '../../services/api.service'; | ||||
| import { SeoService } from '../../services/seo.service'; | ||||
| import { seoDescriptionNetwork } from '../../shared/common.utils'; | ||||
| import { CpfpInfo } from '../../interfaces/node-api.interface'; | ||||
| import { LiquidUnblinding } from './liquid-ublinding'; | ||||
| 
 | ||||
| @ -87,6 +88,7 @@ export class TransactionPreviewComponent implements OnInit, OnDestroy { | ||||
|           this.seoService.setTitle( | ||||
|             $localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:` | ||||
|           ); | ||||
|           this.seoService.setDescription($localize`:@@meta.description.bitcoin.transaction:Get real-time status, addresses, fees, script info, and more for ${this.stateService.network==='liquid'||this.stateService.network==='liquidtestnet'?'Liquid':'Bitcoin'}${seoDescriptionNetwork(this.stateService.network)} transaction with txid {txid}.`); | ||||
|           this.resetTransaction(); | ||||
|           return merge( | ||||
|             of(true), | ||||
|  | ||||
| @ -6,6 +6,13 @@ | ||||
|       <app-truncate [text]="rbfTransaction.txid" [lastChars]="12" [link]="['/tx/' | relativeUrl, rbfTransaction.txid]"></app-truncate> | ||||
|     </div> | ||||
| 
 | ||||
|     <div *ngIf="acceleratorAvailable && accelerateCtaType === 'alert' && !tx?.status?.confirmed && !tx?.acceleration" class="alert alert-mempool alert-dismissible" role="alert"> | ||||
|       <span><a class="link accelerator" (click)="onAccelerateClicked()">Accelerate</a> this transaction using Mempool Accelerator ™</span> | ||||
|       <button type="button" class="close" aria-label="Close" (click)="dismissAccelAlert()"> | ||||
|         <span aria-hidden="true">×</span> | ||||
|       </button> | ||||
|     </div> | ||||
| 
 | ||||
|     <ng-container *ngIf="!rbfTransaction || rbfTransaction?.size || tx"> | ||||
|       <h1 i18n="shared.transaction">Transaction</h1> | ||||
| 
 | ||||
| @ -66,12 +73,22 @@ | ||||
|           <div class="col-sm"> | ||||
|             <ng-container *ngTemplateOutlet="feeTable"></ng-container> | ||||
|           </div> | ||||
| 
 | ||||
|         </div> | ||||
|       </div> | ||||
| 
 | ||||
|     </ng-template> | ||||
| 
 | ||||
|     <!-- Accelerator --> | ||||
|     <ng-container *ngIf="!tx?.status?.confirmed && showAccelerationSummary"> | ||||
|       <div class="title mt-3" id="acceleratePreviewAnchor"> | ||||
|         <h2>Accelerate</h2> | ||||
|       </div> | ||||
|       <div class="box"> | ||||
|         <app-accelerate-preview [tx]="tx" [scrollEvent]="scrollIntoAccelPreview"></app-accelerate-preview> | ||||
|       </div> | ||||
| 
 | ||||
|     </ng-container> | ||||
| 
 | ||||
|     <ng-template #unconfirmedTemplate> | ||||
| 
 | ||||
|       <div class="box"> | ||||
| @ -92,16 +109,16 @@ | ||||
|                   </ng-template> | ||||
|                 </ng-template> | ||||
|                 <tr *ngIf="!replaced && !isCached"> | ||||
|                   <td class="td-width" i18n="transaction.eta|Transaction ETA">ETA</td> | ||||
|                   <td class="td-width align-items-center align-middle" i18n="transaction.eta|Transaction ETA">ETA</td> | ||||
|                   <td> | ||||
|                     <ng-template [ngIf]="this.mempoolPosition?.block == null" [ngIfElse]="estimationTmpl"> | ||||
|                       <span class="skeleton-loader"></span> | ||||
|                     </ng-template> | ||||
|                     <ng-template #estimationTmpl> | ||||
|                       <ng-template [ngIf]="this.mempoolPosition.block >= 7" [ngIfElse]="belowBlockLimit"> | ||||
|                         <span class="eta d-flex"> | ||||
|                         <span [class]="(acceleratorAvailable && accelerateCtaType === 'button') ? 'etaDeepMempool d-flex justify-content-end align-items-center' : ''"> | ||||
|                           <span i18n="transaction.eta.in-several-hours|Transaction ETA in several hours or more">In several hours (or more)</span> | ||||
|                           <span class="ml-2"></span><a *ngIf="stateService.env.OFFICIAL_MEMPOOL_SPACE && stateService.env.ACCELERATOR && stateService.network === ''" [href]="'/services/accelerator/accelerate?txid=' + tx.txid" class="btn badge badge-primary accelerate ml-auto" i18n="transaction.accelerate|Accelerate button label">Accelerate</a> | ||||
|                           <a *ngIf="acceleratorAvailable && accelerateCtaType === 'button' && !tx?.acceleration" [href]="'/services/accelerator/accelerate?txid=' + tx.txid" class="btn btn-sm accelerateDeepMempool" i18n="transaction.accelerate|Accelerate button label" (click)="onAccelerateClicked()">Accelerate</a> | ||||
|                         </span> | ||||
|                       </ng-template> | ||||
|                       <ng-template #belowBlockLimit> | ||||
| @ -109,9 +126,9 @@ | ||||
|                           <app-time kind="until" [time]="(60 * 1000 * this.mempoolPosition.block) + now" [fastRender]="false" [fixedRender]="true"></app-time> | ||||
|                         </ng-template> | ||||
|                         <ng-template #timeEstimateDefault> | ||||
|                           <span class="d-flex"> | ||||
|                           <span class="eta justify-content-end" [class]="(acceleratorAvailable && accelerateCtaType === 'button') ? 'd-flex align-items-center' : ''"> | ||||
|                             <app-time kind="until" *ngIf="(da$ | async) as da;" [time]="da.timeAvg * (this.mempoolPosition.block + 1) + now + da.timeOffset" [fastRender]="false" [fixedRender]="true"></app-time> | ||||
|                             <span class="ml-2"></span><a *ngIf="stateService.env.OFFICIAL_MEMPOOL_SPACE && stateService.env.ACCELERATOR && stateService.network === ''" [href]="'/services/accelerator/accelerate?txid=' + tx.txid" class="btn badge badge-primary accelerate ml-auto" i18n="transaction.accelerate|Accelerate button label">Accelerate</a> | ||||
|                             <a *ngIf="acceleratorAvailable && accelerateCtaType === 'button' && !tx?.acceleration" [href]="'/services/accelerator/accelerate?txid=' + tx.txid" class="btn btn-sm accelerate" i18n="transaction.accelerate|Accelerate button label" (click)="onAccelerateClicked()">Accelerate</a> | ||||
|                           </span> | ||||
|                         </ng-template> | ||||
|                       </ng-template> | ||||
|  | ||||
| @ -130,7 +130,7 @@ | ||||
| } | ||||
| 
 | ||||
| .table { | ||||
|   	tr td { | ||||
|   tr td { | ||||
| 		padding: 0.75rem 0.5rem; | ||||
| 		@media (min-width: 576px) { | ||||
| 			padding: 0.75rem 0.75rem; | ||||
| @ -138,7 +138,7 @@ | ||||
| 		&:last-child { | ||||
| 			text-align: right; | ||||
| 			@media (min-width: 850px) { | ||||
| 			text-align: left; | ||||
|         text-align: left; | ||||
| 			} | ||||
| 		} | ||||
| 		.btn { | ||||
| @ -218,21 +218,52 @@ | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .link.accelerator { | ||||
|   cursor: pointer; | ||||
| } | ||||
| 
 | ||||
| .eta { | ||||
|   display: flex; | ||||
|   justify-content: end; | ||||
|   flex-wrap: wrap; | ||||
|   align-content: center; | ||||
|   @media (min-width: 850px) { | ||||
|     justify-content: space-between; | ||||
|     justify-content: left !important;     | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .accelerate { | ||||
|   display: flex !important; | ||||
|   align-self: auto; | ||||
|   margin-top: 3px; | ||||
|   @media (min-width: 850px) { | ||||
|     justify-self: start; | ||||
|   margin-left: auto; | ||||
|   background-color: #653b9c; | ||||
|   @media (max-width: 849px) { | ||||
|     margin-left: 5px; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .etaDeepMempool { | ||||
|   display: flex !important; | ||||
|   justify-content: end; | ||||
|   flex-wrap: wrap; | ||||
|   align-content: center; | ||||
|   @media (max-width: 995px) { | ||||
|     justify-content: left !important; | ||||
|   } | ||||
|   @media (max-width: 849px) { | ||||
|     justify-content: right !important; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .accelerateDeepMempool { | ||||
|   align-self: auto; | ||||
|   margin-top: 3px; | ||||
|   margin-left: auto; | ||||
|   background-color: #653b9c; | ||||
|   @media (max-width: 995px) { | ||||
|     margin-left: 0px; | ||||
|   } | ||||
| } | ||||
|   @media (max-width: 849px) { | ||||
|     margin-left: 5px; | ||||
|   } | ||||
| } | ||||
|  | ||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user