From 9bc416cce34a5dddccff428ed4a8fdcde07df5bd Mon Sep 17 00:00:00 2001 From: Richard Park Date: Fri, 17 Jan 2020 10:41:22 +0900 Subject: [PATCH] virtual scroll of chat room --- package-lock.json | 34 +- package.json | 2 + .../components/messages.component.html | 103 +-- .../components/messages.component.ts | 680 +++++++++--------- .../lib/components/message-box.component.html | 2 +- .../lib/components/message-box.component.scss | 2 +- .../lib/components/message-box.component.ts | 10 +- .../lib/components/messages.component.html | 132 ++-- .../src/lib/components/messages.component.ts | 289 +++++++- .../src/lib/ucap-ui-chat.module.ts | 15 +- .../src/public-api.ts | 2 + 11 files changed, 774 insertions(+), 497 deletions(-) diff --git a/package-lock.json b/package-lock.json index bcfa12dd..3837ccc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "ucap-webmessenger", - "version": "0.0.4", + "version": "0.0.5", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -349,6 +349,15 @@ } } }, + "@angular/cdk-experimental": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/@angular/cdk-experimental/-/cdk-experimental-8.2.3.tgz", + "integrity": "sha512-pGWLh+njlSK2HavyES1g9xZQXmnqj9HJalL+EJii1gLEyvEatdJfsBxidxvdxrXaIYrY0ZfkiG2qW/4wOOKRHA==", + "dev": true, + "requires": { + "tslib": "^1.7.1" + } + }, "@angular/cli": { "version": "8.3.22", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-8.3.22.tgz", @@ -3272,6 +3281,12 @@ "defer-to-connect": "^1.0.1" } }, + "@tweenjs/tween.js": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-17.4.0.tgz", + "integrity": "sha512-J3fzl1F6wvh8KXVVcIuHN12xi1ZDcPA/0Vix+ZcJYwZWVHUwfIqfvzYXXEw7ybeev6477KCTt9fKydU+ajUqcg==", + "dev": true + }, "@types/anymatch": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz", @@ -3479,6 +3494,12 @@ "integrity": "sha512-78AdXtlhpCHT0K3EytMpn4JNxaf5tbqbLcbIRoQIHzpTIyjpxLQKRoxU55ujBXAtg3Nl2h/XWvfDa9dsMOd0pQ==", "dev": true }, + "@types/tween.js": { + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/@types/tween.js/-/tween.js-17.2.0.tgz", + "integrity": "sha512-mOsqurEtFEzwgkVc/jDVE2XrjZBYTbrmDUyCr9GXmnfc6q5otokxFtKvSY/B21zgz9LVRIvRTawKczjKi57wrA==", + "dev": true + }, "@types/uglify-js": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.0.4.tgz", @@ -11369,6 +11390,17 @@ "resize-observer-polyfill": "^1.5.0" } }, + "ngx-virtual-scroller": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/ngx-virtual-scroller/-/ngx-virtual-scroller-3.0.3.tgz", + "integrity": "sha512-00au5zpcPMzbci9VmM9QlOT6ZQ+NqgpWaXsM2Y7dg6SSlCDcFMW8nMxoMsogP2b2mrBLpIIEB91nVtwLnwWs4A==", + "dev": true, + "requires": { + "@tweenjs/tween.js": "17.4.0", + "@types/tween.js": "17.2.0", + "tslib": "^1.9.0" + } + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", diff --git a/package.json b/package.json index 0ee3a9f7..2f887d6e 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@angular-devkit/build-ng-packagr": "^0.803.22", "@angular/animations": "^8.2.14", "@angular/cdk": "^8.2.3", + "@angular/cdk-experimental": "^8.2.3", "@angular/cli": "^8.3.22", "@angular/common": "^8.2.14", "@angular/compiler": "^8.2.14", @@ -134,6 +135,7 @@ "ngrx-store-freeze": "^0.2.4", "ngx-logger": "^4.0.8", "ngx-perfect-scrollbar": "^8.0.0", + "ngx-virtual-scroller": "^3.0.3", "npm-run-all": "^4.1.5", "parallel-webpack": "^2.4.0", "protractor": "~5.4.0", diff --git a/projects/ucap-webmessenger-app/src/app/layouts/messenger/components/messages.component.html b/projects/ucap-webmessenger-app/src/app/layouts/messenger/components/messages.component.html index bb3d6cfb..46fba732 100644 --- a/projects/ucap-webmessenger-app/src/app/layouts/messenger/components/messages.component.html +++ b/projects/ucap-webmessenger-app/src/app/layouts/messenger/components/messages.component.html @@ -42,18 +42,22 @@ mat-icon-button aria-label="chats button" class="responsive-chats-button chat-timer" - *ngIf="!!roomInfo && roomInfo.isTimeRoom" + *ngIf="!!roomInfoSubject.value && roomInfoSubject.value.isTimeRoom" > timer

