Skip to content

Commit ef2b516

Browse files
authored
Merge pull request #2585 from intersective/2.4.y.z/CORE-8060/safevalue-warning
[CORE-8060] 2.4.y.z/safevalue-warning
2 parents 73a5b3a + c5b10ba commit ef2b516

File tree

6 files changed

+79
-52
lines changed

6 files changed

+79
-52
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<div class="container">
2-
<div class="text-content" role="region" [attr.aria-label]="name || 'Content'">
2+
<div class="text-content" role="region" [attr.aria-label]="ariaLabel || name || 'Content'">
33
<ng-container *ngIf="nonCollapsible; else animated">
44
<div id="{{name}}" #description [innerHtml]="content | detectLanguage"
55
class="full-height"></div>
@@ -26,7 +26,7 @@
2626
(keydown.space)="openShut(); $event.preventDefault()"
2727
[attr.aria-expanded]="!isTruncating"
2828
[attr.aria-controls]="name"
29-
[attr.aria-label]="isTruncating ? 'Show more content' : 'Show less content'"
29+
[attr.aria-label]="isTruncating ? ('Show more of ' + (ariaLabel || 'content')) : ('Show less of ' + (ariaLabel || 'content'))"
3030
i18n-aria-label
3131
class="ion-focusable">
3232
<ng-container *ngIf="isTruncating">
Lines changed: 21 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,26 @@
1-
import { Component, Input, ViewChild, ElementRef, AfterViewInit, OnChanges, SimpleChange, ViewEncapsulation, Output, EventEmitter } from '@angular/core';
1+
import { Component, Input, ViewChild, ElementRef, AfterViewInit, OnChanges, SimpleChanges, ViewEncapsulation, Output, EventEmitter } from '@angular/core';
2+
import { SafeHtml } from '@angular/platform-browser';
23
import { BrowserStorageService } from '@v3/services/storage.service';
34

