Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,32 @@
<img [src]="file.url" alt="uploaded attachment preview" i18n-alt>
</ng-container>

<ng-container *ngIf="file?.type.includes('video')">
<video controls>
<ng-container *ngIf="file?.url">
<source [src]="file.url"
[type]="file.type">
{{ file.url }}
</ng-container>
<ng-container *ngIf="isBrowserSupportedVideo()">
<video
width="100%"
controls
controlsList="nodownload"
preload="metadata"
playsinline
[src]="file.url"
(error)="handleVideoError($event)"
>
<p i18n="video not supported message">
Your browser doesn't support HTML5 video.
Here is a <a [href]="file.url">link to the video</a> instead.
</p>
</video>
</ng-container>

<ng-container *ngIf="file?.type?.includes('video') && !isBrowserSupportedVideo()">
<div class="ion-padding ion-text-center">
<ion-icon name="videocam-off-outline" size="large" color="medium"></ion-icon>
<p class="body-2" i18n="unsupported video format message">
This video format is not supported for preview.
<br>
<a [href]="file.url" target="_blank" rel="noopener">Download to view</a>
</p>
</div>
</ng-container>
</div>
</ion-content>
28 changes: 28 additions & 0 deletions projects/v3/src/app/components/file-popup/file-popup.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ export class FilePopupComponent {
public sanitizer: DomSanitizer
) {}

/**
* @description checks if video format is natively supported by browsers.
* only mp4, webm, and ogg are widely supported.
*/
isBrowserSupportedVideo(): boolean {
if (!this.file?.type || !this.file.type.includes('video')) {
return false;
}
const supportedFormats = ['video/mp4', 'video/webm', 'video/ogg'];
return supportedFormats.some(format => this.file.type.includes(format));
}

