ngx-link-preview
Version:
The Open Graph link preview component for Angular 6+
393 lines (384 loc) • 24.7 kB
JavaScript
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