45
@Component({
56
selector: 'app-description',
67
templateUrl: 'description.component.html',
78
styleUrls: ['./description.component.scss'],
8-
encapsulation: ViewEncapsulation.ShadowDom,
9-
/*animations: [
10-
trigger('truncation', [
11-
state('show', style({
12-
'max-height': '1000px !important',
13-
})),
14-
state('hide', style({
15-
'max-height': '90px !important',
16-
})),
17-
transition('* <=> *', [
18-
animate('0.5s ease-in-out')
19-
])
20-
]),
21-
]*/
9+
encapsulation: ViewEncapsulation.None,
2210
})
23-
export class DescriptionComponent implements OnChanges {
11+
export class DescriptionComponent implements OnChanges, AfterViewInit {
2412
heightLimit = 145; // more accurately adjusted
2513
isTruncating: boolean;
2614
heightExceeded: boolean;
2715
elementHeight: number;
2816
hasBeenTruncated: boolean; // prevent onChange replace the collapsed content
2917

30-
@Input() name; // unique identity of parent element
31-
@Input() content;
32-
@Input() isInPopup;
18+
@Input() name: string; // unique identity of parent element
19+
@Input() content: SafeHtml;
20+
@Input() isInPopup: boolean;
3321
@Input() nonCollapsible?: boolean;
34-
@Output() hasExpanded? = new EventEmitter();
22+
@Input() ariaLabel?: string;
23+
@Output() hasExpanded? = new EventEmitter<boolean>();
3524
@ViewChild('description') descriptionRef: ElementRef;
3625

3726
constructor(
@@ -40,43 +29,40 @@ export class DescriptionComponent implements OnChanges {
4029
this.hasBeenTruncated = false;
4130
}
4231

43-
ngOnChanges(changes: { [propKey: string]: SimpleChange}) {
44-
// reset to default
45-
if (this.hasBeenTruncated === false) {
32+
ngOnChanges(changes: SimpleChanges) {
33+
if (changes.content && !changes.content.firstChange) {
34+
this.hasBeenTruncated = false;
4635
this.isTruncating = false;
4736
this.heightExceeded = false;
37+
this.calculateHeight();
4838
}
39+
}
4940

50-
this.content = changes.content.currentValue;
41+
ngAfterViewInit() {
5142
this.calculateHeight();
5243
}
5344

5445
calculateHeight(): void {
55-
if (this.nonCollapsible === true) {
46+
if (this.nonCollapsible === true || !this.storage.getUser().truncateDescription) {
5647
return;
5748
}
5849

59-
if (!this.storage.getUser().truncateDescription) {
60-
return;
61-
}
62-
setTimeout(
63-
() => {
50+
setTimeout(() => {
51+
if (this.descriptionRef?.nativeElement) {
6452
this.elementHeight = this.descriptionRef.nativeElement.clientHeight;
6553
this.heightExceeded = this.elementHeight >= this.heightLimit;
6654

67-
if (this.heightExceeded) {
55+
if (this.heightExceeded && !this.hasBeenTruncated) {
6856
this.isTruncating = true;
6957
this.hasBeenTruncated = true;
7058
}
71-
},
72-
700
73-
);
59+
}
60+
}, 300); // Reduced timeout
7461
}
7562

7663
openShut(): void {
7764
this.isTruncating = !this.isTruncating;
7865
this.hasExpanded.emit(!this.isTruncating);
79-
return;
8066
}
8167
}
8268

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
<div *ngIf="topic" class="ion-padding main-content" style="min-height: 100%;">
2-
<div class="headline-2 topic-title" aria-live="polite" role="heading" [innerHtml]="sanitizedTitle"></div>
2+
<div class="headline-2 topic-title" aria-live="polite" role="heading" [innerHTML]="sanitizedTitle"></div>
33
<div *ngIf="topic.videolink && topic.videolink !=='magiclink'" class="text-center topic-video">
4-
<div *ngIf="iframeHtml" class="video-embed" [innerHtml]="iframeHtml"></div>
4+
<div *ngIf="iframeHtml" class="video-embed" [innerHTML]="iframeHtml"></div>
55
<video
66
*ngIf="!iframeHtml"
7+
[attr.id]="'topic-video-' + topic.id"
78
class="video-embed topic-video"
89
[ngClass]="{'desktop-view': !isMobile}"
910
width="100%"
@@ -23,7 +24,7 @@
2324
</span>
2425
</div>
2526
<div class="audio-player">
26-
<audio controls class="audio-element" preload="metadata">
27+
<audio [attr.id]="'topic-audio-' + topic.id" controls class="audio-element" preload="metadata">
2728
<source [src]="topic.audio.link" type="audio/mpeg">
2829
Your browser does not support the audio element.
2930
</audio>
@@ -32,9 +33,10 @@
3233

3334
<app-description *ngIf="topic.content"
3435
class="body-2"
35-
[name]="'topic'+topic.id"
36+
[name]="'topic-description-' + topic.id"
3637
[content]="topic.content"
3738
[nonCollapsible]="true"
39+
[ariaLabel]="'Topic content'"
3840
></app-description>
3941

4042
<ion-list *ngIf="topic.files && topic.files.length > 0" class="ion-margin-vertical">

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,17 @@ export class TopicComponent implements OnInit, OnChanges, OnDestroy {
145145
// convert other brand video players to custom player.
146146
private _initVideoPlayer() {
147147
setTimeout(() => {
148-
this.utils.each(this.document.querySelectorAll('.video-embed'), embedVideo => {
148+
this.utils.each(this.document.querySelectorAll('.video-embed'), (embedVideo, index) => {
149149
embedVideo.classList.remove('topic-video');
150150
if (!this.utils.isMobile()) {
151151
embedVideo.classList.remove('desktop-view');
152152
}
153153
embedVideo.classList.add('plyr__video-embed');
154+
155+
// add unique id to prevent duplicate ids from plyr
156+
const uniqueId = `plyr-${this.topic?.id || 'unknown'}-${index}-${Date.now()}`;
157+
embedVideo.setAttribute('data-plyr-id', uniqueId);
158+
154159
new Plyr(embedVideo as HTMLElement, { ratio: '16:9' });
155160
// if we have video tag, plugin will adding div tags to wrap video tag and main div contain .plyr css class.
156161
// so we need to add topic-video and desktop-view to that div to load video properly .

projects/v3/src/app/pipes/language.pipe.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,54 @@
11
import { Pipe, PipeTransform } from '@angular/core';
2+
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
23
import { UtilsService } from '@v3/services/utils.service';
34

45
/**
5-
* Pipe to add lang attributes to HTML content for WCAG 3.1.2 Language of Parts compliance
6-
* Processes HTML content and wraps foreign language passages with lang attributes
6+
* Pipe to add lang attributes to HTML content for WCAG 3.1.2 Language of Parts compliance.
7+
* Processes HTML content and wraps foreign language passages with lang attributes.
8+
* Handles SafeHtml and includes caching for performance.
79
*/
810
@Pipe({
911
name: 'detectLanguage',
1012
standalone: false
1113
})
1214
export class LanguageDetectionPipe implements PipeTransform {
13-
constructor(private utils: UtilsService) {}
15+
private lastContent: string | SafeHtml | null | undefined;
16+
private lastResult: SafeHtml;
1417

15-
transform(htmlContent: string | null | undefined, defaultLang?: string): string {
16-
if (!htmlContent) {
17-
return '';
18+
constructor(
19+
private utils: UtilsService,
20+
private sanitizer: DomSanitizer
21+
) {}
22+
23+
transform(htmlContent: string | SafeHtml | null | undefined, defaultLang?: string): SafeHtml {
24+
if (htmlContent === this.lastContent) {
25+
return this.lastResult;
26+
}
27+
28+
let contentString: string;
29+
if (typeof htmlContent === 'string') {
30+
contentString = htmlContent;
31+
} else if (htmlContent instanceof Object && 'changingThisBreaksApplicationSecurity' in htmlContent) {
32+
// This is a way to check if it's a SafeHtml object without private APIs.
33+
// The ideal way is to get the raw string, but SafeHtml is opaque.
34+
// This workaround extracts the value, but it's fragile.
35+
// A better long-term solution is to apply language detection *before* sanitization.
36+
contentString = (htmlContent as any).changingThisBreaksApplicationSecurity;
37+
} else {
38+
contentString = '';
1839
}
1940

20-
return this.utils.addLanguageAttributes(htmlContent, defaultLang);
41+
if (!contentString) {
42+
this.lastResult = this.sanitizer.bypassSecurityTrustHtml('');
43+
this.lastContent = htmlContent;
44+
return this.lastResult;
45+
}
46+
47+
const processedContent = this.utils.addLanguageAttributes(contentString, defaultLang);
48+
this.lastResult = this.sanitizer.bypassSecurityTrustHtml(processedContent);
49+
this.lastContent = htmlContent;
50+
51+
return this.lastResult;
2152
}
2253
}
2354

projects/v3/src/app/services/ngx-embed-video.service.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,20 +58,23 @@ export class EmbedVideoService {
5858

5959
public embed_youtube(id: string, options?: any): SafeHtml {
6060
options = this.parseOptions(options);
61+
const uniqueId = `youtube-embed-${id}-${Date.now()}`;
6162

62-
return this.sanitize_iframe(`<iframe src="https://www.youtube.com/embed/${id}${options.query}"${options.attr} frameborder="0" allowfullscreen></iframe>`);
63+
return this.sanitize_iframe(`<iframe id="${uniqueId}" src="https://www.youtube.com/embed/${id}${options.query}"${options.attr} frameborder="0" allowfullscreen></iframe>`);
6364
}
6465

6566
public embed_vimeo(id: string, options?: any): SafeHtml {
6667
options = this.parseOptions(options);
68+
const uniqueId = `vimeo-embed-${id}-${Date.now()}`;
6769

68-
return this.sanitize_iframe(`<iframe src="https://player.vimeo.com/video/${id}${options.query}"${options.attr} frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe>`);
70+
return this.sanitize_iframe(`<iframe id="${uniqueId}" src="https://player.vimeo.com/video/${id}${options.query}"${options.attr} frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe>`);
6971
}
7072

7173
public embed_dailymotion(id: string, options?: any): SafeHtml {
7274
options = this.parseOptions(options);
75+
const uniqueId = `dailymotion-embed-${id}-${Date.now()}`;
7376

74-
return this.sanitize_iframe(`<iframe src="https://www.dailymotion.com/embed/video/${id}${options.query}"${options.attr} frameborder="0" allowfullscreen></iframe>`);
77+
return this.sanitize_iframe(`<iframe id="${uniqueId}" src="https://www.dailymotion.com/embed/video/${id}${options.query}"${options.attr} frameborder="0" allowfullscreen></iframe>`);
7578
}
7679

7780
public embed_image(url: any, options?: any): Promise<{

0 commit comments

Comments
 (0)