From adbcd57b58c84a57f5fbf4f8b56ea2dfbc76af14 Mon Sep 17 00:00:00 2001 From: trtshen Date: Mon, 2 Feb 2026 15:01:03 +0800 Subject: [PATCH 1/7] [CORE-8139] assessment-specific review rating toggle --- .../pages/activity-desktop/activity-desktop.page.ts | 3 ++- .../pages/assessment-mobile/assessment-mobile.page.ts | 6 +++++- projects/v3/src/app/services/assessment.service.ts | 8 +++++++- projects/v3/src/app/services/notifications.service.ts | 10 +++++++++- projects/v3/src/app/services/storage.service.ts | 2 +- 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/projects/v3/src/app/pages/activity-desktop/activity-desktop.page.ts b/projects/v3/src/app/pages/activity-desktop/activity-desktop.page.ts index 9befc398e..9c5eccf66 100644 --- a/projects/v3/src/app/pages/activity-desktop/activity-desktop.page.ts +++ b/projects/v3/src/app/pages/activity-desktop/activity-desktop.page.ts @@ -524,7 +524,8 @@ export class ActivityDesktopPage { // display review rating modal return await this.notificationsService.popUpReviewRating( this.review.id, - false + false, + this.assessmentService.assessment?.hasReviewRating ); } diff --git a/projects/v3/src/app/pages/assessment-mobile/assessment-mobile.page.ts b/projects/v3/src/app/pages/assessment-mobile/assessment-mobile.page.ts index a7b596641..0e0760925 100644 --- a/projects/v3/src/app/pages/assessment-mobile/assessment-mobile.page.ts +++ b/projects/v3/src/app/pages/assessment-mobile/assessment-mobile.page.ts @@ -262,7 +262,11 @@ export class AssessmentMobilePage implements OnInit, OnDestroy { try { // display review rating modal - return await this.notificationsService.popUpReviewRating(this.review.id, false); + return await this.notificationsService.popUpReviewRating( + this.review.id, + false, + this.assessment?.hasReviewRating + ); } catch (err) { const header = $localize`Can not get review rating information`; await this.notificationsService.alert({ diff --git a/projects/v3/src/app/services/assessment.service.ts b/projects/v3/src/app/services/assessment.service.ts index b1bbf6c2c..7e6bf1c96 100644 --- a/projects/v3/src/app/services/assessment.service.ts +++ b/projects/v3/src/app/services/assessment.service.ts @@ -52,6 +52,7 @@ export interface Assessment { isOverdue?: boolean; groups: Array; pulseCheck: boolean; + hasReviewRating: boolean; // assessment level setting to enable review rating allowResubmit?: boolean; // indicator to show resubmit button } @@ -138,7 +139,11 @@ export class AssessmentService { .graphQLFetch( `query getAssessment($assessmentId: Int!, $reviewer: Boolean!, $activityId: Int, $contextId: Int!, $submissionId: Int) { assessment(id:$assessmentId, reviewer:$reviewer, activityId:$activityId, submissionId:$submissionId) { - id name type description dueDate isTeam pulseCheck allowResubmit + id name type + description dueDate isTeam + pulseCheck + hasReviewRating + allowResubmit groups { name description questions{ @@ -265,6 +270,7 @@ export class AssessmentService { ? this.utils.timeComparer(data.assessment.dueDate) < 0 : false, pulseCheck: data.assessment.pulseCheck, + hasReviewRating: data.assessment.hasReviewRating, allowResubmit: data.assessment.allowResubmit, groups: [], }; diff --git a/projects/v3/src/app/services/notifications.service.ts b/projects/v3/src/app/services/notifications.service.ts index 6b8a5a6b2..c72676964 100644 --- a/projects/v3/src/app/services/notifications.service.ts +++ b/projects/v3/src/app/services/notifications.service.ts @@ -422,13 +422,21 @@ export class NotificationsService { * @param {number} reviewId submission review record id * @param {string[]} redirect array: routeUrl, boolean: disable * routing (stay at same component) + * @param {boolean} hasReviewRating optional flag from assessment to + * skip popup when disabled * * @return {Promise} deferred ionic modal */ async popUpReviewRating( reviewId, - redirect: string[] | boolean + redirect: string[] | boolean, + hasReviewRating?: boolean ): Promise { + // skip popup if assessment-level review rating is disabled + if (hasReviewRating === false) { + return; + } + return this.modalOnly( ReviewRatingComponent, { diff --git a/projects/v3/src/app/services/storage.service.ts b/projects/v3/src/app/services/storage.service.ts index c56d30673..6141143eb 100644 --- a/projects/v3/src/app/services/storage.service.ts +++ b/projects/v3/src/app/services/storage.service.ts @@ -34,7 +34,7 @@ export interface User { userHash?: string; colors?: Colors; activityCardImage?: string; // default activity card image - hasReviewRating?: boolean; + hasReviewRating?: boolean; // from experience settings (enable/disable entirely from global setting) truncateDescription?: boolean; enrolment?: any; activityCompleteMessage?: string; From f0293667e912556d9cba0bf68f226eceab5ccc29 Mon Sep 17 00:00:00 2001 From: trtshen Date: Tue, 3 Feb 2026 14:06:00 +0800 Subject: [PATCH 2/7] [CORE-8140] hide pulsecheck for no team mentor --- projects/v3/src/app/pages/home/home.page.html | 2 +- projects/v3/src/app/pages/home/home.page.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/projects/v3/src/app/pages/home/home.page.html b/projects/v3/src/app/pages/home/home.page.html index ed392e63e..73d71d88a 100644 --- a/projects/v3/src/app/pages/home/home.page.html +++ b/projects/v3/src/app/pages/home/home.page.html @@ -93,7 +93,7 @@ Skill & Progress Surveys diff --git a/projects/v3/src/app/pages/home/home.page.ts b/projects/v3/src/app/pages/home/home.page.ts index da1a3db30..6ad6192bb 100644 --- a/projects/v3/src/app/pages/home/home.page.ts +++ b/projects/v3/src/app/pages/home/home.page.ts @@ -35,6 +35,7 @@ export class HomePage implements OnInit, OnDestroy, AfterViewChecked { isMobile: boolean; isParticipant: boolean; + isExpertWithoutTeam: boolean; pulseCheckIndicatorEnabled: boolean; activityProgresses = {}; @@ -89,7 +90,9 @@ export class HomePage implements OnInit, OnDestroy, AfterViewChecked { ngOnInit() { const role = this.storageService.getUser().role; + const teamId = this.storageService.getUser().teamId; this.isParticipant = role === 'participant'; + this.isExpertWithoutTeam = role === 'mentor' && !teamId; this.pulseCheckIndicatorEnabled = this.storageService.getFeature('pulseCheckIndicator'); this.isMobile = this.utils.isMobile(); this.homeService.milestones$ From 6256a0adb878c31d1c540de20a7244b45af5afd8 Mon Sep 17 00:00:00 2001 From: trtshen Date: Thu, 5 Feb 2026 15:46:31 +0800 Subject: [PATCH 3/7] [CORE-8140] persistent team cache --- projects/v3/src/app/pages/home/home.page.ts | 23 +++++++++++++++++++ projects/v3/src/app/services/auth.service.ts | 12 +++++++--- .../v3/src/app/services/shared.service.ts | 2 +- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/projects/v3/src/app/pages/home/home.page.ts b/projects/v3/src/app/pages/home/home.page.ts index 6ad6192bb..ef28d08b7 100644 --- a/projects/v3/src/app/pages/home/home.page.ts +++ b/projects/v3/src/app/pages/home/home.page.ts @@ -95,6 +95,20 @@ export class HomePage implements OnInit, OnDestroy, AfterViewChecked { this.isExpertWithoutTeam = role === 'mentor' && !teamId; this.pulseCheckIndicatorEnabled = this.storageService.getFeature('pulseCheckIndicator'); this.isMobile = this.utils.isMobile(); + + // subscribe to team changes broadcast from shared service + this.sharedService.team$ + .pipe( + filter(team => team !== null), + takeUntil(this.unsubscribe$) + ) + .subscribe(() => { + // re-evaluate expert without team status when team changes + const currentRole = this.storageService.getUser().role; + const currentTeamId = this.storageService.getUser().teamId; + this.isExpertWithoutTeam = currentRole === 'mentor' && !currentTeamId; + }); + this.homeService.milestones$ .pipe( distinctUntilChanged(), @@ -185,6 +199,8 @@ export class HomePage implements OnInit, OnDestroy, AfterViewChecked { }, }); + // call updateDashboard on initial load to ensure fresh data + this.updateDashboard(); } ngOnDestroy(): void { @@ -206,6 +222,13 @@ export class HomePage implements OnInit, OnDestroy, AfterViewChecked { async updateDashboard() { await this.sharedService.refreshJWT(); // refresh JWT token [CORE-6083] + + // re-evaluate user role and team status after JWT refresh updates teamId + const role = this.storageService.getUser().role; + const teamId = this.storageService.getUser().teamId; + this.isParticipant = role === 'participant'; + this.isExpertWithoutTeam = role === 'mentor' && !teamId; + this.experience = this.storageService.get("experience"); this.homeService.getMilestones({ forceRefresh: true }); this.achievementService.getAchievements(); diff --git a/projects/v3/src/app/services/auth.service.ts b/projects/v3/src/app/services/auth.service.ts index ded993515..14fc8becc 100644 --- a/projects/v3/src/app/services/auth.service.ts +++ b/projects/v3/src/app/services/auth.service.ts @@ -126,6 +126,7 @@ interface AuthQuery { apikey?: string; service?: string; experienceUuid?: string; + forceRefresh?: boolean; } @Injectable({ @@ -154,9 +155,14 @@ export class AuthService { const lastFetchTime: number = +this.storage.get('lastAuthFetchTime'); const authCache = this.authCache$.getValue() || this.storage.get('authCache'); - // 2 conditions to pull from server: - // when experienceUuid is not null (required for switch experience) - // when authToken available (directLogin) + // conditions to pull from server: + // - when forceRefresh is true (bypass cache) + // - when experienceUuid is not null (required for switch experience) + // - when authToken available (directLogin) + if (data?.forceRefresh) { + return this.fetchData(data); + } + if (!(data?.experienceUuid || data?.authToken) && lastFetchTime && (currentTime - lastFetchTime) < this.authCacheDuration && authCache) { return of(authCache); } else { diff --git a/projects/v3/src/app/services/shared.service.ts b/projects/v3/src/app/services/shared.service.ts index 083538d9b..3007b3d4c 100644 --- a/projects/v3/src/app/services/shared.service.ts +++ b/projects/v3/src/app/services/shared.service.ts @@ -191,7 +191,7 @@ export class SharedService { * @return {Promise} non-strict return value, we won't use */ async refreshJWT(): Promise { - const res: AuthEndpoint = await firstValueFrom(this.authService.authenticate()); + const res: AuthEndpoint = await firstValueFrom(this.authService.authenticate({ forceRefresh: true })); const auth = res?.data?.auth; const latestTeamId = auth?.experience?.team?.id; From eb21d014a68561afe07b1b500d63444dbf861428 Mon Sep 17 00:00:00 2001 From: trtshen Date: Fri, 6 Feb 2026 18:13:22 +0800 Subject: [PATCH 4/7] [CORE-8137] preview compatibility --- .../components/topic/topic.component.spec.ts | 24 +++++++++++++++---- .../app/components/topic/topic.component.ts | 15 +++++++++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/projects/v3/src/app/components/topic/topic.component.spec.ts b/projects/v3/src/app/components/topic/topic.component.spec.ts index 14a66447f..b4bcfa9ec 100644 --- a/projects/v3/src/app/components/topic/topic.component.spec.ts +++ b/projects/v3/src/app/components/topic/topic.component.spec.ts @@ -151,14 +151,30 @@ 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', () => { 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 new tab when index 1 and url is not filestack', () => { + spyOn(window, 'open'); + const file = { url: 'https://example.com/video.mp4', name: 'video.mp4' }; + 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'); }); }); }); diff --git a/projects/v3/src/app/components/topic/topic.component.ts b/projects/v3/src/app/components/topic/topic.component.ts index c254ad6b1..734aaa53a 100644 --- a/projects/v3/src/app/components/topic/topic.component.ts +++ b/projects/v3/src/app/components/topic/topic.component.ts @@ -299,11 +299,24 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe this.utils.downloadFile(file.url); break; case 1: - this.previewFile(file); + if (this._isFilestackUrl(file.url)) { + this.previewFile(file); + } else { + // non-filestack files: open in new tab as download fallback + this.notification.presentToast('Preview not available. Opening file in a new tab.'); + 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; + } + async actionBarContinue(topic): Promise { if (this.continueAction$) { this.continueAction$.next(topic); From e028134bf5edc6e5a5525156a77811186823d39f Mon Sep 17 00:00:00 2001 From: trtshen Date: Mon, 9 Feb 2026 13:21:11 +0800 Subject: [PATCH 5/7] [CORE-8137] handle filestack mp4 natively --- .../app/components/topic/topic.component.html | 2 +- .../components/topic/topic.component.spec.ts | 80 ++++++++++++++++++- .../app/components/topic/topic.component.ts | 60 +++++++++++++- 3 files changed, 137 insertions(+), 5 deletions(-) diff --git a/projects/v3/src/app/components/topic/topic.component.html b/projects/v3/src/app/components/topic/topic.component.html index c5c4497c0..a8c1e643c 100644 --- a/projects/v3/src/app/components/topic/topic.component.html +++ b/projects/v3/src/app/components/topic/topic.component.html @@ -52,7 +52,7 @@ [title]="file.name" leadingIcon="document" lines="full" - [endingActionBtnIcons]="['download', 'search']" + [endingActionBtnIcons]="getFileActionIcons(file)" (actionBtnClick)="actionBtnClick(file, $event)" > diff --git a/projects/v3/src/app/components/topic/topic.component.spec.ts b/projects/v3/src/app/components/topic/topic.component.spec.ts index b4bcfa9ec..04cbc66fd 100644 --- a/projects/v3/src/app/components/topic/topic.component.spec.ts +++ b/projects/v3/src/app/components/topic/topic.component.spec.ts @@ -155,16 +155,32 @@ describe('TopicComponent', () => { expect(utilsSpy.downloadFile).toHaveBeenCalled(); }); - it('should call previewFile when index 1 and url is filestack', () => { + it('should call previewFile when index 1 and url is filestack with supported type', () => { spyOn(component, 'previewFile'); 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 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 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/video.mp4', name: 'video.mp4' }; + 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(); @@ -177,4 +193,64 @@ describe('TopicComponent', () => { 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 only download icon for non-mp4 video files', () => { + const file = { url: 'https://example.com/video.mov', name: 'video.mov' }; + const icons = component.getFileActionIcons(file); + expect(icons).toEqual(['download']); + }); + }); + + describe('previewVideoFile', () => { + it('should open video modal with file properties', 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(); + }); + }); }); diff --git a/projects/v3/src/app/components/topic/topic.component.ts b/projects/v3/src/app/components/topic/topic.component.ts index 734aaa53a..6d5b6d584 100644 --- a/projects/v3/src/app/components/topic/topic.component.ts +++ b/projects/v3/src/app/components/topic/topic.component.ts @@ -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', @@ -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(); @@ -299,11 +302,14 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe this.utils.downloadFile(file.url); break; case 1: - if (this._isFilestackUrl(file.url)) { + if (this._isVideoFile(file)) { + // show mp4 file 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 - this.notification.presentToast('Preview not available. Opening file in a new tab.'); window.open(file.url, '_blank'); } break; @@ -317,6 +323,56 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe return url?.includes('filestackcontent') || false; } + /** + * @description checks if file is an mp4 video (html5 browser-supported format) + */ + private _isVideoFile(file: { url: string; name: string }): boolean { + const urlLower = (file.url || '').toLowerCase(); + const nameLower = (file.name || '').toLowerCase(); + return urlLower.endsWith('.mp4') || nameLower.endsWith('.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 files (videos handled separately by html5 player). + */ + private _isFilestackPreviewSupported(file: { url: string; name: string }): boolean { + const unsupportedExtensions = ['.mp3', '.wav', '.ogg', '.aac', '.flac', '.wma', '.m4a']; + const urlLower = (file.url || '').toLowerCase(); + const nameLower = (file.name || '').toLowerCase(); + return !unsupportedExtensions.some(ext => urlLower.endsWith(ext) || nameLower.endsWith(ext)); + } + + /** + * @description preview mp4 file in modal with html5 video player + */ + async previewVideoFile(file: { url: string; name: string }): Promise { + const modal = await this.modalController.create({ + component: FilePopupComponent, + componentProps: { + file: { + url: file.url, + name: file.name, + type: 'video/mp4', + }, + }, + }); + return await modal.present(); + } + + /** + * @description returns action button icons for file attachment based on preview support. + * preview icon shown for: + * - mp4 video files (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 { if (this.continueAction$) { this.continueAction$.next(topic); From b1971ca84f1e5c52e87b05defc6298f2f27693ef Mon Sep 17 00:00:00 2001 From: trtshen Date: Mon, 9 Feb 2026 14:43:34 +0800 Subject: [PATCH 6/7] [CORE-8137] video preview consistent with topic video player --- .../file-popup/file-popup.component.html | 19 +++++++++++++------ .../file-popup/file-popup.component.ts | 16 ++++++++++++++++ .../list-item/list-item.component.scss | 4 ++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/projects/v3/src/app/components/file-popup/file-popup.component.html b/projects/v3/src/app/components/file-popup/file-popup.component.html index d6c6c7dc3..1df1dfd3c 100644 --- a/projects/v3/src/app/components/file-popup/file-popup.component.html +++ b/projects/v3/src/app/components/file-popup/file-popup.component.html @@ -34,12 +34,19 @@ - diff --git a/projects/v3/src/app/components/file-popup/file-popup.component.ts b/projects/v3/src/app/components/file-popup/file-popup.component.ts index a4f127dbc..cadbc58f3 100644 --- a/projects/v3/src/app/components/file-popup/file-popup.component.ts +++ b/projects/v3/src/app/components/file-popup/file-popup.component.ts @@ -34,4 +34,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 + }); + } + } } diff --git a/projects/v3/src/app/components/list-item/list-item.component.scss b/projects/v3/src/app/components/list-item/list-item.component.scss index 4ab5a130a..4d1f0447c 100644 --- a/projects/v3/src/app/components/list-item/list-item.component.scss +++ b/projects/v3/src/app/components/list-item/list-item.component.scss @@ -1,3 +1,7 @@ +.body-2 { + overflow-wrap: anywhere; +} + .icon-container { margin-right: 20px; font-size: 24px !important; From bf5eca08eb705a131649eb56a8f9d0315b336993 Mon Sep 17 00:00:00 2001 From: trtshen Date: Wed, 11 Feb 2026 17:11:50 +0800 Subject: [PATCH 7/7] [CORE-8137] download only for non mp4 --- .../file-popup/file-popup.component.html | 13 ++- .../file-popup/file-popup.component.ts | 12 +++ .../components/topic/topic.component.spec.ts | 83 +++++++++++++++++-- .../app/components/topic/topic.component.ts | 32 +++++-- .../video-conversion.component.html | 2 +- .../video-conversion.component.scss | 3 + .../chat-preview/chat-preview.component.html | 32 +++++-- .../chat-preview/chat-preview.component.scss | 4 + .../chat-preview.component.spec.ts | 36 ++++++++ .../chat-preview/chat-preview.component.ts | 25 ++++++ 10 files changed, 220 insertions(+), 22 deletions(-) diff --git a/projects/v3/src/app/components/file-popup/file-popup.component.html b/projects/v3/src/app/components/file-popup/file-popup.component.html index 1df1dfd3c..a5bd2545c 100644 --- a/projects/v3/src/app/components/file-popup/file-popup.component.html +++ b/projects/v3/src/app/components/file-popup/file-popup.component.html @@ -33,7 +33,7 @@ uploaded attachment preview - + + + +
+ +

+ This video format is not supported for preview. +
+ Download to view +

+
+
diff --git a/projects/v3/src/app/components/file-popup/file-popup.component.ts b/projects/v3/src/app/components/file-popup/file-popup.component.ts index cadbc58f3..8ff1faa54 100644 --- a/projects/v3/src/app/components/file-popup/file-popup.component.ts +++ b/projects/v3/src/app/components/file-popup/file-popup.component.ts @@ -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(); diff --git a/projects/v3/src/app/components/topic/topic.component.spec.ts b/projects/v3/src/app/components/topic/topic.component.spec.ts index 04cbc66fd..64fd29e2c 100644 --- a/projects/v3/src/app/components/topic/topic.component.spec.ts +++ b/projects/v3/src/app/components/topic/topic.component.spec.ts @@ -162,13 +162,27 @@ describe('TopicComponent', () => { expect(component.previewFile).toHaveBeenCalledWith(file); }); - it('should open video modal when index 1 and file is video', () => { + 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'); @@ -225,15 +239,34 @@ describe('TopicComponent', () => { expect(icons).toEqual(['download']); }); - it('should return only download icon for non-mp4 video files', () => { - const file = { url: 'https://example.com/video.mov', name: 'video.mov' }; + 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']); + 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 file properties', async () => { + 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)); @@ -252,5 +285,45 @@ describe('TopicComponent', () => { }); 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(); + }); }); }); diff --git a/projects/v3/src/app/components/topic/topic.component.ts b/projects/v3/src/app/components/topic/topic.component.ts index 6d5b6d584..b126e61ae 100644 --- a/projects/v3/src/app/components/topic/topic.component.ts +++ b/projects/v3/src/app/components/topic/topic.component.ts @@ -303,7 +303,7 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe break; case 1: if (this._isVideoFile(file)) { - // show mp4 file in modal with html5 player + // 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 @@ -324,28 +324,44 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe } /** - * @description checks if file is an mp4 video (html5 browser-supported format) + * @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 urlLower.endsWith('.mp4') || nameLower.endsWith('.mp4'); + 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 files (videos handled separately by html5 player). + * unsupported: audio and video files (filestack doesn't support media preview). */ private _isFilestackPreviewSupported(file: { url: string; name: string }): boolean { - const unsupportedExtensions = ['.mp3', '.wav', '.ogg', '.aac', '.flac', '.wma', '.m4a']; + 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 mp4 file in modal with html5 video player + * @description preview browser-supported video file in modal with html5 video player */ async previewVideoFile(file: { url: string; name: string }): Promise { const modal = await this.modalController.create({ @@ -354,7 +370,7 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe file: { url: file.url, name: file.name, - type: 'video/mp4', + type: this._getVideoMimeType(file), }, }, }); @@ -364,7 +380,7 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe /** * @description returns action button icons for file attachment based on preview support. * preview icon shown for: - * - mp4 video files (shown in html5 video modal) + * - 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[] { diff --git a/projects/v3/src/app/components/video-conversion/video-conversion.component.html b/projects/v3/src/app/components/video-conversion/video-conversion.component.html index 9d65c32b4..e9cd10abe 100644 --- a/projects/v3/src/app/components/video-conversion/video-conversion.component.html +++ b/projects/v3/src/app/components/video-conversion/video-conversion.component.html @@ -23,6 +23,6 @@

Waited too long?

-

Download here

+

Download here

diff --git a/projects/v3/src/app/components/video-conversion/video-conversion.component.scss b/projects/v3/src/app/components/video-conversion/video-conversion.component.scss index 827125b91..d8859e9eb 100644 --- a/projects/v3/src/app/components/video-conversion/video-conversion.component.scss +++ b/projects/v3/src/app/components/video-conversion/video-conversion.component.scss @@ -1,3 +1,6 @@ +a.contrast { + color: var(--ion-color-primary-contrast); +} .waiting-mgs { display: flex; padding: 16px; diff --git a/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.html b/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.html index d6c6c7dc3..054c5697a 100644 --- a/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.html +++ b/projects/v3/src/app/pages/chat/chat-preview/chat-preview.component.html @@ -33,14 +33,32 @@ uploaded attachment preview
- -