diff --git a/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.html b/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.html index fff9b45bde03..f7f35b3b2a3e 100644 --- a/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.html +++ b/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.html @@ -75,13 +75,13 @@ 'hide-input': isHiddenInputWithCallToAction, }" infinite-scroll - class="conversation-messages-message-list" + class="conversation-messages-message-list position-relative" [scrollWindow]="false" (scrolledUp)="fetchNextPage()" > - @for (group of groupedPosts; track postsTrackByFn($index, group)) { + @for (group of groupedPosts; track postsGroupTrackByFn($index, group)) {
@for (post of group.posts; track postsTrackByFn($index, post)) {
@@ -107,15 +107,10 @@ @if (getAsChannel(_activeConversation)?.isAnnouncementChannel) {
- +
} @else { - + }
} @@ -125,15 +120,10 @@ @if (getAsChannel(_activeConversation)?.isAnnouncementChannel) {
- +
} @else { - + }
} diff --git a/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.ts b/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.ts index 5169cc8f4610..7fdbe98508c2 100644 --- a/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.ts +++ b/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.ts @@ -9,6 +9,7 @@ import { OnInit, Output, QueryList, + Renderer2, ViewChild, ViewChildren, ViewEncapsulation, @@ -32,6 +33,7 @@ import { LayoutService } from 'app/shared/breakpoints/layout.service'; import { CustomBreakpointNames } from 'app/shared/breakpoints/breakpoints.service'; import dayjs from 'dayjs/esm'; import { User } from 'app/core/user/user.model'; +import { PostingThreadComponent } from 'app/shared/metis/posting-thread/posting-thread.component'; interface PostGroup { author: User | undefined; @@ -46,16 +48,23 @@ interface PostGroup { }) export class ConversationMessagesComponent implements OnInit, AfterViewInit, OnDestroy { private ngUnsubscribe = new Subject(); + readonly sessionStorageKey = 'conversationId.scrollPosition.'; + readonly PageType = PageType; readonly ButtonType = ButtonType; + private scrollDebounceTime = 100; // ms + scrollSubject = new Subject(); + canStartSaving = false; + createdNewMessage = false; + @Output() openThread = new EventEmitter(); @ViewChild('searchInput') searchInput: ElementRef; @ViewChildren('postingThread') - messages: QueryList; + messages: QueryList; @ViewChild('container') content: ElementRef; @Input() @@ -77,6 +86,7 @@ export class ConversationMessagesComponent implements OnInit, AfterViewInit, OnD searchText = ''; _activeConversation?: ConversationDTO; + elementsAtScrollPosition: PostingThreadComponent[]; newPost?: Post; posts: Post[] = []; groupedPosts: PostGroup[] = []; @@ -93,6 +103,7 @@ export class ConversationMessagesComponent implements OnInit, AfterViewInit, OnD isHiddenInputFull = false; private layoutService: LayoutService = inject(LayoutService); + private renderer = inject(Renderer2); constructor( public metisService: MetisService, // instance from course-conversations.component @@ -104,6 +115,7 @@ export class ConversationMessagesComponent implements OnInit, AfterViewInit, OnD this.subscribeToSearch(); this.subscribeToMetis(); this.subscribeToActiveConversation(); + this.setupScrollDebounce(); this.isMobile = this.layoutService.isBreakpointActive(CustomBreakpointNames.extraSmall); this.layoutService @@ -147,11 +159,28 @@ export class ConversationMessagesComponent implements OnInit, AfterViewInit, OnD ngAfterViewInit() { this.messages.changes.pipe(takeUntil(this.ngUnsubscribe)).subscribe(this.handleScrollOnNewMessage); + this.messages.changes.pipe(takeUntil(this.ngUnsubscribe)).subscribe(() => { + if (!this.createdNewMessage && this.posts.length > 0) { + this.scrollToStoredId(); + } else { + this.createdNewMessage = false; + } + }); + this.content.nativeElement.addEventListener('scroll', () => { + this.findElementsAtScrollPosition(); + }); } ngOnDestroy(): void { this.ngUnsubscribe.next(); this.ngUnsubscribe.complete(); + this.scrollSubject.complete(); + this.content?.nativeElement.removeEventListener('scroll', this.saveScrollPosition); + } + + private scrollToStoredId() { + const savedScrollId = sessionStorage.getItem(this.sessionStorageKey + this._activeConversation?.id) ?? ''; + requestAnimationFrame(() => this.goToLastSelectedElement(parseInt(savedScrollId, 10))); } private onActiveConversationChange() { @@ -168,6 +197,7 @@ export class ConversationMessagesComponent implements OnInit, AfterViewInit, OnD this.searchInput.nativeElement.value = ''; this.searchText = ''; } + this.canStartSaving = false; this.onSearch(); this.createEmptyPost(); } @@ -260,11 +290,15 @@ export class ConversationMessagesComponent implements OnInit, AfterViewInit, OnD fetchNextPage() { const morePostsAvailable = this.posts.length < this.totalNumberOfPosts; + let addBuffer = 0; if (morePostsAvailable) { this.page += 1; this.commandMetisToFetchPosts(); + addBuffer = 50; + } else if (!this.canStartSaving) { + this.canStartSaving = true; } - this.content.nativeElement.scrollTop = this.content.nativeElement.scrollTop + 50; + this.content.nativeElement.scrollTop = this.content.nativeElement.scrollTop + addBuffer; } public commandMetisToFetchPosts(forceUpdate = false) { @@ -305,7 +339,9 @@ export class ConversationMessagesComponent implements OnInit, AfterViewInit, OnD return this.metisService.createEmptyPostForContext(conversation); } - postsTrackByFn = (index: number, post: Post): number => post.id!; + postsGroupTrackByFn = (index: number, post: PostGroup): string => 'grp_' + post.posts.map((p) => p.id?.toString()).join('_'); + + postsTrackByFn = (index: number, post: Post): string => 'post_' + post.id!; setPostForThread(post: Post) { this.openThread.emit(post); @@ -318,9 +354,9 @@ export class ConversationMessagesComponent implements OnInit, AfterViewInit, OnD scrollToBottomOfMessages() { // Use setTimeout to ensure the scroll happens after the new message is rendered - setTimeout(() => { + requestAnimationFrame(() => { this.content.nativeElement.scrollTop = this.content.nativeElement.scrollHeight; - }, 0); + }); } onSearchQueryInput($event: Event) { @@ -334,4 +370,58 @@ export class ConversationMessagesComponent implements OnInit, AfterViewInit, OnD this.searchInput.nativeElement.dispatchEvent(new Event('input')); } } + + private setupScrollDebounce(): void { + this.scrollSubject.pipe(debounceTime(this.scrollDebounceTime), takeUntil(this.ngUnsubscribe)).subscribe((postId) => { + if (this._activeConversation?.id) { + sessionStorage.setItem(this.sessionStorageKey + this._activeConversation.id, postId.toString()); + } + }); + } + + saveScrollPosition = (postId: number) => { + this.scrollSubject.next(postId); + }; + + handleNewMessageCreated() { + this.createdNewMessage = true; + this.createEmptyPost(); + this.scrollToBottomOfMessages(); + } + + async goToLastSelectedElement(lastScrollPosition: number) { + if (!lastScrollPosition) { + this.scrollToBottomOfMessages(); + this.canStartSaving = true; + return; + } + const messageArray = this.messages.toArray(); + const element = messageArray.find((message) => message.post.id === lastScrollPosition); // Suchen nach dem Post + + if (!element) { + this.fetchNextPage(); + } else { + // We scroll to the element with a slight buffer to ensure its fully visible (-10) + this.content.nativeElement.scrollTop = Math.max(0, element.elementRef.nativeElement.offsetTop - 10); + this.canStartSaving = true; + } + } + + findElementsAtScrollPosition() { + const messageArray = this.messages.toArray(); + const containerRect = this.content.nativeElement.getBoundingClientRect(); + const visibleMessages = []; + for (const message of messageArray) { + if (!message.elementRef?.nativeElement || !message.post?.id) continue; + const rect = message.elementRef.nativeElement.getBoundingClientRect(); + if (rect.top >= containerRect.top && rect.bottom <= containerRect.bottom) { + visibleMessages.push(message); + break; // Only need the first visible message + } + } + this.elementsAtScrollPosition = visibleMessages; + if (this.elementsAtScrollPosition && this.canStartSaving) { + this.saveScrollPosition(this.elementsAtScrollPosition[0].post.id!); + } + } } diff --git a/src/main/webapp/app/shared/metis/posting-thread/posting-thread.component.ts b/src/main/webapp/app/shared/metis/posting-thread/posting-thread.component.ts index 776d29facdd1..8cb7db55b963 100644 --- a/src/main/webapp/app/shared/metis/posting-thread/posting-thread.component.ts +++ b/src/main/webapp/app/shared/metis/posting-thread/posting-thread.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, Output, inject } from '@angular/core'; import { Post } from 'app/entities/metis/post.model'; import dayjs from 'dayjs/esm'; @@ -18,4 +18,6 @@ export class PostingThreadComponent { @Input() hasChannelModerationRights = false; @Output() openThread = new EventEmitter(); @Input() isConsecutive: boolean | undefined = false; + + elementRef = inject(ElementRef); } diff --git a/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-messages/conversation-messages.component.spec.ts b/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-messages/conversation-messages.component.spec.ts index a2cfe53d5cb4..407333ead201 100644 --- a/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-messages/conversation-messages.component.spec.ts +++ b/src/test/javascript/spec/component/overview/course-conversations/layout/conversation-messages/conversation-messages.component.spec.ts @@ -14,7 +14,7 @@ import { Post } from 'app/entities/metis/post.model'; import { BehaviorSubject } from 'rxjs'; import { ConversationDTO } from 'app/entities/metis/conversation/conversation.model'; import { generateExampleChannelDTO, generateExampleGroupChatDTO, generateOneToOneChatDTO } from '../../helpers/conversationExampleModels'; -import { Directive, EventEmitter, Input, Output } from '@angular/core'; +import { Directive, EventEmitter, Input, Output, QueryList } from '@angular/core'; import { By } from '@angular/platform-browser'; import { Course } from 'app/entities/course.model'; import { getAsChannelDTO } from 'app/entities/metis/conversation/channel.model'; @@ -87,6 +87,15 @@ examples.forEach((activeConversation) => { fixture = TestBed.createComponent(ConversationMessagesComponent); component = fixture.componentInstance; component.course = course; + component.messages = { + toArray: jest.fn().mockReturnValue([]), + } as any; + component.content = { + nativeElement: { + getBoundingClientRect: jest.fn().mockReturnValue({ top: 0, bottom: 100 }), + }, + } as any; + component.canStartSaving = true; fixture.detectChanges(); }); @@ -96,8 +105,6 @@ examples.forEach((activeConversation) => { it('should create', fakeAsync(() => { expect(component).toBeTruthy(); - component.handleScrollOnNewMessage(); - tick(); })); it('should set initial values correctly', fakeAsync(() => { @@ -132,6 +139,83 @@ examples.forEach((activeConversation) => { expect(getFilteredPostSpy).toHaveBeenCalledOnce(); })); + it('should save the scroll position in sessionStorage', fakeAsync(() => { + const setItemSpy = jest.spyOn(sessionStorage, 'setItem'); + component.ngOnInit(); + component.saveScrollPosition(15); + tick(100); + const expectedKey = `${component.sessionStorageKey}${component._activeConversation?.id}`; + const expectedValue = '15'; + expect(setItemSpy).toHaveBeenCalledWith(expectedKey, expectedValue); + expect(setItemSpy).toHaveBeenCalledTimes(2); + })); + + it('should scroll to the last selected element or fetch next page if not found', fakeAsync(() => { + const mockMessages = [ + { post: { id: 1 }, elementRef: { nativeElement: { scrollIntoView: jest.fn() } } }, + { post: { id: 2 }, elementRef: { nativeElement: { scrollIntoView: jest.fn() } } }, + ] as unknown as PostingThreadComponent[]; + component.messages = { + toArray: () => mockMessages, + } as QueryList; + + const fetchNextPageSpy = jest.spyOn(component, 'fetchNextPage').mockImplementation(() => {}); + const existingScrollPosition = 1; + + component.goToLastSelectedElement(existingScrollPosition); + tick(); + expect(fetchNextPageSpy).not.toHaveBeenCalled(); + + const nonExistingScrollPosition = 999; + component.goToLastSelectedElement(nonExistingScrollPosition); + tick(); + + expect(fetchNextPageSpy).toHaveBeenCalled(); + })); + + it('should find visible elements at the scroll position and save scroll position', () => { + // Mock des Containers + component.content.nativeElement = { + getBoundingClientRect: jest.fn().mockReturnValue({ top: 0, bottom: 100 }), + scrollTop: 0, + scrollHeight: 200, + removeEventListener: jest.fn(), + }; + const mockMessages = [ + { post: { id: 1 }, elementRef: { nativeElement: { getBoundingClientRect: jest.fn().mockReturnValue({ top: 10, bottom: 90 }) } } }, + { post: { id: 2 }, elementRef: { nativeElement: { getBoundingClientRect: jest.fn().mockReturnValue({ top: 100, bottom: 200 }) } } }, + ] as unknown as PostingThreadComponent[]; + component.messages.toArray = jest.fn().mockReturnValue(mockMessages); + component.canStartSaving = true; + const nextSpy = jest.spyOn(component.scrollSubject, 'next'); + component.findElementsAtScrollPosition(); + expect(component.elementsAtScrollPosition).toEqual([mockMessages[0]]); + expect(nextSpy).toHaveBeenCalledWith(1); + }); + + it('should not save scroll position if no elements are visible', () => { + const mockMessages = [ + { + post: { id: 1 }, + elementRef: { nativeElement: { getBoundingClientRect: jest.fn().mockReturnValue({ top: 200, bottom: 300 }) } }, + }, + ] as unknown as PostingThreadComponent[]; + + component.messages.toArray = jest.fn().mockReturnValue(mockMessages); + const nextSpy = jest.spyOn(component.scrollSubject, 'next'); + component.findElementsAtScrollPosition(); + expect(component.elementsAtScrollPosition).toEqual([]); + expect(nextSpy).not.toHaveBeenCalled(); + }); + + it('should scroll to the bottom when a new message is created', fakeAsync(() => { + component.content.nativeElement.scrollTop = 100; + fixture.detectChanges(); + component.handleNewMessageCreated(); + tick(300); + expect(component.content.nativeElement.scrollTop).toBe(component.content.nativeElement.scrollHeight); + })); + it('should create empty post with the correct conversation type', fakeAsync(() => { const createEmptyPostForContextSpy = jest.spyOn(metisService, 'createEmptyPostForContext').mockReturnValue(new Post()); component.createEmptyPost();