Skip to content

Commit d8a3526

Browse files
Merge pull request #3504 from ita-social-projects/bugfix/#7924-url-link-commenttextarea
[Bugfix] #7924 Add link redirection in comment textarea
2 parents a2339aa + f2178ac commit d8a3526

File tree

3 files changed

+133
-3
lines changed

3 files changed

+133
-3
lines changed

src/app/main/component/comments/components/comment-textarea/comment-textarea.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
(paste)="onPaste($event)"
1212
tabindex="0"
1313
[ngClass]="{ invalid: content.errors?.maxlength }"
14+
(blur)="onCommentTextareaBlur()"
1415
></div>
1516
<p *ngIf="content.errors?.maxlength" class="error-message">
1617
{{ 'homepage.eco-news.comment.reply-error-message' | translate }}

src/app/main/component/comments/components/comment-textarea/comment-textarea.component.spec.ts

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,19 @@ import { SocketService } from '@global-service/socket/socket.service';
55
import { LocalStorageService } from '@global-service/localstorage/local-storage.service';
66
import { BehaviorSubject, Observable } from 'rxjs';
77
import { TranslateModule } from '@ngx-translate/core';
8-
import { By } from '@angular/platform-browser';
8+
import { By, DomSanitizer } from '@angular/platform-browser';
99
import { PlaceholderForDivDirective } from 'src/app/main/component/comments/directives/placeholder-for-div.directive';
1010
import { MatSelectModule } from '@angular/material/select';
1111
import { UserProfileImageComponent } from '@global-user/components/shared/components/user-profile-image/user-profile-image.component';
1212
import { MatMenuModule } from '@angular/material/menu';
1313
import { MatIconModule } from '@angular/material/icon';
1414
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
15+
import { ElementRef } from '@angular/core';
1516

