92282: Support restricted thumbnails

This commit is contained in:
Yura Bondarenko
2022-06-17 15:43:46 +02:00
parent e4f483c308
commit e15da9b76b
9 changed files with 391 additions and 94 deletions

View File

@@ -64,7 +64,8 @@
"bundleName": "dspace-theme" "bundleName": "dspace-theme"
} }
], ],
"scripts": [] "scripts": [],
"baseHref": "/"
}, },
"configurations": { "configurations": {
"production": { "production": {

View File

@@ -14,6 +14,12 @@ import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { getCollectionEditRoute } from '../../../../../collection-page/collection-page-routing-paths'; import { getCollectionEditRoute } from '../../../../../collection-page/collection-page-routing-paths';
import { LinkService } from '../../../../../core/cache/builders/link.service'; import { LinkService } from '../../../../../core/cache/builders/link.service';
import { AuthService } from '../../../../../core/auth/auth.service';
import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub';
import { FileService } from '../../../../../core/shared/file.service';
import { FileServiceStub } from '../../../../../shared/testing/file-service.stub';
import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service';
import { AuthorizationDataServiceStub } from '../../../../../shared/testing/authorization-service.stub';
describe('CollectionAdminSearchResultGridElementComponent', () => { describe('CollectionAdminSearchResultGridElementComponent', () => {
let component: CollectionAdminSearchResultGridElementComponent; let component: CollectionAdminSearchResultGridElementComponent;
@@ -45,7 +51,10 @@ describe('CollectionAdminSearchResultGridElementComponent', () => {
providers: [ providers: [
{ provide: TruncatableService, useValue: mockTruncatableService }, { provide: TruncatableService, useValue: mockTruncatableService },
{ provide: BitstreamDataService, useValue: {} }, { provide: BitstreamDataService, useValue: {} },
{ provide: LinkService, useValue: linkService } { provide: LinkService, useValue: linkService },
{ provide: AuthService, useClass: AuthServiceStub },
{ provide: FileService, useClass: FileServiceStub },
{ provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub },
] ]
}) })
.compileComponents(); .compileComponents();

View File

@@ -16,6 +16,12 @@ import { CommunitySearchResult } from '../../../../../shared/object-collection/s
import { Community } from '../../../../../core/shared/community.model'; import { Community } from '../../../../../core/shared/community.model';
import { getCommunityEditRoute } from '../../../../../community-page/community-page-routing-paths'; import { getCommunityEditRoute } from '../../../../../community-page/community-page-routing-paths';
import { LinkService } from '../../../../../core/cache/builders/link.service'; import { LinkService } from '../../../../../core/cache/builders/link.service';
import { AuthService } from '../../../../../core/auth/auth.service';
import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub';
import { FileService } from '../../../../../core/shared/file.service';
import { FileServiceStub } from '../../../../../shared/testing/file-service.stub';
import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service';
import { AuthorizationDataServiceStub } from '../../../../../shared/testing/authorization-service.stub';
describe('CommunityAdminSearchResultGridElementComponent', () => { describe('CommunityAdminSearchResultGridElementComponent', () => {
let component: CommunityAdminSearchResultGridElementComponent; let component: CommunityAdminSearchResultGridElementComponent;
@@ -47,7 +53,10 @@ describe('CommunityAdminSearchResultGridElementComponent', () => {
providers: [ providers: [
{ provide: TruncatableService, useValue: mockTruncatableService }, { provide: TruncatableService, useValue: mockTruncatableService },
{ provide: BitstreamDataService, useValue: {} }, { provide: BitstreamDataService, useValue: {} },
{ provide: LinkService, useValue: linkService } { provide: LinkService, useValue: linkService },
{ provide: AuthService, useClass: AuthServiceStub },
{ provide: FileService, useClass: FileServiceStub },
{ provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}) })

View File

@@ -18,6 +18,12 @@ import { ItemAdminSearchResultGridElementComponent } from './item-admin-search-r
import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils';
import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock';
import { ThemeService } from '../../../../../shared/theme-support/theme.service'; import { ThemeService } from '../../../../../shared/theme-support/theme.service';
import { AuthService } from '../../../../../core/auth/auth.service';
import { AuthServiceStub } from '../../../../../shared/testing/auth-service.stub';
import { FileService } from '../../../../../core/shared/file.service';
import { FileServiceStub } from '../../../../../shared/testing/file-service.stub';
import { AuthorizationDataService } from '../../../../../core/data/feature-authorization/authorization-data.service';
import { AuthorizationDataServiceStub } from '../../../../../shared/testing/authorization-service.stub';
describe('ItemAdminSearchResultGridElementComponent', () => { describe('ItemAdminSearchResultGridElementComponent', () => {
let component: ItemAdminSearchResultGridElementComponent; let component: ItemAdminSearchResultGridElementComponent;
@@ -55,6 +61,9 @@ describe('ItemAdminSearchResultGridElementComponent', () => {
{ provide: TruncatableService, useValue: mockTruncatableService }, { provide: TruncatableService, useValue: mockTruncatableService },
{ provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: BitstreamDataService, useValue: mockBitstreamDataService },
{ provide: ThemeService, useValue: mockThemeService }, { provide: ThemeService, useValue: mockThemeService },
{ provide: AuthService, useClass: AuthServiceStub },
{ provide: FileService, useClass: FileServiceStub },
{ provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}) })

View File

@@ -0,0 +1,8 @@
import { Observable, of as observableOf } from 'rxjs';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
export class AuthorizationDataServiceStub {
isAuthorized(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string): Observable<boolean> {
return observableOf(false);
}
}

View File

@@ -0,0 +1,7 @@
import { of as observableOf } from 'rxjs';
export class FileServiceStub {
retrieveFileDownloadLink() {
return observableOf(null);
}
}

View File

@@ -1,14 +1,15 @@
<div class="thumbnail" [class.limit-width]="limitWidth"> <div class="thumbnail" [class.limit-width]="limitWidth" *ngVar="(isLoading$ | async) as isLoading">
<ds-loading *ngIf="isLoading; else showThumbnail" class="thumbnail-content" [showMessage]="false"> <ds-loading *ngIf="isLoading" class="thumbnail-content" [showMessage]="false">
text-content text-content
</ds-loading> </ds-loading>
<ng-template #showThumbnail> <ng-container *ngVar="(src$ | async) as src">
<img *ngIf="src !== null" class="thumbnail-content img-fluid" <!-- don't use *ngIf="!isLoading" so the thumbnail can load in while the animation is playing -->
[src]="src | dsSafeUrl" [alt]="alt | translate" (error)="errorHandler()"> <img *ngIf="src !== null" class="thumbnail-content img-fluid" [ngClass]="{'d-none': isLoading}"
<div *ngIf="src === null" class="thumbnail-content outer"> [src]="src | dsSafeUrl" [alt]="alt | translate" (error)="errorHandler()" (load)="successHandler()">
<div *ngIf="src === null && !isLoading" class="thumbnail-content outer">
<div class="inner"> <div class="inner">
<div class="thumbnail-placeholder w-100 h-100 p-3 lead">{{ placeholder | translate }}</div> <div class="thumbnail-placeholder w-100 h-100 p-3 lead">{{ placeholder | translate }}</div>
</div> </div>
</div> </div>
</ng-template> </ng-container>
</div> </div>

View File

@@ -3,12 +3,15 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { Bitstream } from '../core/shared/bitstream.model'; import { Bitstream } from '../core/shared/bitstream.model';
import { SafeUrlPipe } from '../shared/utils/safe-url-pipe'; import { SafeUrlPipe } from '../shared/utils/safe-url-pipe';
import { of as observableOf } from 'rxjs';
import { ThumbnailComponent } from './thumbnail.component'; import { ThumbnailComponent } from './thumbnail.component';
import { RemoteData } from '../core/data/remote-data'; import { RemoteData } from '../core/data/remote-data';
import { import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject } from '../shared/remote-data.utils';
createFailedRemoteDataObject, createPendingRemoteDataObject, createSuccessfulRemoteDataObject, import { AuthService } from '../core/auth/auth.service';
} from '../shared/remote-data.utils'; import { FileService } from '../core/shared/file.service';
import { VarDirective } from '../shared/utils/var.directive';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
// tslint:disable-next-line:pipe-prefix // tslint:disable-next-line:pipe-prefix
@Pipe({ name: 'translate' }) @Pipe({ name: 'translate' })
@@ -18,143 +21,311 @@ class MockTranslatePipe implements PipeTransform {
} }
} }
const CONTENT = 'content.url';
describe('ThumbnailComponent', () => { describe('ThumbnailComponent', () => {
let comp: ThumbnailComponent; let comp: ThumbnailComponent;
let fixture: ComponentFixture<ThumbnailComponent>; let fixture: ComponentFixture<ThumbnailComponent>;
let de: DebugElement; let de: DebugElement;
let el: HTMLElement; let el: HTMLElement;
let authService;
let authorizationService;
let fileService;
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
authService = jasmine.createSpyObj('AuthService', {
isAuthenticated: observableOf(true),
});
authorizationService = jasmine.createSpyObj('AuthorizationService', {
isAuthorized: observableOf(true),
});
fileService = jasmine.createSpyObj('FileService', {
retrieveFileDownloadLink: null
});
fileService.retrieveFileDownloadLink.and.callFake((url) => observableOf(`${url}?authentication-token=fake`));
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ThumbnailComponent, SafeUrlPipe, MockTranslatePipe], declarations: [ThumbnailComponent, SafeUrlPipe, MockTranslatePipe, VarDirective],
providers: [
{ provide: AuthService, useValue: authService },
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: FileService, useValue: fileService }
]
}).compileComponents(); }).compileComponents();
})); }));
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(ThumbnailComponent); fixture = TestBed.createComponent(ThumbnailComponent);
fixture.detectChanges();
authService = TestBed.inject(AuthService);
comp = fixture.componentInstance; // ThumbnailComponent test instance comp = fixture.componentInstance; // ThumbnailComponent test instance
de = fixture.debugElement.query(By.css('div.thumbnail')); de = fixture.debugElement.query(By.css('div.thumbnail'));
el = de.nativeElement; el = de.nativeElement;
}); });
const withoutThumbnail = () => { describe('loading', () => {
describe('and there is a default image', () => { it('should start out with isLoading$ true', () => {
it('should display the default image', () => { expect(comp.isLoading$.getValue()).toBeTrue();
comp.src = 'http://bit.stream'; });
comp.defaultImage = 'http://default.img';
comp.errorHandler(); it('should set isLoading$ to false once an image is successfully loaded', () => {
expect(comp.src).toBe(comp.defaultImage); comp.setSrc('http://bit.stream');
}); fixture.debugElement.query(By.css('img.thumbnail-content')).triggerEventHandler('load', new Event('load'));
it('should include the alt text', () => { expect(comp.isLoading$.getValue()).toBeFalse();
comp.src = 'http://bit.stream'; });
comp.defaultImage = 'http://default.img';
comp.errorHandler(); it('should set isLoading$ to false once the src is set to null', () => {
comp.ngOnChanges(); comp.setSrc(null);
expect(comp.isLoading$.getValue()).toBeFalse();
});
it('should show a loading animation while isLoading$ is true', () => {
expect(de.query(By.css('ds-loading'))).toBeTruthy();
comp.isLoading$.next(false);
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('ds-loading'))).toBeFalsy();
});
describe('with a thumbnail image', () => {
beforeEach(() => {
comp.src$.next('https://bit.stream');
fixture.detectChanges(); fixture.detectChanges();
const image: HTMLElement = de.query(By.css('img')).nativeElement; });
it('should render but hide the image while loading and show it once done', () => {
let img = fixture.debugElement.query(By.css('img.thumbnail-content'));
expect(img).toBeTruthy();
expect(img.classes['d-none']).toBeTrue();
comp.isLoading$.next(false);
fixture.detectChanges();
img = fixture.debugElement.query(By.css('img.thumbnail-content'));
expect(img).toBeTruthy();
expect(img.classes['d-none']).toBeFalsy();
});
});
describe('without a thumbnail image', () => {
beforeEach(() => {
comp.src$.next(null);
fixture.detectChanges();
});
it('should only show the HTML placeholder once done loading', () => {
expect(fixture.debugElement.query(By.css('div.thumbnail-placeholder'))).toBeFalsy();
comp.isLoading$.next(false);
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('div.thumbnail-placeholder'))).toBeTruthy();
});
});
});
const errorHandler = () => {
let fallbackSpy;
beforeEach(() => {
fallbackSpy = spyOn(comp, 'showFallback').and.callThrough();
});
describe('retry with authentication token', () => {
beforeEach(() => {
// disconnect error handler to be sure it's only called once
const img = fixture.debugElement.query(By.css('img.thumbnail-content'));
img.nativeNode.onerror = null;
});
it('should remember that it already retried once', () => {
expect(comp.retriedWithToken).toBeFalse();
comp.errorHandler();
expect(comp.retriedWithToken).toBeTrue();
});
describe('if not logged in', () => {
beforeEach(() => {
authService.isAuthenticated.and.returnValue(observableOf(false));
});
it('should fall back to default', () => {
comp.errorHandler();
expect(fallbackSpy).toHaveBeenCalled();
});
});
describe('if logged in', () => {
beforeEach(() => {
authService.isAuthenticated.and.returnValue(observableOf(true));
});
describe('and authorized to download the thumbnail', () => {
beforeEach(() => {
authorizationService.isAuthorized.and.returnValue(observableOf(true));
});
it('should add an authentication token to the thumbnail URL', () => {
comp.errorHandler();
if ((comp.thumbnail as RemoteData<Bitstream>)?.hasFailed) {
// If we failed to retrieve the Bitstream in the first place, fall back to the default
expect(comp.src$.getValue()).toBe(null);
expect(fallbackSpy).toHaveBeenCalled();
} else {
expect(comp.src$.getValue()).toBe(CONTENT + '?authentication-token=fake');
expect(fallbackSpy).not.toHaveBeenCalled();
}
});
});
describe('but not authorized to download the thumbnail', () => {
beforeEach(() => {
authorizationService.isAuthorized.and.returnValue(observableOf(false));
});
it('should fall back to default', () => {
comp.errorHandler();
expect(comp.src$.getValue()).toBe(null);
expect(fallbackSpy).toHaveBeenCalled();
// We don't need to check authorization if we failed to retrieve the Bitstreamin the first place
if (!(comp.thumbnail as RemoteData<Bitstream>)?.hasFailed) {
expect(authorizationService.isAuthorized).toHaveBeenCalled();
}
});
});
});
});
describe('after retrying with token', () => {
beforeEach(() => {
comp.retriedWithToken = true;
});
it('should fall back to default', () => {
comp.errorHandler();
expect(authService.isAuthenticated).not.toHaveBeenCalled();
expect(fileService.retrieveFileDownloadLink).not.toHaveBeenCalled();
expect(fallbackSpy).toHaveBeenCalled();
});
});
};
describe('fallback', () => {
describe('if there is a default image', () => {
it('should display the default image', () => {
comp.src$.next('http://bit.stream');
comp.defaultImage = 'http://default.img';
comp.errorHandler();
expect(comp.src$.getValue()).toBe(comp.defaultImage);
});
it('should include the alt text', () => {
comp.src$.next('http://bit.stream');
comp.defaultImage = 'http://default.img';
comp.errorHandler();
fixture.detectChanges();
const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement;
expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt); expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt);
}); });
}); });
describe('and there is no default image', () => {
it('should display the placeholder', () => {
comp.src = 'http://default.img';
comp.errorHandler();
expect(comp.src).toBe(null);
comp.ngOnChanges(); describe('if there is no default image', () => {
it('should display the HTML placeholder', () => {
comp.src$.next('http://default.img');
comp.defaultImage = null;
comp.errorHandler();
expect(comp.src$.getValue()).toBe(null);
fixture.detectChanges(); fixture.detectChanges();
const placeholder = fixture.debugElement.query(By.css('div.thumbnail-placeholder')).nativeElement; const placeholder = fixture.debugElement.query(By.css('div.thumbnail-placeholder')).nativeElement;
expect(placeholder.innerHTML).toBe('TRANSLATED ' + comp.placeholder); expect(placeholder.innerHTML).toBe('TRANSLATED ' + comp.placeholder);
}); });
}); });
}; });
describe('with thumbnail as Bitstream', () => { describe('with thumbnail as Bitstream', () => {
let thumbnail: Bitstream; let thumbnail;
beforeEach(() => { beforeEach(() => {
thumbnail = new Bitstream(); thumbnail = new Bitstream();
thumbnail._links = { thumbnail._links = {
self: { href: 'self.url' }, self: { href: 'self.url' },
bundle: { href: 'bundle.url' }, bundle: { href: 'bundle.url' },
format: { href: 'format.url' }, format: { href: 'format.url' },
content: { href: 'content.url' }, content: { href: CONTENT },
thumbnail: undefined, thumbnail: undefined,
}; };
comp.thumbnail = thumbnail;
}); });
it('should display an image', () => { it('should display an image', () => {
comp.thumbnail = thumbnail;
comp.ngOnChanges(); comp.ngOnChanges();
fixture.detectChanges(); fixture.detectChanges();
const image: HTMLElement = de.query(By.css('img')).nativeElement; const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement;
expect(image.getAttribute('src')).toBe(comp.thumbnail._links.content.href); expect(image.getAttribute('src')).toBe(thumbnail._links.content.href);
}); });
it('should include the alt text', () => { it('should include the alt text', () => {
comp.thumbnail = thumbnail;
comp.ngOnChanges(); comp.ngOnChanges();
fixture.detectChanges(); fixture.detectChanges();
const image: HTMLElement = de.query(By.css('img')).nativeElement; const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement;
expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt); expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt);
}); });
describe('when there is no thumbnail', () => { describe('when there is no thumbnail', () => {
withoutThumbnail(); errorHandler();
}); });
}); });
describe('with thumbnail as RemoteData<Bitstream>', () => { describe('with thumbnail as RemoteData<Bitstream>', () => {
let thumbnail: RemoteData<Bitstream>; let thumbnail: Bitstream;
describe('while loading', () => { beforeEach(() => {
beforeEach(() => { thumbnail = new Bitstream();
thumbnail = createPendingRemoteDataObject(); thumbnail._links = {
}); self: { href: 'self.url' },
bundle: { href: 'bundle.url' },
it('should show a loading animation', () => { format: { href: 'format.url' },
comp.thumbnail = thumbnail; content: { href: CONTENT },
comp.ngOnChanges(); thumbnail: undefined
fixture.detectChanges(); };
expect(de.query(By.css('ds-loading'))).toBeTruthy();
});
}); });
describe('when there is a thumbnail', () => { describe('when there is a thumbnail', () => {
beforeEach(() => { beforeEach(() => {
const bitstream = new Bitstream(); comp.thumbnail = createSuccessfulRemoteDataObject(thumbnail);
bitstream._links = {
self: { href: 'self.url' },
bundle: { href: 'bundle.url' },
format: { href: 'format.url' },
content: { href: 'content.url' },
thumbnail: undefined,
};
thumbnail = createSuccessfulRemoteDataObject(bitstream);
}); });
it('should display an image', () => { it('should display an image', () => {
comp.thumbnail = thumbnail;
comp.ngOnChanges(); comp.ngOnChanges();
fixture.detectChanges(); fixture.detectChanges();
const image: HTMLElement = de.query(By.css('img')).nativeElement; const image: HTMLElement = de.query(By.css('img')).nativeElement;
expect(image.getAttribute('src')).toBe(comp.thumbnail.payload._links.content.href); expect(image.getAttribute('src')).toBe(thumbnail._links.content.href);
}); });
it('should display the alt text', () => { it('should display the alt text', () => {
comp.thumbnail = thumbnail;
comp.ngOnChanges(); comp.ngOnChanges();
fixture.detectChanges(); fixture.detectChanges();
const image: HTMLElement = de.query(By.css('img')).nativeElement; const image: HTMLElement = de.query(By.css('img')).nativeElement;
expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt); expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt);
}); });
describe('but it can\'t be loaded', () => {
errorHandler();
});
}); });
describe('when there is no thumbnail', () => { describe('when there is no thumbnail', () => {
beforeEach(() => { beforeEach(() => {
thumbnail = createFailedRemoteDataObject(); comp.thumbnail = createFailedRemoteDataObject();
}); });
withoutThumbnail(); errorHandler();
}); });
}); });
}); });