- + {{ 'chat.getRoomNameInProgress' | translate }} - - + + MyTalk @@ -70,11 +74,12 @@ - {{ roomInfo.roomName }} + {{ roomInfoSubject.value.roomName }} {{ _roomUserInfos | ucapTranslate: 'name':',' }} @@ -85,31 +90,33 @@

{{ getConvertTimer(roomInfo.timeRoomInterval) }} {{ + getConvertTimer(roomInfoSubject.value.timeRoomInterval) + }} {{ 'chat.isRoomTypeSecret' | translate }}
+ +
- - + +
+ + + +
- -
- - - -
+ +
+ + +
+ + diff --git a/projects/ucap-webmessenger-ui-chat/src/lib/components/messages.component.ts b/projects/ucap-webmessenger-ui-chat/src/lib/components/messages.component.ts index 55d6c32e..95052648 100644 --- a/projects/ucap-webmessenger-ui-chat/src/lib/components/messages.component.ts +++ b/projects/ucap-webmessenger-ui-chat/src/lib/components/messages.component.ts @@ -1,4 +1,12 @@ -import { Component, OnInit, Input, EventEmitter, Output } from '@angular/core'; +import { + Component, + OnInit, + Input, + EventEmitter, + Output, + ViewChild, + OnDestroy +} from '@angular/core'; import { Info, @@ -12,40 +20,39 @@ import { LoginResponse } from '@ucap-webmessenger/protocol-authentication'; import { UserInfo, RoomInfo, RoomType } from '@ucap-webmessenger/protocol-room'; import { NGXLogger } from 'ngx-logger'; import { VersionInfo2Response } from '@ucap-webmessenger/api-public'; -import { FileInfo } from '../models/file-info.json'; import { DatePipe } from '@angular/common'; import moment from 'moment'; import { FileDownloadItem } from '@ucap-webmessenger/api'; import { TranslateService } from '@ngx-translate/core'; +import { PerfectScrollbarDirective } from 'ngx-perfect-scrollbar'; +import { Observable, Subscription } from 'rxjs'; +import { VirtualScrollerComponent } from 'ngx-virtual-scroller'; @Component({ selector: 'ucap-chat-messages', templateUrl: './messages.component.html', styleUrls: ['./messages.component.scss'] }) -export class MessagesComponent implements OnInit { +export class MessagesComponent implements OnInit, OnDestroy { @Input() - loginRes: LoginResponse; + loginRes$: Observable; @Input() - roomInfo: RoomInfo; + roomInfo$: Observable; @Input() - set eventList(elist: Info[]) { - if (!!elist && elist.length > 0) { - this.firstEventSeq = elist[0].seq; - } - - this.messages = elist; - } + eventList$: Observable[]>; @Input() - searchedList: Info[]; + newEventList$: Observable[]>; @Input() - eventInfoStatus?: InfoResponse; + searchedList$: Observable[]>; @Input() - eventRemain: boolean; + eventInfoStatus$: Observable; @Input() - userInfos?: UserInfo[]; + eventRemained$: Observable; @Input() sessionVerInfo: VersionInfo2Response; + @Input() + userInfos$: Observable; + @Input() isShowUnreadCount = true; @Input() @@ -53,9 +60,9 @@ export class MessagesComponent implements OnInit { @Input() minShowReadHere = 10; @Input() - initRoomLastEventSeq: number; - @Input() translationSimpleview = false; + @Input() + searchingMode = false; @Output() openProfile = new EventEmitter(); @@ -83,13 +90,50 @@ export class MessagesComponent implements OnInit { type?: string; }>(); - messages: Info[]; + @Output() + scrollUp = new EventEmitter(); + + @Output() + yReachEnd = new EventEmitter(); + @Output() + yReachStart = new EventEmitter(); + + @Output() + existNewMessage = new EventEmitter>(); + + @ViewChild(PerfectScrollbarDirective, { static: false }) + psChatContent?: PerfectScrollbarDirective; + + @ViewChild(VirtualScrollerComponent, { static: false }) + private virtualScroller: VirtualScrollerComponent; + + storedScrollHeight = 0; // 이전대화를 불러올 경우 현재 스크롤 포지션 유지를 위한 값. 0 이면 초기로딩. + scrollUpInitalized = false; // ps 에서 초기 로딩시 scroll reach start 이벤트 발생 버그를 우회하기 위한 init 값으로 scrollUp 에 의해 true 로 된다. + firstCheckReadHere = true; + initRoomLastEventSeq: number; + baseEventSeq = 0; + + loginRes: LoginResponse; + loginResSubscription: Subscription; + roomInfo: RoomInfo; + roomInfoSubscription: Subscription; + eventList: Info[]; + eventListSubscription: Subscription; + newEventList: Info[]; + newEventListSubscription: Subscription; + searchedList: Info[]; + searchedListSubscription: Subscription; + eventInfoStatus: InfoResponse; + eventInfoStatusSubscription: Subscription; + eventRemained: boolean; + eventRemainedSubscription: Subscription; + userInfos: UserInfo[]; + userInfosSubscription: Subscription; EventType = EventType; profileImageRoot: string; moment = moment; - firstEventSeq = 0; existReadHere = false; constructor( @@ -101,6 +145,91 @@ export class MessagesComponent implements OnInit { ngOnInit() { this.profileImageRoot = this.profileImageRoot || this.sessionVerInfo.profileRoot; + + this.loginResSubscription = this.loginRes$.subscribe(loginRes => { + this.loginRes = loginRes; + }); + this.roomInfoSubscription = this.roomInfo$.subscribe(roomInfo => { + this.initEventMore(); + this.roomInfo = roomInfo; + }); + this.eventListSubscription = this.eventList$.subscribe(eventList => { + if ( + !!eventList && + eventList.length > 0 && + !!this.roomInfo && + !!this.roomInfo.lastReadEventSeq && + this.baseEventSeq <= this.roomInfo.lastReadEventSeq + ) { + // 조회된 내용중에 read here 가 있을 경우. + this.firstCheckReadHere = false; + } + + if (this.searchingMode) { + const baseseq = this.baseEventSeq; + // setTimeout(() => { + // this.doSearchTextInEvent(this.searchText, baseseq); + // }, 800); + this.baseEventSeq = eventList[0].seq; + } else { + if (!!eventList && eventList.length > 0) { + this.baseEventSeq = eventList[0].seq; + this.ready(); + } + } + + this.eventList = eventList; + }); + this.newEventListSubscription = this.newEventList$.subscribe( + newEventList => { + this.newEventList = newEventList; + } + ); + this.searchedListSubscription = this.searchedList$.subscribe( + searchedList => { + this.searchedList = searchedList; + } + ); + this.eventInfoStatusSubscription = this.eventInfoStatus$.subscribe( + eventInfoStatus => { + this.eventInfoStatus = eventInfoStatus; + } + ); + this.eventRemainedSubscription = this.eventRemained$.subscribe( + eventRemained => { + this.eventRemained = eventRemained; + } + ); + this.userInfosSubscription = this.userInfos$.subscribe(userInfos => { + this.userInfos = userInfos; + }); + } + + ngOnDestroy(): void { + if (!!this.loginResSubscription) { + this.loginResSubscription.unsubscribe(); + } + if (!!this.roomInfoSubscription) { + this.roomInfoSubscription.unsubscribe(); + } + if (!!this.eventListSubscription) { + this.eventListSubscription.unsubscribe(); + } + if (!!this.newEventListSubscription) { + this.newEventListSubscription.unsubscribe(); + } + if (!!this.searchedListSubscription) { + this.searchedListSubscription.unsubscribe(); + } + if (!!this.eventInfoStatusSubscription) { + this.eventInfoStatusSubscription.unsubscribe(); + } + if (!!this.eventRemainedSubscription) { + this.eventRemainedSubscription.unsubscribe(); + } + if (!!this.userInfosSubscription) { + this.userInfosSubscription.unsubscribe(); + } } /** @@ -132,7 +261,8 @@ export class MessagesComponent implements OnInit { } return ''; } - getEventSearched(seq: number): boolean { + + isHighlightedEvent(seq: number): boolean { return ( !!this.searchedList && this.searchedList.filter(event => event.seq === seq).length > 0 @@ -177,11 +307,11 @@ export class MessagesComponent implements OnInit { if (curIndex > 0) { return ( this.datePipe.transform( - moment(this.messages[curIndex].sendDate).toDate(), + moment(this.eventList[curIndex].sendDate).toDate(), 'yyyyMMdd' ) !== this.datePipe.transform( - moment(this.messages[curIndex - 1].sendDate).toDate(), + moment(this.eventList[curIndex - 1].sendDate).toDate(), 'yyyyMMdd' ) ); @@ -202,7 +332,7 @@ export class MessagesComponent implements OnInit { ) { if (!this.roomInfo.isTimeRoom) { if ( - this.messages[messageIndex].seq === + this.eventList[messageIndex].seq === this.roomInfo.lastReadEventSeq + 1 ) { this.existReadHere = true; @@ -217,11 +347,99 @@ export class MessagesComponent implements OnInit { getStringReadHereMore(): string { let rtnStr = ''; rtnStr = this.translateService.instant('chat.event.moreUnreadEventsWith', { - countOfUnread: this.firstEventSeq - (this.roomInfo.lastReadEventSeq + 1) + countOfUnread: this.baseEventSeq - (this.roomInfo.lastReadEventSeq + 1) }); return rtnStr; } + storeScrollHeight() { + this.storedScrollHeight = this.psChatContent.elementRef.nativeElement.scrollHeight; + } + + ready(): void { + setTimeout(() => { + this.scrollToBottom(); + }); + } + + scrollToBottom(speed?: number): void { + if (this.storedScrollHeight > 0) { + if (this.psChatContent) { + this.psChatContent.update(); + + const element = document.getElementById('message-box-readhere'); + if (!!element && this.firstCheckReadHere) { + setTimeout(() => { + this.psChatContent.scrollToTop(element.offsetTop - 200, speed); + }); + + this.firstCheckReadHere = false; + } else { + setTimeout(() => { + this.psChatContent.scrollToTop( + this.psChatContent.elementRef.nativeElement.scrollHeight - + this.storedScrollHeight, + speed + ); + + this.storedScrollHeight = 0; + }); + } + } + } else if (this.scrollUpInitalized) { + if (!!this.newEventList && this.newEventList.length > 0) { + this.existNewMessage.emit( + this.newEventList[this.newEventList.length - 1] + ); + } + } else { + speed = speed || 0; + if (this.psChatContent) { + this.psChatContent.update(); + + const element = document.getElementById('message-box-readhere'); + if (!!element && this.firstCheckReadHere) { + setTimeout(() => { + this.psChatContent.scrollToTop(element.offsetTop - 200, speed); + }); + + this.firstCheckReadHere = false; + } else { + setTimeout(() => { + this.psChatContent.scrollToBottom(0, speed); + }); + } + } + } + } + + initEventMore() { + // 방정보가 바뀌면 이전대화 보기 관련 값들을 초기화 한다. + this.scrollUpInitalized = false; + this.storedScrollHeight = 0; + } + + clear() {} + + gotoPosition(eventSeq: number) { + // if (this.psChatContent) { + // this.psChatContent.update(); + + // const element = document.getElementById(eventSeq.toString()); + // if (!!element) { + // setTimeout(() => { + // this.psChatContent.scrollToTop(element.offsetTop - 200); + // }); + // } + // } + + if (!!this.virtualScroller) { + const e = this.eventList.find(v => v.seq === eventSeq); + this.virtualScroller.scrollDebounceTime = 0; + this.virtualScroller.scrollInto(e); + } + } + onClickOpenProfile(userSeq: number) { this.openProfile.emit(userSeq); } @@ -230,7 +448,11 @@ export class MessagesComponent implements OnInit { event.preventDefault(); event.stopPropagation(); - this.moreEvent.emit(this.messages[0].seq); + if (this.scrollUpInitalized && this.eventRemained) { + this.storedScrollHeight = this.psChatContent.elementRef.nativeElement.scrollHeight; + + this.moreEvent.emit(this.eventList[0].seq); + } } /** [Event] MassTalk Detail View */ @@ -267,4 +489,19 @@ export class MessagesComponent implements OnInit { }) { this.contextMenu.emit(event); } + + onScrollup(event: any) { + this.scrollUpInitalized = true; + this.scrollUp.emit(event); + } + onYReachStart(event: any) { + this.yReachStart.emit(event); + } + onYReachEnd(event: any) { + this.yReachEnd.emit(event); + } + + trackByEvent(index: number, info: Info): number { + return info.seq; + } } diff --git a/projects/ucap-webmessenger-ui-chat/src/lib/ucap-ui-chat.module.ts b/projects/ucap-webmessenger-ui-chat/src/lib/ucap-ui-chat.module.ts index 34bccb3b..d1155ae9 100644 --- a/projects/ucap-webmessenger-ui-chat/src/lib/ucap-ui-chat.module.ts +++ b/projects/ucap-webmessenger-ui-chat/src/lib/ucap-ui-chat.module.ts @@ -4,12 +4,19 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FlexLayoutModule } from '@angular/flex-layout'; +import { ScrollingModule } from '@angular/cdk/scrolling'; +import { ScrollingModule as ExperimentalScrollingModule } from '@angular/cdk-experimental/scrolling'; + +import { MatTooltipModule } from '@angular/material'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatButtonModule } from '@angular/material/button'; import { MatMenuModule } from '@angular/material/menu'; +import { PerfectScrollbarModule } from 'ngx-perfect-scrollbar'; +import { VirtualScrollerModule } from 'ngx-virtual-scroller'; + import { TranslateModule } from '@ngx-translate/core'; import { UCapUiModule } from '@ucap-webmessenger/ui'; @@ -36,7 +43,6 @@ import { TranslationComponent as MBTranslationComponent } from './components/mes import { VideoComponent as MBVideoComponent } from './components/message-box/video.component'; import { VideoConferenceComponent as MBVideoConferenceComponent } from './components/message-box/video-conference.component'; import { AllimComponent as MBAllimComponent } from './components/message-box/allim.component'; -import { MatTooltipModule } from '@angular/material'; const COMPONENTS = [ FormComponent, @@ -71,6 +77,10 @@ const PROVIDERS = [DatePipe]; FormsModule, ReactiveFormsModule, FlexLayoutModule, + + ScrollingModule, + ExperimentalScrollingModule, + MatFormFieldModule, MatIconModule, MatInputModule, @@ -78,6 +88,9 @@ const PROVIDERS = [DatePipe]; MatMenuModule, MatTooltipModule, + PerfectScrollbarModule, + VirtualScrollerModule, + TranslateModule, UCapUiModule diff --git a/projects/ucap-webmessenger-ui-chat/src/public-api.ts b/projects/ucap-webmessenger-ui-chat/src/public-api.ts index 3ff10887..694b6cd7 100644 --- a/projects/ucap-webmessenger-ui-chat/src/public-api.ts +++ b/projects/ucap-webmessenger-ui-chat/src/public-api.ts @@ -19,7 +19,9 @@ export * from './lib/components/message-box/video-conference.component'; export * from './lib/components/message-box/video.component'; export * from './lib/components/form.component'; +export * from './lib/components/message-box.component'; export * from './lib/components/messages.component'; +export * from './lib/components/search.component'; export * from './lib/models/file-info.json'; export * from './lib/models/mass-talk-info.json';