diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index c9f7e19d4..3489869ec 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -290,7 +290,10 @@ let routes: Routes = [ children: [ { path: ':id', - component: BlockComponent + component: BlockComponent, + data: { + ogImage: true + } }, ], }, diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 97c8f9957..b6b8859f6 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -10,6 +10,7 @@ import { EnterpriseService } from './services/enterprise.service'; import { WebsocketService } from './services/websocket.service'; import { AudioService } from './services/audio.service'; import { SeoService } from './services/seo.service'; +import { OpenGraphService } from './services/opengraph.service'; import { SharedModule } from './shared/shared.module'; import { StorageService } from './services/storage.service'; import { HttpCacheInterceptor } from './services/http-cache.interceptor'; @@ -36,6 +37,7 @@ import { CapAddressPipe } from './shared/pipes/cap-address-pipe/cap-address-pipe WebsocketService, AudioService, SeoService, + OpenGraphService, StorageService, EnterpriseService, LanguageService, diff --git a/frontend/src/app/components/app/app.component.ts b/frontend/src/app/components/app/app.component.ts index e060fae54..c96489454 100644 --- a/frontend/src/app/components/app/app.component.ts +++ b/frontend/src/app/components/app/app.component.ts @@ -2,6 +2,7 @@ import { Location } from '@angular/common'; import { Component, HostListener, OnInit, Inject, LOCALE_ID, HostBinding } from '@angular/core'; import { Router, NavigationEnd } from '@angular/router'; import { StateService } from 'src/app/services/state.service'; +import { OpenGraphService } from 'src/app/services/opengraph.service'; import { NgbTooltipConfig } from '@ng-bootstrap/ng-bootstrap'; @Component({ @@ -16,6 +17,7 @@ export class AppComponent implements OnInit { constructor( public router: Router, private stateService: StateService, + private openGraphService: OpenGraphService, private location: Location, tooltipConfig: NgbTooltipConfig, @Inject(LOCALE_ID) private locale: string, diff --git a/frontend/src/app/services/opengraph.service.ts b/frontend/src/app/services/opengraph.service.ts new file mode 100644 index 000000000..48064fdea --- /dev/null +++ b/frontend/src/app/services/opengraph.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@angular/core'; +import { Meta } from '@angular/platform-browser'; +import { Router, ActivatedRoute, NavigationEnd } from '@angular/router'; +import { filter, map, switchMap } from 'rxjs/operators'; +import { combineLatest } from 'rxjs'; +import { StateService } from './state.service'; +import { LanguageService } from './language.service'; + +@Injectable({ + providedIn: 'root' +}) +export class OpenGraphService { + network = ''; + defaultImageUrl = ''; + + constructor( + private metaService: Meta, + private stateService: StateService, + private LanguageService: LanguageService, + private router: Router, + private activatedRoute: ActivatedRoute, + ) { + // save og:image tag from original template + const initialOgImageTag = metaService.getTag("property='og:image'"); + this.defaultImageUrl = initialOgImageTag?.content || 'https://mempool.space/resources/mempool-space-preview.png'; + this.router.events.pipe( + filter(event => event instanceof NavigationEnd), + map(() => this.activatedRoute), + map(route => { + while (route.firstChild) route = route.firstChild; + return route; + }), + filter(route => route.outlet === 'primary'), + switchMap(route => route.data), + ).subscribe((data) => { + if (data.ogImage) { + this.setOgImage(); + } else { + this.clearOgImage(); + } + }); + } + + setOgImage() { + const lang = this.LanguageService.getLanguage(); + const ogImageUrl = `${window.location.protocol}//${window.location.host}/render/${lang}/preview${this.router.url}`; + this.metaService.updateTag({ property: 'og:image', content: ogImageUrl }); + this.metaService.updateTag({ property: 'twitter:image:src', content: ogImageUrl }); + this.metaService.updateTag({ property: 'og:image:type', content: 'image/png' }); + this.metaService.updateTag({ property: 'og:image:width', content: '1024' }); + this.metaService.updateTag({ property: 'og:image:height', content: '512' }); + } + + clearOgImage() { + this.metaService.updateTag({ property: 'og:image', content: this.defaultImageUrl }); + this.metaService.updateTag({ property: 'twitter:image:src', content: this.defaultImageUrl }); + this.metaService.updateTag({ property: 'og:image:type', content: 'image/png' }); + this.metaService.updateTag({ property: 'og:image:width', content: '1000' }); + this.metaService.updateTag({ property: 'og:image:height', content: '500' }); + } +} diff --git a/frontend/src/app/services/seo.service.ts b/frontend/src/app/services/seo.service.ts index 772c0410a..af96dc81b 100644 --- a/frontend/src/app/services/seo.service.ts +++ b/frontend/src/app/services/seo.service.ts @@ -20,11 +20,13 @@ export class SeoService { setTitle(newTitle: string): void { this.titleService.setTitle(newTitle + ' - ' + this.getTitle()); this.metaService.updateTag({ property: 'og:title', content: newTitle}); + this.metaService.updateTag({ property: 'twitter:description', content: newTitle}); } resetTitle(): void { this.titleService.setTitle(this.getTitle()); this.metaService.updateTag({ property: 'og:title', content: this.getTitle()}); + this.metaService.updateTag({ property: 'twitter:description', content: this.getTitle()}); } setEnterpriseTitle(title: string) { diff --git a/unfurler/src/index.ts b/unfurler/src/index.ts index 8d0011a44..31e4d423b 100644 --- a/unfurler/src/index.ts +++ b/unfurler/src/index.ts @@ -36,7 +36,7 @@ class Server { maxConcurrency: config.PUPPETEER.CLUSTER_SIZE, puppeteerOptions: puppeteerConfig, }); - await this.cluster?.task(async (args) => { return this.renderPreviewTask(args) }); + await this.cluster?.task(async (args) => { return this.clusterTask(args) }); this.setUpRoutes(); @@ -52,31 +52,40 @@ class Server { this.app.get('*', (req, res) => { return this.renderHTML(req, res) }) } - async renderPreviewTask({ page, data: url }) { + async clusterTask({ page, data: { url, action } }) { await page.goto(url, { waitUntil: "domcontentloaded" }); - await page.evaluate(async () => { - // wait for all images to finish loading - const imgs = Array.from(document.querySelectorAll("img")); - await Promise.all([ - document.fonts.ready, - ...imgs.map((img) => { - if (img.complete) { - if (img.naturalHeight !== 0) return; - throw new Error("Image failed to load"); - } - return new Promise((resolve, reject) => { - img.addEventListener("load", resolve); - img.addEventListener("error", reject); - }); - }), - ]); - }); - return page.screenshot(); + switch (action) { + case 'screenshot': { + await page.evaluate(async () => { + // wait for all images to finish loading + const imgs = Array.from(document.querySelectorAll("img")); + await Promise.all([ + document.fonts.ready, + ...imgs.map((img) => { + if (img.complete) { + if (img.naturalHeight !== 0) return; + throw new Error("Image failed to load"); + } + return new Promise((resolve, reject) => { + img.addEventListener("load", resolve); + img.addEventListener("error", reject); + }); + }), + ]); + }); + return page.screenshot(); + } break; + default: { + return page.content(); + } + } } async renderPreview(req, res) { try { - const img = await this.cluster?.execute(this.mempoolHost + req.params[0]); + // strip default language code for compatibility + const path = req.params[0].replace('/en/', '/'); + const img = await this.cluster?.execute({ url: this.mempoolHost + path, action: 'screenshot' }); res.contentType('image/png'); res.send(img); @@ -86,39 +95,15 @@ class Server { } } - renderHTML(req, res) { - let lang = ''; - let path = req.originalUrl - // extract the language setting (if any) - const parts = path.split(/^\/(ar|bg|bs|ca|cs|da|de|et|el|es|eo|eu|fa|fr|gl|ko|hr|id|it|he|ka|lv|lt|hu|mk|ms|nl|ja|nb|nn|pl|pt|pt-BR|ro|ru|sk|sl|sr|sh|fi|sv|th|tr|uk|vi|zh|hi)\//) - if (parts.length > 1) { - lang = "/" + parts[1]; - path = "/" + parts[2]; + async renderHTML(req, res) { + try { + let html = await this.cluster?.execute({ url: this.mempoolHost + req.params[0], action: 'html' }); + + res.send(html) + } catch (e) { + console.log(e); + res.status(500).send(e instanceof Error ? e.message : e); } - const ogImageUrl = config.SERVER.HOST + '/render' + lang + "/preview" + path; - res.send(` - - - - - mempool - Bitcoin Explorer - - - - - - - - - - - - - - - - - `); } }