View File

@@ -1,7 +1,13 @@
import { Component, Input, OnChanges } from '@angular/core'; import { Component, Input, OnChanges } from '@angular/core';
import { Bitstream } from '../core/shared/bitstream.model'; import { Bitstream } from '../core/shared/bitstream.model';
import { hasValue } from '../shared/empty.util'; import { hasNoValue, hasValue } from '../shared/empty.util';
import { RemoteData } from '../core/data/remote-data'; import { RemoteData } from '../core/data/remote-data';
import { BehaviorSubject, of as observableOf } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { FeatureID } from '../core/data/feature-authorization/feature-id';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
import { AuthService } from '../core/auth/auth.service';
import { FileService } from '../core/shared/file.service';
/** /**
* This component renders a given Bitstream as a thumbnail. * This component renders a given Bitstream as a thumbnail.
@@ -28,7 +34,9 @@ export class ThumbnailComponent implements OnChanges {
/** /**
* The src attribute used in the template to render the image. * The src attribute used in the template to render the image.
*/ */
src: string = null; src$ = new BehaviorSubject<string>(undefined);
retriedWithToken = false;
/** /**
* i18n key of thumbnail alt text * i18n key of thumbnail alt text
@@ -45,49 +53,123 @@ export class ThumbnailComponent implements OnChanges {
*/ */
@Input() limitWidth? = true; @Input() limitWidth? = true;
isLoading: boolean; /**
* Whether the thumbnail is currently loading
* Start out as true to avoid flashing the alt text while a thumbnail is being loaded.
*/
isLoading$ = new BehaviorSubject(true);
constructor(
protected auth: AuthService,
protected authorizationService: AuthorizationDataService,
protected fileService: FileService,
) {
}
/** /**
* Resolve the thumbnail. * Resolve the thumbnail.
* Use a default image if no actual image is available. * Use a default image if no actual image is available.
*/ */
ngOnChanges(): void { ngOnChanges(): void {
if (this.thumbnail === undefined || this.thumbnail === null) { if (hasNoValue(this.thumbnail)) {
return; return;
} }
if (this.thumbnail instanceof Bitstream) {
this.resolveThumbnail(this.thumbnail as Bitstream); const thumbnail = this.bitstream;
if (hasValue(thumbnail?._links?.content?.href)) {
this.setSrc(thumbnail?._links?.content?.href);
} else { } else {
const thumbnailRD = this.thumbnail as RemoteData<Bitstream>; this.showFallback();
if (thumbnailRD.isLoading) {
this.isLoading = true;
} else {
this.resolveThumbnail(thumbnailRD.payload as Bitstream);
}
} }
} }
private resolveThumbnail(thumbnail: Bitstream): void { /**
if (hasValue(thumbnail) && hasValue(thumbnail._links) * The current thumbnail Bitstream
&& hasValue(thumbnail._links.content) * @private
&& thumbnail._links.content.href) { */
this.src = thumbnail._links.content.href; private get bitstream(): Bitstream {
} else { if (this.thumbnail instanceof Bitstream) {
this.src = this.defaultImage; return this.thumbnail as Bitstream;
} else if (this.thumbnail instanceof RemoteData) {
return (this.thumbnail as RemoteData<Bitstream>).payload;
} }
this.isLoading = false;
} }
/** /**
* Handle image download errors. * Handle image download errors.
* If the image can't be found, use the defaultImage instead. * If the image can't be loaded, try re-requesting it with an authorization token in case it's a restricted Bitstream
* If that also can't be found, use null to fall back to the HTML placeholder. * Otherwise, fall back to the default image or a HTML placeholder
*/ */
errorHandler() { errorHandler() {
if (this.src !== this.defaultImage) { if (!this.retriedWithToken && hasValue(this.thumbnail)) {
this.src = this.defaultImage; // the thumbnail may have failed to load because it's restricted
// → retry with an authorization token
// only do this once; fall back to the default if it still fails
this.retriedWithToken = true;
const thumbnail = this.bitstream;
this.auth.isAuthenticated().pipe(
switchMap((isLoggedIn) => {
if (isLoggedIn && hasValue(thumbnail)) {
return this.authorizationService.isAuthorized(FeatureID.CanDownload, thumbnail.self);
} else {
return observableOf(false);
}
}),
switchMap((isAuthorized) => {
if (isAuthorized) {
return this.fileService.retrieveFileDownloadLink(thumbnail._links.content.href);
} else {
return observableOf(null);
}
})
).subscribe((url: string) => {
if (hasValue(url)) {
// If we got a URL, try to load it
// (if it still fails this method will be called again, and we'll fall back to the default)
// Otherwise, fall back to the default image right now
this.setSrc(url);
} else {
this.showFallback();
}
});
} else { } else {
this.src = null; this.showFallback();
} }
} }
/**
* To be called when the requested thumbnail could not be found
* - If the current src is not the default image, try that first
* - If this was already the case and the default image could not be found either,
* show an HTML placecholder by setting src to null
*
* Also stops the loading animation.
*/
showFallback() {
if (this.src$.getValue() !== this.defaultImage) {
this.setSrc(this.defaultImage);
} else {
this.setSrc(null);
}
}
/**
* Set the thumbnail.
* Stop the loading animation if setting to null.
* @param src
*/
setSrc(src: string): void {
this.src$.next(src);
if (src === null) {
this.isLoading$.next(false);
}
}
/**
* Stop the loading animation once the thumbnail is successfully loaded
*/
successHandler() {
this.isLoading$.next(false);
}
} }