download(keyboardEvent?: KeyboardEvent) {
if (keyboardEvent && (keyboardEvent?.code === 'Space' || keyboardEvent?.code === 'Enter')) {
keyboardEvent.preventDefault();
Expand All @@ -34,4 +46,20 @@ export class FilePopupComponent {

this.modalController.dismiss();
}

handleVideoError(videoError: Event): void {
console.error('Video playback error:', videoError);
const target = videoError.target as HTMLVideoElement;
if (target?.error) {
const errorCode = target.error.code;
const errorMessage = target.error.message;
console.error('Video error details:', {
code: errorCode,
message: errorMessage,
src: target.src,
networkState: target.networkState,
readyState: target.readyState
});
}
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
.body-2 {
overflow-wrap: anywhere;
}

.icon-container {
margin-right: 20px;
font-size: 24px !important;
Expand Down
2 changes: 1 addition & 1 deletion projects/v3/src/app/components/topic/topic.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
[title]="file.name"
leadingIcon="document"
lines="full"
[endingActionBtnIcons]="['download', 'search']"
[endingActionBtnIcons]="getFileActionIcons(file)"
(actionBtnClick)="actionBtnClick(file, $event)"
></app-list-item>

Expand Down
173 changes: 169 additions & 4 deletions projects/v3/src/app/components/topic/topic.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,14 +151,179 @@ describe('TopicComponent', () => {

describe('actionBtnClick', () => {
it('should call downloadFile when index 0', () => {
component.actionBtnClick({} as any, 0);
component.actionBtnClick({ url: 'https://example.com/file.pdf' } as any, 0);
expect(utilsSpy.downloadFile).toHaveBeenCalled();
});

it('should call previewFile when index 1', () => {
it('should call previewFile when index 1 and url is filestack with supported type', () => {
spyOn(component, 'previewFile');
component.actionBtnClick({} as any, 1);
expect(component.previewFile).toHaveBeenCalled();
const file = { url: 'https://cdn.filestackcontent.com/abc123', name: 'doc.pdf' };
component.actionBtnClick(file, 1);
expect(component.previewFile).toHaveBeenCalledWith(file);
});

it('should open video modal when index 1 and file is mp4 video', () => {
spyOn(component, 'previewVideoFile');
const file = { url: 'https://cdn.filestackcontent.com/abc123.mp4', name: 'video.mp4' };
component.actionBtnClick(file, 1);
expect(component.previewVideoFile).toHaveBeenCalledWith(file);
});

it('should open video modal when index 1 and file is webm video', () => {
spyOn(component, 'previewVideoFile');
const file = { url: 'https://cdn.filestackcontent.com/abc123.webm', name: 'video.webm' };
component.actionBtnClick(file, 1);
expect(component.previewVideoFile).toHaveBeenCalledWith(file);
});

it('should open video modal when index 1 and file is ogg video', () => {
spyOn(component, 'previewVideoFile');
const file = { url: 'https://cdn.filestackcontent.com/abc123.ogg', name: 'video.ogg' };
component.actionBtnClick(file, 1);
expect(component.previewVideoFile).toHaveBeenCalledWith(file);
});

it('should open new tab when index 1 and url is filestack but file is audio', () => {
spyOn(window, 'open');
spyOn(component, 'previewFile');
const file = { url: 'https://cdn.filestackcontent.com/abc123', name: 'recording.mp3' };
component.actionBtnClick(file, 1);
expect(component.previewFile).not.toHaveBeenCalled();
expect(window.open).toHaveBeenCalledWith(file.url, '_blank');
});

it('should open new tab when index 1 and url is not filestack', () => {
spyOn(window, 'open');
const file = { url: 'https://example.com/document.pdf', name: 'document.pdf' };
component.actionBtnClick(file, 1);
expect(window.open).toHaveBeenCalledWith(file.url, '_blank');
expect(notificationSpy.presentToast).toHaveBeenCalled();
});

it('should open new tab for non-filestack url even without extension', () => {
spyOn(window, 'open');
const file = { url: 'https://storage.example.com/files/12345', name: 'report' };
component.actionBtnClick(file, 1);
expect(window.open).toHaveBeenCalledWith(file.url, '_blank');
});
});

describe('getFileActionIcons', () => {
it('should return both download and search icons for filestack url with supported type', () => {
const file = { url: 'https://cdn.filestackcontent.com/abc123', name: 'document.pdf' };
const icons = component.getFileActionIcons(file);
expect(icons).toEqual(['download', 'search']);
});

it('should return both download and search icons for video files', () => {
const file = { url: 'https://cdn.filestackcontent.com/abc123.mp4', name: 'video.mp4' };
const icons = component.getFileActionIcons(file);
expect(icons).toEqual(['download', 'search']);
});

it('should return both download and search icons for non-filestack video', () => {
const file = { url: 'https://example.com/video.mp4', name: 'video.mp4' };
const icons = component.getFileActionIcons(file);
expect(icons).toEqual(['download', 'search']);
});

it('should return only download icon for filestack url with audio', () => {
const file = { url: 'https://cdn.filestackcontent.com/abc123', name: 'audio.mp3' };
const icons = component.getFileActionIcons(file);
expect(icons).toEqual(['download']);
});

it('should return only download icon for non-filestack non-video file', () => {
const file = { url: 'https://example.com/file.pdf', name: 'document.pdf' };
const icons = component.getFileActionIcons(file);
expect(icons).toEqual(['download']);
});

it('should return both download and search icons for webm video', () => {
const file = { url: 'https://example.com/video.webm', name: 'video.webm' };
const icons = component.getFileActionIcons(file);
expect(icons).toEqual(['download', 'search']);
});

it('should return both download and search icons for ogg video', () => {
const file = { url: 'https://example.com/video.ogg', name: 'video.ogg' };
const icons = component.getFileActionIcons(file);
expect(icons).toEqual(['download', 'search']);
});

it('should return only download icon for unsupported video formats', () => {
const formats = [
{ url: 'https://example.com/video.mov', name: 'video.mov' },
{ url: 'https://cdn.filestackcontent.com/abc123', name: 'file_example_AVI_640_800kB.avi' },
{ url: 'https://cdn.filestackcontent.com/abc123.wmv', name: 'video.wmv' },
{ url: 'https://example.com/video.mkv', name: 'video.mkv' },
];
for (const file of formats) {
const icons = component.getFileActionIcons(file);
expect(icons).withContext(file.name).toEqual(['download']);
}
});
});

describe('previewVideoFile', () => {
it('should open video modal with mp4 mime type', async () => {
const modalSpy = jasmine.createSpyObj('Modal', ['present']);
spyOn(component['modalController'], 'create').and.returnValue(Promise.resolve(modalSpy));

const file = { url: 'https://example.com/video.mp4', name: 'test.mp4' };
await component.previewVideoFile(file);

expect(component['modalController'].create).toHaveBeenCalledWith({
component: jasmine.anything(),
componentProps: {
file: {
url: file.url,
name: file.name,
type: 'video/mp4',
},
},
});
expect(modalSpy.present).toHaveBeenCalled();
});

it('should open video modal with webm mime type', async () => {
const modalSpy = jasmine.createSpyObj('Modal', ['present']);
spyOn(component['modalController'], 'create').and.returnValue(Promise.resolve(modalSpy));

const file = { url: 'https://example.com/video.webm', name: 'test.webm' };
await component.previewVideoFile(file);

expect(component['modalController'].create).toHaveBeenCalledWith({
component: jasmine.anything(),
componentProps: {
file: {
url: file.url,
name: file.name,
type: 'video/webm',
},
},
});
expect(modalSpy.present).toHaveBeenCalled();
});

it('should open video modal with ogg mime type', async () => {
const modalSpy = jasmine.createSpyObj('Modal', ['present']);
spyOn(component['modalController'], 'create').and.returnValue(Promise.resolve(modalSpy));

const file = { url: 'https://example.com/video.ogg', name: 'test.ogg' };
await component.previewVideoFile(file);

expect(component['modalController'].create).toHaveBeenCalledWith({
component: jasmine.anything(),
componentProps: {
file: {
url: file.url,
name: file.name,
type: 'video/ogg',
},
},
});
expect(modalSpy.present).toHaveBeenCalled();
});
});
});
87 changes: 86 additions & 1 deletion projects/v3/src/app/components/topic/topic.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { NotificationsService } from '@v3/app/services/notifications.service';
import { BehaviorSubject, exhaustMap, filter, finalize, Subject, Subscription, takeUntil } from 'rxjs';
import { Task } from '@v3/app/services/activity.service';
import { ComponentCleanupService } from '@v3/app/services/component-cleanup.service';
import { ModalController } from '@ionic/angular';
import { FilePopupComponent } from '../file-popup/file-popup.component';

@Component({
selector: 'app-topic',
Expand Down Expand Up @@ -52,6 +54,7 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe
private sanitizer: DomSanitizer,
private cleanupService: ComponentCleanupService,
private cdr: ChangeDetectorRef,
private modalController: ModalController,
@Inject(DOCUMENT) private readonly document: Document
) {
this.isMobile = this.utils.isMobile();
Expand Down Expand Up @@ -299,11 +302,93 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe
this.utils.downloadFile(file.url);
break;
case 1:
this.previewFile(file);
if (this._isVideoFile(file)) {
// show browser-supported video in modal with html5 player
this.previewVideoFile(file);
} else if (this._isFilestackUrl(file.url) && this._isFilestackPreviewSupported(file)) {
// show filestack document viewer
this.previewFile(file);
} else {
// non-filestack files: open in new tab as download fallback
window.open(file.url, '_blank');
}
break;
}
}

/**
* @description checks if a url is a filestack cdn url
*/
private _isFilestackUrl(url: string): boolean {
return url?.includes('filestackcontent') || false;
}

/**
* @description checks if file is a browser-supported video format (mp4, webm, ogg)
*/
private _isVideoFile(file: { url: string; name: string }): boolean {
const supportedExtensions = ['.mp4', '.webm', '.ogg'];
const urlLower = (file.url || '').toLowerCase();
const nameLower = (file.name || '').toLowerCase();
return supportedExtensions.some(ext => urlLower.endsWith(ext) || nameLower.endsWith(ext));
}

/**
* @description derives video mime type from file extension
*/
private _getVideoMimeType(file: { url: string; name: string }): string {
const name = (file.name || file.url || '').toLowerCase();
if (name.endsWith('.webm')) return 'video/webm';
if (name.endsWith('.ogg')) return 'video/ogg';
return 'video/mp4';
}

/**
* @description checks if a file type is supported by filestack document viewer.
* supported: pdf, ppt/pptx, xls/xlsx, doc/docx, odt, odp, images, html, txt, ai, psd.
* unsupported: audio and video files (filestack doesn't support media preview).
*/
private _isFilestackPreviewSupported(file: { url: string; name: string }): boolean {
const unsupportedExtensions = [
// audio formats
'.mp3', '.wav', '.ogg', '.aac', '.flac', '.wma', '.m4a',
// video formats (filestack doesn't support any video preview)
'.mp4', '.webm', '.avi', '.mov', '.wmv', '.mkv', '.flv', '.m4v',
];
const urlLower = (file.url || '').toLowerCase();
const nameLower = (file.name || '').toLowerCase();
return !unsupportedExtensions.some(ext => urlLower.endsWith(ext) || nameLower.endsWith(ext));
}

/**
* @description preview browser-supported video file in modal with html5 video player
*/
async previewVideoFile(file: { url: string; name: string }): Promise<void> {
const modal = await this.modalController.create({
component: FilePopupComponent,
componentProps: {
file: {
url: file.url,
name: file.name,
type: this._getVideoMimeType(file),
},
},
});
return await modal.present();
}

/**
* @description returns action button icons for file attachment based on preview support.
* preview icon shown for:
* - browser-supported video files: mp4, webm, ogg (shown in html5 video modal)
* - filestack urls with document viewer supported file types
*/
getFileActionIcons(file: { url: string; name: string }): string[] {
const canPreview = this._isVideoFile(file) ||
(this._isFilestackUrl(file.url) && this._isFilestackPreviewSupported(file));
return canPreview ? ['download', 'search'] : ['download'];
}

async actionBarContinue(topic): Promise<void> {
if (this.continueAction$) {
this.continueAction$.next(topic);
Expand Down
Loading
Loading