UNPKG

ngx-link-preview

Version:

The Open Graph link preview component for Angular 6+

393 lines (384 loc) 24.7 kB
import * as i0 from '@angular/core'; import { Injectable, EventEmitter, Component, Input, Output, NgModule } from '@angular/core'; import * as i3 from '@angular/common'; import { CommonModule } from '@angular/common'; import { BehaviorSubject } from 'rxjs'; import { map } from 'rxjs/operators'; import * as i1 from '@angular/platform-browser'; /* tslint:disable:max-line-length */ const NgxLinkPreviewLoadingSpinner = ` <svg class="lds-message" width="60px" height="60px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid"><g transform="translate(20 50)"> <circle cx="0" cy="0" r="7" fill="#1B364C" transform="scale(0.99275 0.99275)"> <animateTransform attributeName="transform" type="scale" begin="-0.375s" calcMode="spline" keySplines="0.3 0 0.7 1;0.3 0 0.7 1" values="0;1;0" keyTimes="0;0.5;1" dur="1s" repeatCount="indefinite"></animateTransform> </circle> </g><g transform="translate(40 50)"> <circle cx="0" cy="0" r="7" fill="#285072" transform="scale(0.773605 0.773605)"> <animateTransform attributeName="transform" type="scale" begin="-0.25s" calcMode="spline" keySplines="0.3 0 0.7 1;0.3 0 0.7 1" values="0;1;0" keyTimes="0;0.5;1" dur="1s" repeatCount="indefinite"></animateTransform> </circle> </g><g transform="translate(60 50)"> <circle cx="0" cy="0" r="7" fill="#346B99" transform="scale(0.42525 0.42525)"> <animateTransform attributeName="transform" type="scale" begin="-0.125s" calcMode="spline" keySplines="0.3 0 0.7 1;0.3 0 0.7 1" values="0;1;0" keyTimes="0;0.5;1" dur="1s" repeatCount="indefinite"></animateTransform> </circle> </g><g transform="translate(80 50)"> <circle cx="0" cy="0" r="7" fill="#999999" transform="scale(0.113418 0.113418)"> <animateTransform attributeName="transform" type="scale" begin="0s" calcMode="spline" keySplines="0.3 0 0.7 1;0.3 0 0.7 1" values="0;1;0" keyTimes="0;0.5;1" dur="1s" repeatCount="indefinite"></animateTransform> </circle> </g></svg> `; class NgxLinkPreviewLoadingManager { constructor() { /** The loading stack, holding an item for each */ this.loadingStack = []; /** Observable to retrieve if has pending jobs */ this.hasPendingJobs$ = new BehaviorSubject(false); } /** * Add task to stack */ addTask(name) { name = this.getTaskName(name); this.loadingStack.push(name); this.emitHasPendingJobs(); } /** * Remove task from stack */ removeTask(name) { name = this.getTaskName(name); if (this.loadingStack.length) { const taskIndex = this.loadingStack.indexOf(name); if (taskIndex !== -1) { this.loadingStack.splice(taskIndex, 1); } else { console.error(`[NgxLinkPreviewLoadingManager] Task [${name}] not found and can't be removed.`); } } else { console.error('[NgxLinkPreviewLoadingManager] No pending task available for remove'); } this.emitHasPendingJobs(); } /** * Get task name */ getTaskName(name) { return name ? name : 'UNKNOWN_TASK'; } /** * Emit if has loading jobs */ emitHasPendingJobs() { if (this.loadingStack.length) { this.hasPendingJobs$.next(true); } else { this.hasPendingJobs$.next(false); } } } class NgxLinkPreviewCacheService { constructor() { /** Holds the current cache state (RAM cache) */ this.cache = {}; /** localStorage cache key */ this.localStorageKey = 'NgxLinkPreviewCache_q16qy4aOCzm2'; this.loadCacheFromLocalStorage(); } /** * Try to load the cache from localstorage */ loadCacheFromLocalStorage() { try { this.cache = JSON.parse(localStorage.getItem(this.localStorageKey)); } catch (e) { this.cache = {}; } } /** * Update item in RAM cache */ updateCacheItem(cacheKey, data) { if (!this.cache) { this.cache = {}; } this.cache[cacheKey] = data; this.saveCache(); } /** * Get item from cache */ getCacheItem(cacheKey) { let cacheData; try { cacheData = this.cache[cacheKey]; } catch (e) { cacheData = undefined; } if (!cacheData) { cacheData = undefined; } return cacheData; } /** * Save whole cache to localStorage */ saveCache() { try { localStorage.setItem(this.localStorageKey, JSON.stringify(this.cache)); } catch (e) { console.warn('Failed to save OgLinkPreviewCache in localStorage!', e); } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.1.6", ngImport: i0, type: NgxLinkPreviewCacheService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "16.1.6", ngImport: i0, type: NgxLinkPreviewCacheService }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.1.6", ngImport: i0, type: NgxLinkPreviewCacheService, decorators: [{ type: Injectable }], ctorParameters: function () { return []; } }); class NgxLinkPreviewComponent { constructor(sanitizer, cacheSvc) { this.sanitizer = sanitizer; this.cacheSvc = cacheSvc; /** Plain links string array */ this.links = []; /** Target url will be attached as encodeURI(btoa(url)), so it must be decoded on the server */ this.queryParamName = 'url'; /** boolean: show image in preview */ this.showImage = true; /** boolean: show site name in preview */ this.showSiteName = true; /** boolean: show title in preview */ this.showTitle = true; /** boolean: show description in preview */ this.showDescription = true; /** boolean: show link url in preview */ this.showLinkUrl = false; /** boolean: use cache to display previews faster on next rendering */ this.useCache = true; /** number: max age the data cache of a link preview should be used */ this.maxCacheAgeMs = 1000 * 60 * 60 * 24 * 7; // 7 days /** boolean: show loading indicator */ this.showLoadingIndicator = true; /** * boolean: whether the <a href="..."></a> link should be clickable. * This is a question of context security. Otherwise use (previewClick) event. */ this.useHtmlLinkDefaultClickEvent = false; /** * HtmlLinkTarget: where the HTML link should be opened on click. * Only has an effect if [useHtmlLinkDefaultClickEvent]="true" */ this.htmlLinkTarget = '_blank'; /** Event emitter: on click to handle the click event, emits the clicked URL */ this.previewClicked = new EventEmitter(); /** Scanned links[] from @Input() links & @Input() parseForLinks */ this.scannedLinks = []; /** Array of metadata objects where the preview is rendered from */ this.previews = []; this.loadingMgr = new NgxLinkPreviewLoadingManager(); this.loadingSpinner = this.sanitizer.bypassSecurityTrustHtml(NgxLinkPreviewLoadingSpinner); this.showLoadingSpinner = false; this.subscriptions = []; this.subscribeLoadingMgrHasJobs(); } ngOnDestroy() { this.unsubscribeAll(); } /** * Preview will be refreshed every time a change is recognized */ ngOnChanges(changes) { this.init(); } /** * Subscribe to loading manager has jobs stream */ subscribeLoadingMgrHasJobs() { this.subscriptions.push(this.loadingMgr.hasPendingJobs$.subscribe(hasJobs => { this.showLoadingSpinner = hasJobs; })); } /** * Unsubscribe all subscriptions */ unsubscribeAll() { this.subscriptions.forEach((sub) => { sub.unsubscribe(); }); } /** * Init preview */ init() { this.scannedLinks = []; this.previews = []; this.checkInputParameters(); // Find links in string if (this.parseForLinksStr) { // Parse for links and push to links const links = this.parseStringForLinks(this.parseForLinksStr); this.scannedLinks = this.scannedLinks.concat(links); } // Add links passed as string[] if (this.links && this.links.length) { this.scannedLinks = this.scannedLinks.concat(this.links); } for (const link of this.scannedLinks) { this.loadCacheOrGet(link); } } /** * Load cache or get from api */ loadCacheOrGet(link) { const encodedLink = this.encodeUrlSafe(link); const requestUrl = this.apiRoute + '?' + this.queryParamName + '=' + encodedLink; // Try to load from cache, use encodedLink as key const cacheItem = this.cacheSvc.getCacheItem(encodedLink); if (this.useCache && cacheItem && !this.isCacheOutdated(cacheItem)) { this.previews.push(cacheItem); } else { this.loadingMgr.addTask(encodedLink); this.getApiEndpoint$(requestUrl) .pipe(map((resp) => { resp.timestampMs = new Date().valueOf(); return resp; })) .subscribe((resp) => { this.cacheSvc.updateCacheItem(encodedLink, resp); this.loadingMgr.removeTask(encodedLink); this.previews.push(resp); }); } } /** * Check if cache item is outdated by max-cache-age */ isCacheOutdated(item) { const now = new Date().valueOf(); const maxValidTimestamp = now - this.maxCacheAgeMs; return maxValidTimestamp > item.timestampMs; } /** * On link click emit to EventEmitter */ onLinkClick(url) { this.previewClicked.emit(url); } disableDefaultLink(event) { if (!this.useHtmlLinkDefaultClickEvent) { event.preventDefault(); } } /** * Encode string url safe */ encodeUrlSafe(url) { return encodeURI(btoa(url)); } /** * Check required input parameters */ checkInputParameters() { if (!this.apiRoute) { throw new Error('<ngx-link-preview></ngx-link-preview> Missing [apiRoute] input parameter'); } if (!this.getApiEndpoint$) { throw new Error('<ngx-link-preview></ngx-link-preview> Missing [getApiEndpoint$] input parameter'); } } /** * Parse string for links */ parseStringForLinks(msg) { const links = msg.match(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&\/=]*)/gim); if (links) { return links; } else { return []; } } /** * Get sanitized image url due data often contains url errors */ getSanitizedImageUrl(p) { if (p['og:image'].startsWith('http')) { return p['og:image']; } else if (p['og:image'].startsWith('www')) { return p['og:image']; } else if (p['og:image'].startsWith('//www')) { return 'https://' + p['og:image'].slice(2); } else if (p['og:image'].startsWith('/yts/')) { return 'https://' + p.source + p['og:image']; } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.1.6", ngImport: i0, type: NgxLinkPreviewComponent, deps: [{ token: i1.DomSanitizer }, { token: NgxLinkPreviewCacheService }], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "16.1.6", type: NgxLinkPreviewComponent, selector: "ngx-link-preview", inputs: { links: "links", parseForLinksStr: "parseForLinksStr", getApiEndpoint$: "getApiEndpoint$", apiRoute: "apiRoute", queryParamName: "queryParamName", showImage: "showImage", showSiteName: "showSiteName", showTitle: "showTitle", showDescription: "showDescription", showLinkUrl: "showLinkUrl", useCache: "useCache", maxCacheAgeMs: "maxCacheAgeMs", showLoadingIndicator: "showLoadingIndicator", useHtmlLinkDefaultClickEvent: "useHtmlLinkDefaultClickEvent", htmlLinkTarget: "htmlLinkTarget" }, outputs: { previewClicked: "previewClicked" }, usesOnChanges: true, ngImport: i0, template: "<div class=\"ngx-link-preview-container\">\n\n <ng-container *ngFor=\"let p of previews\">\n\n <div class=\"og-link-preview\" *ngIf=\"p.url\" (click)=\"onLinkClick(p.url)\">\n <a class=\"row\" [href]=\"p.url\" (click)=\"disableDefaultLink($event)\" [target]=\"htmlLinkTarget\">\n\n <div class=\"col preview-image\" [ngStyle]=\"{backgroundImage: 'url('+getSanitizedImageUrl(p)+')'}\">\n <div class=\"image\" *ngIf=\"showImage && (p['og:image'] || p.image)\">\n <img [src]=\"getSanitizedImageUrl(p)\" alt=\"\">\n </div>\n </div>\n\n <div class=\"col text-data\">\n <div class=\"header\" *ngIf=\"showSiteName\">\n <div class=\"site-name\" *ngIf=\"p['og:site_name']\">\n {{ p['og:site_name'] }}\n </div>\n </div>\n\n <div class=\"title\" *ngIf=\"showTitle && (p['og:title'] || p.title)\">\n {{ p['og:title'] || p.title }}\n </div>\n\n <div class=\"description\" *ngIf=\"showDescription && (p['og:description'] || p.description)\">\n {{ p['og:description'] || p.description }}\n </div>\n\n <div class=\"footer\">\n <div class=\"url\" *ngIf=\"showLinkUrl && (p['og:url'] || p.url)\">\n {{ p['og:url'] || p.url }}\n </div>\n </div>\n </div>\n\n </a><!-- a.row -->\n </div><!-- og-link-preview -->\n\n </ng-container><!-- *ngFor -->\n</div>\n\n<div class=\"loading-spinner\" *ngIf=\"showLoadingIndicator && showLoadingSpinner\">\n <!-- Custom loading spinner -->\n <ng-container>\n <div class=\"wrapper-custom\" #ngContentLoading>\n <ng-content></ng-content>\n </div>\n </ng-container>\n\n <!-- Default loading spinner -->\n <ng-container *ngIf=\"ngContentLoading.childNodes.length === 0\">\n <div class=\"wrapper-default\" [innerHTML]=\"loadingSpinner\"></div>\n </ng-container>\n</div>\n", styles: [".ngx-link-preview-container .og-link-preview{border-radius:4px;background-color:#c8c8c840}.ngx-link-preview-container .og-link-preview:hover{background-color:#c8c8c866}.ngx-link-preview-container .og-link-preview .row{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;text-decoration:none;color:unset;-webkit-user-select:none;-moz-user-select:none;user-select:none}.ngx-link-preview-container .og-link-preview .row .col{display:flex;flex-direction:column;align-items:flex-start;justify-content:flex-start;background-position:-9999px;background-repeat:no-repeat}.ngx-link-preview-container .og-link-preview .row .col.preview-image{padding:5px 0 5px 5px;margin-right:6px}.ngx-link-preview-container .og-link-preview .row .col.text-data{padding:6px 6px 4px 0}.ngx-link-preview-container .og-link-preview .row .col .image{border-radius:3px}.ngx-link-preview-container .og-link-preview .row .col .image img{max-height:70px;height:13vw;border-radius:3px;vertical-align:middle}.ngx-link-preview-container .og-link-preview .row .col .title{font-weight:500;font-size:14px}.ngx-link-preview-container .og-link-preview .row .col .description{margin-top:4px;max-height:40px;font-size:12px;filter:contrast(.8);overflow:hidden;text-overflow:ellipsis}.ngx-link-preview-container .og-link-preview .row .col .header{display:flex;flex-direction:row;align-items:center;margin-bottom:4px;font-size:12px;filter:contrast(.7)}.ngx-link-preview-container .og-link-preview .row .col .footer{display:flex;flex-direction:row;align-items:center;font-size:12px;filter:contrast(.7);margin-top:4px}.loading-spinner .wrapper-custom{text-align:center}.loading-spinner .wrapper-default{height:40px;display:flex;justify-content:center;align-items:center}:host.modern .og-link-preview .row{align-items:stretch}:host.modern .og-link-preview .col{justify-content:center!important}:host.modern .og-link-preview .image{width:150px}:host.modern .og-link-preview .image img{opacity:0}:host.modern .og-link-preview .col.preview-image{background-size:cover;background-position:center center!important;border-bottom-left-radius:4px;border-top-left-radius:4px}\n"], dependencies: [{ kind: "directive", type: i3.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i3.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i3.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.1.6", ngImport: i0, type: NgxLinkPreviewComponent, decorators: [{ type: Component, args: [{ selector: 'ngx-link-preview', template: "<div class=\"ngx-link-preview-container\">\n\n <ng-container *ngFor=\"let p of previews\">\n\n <div class=\"og-link-preview\" *ngIf=\"p.url\" (click)=\"onLinkClick(p.url)\">\n <a class=\"row\" [href]=\"p.url\" (click)=\"disableDefaultLink($event)\" [target]=\"htmlLinkTarget\">\n\n <div class=\"col preview-image\" [ngStyle]=\"{backgroundImage: 'url('+getSanitizedImageUrl(p)+')'}\">\n <div class=\"image\" *ngIf=\"showImage && (p['og:image'] || p.image)\">\n <img [src]=\"getSanitizedImageUrl(p)\" alt=\"\">\n </div>\n </div>\n\n <div class=\"col text-data\">\n <div class=\"header\" *ngIf=\"showSiteName\">\n <div class=\"site-name\" *ngIf=\"p['og:site_name']\">\n {{ p['og:site_name'] }}\n </div>\n </div>\n\n <div class=\"title\" *ngIf=\"showTitle && (p['og:title'] || p.title)\">\n {{ p['og:title'] || p.title }}\n </div>\n\n <div class=\"description\" *ngIf=\"showDescription && (p['og:description'] || p.description)\">\n {{ p['og:description'] || p.description }}\n </div>\n\n <div class=\"footer\">\n <div class=\"url\" *ngIf=\"showLinkUrl && (p['og:url'] || p.url)\">\n {{ p['og:url'] || p.url }}\n </div>\n </div>\n </div>\n\n </a><!-- a.row -->\n </div><!-- og-link-preview -->\n\n </ng-container><!-- *ngFor -->\n</div>\n\n<div class=\"loading-spinner\" *ngIf=\"showLoadingIndicator && showLoadingSpinner\">\n <!-- Custom loading spinner -->\n <ng-container>\n <div class=\"wrapper-custom\" #ngContentLoading>\n <ng-content></ng-content>\n </div>\n </ng-container>\n\n <!-- Default loading spinner -->\n <ng-container *ngIf=\"ngContentLoading.childNodes.length === 0\">\n <div class=\"wrapper-default\" [innerHTML]=\"loadingSpinner\"></div>\n </ng-container>\n</div>\n", styles: [".ngx-link-preview-container .og-link-preview{border-radius:4px;background-color:#c8c8c840}.ngx-link-preview-container .og-link-preview:hover{background-color:#c8c8c866}.ngx-link-preview-container .og-link-preview .row{display:flex;flex-direction:row;align-items:center;justify-content:flex-start;text-decoration:none;color:unset;-webkit-user-select:none;-moz-user-select:none;user-select:none}.ngx-link-preview-container .og-link-preview .row .col{display:flex;flex-direction:column;align-items:flex-start;justify-content:flex-start;background-position:-9999px;background-repeat:no-repeat}.ngx-link-preview-container .og-link-preview .row .col.preview-image{padding:5px 0 5px 5px;margin-right:6px}.ngx-link-preview-container .og-link-preview .row .col.text-data{padding:6px 6px 4px 0}.ngx-link-preview-container .og-link-preview .row .col .image{border-radius:3px}.ngx-link-preview-container .og-link-preview .row .col .image img{max-height:70px;height:13vw;border-radius:3px;vertical-align:middle}.ngx-link-preview-container .og-link-preview .row .col .title{font-weight:500;font-size:14px}.ngx-link-preview-container .og-link-preview .row .col .description{margin-top:4px;max-height:40px;font-size:12px;filter:contrast(.8);overflow:hidden;text-overflow:ellipsis}.ngx-link-preview-container .og-link-preview .row .col .header{display:flex;flex-direction:row;align-items:center;margin-bottom:4px;font-size:12px;filter:contrast(.7)}.ngx-link-preview-container .og-link-preview .row .col .footer{display:flex;flex-direction:row;align-items:center;font-size:12px;filter:contrast(.7);margin-top:4px}.loading-spinner .wrapper-custom{text-align:center}.loading-spinner .wrapper-default{height:40px;display:flex;justify-content:center;align-items:center}:host.modern .og-link-preview .row{align-items:stretch}:host.modern .og-link-preview .col{justify-content:center!important}:host.modern .og-link-preview .image{width:150px}:host.modern .og-link-preview .image img{opacity:0}:host.modern .og-link-preview .col.preview-image{background-size:cover;background-position:center center!important;border-bottom-left-radius:4px;border-top-left-radius:4px}\n"] }] }], ctorParameters: function () { return [{ type: i1.DomSanitizer }, { type: NgxLinkPreviewCacheService }]; }, propDecorators: { links: [{ type: Input }], parseForLinksStr: [{ type: Input }], getApiEndpoint$: [{ type: Input }], apiRoute: [{ type: Input }], queryParamName: [{ type: Input }], showImage: [{ type: Input }], showSiteName: [{ type: Input }], showTitle: [{ type: Input }], showDescription: [{ type: Input }], showLinkUrl: [{ type: Input }], useCache: [{ type: Input }], maxCacheAgeMs: [{ type: Input }], showLoadingIndicator: [{ type: Input }], useHtmlLinkDefaultClickEvent: [{ type: Input }], htmlLinkTarget: [{ type: Input }], previewClicked: [{ type: Output }] } }); class NgxLinkPreviewModule { static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.1.6", ngImport: i0, type: NgxLinkPreviewModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); } static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "16.1.6", ngImport: i0, type: NgxLinkPreviewModule, declarations: [NgxLinkPreviewComponent], imports: [CommonModule], exports: [NgxLinkPreviewComponent] }); } static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "16.1.6", ngImport: i0, type: NgxLinkPreviewModule, providers: [NgxLinkPreviewCacheService], imports: [CommonModule] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.1.6", ngImport: i0, type: NgxLinkPreviewModule, decorators: [{ type: NgModule, args: [{ declarations: [NgxLinkPreviewComponent], exports: [NgxLinkPreviewComponent], imports: [CommonModule], providers: [NgxLinkPreviewCacheService] }] }] }); /* * Public API Surface of ngx-link-preview */ /** * Generated bundle index. Do not edit. */ export { NgxLinkPreviewCacheService, NgxLinkPreviewComponent, NgxLinkPreviewLoadingManager, NgxLinkPreviewModule }; //# sourceMappingURL=ngx-link-preview.mjs.map