diff --git a/angular.json b/angular.json index a0a4cd8ea1..7a83fcce31 100644 --- a/angular.json +++ b/angular.json @@ -64,7 +64,8 @@ "bundleName": "dspace-theme" } ], - "scripts": [] + "scripts": [], + "baseHref": "/" }, "configurations": { "production": { diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts index 2cb0413bbc..0f470fcbee 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts @@ -14,6 +14,12 @@ import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; import { getCollectionEditRoute } from '../../../../../collection-page/collection-page-routing-paths'; 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', () => { let component: CollectionAdminSearchResultGridElementComponent; @@ -45,7 +51,10 @@ describe('CollectionAdminSearchResultGridElementComponent', () => { providers: [ { provide: TruncatableService, useValue: mockTruncatableService }, { provide: BitstreamDataService, useValue: {} }, - { provide: LinkService, useValue: linkService } + { provide: LinkService, useValue: linkService }, + { provide: AuthService, useClass: AuthServiceStub }, + { provide: FileService, useClass: FileServiceStub }, + { provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub }, ] }) .compileComponents(); diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts index 17ce2cd7a1..c9f2cda02a 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts @@ -16,6 +16,12 @@ import { CommunitySearchResult } from '../../../../../shared/object-collection/s import { Community } from '../../../../../core/shared/community.model'; import { getCommunityEditRoute } from '../../../../../community-page/community-page-routing-paths'; 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', () => { let component: CommunityAdminSearchResultGridElementComponent; @@ -47,7 +53,10 @@ describe('CommunityAdminSearchResultGridElementComponent', () => { providers: [ { provide: TruncatableService, useValue: mockTruncatableService }, { 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] }) diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts index dedada5f5f..7488b935f3 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts @@ -18,6 +18,12 @@ import { ItemAdminSearchResultGridElementComponent } from './item-admin-search-r import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; 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', () => { let component: ItemAdminSearchResultGridElementComponent; @@ -55,6 +61,9 @@ describe('ItemAdminSearchResultGridElementComponent', () => { { provide: TruncatableService, useValue: mockTruncatableService }, { provide: BitstreamDataService, useValue: mockBitstreamDataService }, { provide: ThemeService, useValue: mockThemeService }, + { provide: AuthService, useClass: AuthServiceStub }, + { provide: FileService, useClass: FileServiceStub }, + { provide: AuthorizationDataService, useClass: AuthorizationDataServiceStub }, ], schemas: [NO_ERRORS_SCHEMA] }) diff --git a/src/app/shared/testing/authorization-service.stub.ts b/src/app/shared/testing/authorization-service.stub.ts new file mode 100644 index 0000000000..253599233e --- /dev/null +++ b/src/app/shared/testing/authorization-service.stub.ts @@ -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 { + return observableOf(false); + } +} diff --git a/src/app/shared/testing/file-service.stub.ts b/src/app/shared/testing/file-service.stub.ts new file mode 100644 index 0000000000..c675df83e1 --- /dev/null +++ b/src/app/shared/testing/file-service.stub.ts @@ -0,0 +1,7 @@ +import { of as observableOf } from 'rxjs'; + +export class FileServiceStub { + retrieveFileDownloadLink() { + return observableOf(null); + } +} diff --git a/src/app/thumbnail/thumbnail.component.html b/src/app/thumbnail/thumbnail.component.html index bf70928392..0a6cdedc26 100644 --- a/src/app/thumbnail/thumbnail.component.html +++ b/src/app/thumbnail/thumbnail.component.html @@ -1,14 +1,15 @@ -
- +
+ text-content - - -
+ + + +
{{ placeholder | translate }}
- +
diff --git a/src/app/thumbnail/thumbnail.component.spec.ts b/src/app/thumbnail/thumbnail.component.spec.ts index eea585f9f8..5d74df02b0 100644 --- a/src/app/thumbnail/thumbnail.component.spec.ts +++ b/src/app/thumbnail/thumbnail.component.spec.ts @@ -3,12 +3,15 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { Bitstream } from '../core/shared/bitstream.model'; import { SafeUrlPipe } from '../shared/utils/safe-url-pipe'; +import { of as observableOf } from 'rxjs'; import { ThumbnailComponent } from './thumbnail.component'; import { RemoteData } from '../core/data/remote-data'; -import { - createFailedRemoteDataObject, createPendingRemoteDataObject, createSuccessfulRemoteDataObject, -} from '../shared/remote-data.utils'; +import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject } from '../shared/remote-data.utils'; +import { AuthService } from '../core/auth/auth.service'; +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 @Pipe({ name: 'translate' }) @@ -18,143 +21,311 @@ class MockTranslatePipe implements PipeTransform { } } +const CONTENT = 'content.url'; + describe('ThumbnailComponent', () => { let comp: ThumbnailComponent; let fixture: ComponentFixture; let de: DebugElement; let el: HTMLElement; + let authService; + let authorizationService; + let fileService; 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({ - declarations: [ThumbnailComponent, SafeUrlPipe, MockTranslatePipe], + declarations: [ThumbnailComponent, SafeUrlPipe, MockTranslatePipe, VarDirective], + providers: [ + { provide: AuthService, useValue: authService }, + { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: FileService, useValue: fileService } + ] }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(ThumbnailComponent); + fixture.detectChanges(); + + authService = TestBed.inject(AuthService); + comp = fixture.componentInstance; // ThumbnailComponent test instance de = fixture.debugElement.query(By.css('div.thumbnail')); el = de.nativeElement; }); - const withoutThumbnail = () => { - describe('and there is a default image', () => { - it('should display the default image', () => { - comp.src = 'http://bit.stream'; - comp.defaultImage = 'http://default.img'; - comp.errorHandler(); - expect(comp.src).toBe(comp.defaultImage); - }); - it('should include the alt text', () => { - comp.src = 'http://bit.stream'; - comp.defaultImage = 'http://default.img'; - comp.errorHandler(); - comp.ngOnChanges(); + describe('loading', () => { + it('should start out with isLoading$ true', () => { + expect(comp.isLoading$.getValue()).toBeTrue(); + }); + + it('should set isLoading$ to false once an image is successfully loaded', () => { + comp.setSrc('http://bit.stream'); + fixture.debugElement.query(By.css('img.thumbnail-content')).triggerEventHandler('load', new Event('load')); + expect(comp.isLoading$.getValue()).toBeFalse(); + }); + + it('should set isLoading$ to false once the src is set to null', () => { + 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(); - 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)?.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)?.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); }); }); - 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(); const placeholder = fixture.debugElement.query(By.css('div.thumbnail-placeholder')).nativeElement; expect(placeholder.innerHTML).toBe('TRANSLATED ' + comp.placeholder); }); }); - }; + }); describe('with thumbnail as Bitstream', () => { - let thumbnail: Bitstream; + let thumbnail; beforeEach(() => { thumbnail = new Bitstream(); thumbnail._links = { self: { href: 'self.url' }, bundle: { href: 'bundle.url' }, format: { href: 'format.url' }, - content: { href: 'content.url' }, + content: { href: CONTENT }, thumbnail: undefined, }; + comp.thumbnail = thumbnail; }); it('should display an image', () => { - comp.thumbnail = thumbnail; comp.ngOnChanges(); fixture.detectChanges(); - const image: HTMLElement = de.query(By.css('img')).nativeElement; - expect(image.getAttribute('src')).toBe(comp.thumbnail._links.content.href); + const image: HTMLElement = fixture.debugElement.query(By.css('img')).nativeElement; + expect(image.getAttribute('src')).toBe(thumbnail._links.content.href); }); it('should include the alt text', () => { - comp.thumbnail = thumbnail; comp.ngOnChanges(); 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); }); describe('when there is no thumbnail', () => { - withoutThumbnail(); + errorHandler(); }); }); describe('with thumbnail as RemoteData', () => { - let thumbnail: RemoteData; + let thumbnail: Bitstream; - describe('while loading', () => { - beforeEach(() => { - thumbnail = createPendingRemoteDataObject(); - }); - - it('should show a loading animation', () => { - comp.thumbnail = thumbnail; - comp.ngOnChanges(); - fixture.detectChanges(); - expect(de.query(By.css('ds-loading'))).toBeTruthy(); - }); + beforeEach(() => { + thumbnail = new Bitstream(); + thumbnail._links = { + self: { href: 'self.url' }, + bundle: { href: 'bundle.url' }, + format: { href: 'format.url' }, + content: { href: CONTENT }, + thumbnail: undefined + }; }); describe('when there is a thumbnail', () => { beforeEach(() => { - const bitstream = new Bitstream(); - bitstream._links = { - self: { href: 'self.url' }, - bundle: { href: 'bundle.url' }, - format: { href: 'format.url' }, - content: { href: 'content.url' }, - thumbnail: undefined, - }; - thumbnail = createSuccessfulRemoteDataObject(bitstream); + comp.thumbnail = createSuccessfulRemoteDataObject(thumbnail); }); it('should display an image', () => { - comp.thumbnail = thumbnail; comp.ngOnChanges(); fixture.detectChanges(); 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', () => { - comp.thumbnail = thumbnail; comp.ngOnChanges(); fixture.detectChanges(); const image: HTMLElement = de.query(By.css('img')).nativeElement; expect(image.getAttribute('alt')).toBe('TRANSLATED ' + comp.alt); }); + + describe('but it can\'t be loaded', () => { + errorHandler(); + }); }); describe('when there is no thumbnail', () => { beforeEach(() => { - thumbnail = createFailedRemoteDataObject(); + comp.thumbnail = createFailedRemoteDataObject(); }); - withoutThumbnail(); + errorHandler(); }); }); }); diff --git a/src/app/thumbnail/thumbnail.component.ts b/src/app/thumbnail/thumbnail.component.ts index 3e122cde78..ac0992c345 100644 --- a/src/app/thumbnail/thumbnail.component.ts +++ b/src/app/thumbnail/thumbnail.component.ts @@ -1,7 +1,13 @@ import { Component, Input, OnChanges } from '@angular/core'; 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 { 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. @@ -28,7 +34,9 @@ export class ThumbnailComponent implements OnChanges { /** * The src attribute used in the template to render the image. */ - src: string = null; + src$ = new BehaviorSubject(undefined); + + retriedWithToken = false; /** * i18n key of thumbnail alt text @@ -45,49 +53,123 @@ export class ThumbnailComponent implements OnChanges { */ @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. * Use a default image if no actual image is available. */ ngOnChanges(): void { - if (this.thumbnail === undefined || this.thumbnail === null) { + if (hasNoValue(this.thumbnail)) { 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 { - const thumbnailRD = this.thumbnail as RemoteData; - if (thumbnailRD.isLoading) { - this.isLoading = true; - } else { - this.resolveThumbnail(thumbnailRD.payload as Bitstream); - } + this.showFallback(); } } - private resolveThumbnail(thumbnail: Bitstream): void { - if (hasValue(thumbnail) && hasValue(thumbnail._links) - && hasValue(thumbnail._links.content) - && thumbnail._links.content.href) { - this.src = thumbnail._links.content.href; - } else { - this.src = this.defaultImage; + /** + * The current thumbnail Bitstream + * @private + */ + private get bitstream(): Bitstream { + if (this.thumbnail instanceof Bitstream) { + return this.thumbnail as Bitstream; + } else if (this.thumbnail instanceof RemoteData) { + return (this.thumbnail as RemoteData).payload; } - this.isLoading = false; } /** * Handle image download errors. - * If the image can't be found, use the defaultImage instead. - * If that also can't be found, use null to fall back to the HTML placeholder. + * If the image can't be loaded, try re-requesting it with an authorization token in case it's a restricted Bitstream + * Otherwise, fall back to the default image or a HTML placeholder */ errorHandler() { - if (this.src !== this.defaultImage) { - this.src = this.defaultImage; + if (!this.retriedWithToken && hasValue(this.thumbnail)) { + // 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 { - 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); + } }