import { Component, OnInit, Input, EventEmitter, Output, ViewChild, OnDestroy, ChangeDetectionStrategy, ElementRef, ChangeDetectorRef, ViewChildren, QueryList } from '@angular/core'; import { Info, EventType, InfoResponse, EventJson, FileEventJson, MassTranslationEventJson } from '@ucap-webmessenger/protocol-event'; 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 moment from 'moment'; import { FileDownloadItem } from '@ucap-webmessenger/api'; import { TranslateService } from '@ngx-translate/core'; import { Observable, Subscription, timer } from 'rxjs'; import { VirtualScrollerComponent, IPageInfo } from 'ngx-virtual-scroller'; import { MessageBoxComponent } from './message-box.component'; import { debounce } from 'rxjs/operators'; import { SelectFileInfo } from '@ucap-webmessenger/ui'; @Component({ selector: 'ucap-chat-messages', templateUrl: './messages.component.html', styleUrls: ['./messages.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class MessagesComponent implements OnInit, OnDestroy { @Input() loginRes$: Observable; @Input() roomInfo$: Observable; @Input() eventList$: Observable[]>; @Input() newEventList$: Observable[]>; @Input() searchedList$: Observable[]>; @Input() eventInfoStatus$: Observable; @Input() eventRemained$: Observable; @Input() sessionVerInfo: VersionInfo2Response; @Input() userInfos$: Observable; @Input() lock$: Observable; @Input() isShowUnreadCount = true; @Input() clearReadHere: boolean; @Input() minShowReadHere = 10; @Input() translationSimpleview = false; @Input() searchingMode = false; @Output() openProfile = new EventEmitter(); @Output() moreEvent = new EventEmitter(); @Output() massDetail = new EventEmitter(); @Output() massTranslationDetail = new EventEmitter<{ message: Info; contentsType: string; }>(); @Output() fileViewer = new EventEmitter(); @Output() save = new EventEmitter<{ fileInfo: FileEventJson; fileDownloadItem: FileDownloadItem; type: string; }>(); @Output() contextMenu = new EventEmitter<{ event: MouseEvent; message: Info; type?: string; }>(); @Output() scrollUp = new EventEmitter(); @Output() yReachEnd = new EventEmitter(); @Output() yReachStart = new EventEmitter(); @Output() existNewMessage = new EventEmitter>(); @ViewChild('chatMessagesContainer', { static: false }) chatMessagesContainer: ElementRef; @ViewChild('chatMessagesBuffer', { static: false }) chatMessagesBuffer: ElementRef; @ViewChild('chatMessagesBufferContainer', { static: false }) chatMessagesBufferContainer: ElementRef; @ViewChild(VirtualScrollerComponent, { static: false }) private virtualScroller: VirtualScrollerComponent; @ViewChildren('chatMessageBox') chatMessageBoxList: QueryList; storedScrollItem: Info; // 이전대화를 불러올 경우 현재 스크롤 포지션 유지를 위한 값. 0 이면 초기로딩. storedScrollItemOffsetTop: number | undefined; scrollUpInitalized = false; // ps 에서 초기 로딩시 scroll reach start 이벤트 발생 버그를 우회하기 위한 init 값으로 scrollUp 에 의해 true 로 된다. firstCheckReadHere = true; initRoomLastEventSeq: number; baseEventSeq = 0; gotoEventSeq: number; 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; readToHereEvent: Info; existReadToHereEvent = true; hidden = false; swapped = false; initalized = false; constructor( private logger: NGXLogger, private changeDetectorRef: ChangeDetectorRef, private translateService: TranslateService ) {} ngOnInit() { this.profileImageRoot = this.profileImageRoot || this.sessionVerInfo.profileRoot; this.loginResSubscription = this.loginRes$.subscribe(loginRes => { this.loginRes = loginRes; }); this.roomInfoSubscription = this.roomInfo$.subscribe(roomInfo => { this.roomInfo = roomInfo; this.initalized = false; /** [S] initializing by changed room */ // reset :: roomLastEventSeq if (!!roomInfo && !!roomInfo.finalEventSeq) { this.initRoomLastEventSeq = roomInfo.finalEventSeq; } // clear :: readToHearEvent object this.readToHereEvent = undefined; this.existReadToHereEvent = true; /** [E] initializing by changed room */ if (!this.roomInfo || this.roomInfo.roomSeq !== roomInfo.roomSeq) { this.initEventMore(); } }); this.eventListSubscription = this.eventList$ .pipe(debounce(() => timer(100))) .subscribe(eventList => { this.eventList = eventList; if (!!eventList && eventList.length > 0) { if (!this.readToHereEvent && this.existReadToHereEvent) { this.readToHereEvent = this.getReadHere(); } if ( this.baseEventSeq > 0 && !!this.roomInfo && !!this.roomInfo.lastReadEventSeq && this.baseEventSeq <= this.roomInfo.lastReadEventSeq ) { // 기존 대화 내용이 있는 상태에서 추가로 조회된 내용중에 read here 가 있을 경우. this.firstCheckReadHere = false; } } else { this.readToHereEvent = undefined; } this.changeDetectorRef.detectChanges(); 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.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(); } } /** * UserInfo getter */ getUserName(seq: number): string { if (!this.userInfos) { return ''; } const userInfo: UserInfo[] = this.userInfos.filter( user => user.seq === seq ); if (!!userInfo && userInfo.length > 0) { return userInfo[0].name; } return '(알수없는 사용자)'; } getUserProfile(seq: number): string { if (!this.userInfos) { return ''; } const userInfo: UserInfo[] = this.userInfos.filter( user => user.seq === seq ); if (!!userInfo && userInfo.length > 0) { return userInfo[0].profileImageFile; } return ''; } isHighlightedEvent(seq: number): boolean { return ( !!this.searchedList && this.searchedList.filter(event => event.seq === seq).length > 0 ); } getUnreadCount(message: Info): string | number { // if (!this.userInfos || 0 === this.userInfos.length) { // return ''; // } const unreadCnt = this.userInfos .filter(user => user.isJoinRoom && user.seq !== message.senderSeq) .filter(user => user.lastReadEventSeq < message.seq).length; return unreadCnt === 0 ? '' : unreadCnt; } /** * 정보성 Event 인지 판단. * @description 정보성 event 일 경우 프로필, 일시 를 표현하지 않는다. * Edit with reducers.ts / sync / updateRoomForNewEventMessage */ getIsInformation(info: Info) { if ( info.type === EventType.Join || info.type === EventType.Exit || info.type === EventType.ForcedExit || info.type === EventType.RenameRoom || info.type === EventType.NotificationForTimerRoom || info.type === EventType.GuideForRoomTimerChanged ) { return true; } return false; } /** Date Splitter show check */ getDateSplitter(message: Info): boolean { const curIndex = this.eventList.findIndex(v => v.seq === message.seq); if (curIndex === 0) { return true; } if (curIndex > 0) { if (!this.eventList[curIndex]) { return false; } return !moment(this.eventList[curIndex].sendDate).isSame( moment(this.eventList[curIndex - 1].sendDate), 'day' ); } return false; } getReadHere(): Info | undefined { if ( !!this.roomInfo && !!this.roomInfo.lastReadEventSeq && this.initRoomLastEventSeq - this.roomInfo.lastReadEventSeq > this.minShowReadHere ) { if ( this.roomInfo.roomType === RoomType.Single || this.roomInfo.roomType === RoomType.Multi ) { if (!this.roomInfo.isTimeRoom) { return this.eventList.find( v => v.seq === this.roomInfo.lastReadEventSeq + 1 ); } } } else { this.existReadToHereEvent = false; } return undefined; } getStringReadHereMore(): string { let rtnStr = ''; rtnStr = this.translateService.instant('chat.event.moreUnreadEventsWith', { countOfUnread: this.baseEventSeq - (this.roomInfo.lastReadEventSeq + 1) }); return rtnStr; } storeScrollPosition() { this.storedScrollItem = this.eventList[ this.virtualScroller.viewPortInfo.startIndex ]; const chatMessageBox = this.chatMessageBoxList.find( el => el.message.seq === this.eventList[this.virtualScroller.viewPortInfo.startIndex].seq ); if (!!chatMessageBox) { this.storedScrollItemOffsetTop = chatMessageBox.offsetTop - this.virtualScroller.viewPortInfo.scrollStartPosition; } else { this.storedScrollItemOffsetTop = 0; } } swapScrollTo( to: Info, preCallback: () => void, postCallback: () => void, useHide: boolean, useSwap: boolean, addtionalOffset?: number ) { this.preSwapScroll(useHide, useSwap); if (!!preCallback) { preCallback(); } this.virtualScroller.scrollInto( to, true, undefined !== this.storedScrollItemOffsetTop ? -this.storedScrollItemOffsetTop : undefined !== addtionalOffset ? -addtionalOffset : 0, 0, () => { setTimeout(() => { if (!!postCallback) { postCallback(); } this.postSwapScroll(useHide, useSwap); }); } ); } preSwapScroll(useHide: boolean, useSwap: boolean) { if (useSwap && !this.swapped) { this.chatMessagesBuffer.nativeElement.innerHTML = this.chatMessagesContainer.nativeElement.innerHTML; this.chatMessagesBuffer.nativeElement.scrollTop = this.chatMessagesContainer.nativeElement.scrollTop; this.chatMessagesBufferContainer.nativeElement.classList.remove( 'disappear' ); this.swapped = true; } if (useHide && !this.hidden) { this.chatMessagesContainer.nativeElement.classList.add('hide'); this.hidden = true; } } postSwapScroll(useHide: boolean, useSwap: boolean) { if (useSwap && this.swapped) { this.chatMessagesBuffer.nativeElement.innerHTML = ''; this.chatMessagesBuffer.nativeElement.scrollTop = 0; this.chatMessagesBufferContainer.nativeElement.classList.add('disappear'); this.swapped = false; } if (useHide && this.hidden) { this.chatMessagesContainer.nativeElement.classList.remove('hide'); this.hidden = false; } } ready(): void { this.scrollToBottom(); } scrollToBottom(): void { if (!!this.storedScrollItem) { if (!!this.readToHereEvent && this.firstCheckReadHere) { this.swapScrollTo( this.readToHereEvent, () => {}, () => { this.firstCheckReadHere = false; }, true, true ); } else { this.swapScrollTo( this.storedScrollItem, () => {}, () => { this.storedScrollItem = undefined; this.storedScrollItemOffsetTop = undefined; }, true, true ); } } else if (this.scrollUpInitalized) { if (!!this.newEventList && this.newEventList.length > 0) { this.existNewMessage.emit( this.newEventList[this.newEventList.length - 1] ); } } else { if (!!this.readToHereEvent && this.firstCheckReadHere) { this.swapScrollTo( this.readToHereEvent, () => {}, () => { this.firstCheckReadHere = false; }, false, false ); } else { if (!this.eventList || 0 === this.eventList.length) { return; } if (!!this.gotoEventSeq) { this.gotoPosition(this.gotoEventSeq); this.gotoEventSeq = undefined; return; } const lastEvent = !!this.eventList && 0 < this.eventList.length ? this.eventList[this.eventList.length - 1] : undefined; if (undefined === lastEvent) { return; } const isInViewPortItems = this.isInViewPortItems(lastEvent.seq); this.swapScrollTo( this.eventList[this.eventList.length - 1], () => {}, () => { this.initalized = true; }, -1 === this.virtualScroller.viewPortInfo.endIndex || !isInViewPortItems, !isInViewPortItems ); } } } initEventMore(gotoEventSeq?: number) { // 방정보가 바뀌면 이전대화 보기 관련 값들을 초기화 한다. this.scrollUpInitalized = false; this.storedScrollItem = undefined; this.storedScrollItemOffsetTop = undefined; this.gotoEventSeq = gotoEventSeq; } clear() {} gotoPosition(eventSeq: number) { const isInViewPortItems = this.isInViewPortItems(eventSeq); if (!!this.virtualScroller) { const e = this.eventList.find(v => v.seq === eventSeq); this.swapScrollTo( e, () => {}, () => { const chatMessageBox = this.chatMessageBoxList.find( el => el.message.seq === eventSeq ); if (!!chatMessageBox) { chatMessageBox.shake(); } }, !isInViewPortItems, !isInViewPortItems, 50 ); } } isInViewPortItems(eventSeq: number): boolean { if (undefined === eventSeq) { return false; } const viewPortItemIndex = this.virtualScroller.viewPortItems.findIndex( v => v.seq === eventSeq ); const newEvent = !!this.newEventList && -1 !== this.newEventList.findIndex(e => e.seq === eventSeq); return -1 !== viewPortItemIndex || newEvent; } onClickOpenProfile(userSeq: number) { this.openProfile.emit(userSeq); } onClickMore(event: any) { event.preventDefault(); event.stopPropagation(); if (this.scrollUpInitalized && this.eventRemained) { this.virtualScroller.scrollToPosition(0); this.virtualScroller.invalidateCachedMeasurementAtIndex(0); this.storeScrollPosition(); this.preSwapScroll(true, true); this.moreEvent.emit(this.eventList[0].seq); } } /** [Event] MassTalk Detail View */ onMassDetail(value: number) { this.massDetail.emit(value); } onMassTranslationDetail(params: { message: Info; contentsType: string; }) { this.massTranslationDetail.emit(params); } /** [Event] Image Viewer */ onFileViewer(fileInfo: SelectFileInfo) { this.fileViewer.emit(fileInfo); } /** [Event] Attach File Save & Save As */ onSave(value: { fileInfo: FileEventJson; fileDownloadItem: FileDownloadItem; type: string; }) { this.save.emit(value); } /** [Event] Context Menu */ onContextMenu(event: { event: MouseEvent; message: Info; type?: string; }) { this.contextMenu.emit(event); } onScrollup(event: any) { if (!this.eventList || 0 === this.eventList.length) { return; } this.scrollUpInitalized = true; this.scrollUp.emit(event); } onYReachStart(event: any) { this.yReachStart.emit(event); } onYReachEnd(event: any) { this.yReachEnd.emit(event); } onVsChange(event: IPageInfo) { if ( -1 === event.startIndex || -1 === event.endIndex || (0 === event.startIndex && 0 === event.endIndex) ) { return; } // this.logger.debug('onVsChange', event); } trackByEvent(index: number, info: Info): number { return info.seq; } compareItemsFunc = ( item1: Info, item2: Info // tslint:disable-next-line: semicolon ): boolean => !!item1 && !!item2 && item1.seq === item2.seq; }