Skip to content

Commit 37e9039

Browse files
authored
Merge pull request #2621 from intersective/prerelease
Release 2.4.7.2
2 parents 8900e05 + fb91875 commit 37e9039

21 files changed

Lines changed: 468 additions & 31 deletions

projects/v3/src/app/components/file-popup/file-popup.component.html

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,32 @@
3333
<img [src]="file.url" alt="uploaded attachment preview" i18n-alt>
3434
</ng-container>
3535

36-
<ng-container *ngIf="file?.type.includes('video')">
37-
<video controls>
38-
<ng-container *ngIf="file?.url">
39-
<source [src]="file.url"
40-
[type]="file.type">
41-
{{ file.url }}
42-
</ng-container>
36+
<ng-container *ngIf="isBrowserSupportedVideo()">
37+
<video
38+
width="100%"
39+
controls
40+
controlsList="nodownload"
41+
preload="metadata"
42+
playsinline
43+
[src]="file.url"
44+
(error)="handleVideoError($event)"
45+
>
46+
<p i18n="video not supported message">
47+
Your browser doesn't support HTML5 video.
48+
Here is a <a [href]="file.url">link to the video</a> instead.
49+
</p>
4350
</video>
4451
</ng-container>
52+
53+
<ng-container *ngIf="file?.type?.includes('video') && !isBrowserSupportedVideo()">
54+
<div class="ion-padding ion-text-center">
55+
<ion-icon name="videocam-off-outline" size="large" color="medium"></ion-icon>
56+
<p class="body-2" i18n="unsupported video format message">
57+
This video format is not supported for preview.
58+
<br>
59+
<a [href]="file.url" target="_blank" rel="noopener">Download to view</a>
60+
</p>
61+
</div>
62+
</ng-container>
4563
</div>
4664
</ion-content>

projects/v3/src/app/components/file-popup/file-popup.component.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ export class FilePopupComponent {
1515
public sanitizer: DomSanitizer
1616
) {}
1717

