Compare commits
19 Commits
translatio
...
mononaut/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd300578b6 | ||
|
|
9fc3a9130b | ||
|
|
601f0ea671 | ||
|
|
341da85c77 | ||
|
|
0d8f63feff | ||
|
|
e7af43efa2 | ||
|
|
aca2f2ec7d | ||
|
|
803b005880 | ||
|
|
204d54b189 | ||
|
|
c248544fe8 | ||
|
|
b65d00f289 | ||
|
|
f77dc68ec7 | ||
|
|
c4ec50b771 | ||
|
|
8529b99675 | ||
|
|
cd02d89235 | ||
|
|
4dcbccd9b2 | ||
|
|
6a4aeaf7ed | ||
|
|
6432f72664 | ||
|
|
f6ab2caaf9 |
@@ -119,7 +119,11 @@ class RbfCache {
|
|||||||
|
|
||||||
|
|
||||||
public add(replaced: MempoolTransactionExtended[], newTxExtended: MempoolTransactionExtended): void {
|
public add(replaced: MempoolTransactionExtended[], newTxExtended: MempoolTransactionExtended): void {
|
||||||
if (!newTxExtended || !replaced?.length || this.txs.has(newTxExtended.txid)) {
|
if ( !newTxExtended
|
||||||
|
|| !replaced?.length
|
||||||
|
|| this.txs.has(newTxExtended.txid)
|
||||||
|
|| !(replaced.some(tx => !this.replacedBy.has(tx.txid)))
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class ServicesRoutes {
|
|||||||
public initRoutes(app: Application): void {
|
public initRoutes(app: Application): void {
|
||||||
app
|
app
|
||||||
.get(config.MEMPOOL.API_URL_PREFIX + 'wallet/:walletId', this.$getWallet)
|
.get(config.MEMPOOL.API_URL_PREFIX + 'wallet/:walletId', this.$getWallet)
|
||||||
|
.get(config.MEMPOOL.API_URL_PREFIX + 'services/custom/config', this.$getCustomConfig)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +23,11 @@ class ServicesRoutes {
|
|||||||
handleError(req, res, 500, 'Failed to get wallet');
|
handleError(req, res, 500, 'Failed to get wallet');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serve a blank custom config file by default
|
||||||
|
private async $getCustomConfig(req: Request, res: Response): Promise<void> {
|
||||||
|
res.status(200).contentType('application/javascript').send('');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ServicesRoutes();
|
export default new ServicesRoutes();
|
||||||
|
|||||||
@@ -478,25 +478,30 @@ export class AddressGraphComponent implements OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extendSummary(summary) {
|
extendSummary(summary) {
|
||||||
let extendedSummary = summary.slice();
|
const extendedSummary = summary.slice();
|
||||||
|
|
||||||
// Add a point at today's date to make the graph end at the current time
|
// Add a point at today's date to make the graph end at the current time
|
||||||
extendedSummary.unshift({ time: Date.now() / 1000, value: 0 });
|
extendedSummary.unshift({ time: Date.now() / 1000, value: 0 });
|
||||||
extendedSummary.reverse();
|
|
||||||
|
|
||||||
let oneHour = 60 * 60;
|
let maxTime = Date.now() / 1000;
|
||||||
|
|
||||||
|
const oneHour = 60 * 60;
|
||||||
// Fill gaps longer than interval
|
// Fill gaps longer than interval
|
||||||
for (let i = 0; i < extendedSummary.length - 1; i++) {
|
for (let i = 0; i < extendedSummary.length - 1; i++) {
|
||||||
let hours = Math.floor((extendedSummary[i + 1].time - extendedSummary[i].time) / oneHour);
|
if (extendedSummary[i].time > maxTime) {
|
||||||
|
extendedSummary[i].time = maxTime - 30;
|
||||||
|
}
|
||||||
|
maxTime = extendedSummary[i].time;
|
||||||
|
const hours = Math.floor((extendedSummary[i].time - extendedSummary[i + 1].time) / oneHour);
|
||||||
if (hours > 1) {
|
if (hours > 1) {
|
||||||
for (let j = 1; j < hours; j++) {
|
for (let j = 1; j < hours; j++) {
|
||||||
let newTime = extendedSummary[i].time + oneHour * j;
|
const newTime = extendedSummary[i].time - oneHour * j;
|
||||||
extendedSummary.splice(i + j, 0, { time: newTime, value: 0 });
|
extendedSummary.splice(i + j, 0, { time: newTime, value: 0 });
|
||||||
}
|
}
|
||||||
i += hours - 1;
|
i += hours - 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return extendedSummary.reverse();
|
return extendedSummary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export class AppComponent implements OnInit {
|
|||||||
|
|
||||||
@HostListener('document:keydown', ['$event'])
|
@HostListener('document:keydown', ['$event'])
|
||||||
handleKeyboardEvents(event: KeyboardEvent) {
|
handleKeyboardEvents(event: KeyboardEvent) {
|
||||||
if (event.target instanceof HTMLInputElement) {
|
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// prevent arrow key horizontal scrolling
|
// prevent arrow key horizontal scrolling
|
||||||
|
|||||||
@@ -281,9 +281,11 @@
|
|||||||
<div class="col" style="max-height: 410px" [style.order]="isMobile && widget.mobileOrder || 8">
|
<div class="col" style="max-height: 410px" [style.order]="isMobile && widget.mobileOrder || 8">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<span class="title-link">
|
<a class="title-link mb-0" style="margin-top: -2px" href="" [routerLink]="['/wallet/' + widget.props.wallet | relativeUrl]">
|
||||||
<h5 class="card-title d-inline" i18n="dashboard.treasury-transactions">Treasury Transactions</h5>
|
<h5 class="card-title d-inline" i18n="dashboard.treasury-transactions">Treasury Transactions</h5>
|
||||||
</span>
|
<span> </span>
|
||||||
|
<fa-icon [icon]="['fas', 'external-link-alt']" [fixedWidth]="true" style="vertical-align: text-top; font-size: 13px; color: var(--title-fg)"></fa-icon>
|
||||||
|
</a>
|
||||||
<app-address-transactions-widget [addressSummary$]="walletSummary$"></app-address-transactions-widget>
|
<app-address-transactions-widget [addressSummary$]="walletSummary$"></app-address-transactions-widget>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
[height]="tx?.status?.block_height"
|
[height]="tx?.status?.block_height"
|
||||||
[replaced]="replaced"
|
[replaced]="replaced"
|
||||||
[removed]="this.rbfInfo?.mined && !this.tx?.status?.confirmed"
|
[removed]="this.rbfInfo?.mined && !this.tx?.status?.confirmed"
|
||||||
|
[cached]="isCached"
|
||||||
></app-confirmations>
|
></app-confirmations>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="container-xl" [class.liquid-address]="network === 'liquid' || network === 'liquidtestnet'">
|
<div class="container-xl" [class.liquid-address]="network === 'liquid' || network === 'liquidtestnet'">
|
||||||
<div class="title-address">
|
<div class="title-address">
|
||||||
<h1 i18n="shared.wallet">Wallet</h1>
|
<h1>{{ walletName }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="clearfix"></div>
|
<div class="clearfix"></div>
|
||||||
@@ -74,6 +74,36 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div class="title-tx">
|
||||||
|
<h2 class="text-left" i18n="address.transactions">Transactions</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-transactions-list [transactions]="transactions" [showConfirmations]="true" [addresses]="addressStrings" (loadMore)="loadMore()"></app-transactions-list>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<ng-template [ngIf]="isLoadingTransactions">
|
||||||
|
<div class="header-bg box">
|
||||||
|
<div class="row" style="height: 107px;">
|
||||||
|
<div class="col-sm">
|
||||||
|
<span class="skeleton-loader"></span>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm">
|
||||||
|
<span class="skeleton-loader"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template [ngIf]="retryLoadMore">
|
||||||
|
<br>
|
||||||
|
<button type="button" class="btn btn-outline-info btn-sm" (click)="loadMore()"><fa-icon [icon]="['fas', 'redo-alt']" [fixedWidth]="true"></fa-icon></button>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<ng-template #loadingTemplate>
|
<ng-template #loadingTemplate>
|
||||||
|
|
||||||
<div class="box" *ngIf="!error; else errorTemplate">
|
<div class="box" *ngIf="!error; else errorTemplate">
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { of, Observable, Subscription } from 'rxjs';
|
|||||||
import { SeoService } from '@app/services/seo.service';
|
import { SeoService } from '@app/services/seo.service';
|
||||||
import { seoDescriptionNetwork } from '@app/shared/common.utils';
|
import { seoDescriptionNetwork } from '@app/shared/common.utils';
|
||||||
import { WalletAddress } from '@interfaces/node-api.interface';
|
import { WalletAddress } from '@interfaces/node-api.interface';
|
||||||
|
import { ElectrsApiService } from '@app/services/electrs-api.service';
|
||||||
|
import { AudioService } from '@app/services/audio.service';
|
||||||
|
|
||||||
class WalletStats implements ChainStats {
|
class WalletStats implements ChainStats {
|
||||||
addresses: string[];
|
addresses: string[];
|
||||||
@@ -24,6 +26,7 @@ class WalletStats implements ChainStats {
|
|||||||
acc.funded_txo_sum += stat.funded_txo_sum;
|
acc.funded_txo_sum += stat.funded_txo_sum;
|
||||||
acc.spent_txo_count += stat.spent_txo_count;
|
acc.spent_txo_count += stat.spent_txo_count;
|
||||||
acc.spent_txo_sum += stat.spent_txo_sum;
|
acc.spent_txo_sum += stat.spent_txo_sum;
|
||||||
|
acc.tx_count += stat.tx_count;
|
||||||
return acc;
|
return acc;
|
||||||
}, {
|
}, {
|
||||||
funded_txo_count: 0,
|
funded_txo_count: 0,
|
||||||
@@ -109,12 +112,17 @@ export class WalletComponent implements OnInit, OnDestroy {
|
|||||||
addressStrings: string[] = [];
|
addressStrings: string[] = [];
|
||||||
walletName: string;
|
walletName: string;
|
||||||
isLoadingWallet = true;
|
isLoadingWallet = true;
|
||||||
|
isLoadingTransactions = true;
|
||||||
|
transactions: Transaction[];
|
||||||
|
totalTransactionCount: number;
|
||||||
|
retryLoadMore = false;
|
||||||
wallet$: Observable<Record<string, WalletAddress>>;
|
wallet$: Observable<Record<string, WalletAddress>>;
|
||||||
walletAddresses$: Observable<Record<string, Address>>;
|
walletAddresses$: Observable<Record<string, Address>>;
|
||||||
walletSummary$: Observable<AddressTxSummary[]>;
|
walletSummary$: Observable<AddressTxSummary[]>;
|
||||||
walletStats$: Observable<WalletStats>;
|
walletStats$: Observable<WalletStats>;
|
||||||
error: any;
|
error: any;
|
||||||
walletSubscription: Subscription;
|
walletSubscription: Subscription;
|
||||||
|
transactionSubscription: Subscription;
|
||||||
|
|
||||||
collapseAddresses: boolean = true;
|
collapseAddresses: boolean = true;
|
||||||
|
|
||||||
@@ -129,6 +137,8 @@ export class WalletComponent implements OnInit, OnDestroy {
|
|||||||
private websocketService: WebsocketService,
|
private websocketService: WebsocketService,
|
||||||
private stateService: StateService,
|
private stateService: StateService,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
|
private electrsApiService: ElectrsApiService,
|
||||||
|
private audioService: AudioService,
|
||||||
private seoService: SeoService,
|
private seoService: SeoService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@@ -172,6 +182,21 @@ export class WalletComponent implements OnInit, OnDestroy {
|
|||||||
}),
|
}),
|
||||||
switchMap(initial => this.stateService.walletTransactions$.pipe(
|
switchMap(initial => this.stateService.walletTransactions$.pipe(
|
||||||
startWith(null),
|
startWith(null),
|
||||||
|
tap((transactions) => {
|
||||||
|
if (!transactions?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const transaction of transactions) {
|
||||||
|
const tx = this.transactions.find((t) => t.txid === transaction.txid);
|
||||||
|
if (tx) {
|
||||||
|
tx.status = transaction.status;
|
||||||
|
} else {
|
||||||
|
this.transactions.unshift(transaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.transactions = this.transactions.slice();
|
||||||
|
this.audioService.playSound('magic');
|
||||||
|
}),
|
||||||
scan((wallet, walletTransactions) => {
|
scan((wallet, walletTransactions) => {
|
||||||
for (const tx of (walletTransactions || [])) {
|
for (const tx of (walletTransactions || [])) {
|
||||||
const funded: Record<string, number> = {};
|
const funded: Record<string, number> = {};
|
||||||
@@ -267,8 +292,57 @@ export class WalletComponent implements OnInit, OnDestroy {
|
|||||||
return stats;
|
return stats;
|
||||||
}, walletStats),
|
}, walletStats),
|
||||||
);
|
);
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.transactionSubscription = this.wallet$.pipe(
|
||||||
|
switchMap(wallet => {
|
||||||
|
const addresses = Object.keys(wallet).map(addr => this.normalizeAddress(addr));
|
||||||
|
return this.electrsApiService.getAddressesTransactions$(addresses);
|
||||||
|
}),
|
||||||
|
map(transactions => {
|
||||||
|
// only confirmed transactions supported for now
|
||||||
|
return transactions.filter(tx => tx.status.confirmed).sort((a, b) => b.status.block_height - a.status.block_height);
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
console.log(error);
|
||||||
|
this.error = error;
|
||||||
|
this.seoService.logSoft404();
|
||||||
|
this.isLoadingWallet = false;
|
||||||
|
return of([]);
|
||||||
|
})
|
||||||
|
).subscribe((transactions: Transaction[] | null) => {
|
||||||
|
if (!transactions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.transactions = transactions;
|
||||||
|
this.isLoadingTransactions = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMore(): void {
|
||||||
|
if (this.isLoadingTransactions || this.fullyLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isLoadingTransactions = true;
|
||||||
|
this.retryLoadMore = false;
|
||||||
|
this.electrsApiService.getAddressesTransactions$(this.addressStrings, this.transactions[this.transactions.length - 1].txid)
|
||||||
|
.subscribe((transactions: Transaction[]) => {
|
||||||
|
if (transactions && transactions.length) {
|
||||||
|
this.transactions = this.transactions.concat(transactions.sort((a, b) => b.status.block_height - a.status.block_height));
|
||||||
|
} else {
|
||||||
|
this.fullyLoaded = true;
|
||||||
|
}
|
||||||
|
this.isLoadingTransactions = false;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
this.isLoadingTransactions = false;
|
||||||
|
this.retryLoadMore = true;
|
||||||
|
// In the unlikely event of the txid wasn't found in the mempool anymore and we must reload the page.
|
||||||
|
if (error.status === 422) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deduplicateWalletTransactions(walletTransactions: AddressTxSummary[]): AddressTxSummary[] {
|
deduplicateWalletTransactions(walletTransactions: AddressTxSummary[]): AddressTxSummary[] {
|
||||||
@@ -299,5 +373,6 @@ export class WalletComponent implements OnInit, OnDestroy {
|
|||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.websocketService.stopTrackingWallet();
|
this.websocketService.stopTrackingWallet();
|
||||||
this.walletSubscription.unsubscribe();
|
this.walletSubscription.unsubscribe();
|
||||||
|
this.transactionSubscription.unsubscribe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AddressTxSummary, Block, ChainStats, Transaction } from "./electrs.interface";
|
import { AddressTxSummary, Block, ChainStats } from "./electrs.interface";
|
||||||
|
|
||||||
export interface OptimizedMempoolStats {
|
export interface OptimizedMempoolStats {
|
||||||
added: number;
|
added: number;
|
||||||
|
|||||||
@@ -142,12 +142,16 @@ export class ElectrsApiService {
|
|||||||
return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs', { params });
|
return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/address/' + address + '/txs', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
getAddressesTransactions$(addresses: string[], txid?: string): Observable<Transaction[]> {
|
getAddressesTransactions$(addresses: string[], txid?: string): Observable<Transaction[]> {
|
||||||
let params = new HttpParams();
|
let params = new HttpParams();
|
||||||
if (txid) {
|
if (txid) {
|
||||||
params = params.append('after_txid', txid);
|
params = params.append('after_txid', txid);
|
||||||
}
|
}
|
||||||
return this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + `/api/addresses/txs?addresses=${addresses.join(',')}`, { params });
|
return this.httpClient.post<Transaction[]>(
|
||||||
|
this.apiBaseUrl + this.apiBasePath + '/api/addresses/txs',
|
||||||
|
addresses,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAddressSummary$(address: string, txid?: string): Observable<AddressTxSummary[]> {
|
getAddressSummary$(address: string, txid?: string): Observable<AddressTxSummary[]> {
|
||||||
@@ -163,7 +167,7 @@ export class ElectrsApiService {
|
|||||||
if (txid) {
|
if (txid) {
|
||||||
params = params.append('after_txid', txid);
|
params = params.append('after_txid', txid);
|
||||||
}
|
}
|
||||||
return this.httpClient.get<AddressTxSummary[]>(this.apiBaseUrl + this.apiBasePath + `/api/addresses/txs/summary?addresses=${addresses.join(',')}`, { params });
|
return this.httpClient.post<AddressTxSummary[]>(this.apiBaseUrl + this.apiBasePath + '/api/addresses/txs/summary', addresses, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
getScriptHashTransactions$(script: string, txid?: string): Observable<Transaction[]> {
|
getScriptHashTransactions$(script: string, txid?: string): Observable<Transaction[]> {
|
||||||
@@ -182,7 +186,7 @@ export class ElectrsApiService {
|
|||||||
params = params.append('after_txid', txid);
|
params = params.append('after_txid', txid);
|
||||||
}
|
}
|
||||||
return from(Promise.all(scripts.map(script => calcScriptHash$(script)))).pipe(
|
return from(Promise.all(scripts.map(script => calcScriptHash$(script)))).pipe(
|
||||||
switchMap(scriptHashes => this.httpClient.get<Transaction[]>(this.apiBaseUrl + this.apiBasePath + `/api/scripthashes/txs?scripthashes=${scriptHashes.join(',')}`, { params })),
|
switchMap(scriptHashes => this.httpClient.post<Transaction[]>(this.apiBaseUrl + this.apiBasePath + '/api/scripthashes/txs', scriptHashes, { params })),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +216,7 @@ export class ElectrsApiService {
|
|||||||
params = params.append('after_txid', txid);
|
params = params.append('after_txid', txid);
|
||||||
}
|
}
|
||||||
return from(Promise.all(scripts.map(script => calcScriptHash$(script)))).pipe(
|
return from(Promise.all(scripts.map(script => calcScriptHash$(script)))).pipe(
|
||||||
switchMap(scriptHashes => this.httpClient.get<AddressTxSummary[]>(this.apiBaseUrl + this.apiBasePath + `/api/scripthashes/txs/summary?scripthashes=${scriptHashes.join(',')}`, { params })),
|
switchMap(scriptHashes => this.httpClient.post<AddressTxSummary[]>(this.apiBaseUrl + this.apiBasePath + '/api/scripthashes/txs/summary', scriptHashes, { params })),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { DOCUMENT } from '@angular/common';
|
|||||||
import { Inject, Injectable } from '@angular/core';
|
import { Inject, Injectable } from '@angular/core';
|
||||||
import { ApiService } from '@app/services/api.service';
|
import { ApiService } from '@app/services/api.service';
|
||||||
import { SeoService } from '@app/services/seo.service';
|
import { SeoService } from '@app/services/seo.service';
|
||||||
import { StateService } from '@app/services/state.service';
|
import { Customization, StateService } from '@app/services/state.service';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { BehaviorSubject } from 'rxjs';
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
|
||||||
@@ -51,6 +51,8 @@ export class EnterpriseService {
|
|||||||
if (this.stateService.env.customize?.branding) {
|
if (this.stateService.env.customize?.branding) {
|
||||||
const info = this.stateService.env.customize?.branding;
|
const info = this.stateService.env.customize?.branding;
|
||||||
this.insertMatomo(info.site_id);
|
this.insertMatomo(info.site_id);
|
||||||
|
this.setFavicons(this.stateService.env.customize);
|
||||||
|
this.seoService.setCustomMeta(this.stateService.env.customize);
|
||||||
this.seoService.setEnterpriseTitle(info.title, true);
|
this.seoService.setEnterpriseTitle(info.title, true);
|
||||||
this.info$.next(info);
|
this.info$.next(info);
|
||||||
} else {
|
} else {
|
||||||
@@ -67,6 +69,50 @@ export class EnterpriseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setFavicons(customize: Customization): void {
|
||||||
|
const enterprise = customize.enterprise;
|
||||||
|
const head = this.document.getElementsByTagName('head')[0];
|
||||||
|
|
||||||
|
const faviconLinks = [
|
||||||
|
{
|
||||||
|
rel: 'apple-touch-icon',
|
||||||
|
sizes: '180x180',
|
||||||
|
href: `/resources/${enterprise}/favicons/apple-touch-icon.png`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: 'icon',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '32x32',
|
||||||
|
href: `/resources/${enterprise}/favicons/favicon-32x32.png`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: 'icon',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '16x16',
|
||||||
|
href: `/resources/${enterprise}/favicons/favicon-16x16.png`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: 'manifest',
|
||||||
|
href: `/resources/${enterprise}/favicons/site.webmanifest`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: 'shortcut icon',
|
||||||
|
href: `/resources/${enterprise}/favicons/favicon.ico`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
faviconLinks.forEach(linkInfo => {
|
||||||
|
let link = this.document.querySelector(`link[rel="${linkInfo.rel}"]${linkInfo.sizes ? `[sizes="${linkInfo.sizes}"]` : ''}`) as HTMLLinkElement;
|
||||||
|
if (!link) {
|
||||||
|
link = this.document.createElement('link');
|
||||||
|
head.appendChild(link);
|
||||||
|
}
|
||||||
|
Object.entries(linkInfo).forEach(([attr, value]) => {
|
||||||
|
link.setAttribute(attr, value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
insertMatomo(siteId?: number): void {
|
insertMatomo(siteId?: number): void {
|
||||||
let statsUrl = '//stats.mempool.space/';
|
let statsUrl = '//stats.mempool.space/';
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import { LanguageService } from '@app/services/language.service';
|
|||||||
export class OpenGraphService {
|
export class OpenGraphService {
|
||||||
network = '';
|
network = '';
|
||||||
defaultImageUrl = '';
|
defaultImageUrl = '';
|
||||||
|
defaultImageType = 'image/png';
|
||||||
|
defaultImageWidth = '1000';
|
||||||
|
defaultImageHeight = '500';
|
||||||
previewLoadingEvents = {};
|
previewLoadingEvents = {};
|
||||||
previewLoadingCount = 0;
|
previewLoadingCount = 0;
|
||||||
|
|
||||||
@@ -25,12 +28,17 @@ export class OpenGraphService {
|
|||||||
) {
|
) {
|
||||||
// save og:image tag from original template
|
// save og:image tag from original template
|
||||||
const initialOgImageTag = metaService.getTag("property='og:image'");
|
const initialOgImageTag = metaService.getTag("property='og:image'");
|
||||||
this.defaultImageUrl = initialOgImageTag?.content || 'https://mempool.space/resources/previews/mempool-space-preview.jpg';
|
this.defaultImageUrl = (this.stateService.env.customize?.meta?.image?.src ? this.stateService.env.customize.meta.image.src : initialOgImageTag?.content) || 'https://mempool.space/resources/previews/mempool-space-preview.jpg';
|
||||||
|
this.defaultImageType = (this.stateService.env.customize?.meta?.image?.type ? this.stateService.env.customize.meta.image.type : 'image/png');
|
||||||
|
this.defaultImageWidth = (this.stateService.env.customize?.meta?.image?.width ? this.stateService.env.customize.meta.image.width : '1000');
|
||||||
|
this.defaultImageHeight = (this.stateService.env.customize?.meta?.image?.height ? this.stateService.env.customize.meta.image.height : '500');
|
||||||
this.router.events.pipe(
|
this.router.events.pipe(
|
||||||
filter(event => event instanceof NavigationEnd),
|
filter(event => event instanceof NavigationEnd),
|
||||||
map(() => this.activatedRoute),
|
map(() => this.activatedRoute),
|
||||||
map(route => {
|
map(route => {
|
||||||
while (route.firstChild) route = route.firstChild;
|
while (route.firstChild) {
|
||||||
|
route = route.firstChild;
|
||||||
|
}
|
||||||
return route;
|
return route;
|
||||||
}),
|
}),
|
||||||
filter(route => route.outlet === 'primary'),
|
filter(route => route.outlet === 'primary'),
|
||||||
@@ -45,7 +53,7 @@ export class OpenGraphService {
|
|||||||
|
|
||||||
// expose routing method to global scope, so we can access it from the unfurler
|
// expose routing method to global scope, so we can access it from the unfurler
|
||||||
window['ogService'] = {
|
window['ogService'] = {
|
||||||
loadPage: (path) => { return this.loadPage(path) }
|
loadPage: (path) => { return this.loadPage(path); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,9 +70,9 @@ export class OpenGraphService {
|
|||||||
clearOgImage() {
|
clearOgImage() {
|
||||||
this.metaService.updateTag({ property: 'og:image', content: this.defaultImageUrl });
|
this.metaService.updateTag({ property: 'og:image', content: this.defaultImageUrl });
|
||||||
this.metaService.updateTag({ name: 'twitter:image', content: this.defaultImageUrl });
|
this.metaService.updateTag({ name: 'twitter:image', content: this.defaultImageUrl });
|
||||||
this.metaService.updateTag({ property: 'og:image:type', content: 'image/png' });
|
this.metaService.updateTag({ property: 'og:image:type', content: this.defaultImageType });
|
||||||
this.metaService.updateTag({ property: 'og:image:width', content: '1000' });
|
this.metaService.updateTag({ property: 'og:image:width', content: this.defaultImageWidth });
|
||||||
this.metaService.updateTag({ property: 'og:image:height', content: '500' });
|
this.metaService.updateTag({ property: 'og:image:height', content: this.defaultImageHeight });
|
||||||
}
|
}
|
||||||
|
|
||||||
setManualOgImage(imageFilename) {
|
setManualOgImage(imageFilename) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
|
|||||||
import { Title, Meta } from '@angular/platform-browser';
|
import { Title, Meta } from '@angular/platform-browser';
|
||||||
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
||||||
import { filter, map, switchMap } from 'rxjs';
|
import { filter, map, switchMap } from 'rxjs';
|
||||||
import { StateService } from '@app/services/state.service';
|
import { Customization, StateService } from '@app/services/state.service';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -23,13 +23,17 @@ export class SeoService {
|
|||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
) {
|
) {
|
||||||
// save original meta tags
|
// save original meta tags
|
||||||
this.baseDescription = metaService.getTag('name=\'description\'')?.content || this.baseDescription;
|
this.baseDescription = this.stateService.env.customize?.meta?.description || metaService.getTag('name=\'description\'')?.content || this.baseDescription;
|
||||||
this.baseTitle = titleService.getTitle()?.split(' - ')?.[0] || this.baseTitle;
|
this.baseTitle = this.stateService.env.customize?.meta?.title || titleService.getTitle()?.split(' - ')?.[0] || this.baseTitle;
|
||||||
try {
|
if (this.stateService.env.customize?.domains?.length) {
|
||||||
const canonicalUrl = new URL(this.canonicalLink?.href || '');
|
this.baseDomain = this.stateService.env.customize.domains[0];
|
||||||
this.baseDomain = canonicalUrl?.host;
|
} else {
|
||||||
} catch (e) {
|
try {
|
||||||
// leave as default
|
const canonicalUrl = new URL(this.canonicalLink?.href || '');
|
||||||
|
this.baseDomain = canonicalUrl?.host;
|
||||||
|
} catch (e) {
|
||||||
|
// leave as default
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.stateService.networkChanged$.subscribe((network) => this.network = network);
|
this.stateService.networkChanged$.subscribe((network) => this.network = network);
|
||||||
@@ -70,6 +74,22 @@ export class SeoService {
|
|||||||
this.resetTitle();
|
this.resetTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCustomMeta(customize: Customization) {
|
||||||
|
if (!customize.meta) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.metaService.updateTag({ name: 'description', content: customize.meta.description});
|
||||||
|
this.metaService.updateTag({ name: 'twitter:description', content: customize.meta.description});
|
||||||
|
this.metaService.updateTag({ property: 'og:description', content: customize.meta.description});
|
||||||
|
this.metaService.updateTag({ name: 'twitter:image', content: customize.meta.image.src});
|
||||||
|
this.metaService.updateTag({ property: 'og:image', content: customize.meta.image.src});
|
||||||
|
this.metaService.updateTag({ property: 'og:image:type', content: customize.meta.image.type});
|
||||||
|
this.metaService.updateTag({ property: 'og:image:width', content: customize.meta.image.width});
|
||||||
|
this.metaService.updateTag({ property: 'og:image:height', content: customize.meta.image.height});
|
||||||
|
const domain = customize.domains?.[0] || window.location.hostname;
|
||||||
|
this.metaService.updateTag({ name: 'twitter:domain', content: domain});
|
||||||
|
}
|
||||||
|
|
||||||
setDescription(newDescription: string): void {
|
setDescription(newDescription: string): void {
|
||||||
this.metaService.updateTag({ name: 'description', content: newDescription});
|
this.metaService.updateTag({ name: 'description', content: newDescription});
|
||||||
this.metaService.updateTag({ name: 'twitter:description', content: newDescription});
|
this.metaService.updateTag({ name: 'twitter:description', content: newDescription});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface MarkBlockState {
|
|||||||
export interface ILoadingIndicators { [name: string]: number; }
|
export interface ILoadingIndicators { [name: string]: number; }
|
||||||
|
|
||||||
export interface Customization {
|
export interface Customization {
|
||||||
|
domains: string[];
|
||||||
theme: string;
|
theme: string;
|
||||||
enterprise?: string;
|
enterprise?: string;
|
||||||
branding: {
|
branding: {
|
||||||
@@ -33,6 +34,16 @@ export interface Customization {
|
|||||||
footer_img?: string;
|
footer_img?: string;
|
||||||
rounded_corner: boolean;
|
rounded_corner: boolean;
|
||||||
},
|
},
|
||||||
|
meta: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
image: {
|
||||||
|
src: string;
|
||||||
|
type: string;
|
||||||
|
width: string;
|
||||||
|
height: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
dashboard: {
|
dashboard: {
|
||||||
widgets: {
|
widgets: {
|
||||||
component: string;
|
component: string;
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && replaced">
|
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && replaced">
|
||||||
<button type="button" class="btn btn-sm btn-warning no-cursor {{buttonClass}}" i18n="transaction.replaced|Transaction replaced state">Replaced</button>
|
<button type="button" class="btn btn-sm btn-warning no-cursor {{buttonClass}}" i18n="transaction.replaced|Transaction replaced state">Replaced</button>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && !replaced && removed">
|
<ng-template [ngIf]="!hideUnconfirmed && !confirmations && !replaced && (removed || cached)">
|
||||||
<button type="button" class="btn btn-sm btn-warning no-cursor {{buttonClass}}" i18n="transaction.audit.removed|Transaction removed state">Removed</button>
|
<button type="button" class="btn btn-sm btn-warning no-cursor {{buttonClass}}" i18n="transaction.audit.removed|Transaction removed state">Removed</button>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template [ngIf]="!hideUnconfirmed && chainTip != null && !confirmations && !replaced && !removed">
|
<ng-template [ngIf]="!hideUnconfirmed && chainTip != null && !confirmations && !replaced && !(removed || cached)">
|
||||||
<button type="button" class="btn btn-sm btn-danger no-cursor {{buttonClass}}" i18n="transaction.unconfirmed|Transaction unconfirmed state">Unconfirmed</button>
|
<button type="button" class="btn btn-sm btn-danger no-cursor {{buttonClass}}" i18n="transaction.unconfirmed|Transaction unconfirmed state">Unconfirmed</button>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@@ -12,6 +12,7 @@ export class ConfirmationsComponent implements OnChanges {
|
|||||||
@Input() height: number;
|
@Input() height: number;
|
||||||
@Input() replaced: boolean = false;
|
@Input() replaced: boolean = false;
|
||||||
@Input() removed: boolean = false;
|
@Input() removed: boolean = false;
|
||||||
|
@Input() cached: boolean = false;
|
||||||
@Input() hideUnconfirmed: boolean = false;
|
@Input() hideUnconfirmed: boolean = false;
|
||||||
@Input() buttonClass: string = '';
|
@Input() buttonClass: string = '';
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>mempool - Bitcoin Explorer</title>
|
<title>mempool - Bitcoin Explorer</title>
|
||||||
<script src="/resources/config.js"></script>
|
<script src="/resources/config.js"></script>
|
||||||
<script src="/resources/customize.js"></script>
|
<script src="/api/v1/services/custom/config"></script>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
|
|
||||||
<meta name="description" content="Explore the full Bitcoin ecosystem with The Mempool Open Source Project®. See the real-time status of your transactions, get network info, and more." />
|
<meta name="description" content="Explore the full Bitcoin ecosystem with The Mempool Open Source Project®. See the real-time status of your transactions, get network info, and more." />
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ location @mempool-api-v1-cache-normal {
|
|||||||
proxy_cache_valid 200 2s;
|
proxy_cache_valid 200 2s;
|
||||||
proxy_redirect off;
|
proxy_redirect off;
|
||||||
|
|
||||||
expires 2s;
|
# cache for 2 seconds on server, but send expires -1 so browser doesn't cache
|
||||||
|
expires -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
location @mempool-api-v1-cache-disabled {
|
location @mempool-api-v1-cache-disabled {
|
||||||
|
|||||||
Reference in New Issue
Block a user