1617
describe('CommentTextareaComponent', () => {
1718
let component: CommentTextareaComponent;
1819
let fixture: ComponentFixture<CommentTextareaComponent>;
20+
let mockSanitizer: jasmine.SpyObj<DomSanitizer>;
1921

2022
const socketServiceMock: SocketService = jasmine.createSpyObj('SocketService', ['onMessage', 'send', 'initiateConnection']);
2123
socketServiceMock.onMessage = () => new Observable();
@@ -45,12 +47,14 @@ describe('CommentTextareaComponent', () => {
4547
];
4648

4749
beforeEach(waitForAsync(() => {
50+
mockSanitizer = jasmine.createSpyObj('DomSanitizer', ['sanitize']);
4851
TestBed.configureTestingModule({
4952
declarations: [CommentTextareaComponent, PlaceholderForDivDirective, UserProfileImageComponent],
5053
providers: [
5154
{ provide: Router, useValue: {} },
5255
{ provide: SocketService, useValue: socketServiceMock },
53-
{ provide: LocalStorageService, useValue: localStorageServiceMock }
56+
{ provide: LocalStorageService, useValue: localStorageServiceMock },
57+
{ provide: DomSanitizer, useValue: mockSanitizer }
5458
],
5559
imports: [MatSelectModule, TranslateModule.forRoot(), BrowserAnimationsModule, MatMenuModule, MatIconModule]
5660
}).compileComponents();
@@ -64,6 +68,7 @@ describe('CommentTextareaComponent', () => {
6468
beforeEach(() => {
6569
fixture = TestBed.createComponent(CommentTextareaComponent);
6670
component = fixture.componentInstance;
71+
component.commentTextarea = new ElementRef(document.createElement('div'));
6772
fixture.detectChanges();
6873
});
6974

@@ -253,4 +258,82 @@ describe('CommentTextareaComponent', () => {
253258
imageFiles: null
254259
});
255260
});
261+
262+
describe('handleInputChange', () => {
263+
it('should update the content value if the text content changes', () => {
264+
component.commentTextarea.nativeElement.textContent = 'New content';
265+
component.content.setValue('Old content');
266+
267+
component['handleInputChange']();
268+
269+
expect(component.content.value).toBe('New content');
270+
});
271+
272+
it('should call updateLinksInTextarea with the new text content', () => {
273+
const updateLinksSpy = spyOn<any>(component, 'updateLinksInTextarea');
274+
component.commentTextarea.nativeElement.textContent = 'New content';
275+
276+
component['handleInputChange']();
277+
278+
expect(updateLinksSpy).toHaveBeenCalledWith('New content');
279+
});
280+
});
281+
282+
describe('onCommentTextareaFocus', () => {
283+
it('should call updateLinksInTextarea with trimmed current text', () => {
284+
const updateLinksSpy = spyOn<any>(component, 'updateLinksInTextarea');
285+
component.commentTextarea.nativeElement.textContent = ' Current text ';
286+
component.onCommentTextareaFocus();
287+
288+
expect(updateLinksSpy).toHaveBeenCalledWith('Current text');
289+
});
290+
});
291+
292+
describe('onCommentTextareaBlur', () => {
293+
it('should set the stripped text to the content value', () => {
294+
component.commentTextarea.nativeElement.textContent = 'Blur text';
295+
component.onCommentTextareaBlur();
296+
297+
expect(component.content.value).toBe('Blur text');
298+
});
299+
300+
it('should call emitComment', () => {
301+
const emitCommentSpy = spyOn<any>(component, 'emitComment');
302+
component.onCommentTextareaBlur();
303+
304+
expect(emitCommentSpy).toHaveBeenCalled();
305+
});
306+
});
307+
308+
describe('updateLinksInTextarea', () => {
309+
it('should sanitize and set innerHTML if a URL pattern is found', () => {
310+
const renderLinksSpy = spyOn(component, 'renderLinks').and.returnValue('<a href="http://example.com">http://example.com</a>');
311+
mockSanitizer.sanitize.and.returnValue('<a href="http://example.com">http://example.com</a>');
312+
const initializeLinksSpy = spyOn<any>(component, 'initializeLinkClickListeners');
313+
const testText = 'Check this link http://example.com';
314+
315+
component['updateLinksInTextarea'](testText);
316+
317+
expect(renderLinksSpy).toHaveBeenCalledWith(testText);
318+
expect(component.commentTextarea.nativeElement.innerHTML).toBe('<a href="http://example.com">http://example.com</a>');
319+
expect(initializeLinksSpy).toHaveBeenCalledWith(component.commentTextarea.nativeElement);
320+
});
321+
322+
describe('initializeLinkClickListeners', () => {
323+
it('should open links in a new tab on click', () => {
324+
const element = document.createElement('div');
325+
const link = document.createElement('a');
326+
link.href = 'http://example.com';
327+
link.textContent = 'example';
328+
element.appendChild(link);
329+
330+
const openSpy = spyOn(window, 'open');
331+
component['initializeLinkClickListeners'](element);
332+
333+
link.click();
334+
335+
expect(openSpy).toHaveBeenCalledWith('http://example.com', '_blank', 'noopener,noreferrer');
336+
});
337+
});
338+
});
256339
});

src/app/main/component/comments/components/comment-textarea/comment-textarea.component.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { debounceTime, filter, takeUntil, tap } from 'rxjs/operators';
2121
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
2222
import { CHAT_ICONS } from 'src/app/chat/chat-icons';
2323
import { insertEmoji } from '../add-emoji/add-emoji';
24+
import { Patterns } from '@assets/patterns/patterns';
2425

2526
@Component({
2627
selector: 'app-comment-textarea',
@@ -115,7 +116,14 @@ export class CommentTextareaComponent implements OnInit, AfterViewInit, OnChange
115116
}
116117

117118
private handleInputChange(): void {
118-
this.content.setValue(this.commentTextarea.nativeElement.textContent);
119+
const textContent = this.commentTextarea.nativeElement.textContent;
120+
121+
if (this.content.value !== textContent) {
122+
this.content.setValue(textContent);
123+
}
124+
125+
this.updateLinksInTextarea(textContent);
126+
119127
this.emitComment();
120128
this.closeDropdownIfNoTag();
121129
}
@@ -196,7 +204,10 @@ export class CommentTextareaComponent implements OnInit, AfterViewInit, OnChange
196204
}
197205

198206
onCommentTextareaFocus(): void {
207+
const currentText = this.commentTextarea.nativeElement.textContent.trim();
208+
this.updateLinksInTextarea(currentText);
199209
this.clearPlaceholderIfNeeded();
210+
200211
const range = document.createRange();
201212
const nodeAmount = this.commentTextarea.nativeElement.childNodes.length;
202213
range.setStartAfter(this.commentTextarea.nativeElement.childNodes[nodeAmount - 1]);
@@ -207,6 +218,12 @@ export class CommentTextareaComponent implements OnInit, AfterViewInit, OnChange
207218
selection.addRange(range);
208219
}
209220

221+
onCommentTextareaBlur(): void {
222+
const strippedText = this.commentTextarea.nativeElement.textContent;
223+
this.content.setValue(strippedText);
224+
this.emitComment();
225+
}
226+
210227
onCommentKeyDown(event: KeyboardEvent): void {
211228
if (event.key === 'Enter' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {
212229
event.preventDefault();
@@ -265,6 +282,35 @@ export class CommentTextareaComponent implements OnInit, AfterViewInit, OnChange
265282
});
266283
}
267284

285+
private updateLinksInTextarea(currentText: string): void {
286+
if (Patterns.urlLinkifyPattern.test(currentText)) {
287+
const sanitizedHtml = this.sanitizer.sanitize(SecurityContext.HTML, this.renderLinks(currentText));
288+
if (sanitizedHtml) {
289+
this.commentTextarea.nativeElement.innerHTML = sanitizedHtml;
290+
this.initializeLinkClickListeners(this.commentTextarea.nativeElement);
291+
}
292+
}
293+
}
294+
295+
renderLinks(text: string): string {
296+
return text.replace(Patterns.urlLinkifyPattern, (match) => {
297+
const safeUrl = this.sanitizer.sanitize(SecurityContext.URL, match) || '';
298+
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${match}</a>`;
299+
});
300+
}
301+
302+
initializeLinkClickListeners(element: HTMLElement): void {
303+
element.addEventListener('click', (event) => {
304+
const target = event.target as HTMLElement;
305+
if (target.tagName === 'A') {
306+
event.preventDefault();
307+
const href = target.getAttribute('href');
308+
if (href) {
309+
window.open(href, '_blank', 'noopener,noreferrer');
310+
}
311+
}
312+
});
313+
268314
private clearPlaceholderIfNeeded(): void {
269315
const currentText = this.commentTextarea?.nativeElement?.textContent?.trim() || '';
270316
if (this.isPlaceholderText(currentText)) {

0 commit comments

Comments
 (0)