18+
/**
19+
* @description checks if video format is natively supported by browsers.
20+
* only mp4, webm, and ogg are widely supported.
21+
*/
22+
isBrowserSupportedVideo(): boolean {
23+
if (!this.file?.type || !this.file.type.includes('video')) {
24+
return false;
25+
}
26+
const supportedFormats = ['video/mp4', 'video/webm', 'video/ogg'];
27+
return supportedFormats.some(format => this.file.type.includes(format));
28+
}
29+
1830
download(keyboardEvent?: KeyboardEvent) {
1931
if (keyboardEvent && (keyboardEvent?.code === 'Space' || keyboardEvent?.code === 'Enter')) {
2032
keyboardEvent.preventDefault();
@@ -34,4 +46,20 @@ export class FilePopupComponent {
3446

3547
this.modalController.dismiss();
3648
}
49+
50+
handleVideoError(videoError: Event): void {
51+
console.error('Video playback error:', videoError);
52+
const target = videoError.target as HTMLVideoElement;
53+
if (target?.error) {
54+
const errorCode = target.error.code;
55+
const errorMessage = target.error.message;
56+
console.error('Video error details:', {
57+
code: errorCode,
58+
message: errorMessage,
59+
src: target.src,
60+
networkState: target.networkState,
61+
readyState: target.readyState
62+
});
63+
}
64+
}
3765
}

projects/v3/src/app/components/list-item/list-item.component.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
.body-2 {
2+
overflow-wrap: anywhere;
3+
}
4+
15
.icon-container {
26
margin-right: 20px;
37
font-size: 24px !important;

projects/v3/src/app/components/topic/topic.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
[title]="file.name"
5353
leadingIcon="document"
5454
lines="full"
55-
[endingActionBtnIcons]="['download', 'search']"
55+
[endingActionBtnIcons]="getFileActionIcons(file)"
5656
(actionBtnClick)="actionBtnClick(file, $event)"
5757
></app-list-item>
5858

projects/v3/src/app/components/topic/topic.component.spec.ts

Lines changed: 169 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -151,14 +151,179 @@ describe('TopicComponent', () => {
151151

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

158-
it('should call previewFile when index 1', () => {
158+
it('should call previewFile when index 1 and url is filestack with supported type', () => {
159159
spyOn(component, 'previewFile');
160-
component.actionBtnClick({} as any, 1);
161-
expect(component.previewFile).toHaveBeenCalled();
160+
const file = { url: 'https://cdn.filestackcontent.com/abc123', name: 'doc.pdf' };
161+
component.actionBtnClick(file, 1);
162+
expect(component.previewFile).toHaveBeenCalledWith(file);
163+
});
164+
165+
it('should open video modal when index 1 and file is mp4 video', () => {
166+
spyOn(component, 'previewVideoFile');
167+
const file = { url: 'https://cdn.filestackcontent.com/abc123.mp4', name: 'video.mp4' };
168+
component.actionBtnClick(file, 1);
169+
expect(component.previewVideoFile).toHaveBeenCalledWith(file);
170+
});
171+
172+
it('should open video modal when index 1 and file is webm video', () => {
173+
spyOn(component, 'previewVideoFile');
174+
const file = { url: 'https://cdn.filestackcontent.com/abc123.webm', name: 'video.webm' };
175+
component.actionBtnClick(file, 1);
176+
expect(component.previewVideoFile).toHaveBeenCalledWith(file);
177+
});
178+
179+
it('should open video modal when index 1 and file is ogg video', () => {
180+
spyOn(component, 'previewVideoFile');
181+
const file = { url: 'https://cdn.filestackcontent.com/abc123.ogg', name: 'video.ogg' };
182+
component.actionBtnClick(file, 1);
183+
expect(component.previewVideoFile).toHaveBeenCalledWith(file);
184+
});
185+
186+
it('should open new tab when index 1 and url is filestack but file is audio', () => {
187+
spyOn(window, 'open');
188+
spyOn(component, 'previewFile');
189+
const file = { url: 'https://cdn.filestackcontent.com/abc123', name: 'recording.mp3' };
190+
component.actionBtnClick(file, 1);
191+
expect(component.previewFile).not.toHaveBeenCalled();
192+
expect(window.open).toHaveBeenCalledWith(file.url, '_blank');
193+
});
194+
195+
it('should open new tab when index 1 and url is not filestack', () => {
196+
spyOn(window, 'open');
197+
const file = { url: 'https://example.com/document.pdf', name: 'document.pdf' };
198+
component.actionBtnClick(file, 1);
199+
expect(window.open).toHaveBeenCalledWith(file.url, '_blank');
200+
expect(notificationSpy.presentToast).toHaveBeenCalled();
201+
});
202+
203+
it('should open new tab for non-filestack url even without extension', () => {
204+
spyOn(window, 'open');
205+
const file = { url: 'https://storage.example.com/files/12345', name: 'report' };
206+
component.actionBtnClick(file, 1);
207+
expect(window.open).toHaveBeenCalledWith(file.url, '_blank');
208+
});
209+
});
210+
211+
describe('getFileActionIcons', () => {
212+
it('should return both download and search icons for filestack url with supported type', () => {
213+
const file = { url: 'https://cdn.filestackcontent.com/abc123', name: 'document.pdf' };
214+
const icons = component.getFileActionIcons(file);
215+
expect(icons).toEqual(['download', 'search']);
216+
});
217+
218+
it('should return both download and search icons for video files', () => {
219+
const file = { url: 'https://cdn.filestackcontent.com/abc123.mp4', name: 'video.mp4' };
220+
const icons = component.getFileActionIcons(file);
221+
expect(icons).toEqual(['download', 'search']);
222+
});
223+
224+
it('should return both download and search icons for non-filestack video', () => {
225+
const file = { url: 'https://example.com/video.mp4', name: 'video.mp4' };
226+
const icons = component.getFileActionIcons(file);
227+
expect(icons).toEqual(['download', 'search']);
228+
});
229+
230+
it('should return only download icon for filestack url with audio', () => {
231+
const file = { url: 'https://cdn.filestackcontent.com/abc123', name: 'audio.mp3' };
232+
const icons = component.getFileActionIcons(file);
233+
expect(icons).toEqual(['download']);
234+
});
235+
236+
it('should return only download icon for non-filestack non-video file', () => {
237+
const file = { url: 'https://example.com/file.pdf', name: 'document.pdf' };
238+
const icons = component.getFileActionIcons(file);
239+
expect(icons).toEqual(['download']);
240+
});
241+
242+
it('should return both download and search icons for webm video', () => {
243+
const file = { url: 'https://example.com/video.webm', name: 'video.webm' };
244+
const icons = component.getFileActionIcons(file);
245+
expect(icons).toEqual(['download', 'search']);
246+
});
247+
248+
it('should return both download and search icons for ogg video', () => {
249+
const file = { url: 'https://example.com/video.ogg', name: 'video.ogg' };
250+
const icons = component.getFileActionIcons(file);
251+
expect(icons).toEqual(['download', 'search']);
252+
});
253+
254+
it('should return only download icon for unsupported video formats', () => {
255+
const formats = [
256+
{ url: 'https://example.com/video.mov', name: 'video.mov' },
257+
{ url: 'https://cdn.filestackcontent.com/abc123', name: 'file_example_AVI_640_800kB.avi' },
258+
{ url: 'https://cdn.filestackcontent.com/abc123.wmv', name: 'video.wmv' },
259+
{ url: 'https://example.com/video.mkv', name: 'video.mkv' },
260+
];
261+
for (const file of formats) {
262+
const icons = component.getFileActionIcons(file);
263+
expect(icons).withContext(file.name).toEqual(['download']);
264+
}
265+
});
266+
});
267+
268+
describe('previewVideoFile', () => {
269+
it('should open video modal with mp4 mime type', async () => {
270+
const modalSpy = jasmine.createSpyObj('Modal', ['present']);
271+
spyOn(component['modalController'], 'create').and.returnValue(Promise.resolve(modalSpy));
272+
273+
const file = { url: 'https://example.com/video.mp4', name: 'test.mp4' };
274+
await component.previewVideoFile(file);
275+
276+
expect(component['modalController'].create).toHaveBeenCalledWith({
277+
component: jasmine.anything(),
278+
componentProps: {
279+
file: {
280+
url: file.url,
281+
name: file.name,
282+
type: 'video/mp4',
283+
},
284+
},
285+
});
286+
expect(modalSpy.present).toHaveBeenCalled();
287+
});
288+
289+
it('should open video modal with webm mime type', async () => {
290+
const modalSpy = jasmine.createSpyObj('Modal', ['present']);
291+
spyOn(component['modalController'], 'create').and.returnValue(Promise.resolve(modalSpy));
292+
293+
const file = { url: 'https://example.com/video.webm', name: 'test.webm' };
294+
await component.previewVideoFile(file);
295+
296+
expect(component['modalController'].create).toHaveBeenCalledWith({
297+
component: jasmine.anything(),
298+
componentProps: {
299+
file: {
300+
url: file.url,
301+
name: file.name,
302+
type: 'video/webm',
303+
},
304+
},
305+
});
306+
expect(modalSpy.present).toHaveBeenCalled();
307+
});
308+
309+
it('should open video modal with ogg mime type', async () => {
310+
const modalSpy = jasmine.createSpyObj('Modal', ['present']);
311+
spyOn(component['modalController'], 'create').and.returnValue(Promise.resolve(modalSpy));
312+
313+
const file = { url: 'https://example.com/video.ogg', name: 'test.ogg' };
314+
await component.previewVideoFile(file);
315+
316+
expect(component['modalController'].create).toHaveBeenCalledWith({
317+
component: jasmine.anything(),
318+
componentProps: {
319+
file: {
320+
url: file.url,
321+
name: file.name,
322+
type: 'video/ogg',
323+
},
324+
},
325+
});
326+
expect(modalSpy.present).toHaveBeenCalled();
162327
});
163328
});
164329
});

projects/v3/src/app/components/topic/topic.component.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { NotificationsService } from '@v3/app/services/notifications.service';
1111
import { BehaviorSubject, exhaustMap, filter, finalize, Subject, Subscription, takeUntil } from 'rxjs';
1212
import { Task } from '@v3/app/services/activity.service';
1313
import { ComponentCleanupService } from '@v3/app/services/component-cleanup.service';
14+
import { ModalController } from '@ionic/angular';
15+
import { FilePopupComponent } from '../file-popup/file-popup.component';
1416

1517
@Component({
1618
selector: 'app-topic',
@@ -52,6 +54,7 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe
5254
private sanitizer: DomSanitizer,
5355
private cleanupService: ComponentCleanupService,
5456
private cdr: ChangeDetectorRef,
57+
private modalController: ModalController,
5558
@Inject(DOCUMENT) private readonly document: Document
5659
) {
5760
this.isMobile = this.utils.isMobile();
@@ -299,11 +302,93 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe
299302
this.utils.downloadFile(file.url);
300303
break;
301304
case 1:
302-
this.previewFile(file);
305+
if (this._isVideoFile(file)) {
306+
// show browser-supported video in modal with html5 player
307+
this.previewVideoFile(file);
308+
} else if (this._isFilestackUrl(file.url) && this._isFilestackPreviewSupported(file)) {
309+
// show filestack document viewer
310+
this.previewFile(file);
311+
} else {
312+
// non-filestack files: open in new tab as download fallback
313+
window.open(file.url, '_blank');
314+
}
303315
break;
304316
}
305317
}
306318

319+
/**
320+
* @description checks if a url is a filestack cdn url
321+
*/
322+
private _isFilestackUrl(url: string): boolean {
323+
return url?.includes('filestackcontent') || false;
324+
}
325+
326+
/**
327+
* @description checks if file is a browser-supported video format (mp4, webm, ogg)
328+
*/
329+
private _isVideoFile(file: { url: string; name: string }): boolean {
330+
const supportedExtensions = ['.mp4', '.webm', '.ogg'];
331+
const urlLower = (file.url || '').toLowerCase();
332+
const nameLower = (file.name || '').toLowerCase();
333+
return supportedExtensions.some(ext => urlLower.endsWith(ext) || nameLower.endsWith(ext));
334+
}
335+
336+
/**
337+
* @description derives video mime type from file extension
338+
*/
339+
private _getVideoMimeType(file: { url: string; name: string }): string {
340+
const name = (file.name || file.url || '').toLowerCase();
341+
if (name.endsWith('.webm')) return 'video/webm';
342+
if (name.endsWith('.ogg')) return 'video/ogg';
343+
return 'video/mp4';
344+
}
345+
346+
/**
347+
* @description checks if a file type is supported by filestack document viewer.
348+
* supported: pdf, ppt/pptx, xls/xlsx, doc/docx, odt, odp, images, html, txt, ai, psd.
349+
* unsupported: audio and video files (filestack doesn't support media preview).
350+
*/
351+
private _isFilestackPreviewSupported(file: { url: string; name: string }): boolean {
352+
const unsupportedExtensions = [
353+
// audio formats
354+
'.mp3', '.wav', '.ogg', '.aac', '.flac', '.wma', '.m4a',
355+
// video formats (filestack doesn't support any video preview)
356+
'.mp4', '.webm', '.avi', '.mov', '.wmv', '.mkv', '.flv', '.m4v',
357+
];
358+
const urlLower = (file.url || '').toLowerCase();
359+
const nameLower = (file.name || '').toLowerCase();
360+
return !unsupportedExtensions.some(ext => urlLower.endsWith(ext) || nameLower.endsWith(ext));
361+
}
362+
363+
/**
364+
* @description preview browser-supported video file in modal with html5 video player
365+
*/
366+
async previewVideoFile(file: { url: string; name: string }): Promise<void> {
367+
const modal = await this.modalController.create({
368+
component: FilePopupComponent,
369+
componentProps: {
370+
file: {
371+
url: file.url,
372+
name: file.name,
373+
type: this._getVideoMimeType(file),
374+
},
375+
},
376+
});
377+
return await modal.present();
378+
}
379+
380+
/**
381+
* @description returns action button icons for file attachment based on preview support.
382+
* preview icon shown for:
383+
* - browser-supported video files: mp4, webm, ogg (shown in html5 video modal)
384+
* - filestack urls with document viewer supported file types
385+
*/
386+
getFileActionIcons(file: { url: string; name: string }): string[] {
387+
const canPreview = this._isVideoFile(file) ||
388+
(this._isFilestackUrl(file.url) && this._isFilestackPreviewSupported(file));
389+
return canPreview ? ['download', 'search'] : ['download'];
390+
}
391+
307392
async actionBarContinue(topic): Promise<void> {
308393
if (this.continueAction$) {
309394
this.continueAction$.next(topic);

0 commit comments

Comments
 (0)