import { Component, OnInit, OnDestroy, ViewChild, ElementRef, AfterViewInit, Output, EventEmitter } from '@angular/core'; import { ucapAnimations, SnackBarService, ClipboardService, DialogService, ConfirmDialogComponent, ConfirmDialogData, ConfirmDialogResult, AlertDialogComponent, AlertDialogData, AlertDialogResult, FileUploadQueueComponent, StringUtil } from '@ucap-webmessenger/ui'; import { Store, select } from '@ngrx/store'; import { NGXLogger } from 'ngx-logger'; import { Observable, Subscription, forkJoin, of } from 'rxjs'; import { Info, EventType, isRecalled, isCopyable, isRecallable, InfoResponse, EventJson, FileEventJson } from '@ucap-webmessenger/protocol-event'; import * as AppStore from '@app/store'; import * as EventStore from '@app/store/messenger/event'; import * as ChatStore from '@app/store/messenger/chat'; import * as RoomStore from '@app/store/messenger/room'; import * as SyncStore from '@app/store/messenger/sync'; import { LoginResponse } from '@ucap-webmessenger/protocol-authentication'; import { SessionStorageService } from '@ucap-webmessenger/web-storage'; import { EnvironmentsInfo, KEY_ENVIRONMENTS_INFO, UserSelectDialogType, RightDrawer } from '@app/types'; import { RoomInfo, UserInfo, RoomType } from '@ucap-webmessenger/protocol-room'; import { tap, take, map, catchError } from 'rxjs/operators'; import { FileInfo, FormComponent as UCapUiChatFormComponent } from '@ucap-webmessenger/ui-chat'; import { KEY_VER_INFO } from '@app/types/ver-info.type'; import { VersionInfo2Response } from '@ucap-webmessenger/api-public'; import { MatMenuTrigger, MatSnackBarRef, SimpleSnackBar } from '@angular/material'; import { CommonApiService, FileUploadItem, FileTalkSaveRequest, FileTalkSaveResponse } from '@ucap-webmessenger/api-common'; import { CreateChatDialogComponent, CreateChatDialogData, CreateChatDialogResult } from '../dialogs/chat/create-chat.dialog.component'; import { FileViewerDialogComponent, FileViewerDialogData, FileViewerDialogResult } from '@app/layouts/common/dialogs/file-viewer.dialog.component'; import { CONST, FileUtil } from '@ucap-webmessenger/core'; import { PerfectScrollbarComponent } from 'ngx-perfect-scrollbar'; import { StatusCode } from '@ucap-webmessenger/api'; import { EditChatRoomDialogComponent, EditChatRoomDialogResult, EditChatRoomDialogData } from '../dialogs/chat/edit-chat-room.dialog.component'; import { SelectGroupDialogComponent, SelectGroupDialogResult, SelectGroupDialogData } from '../dialogs/group/select-group.dialog.component'; import { GroupDetailData } from '@ucap-webmessenger/protocol-sync'; @Component({ selector: 'app-layout-messenger-messages', templateUrl: './messages.component.html', styleUrls: ['./messages.component.scss'], animations: ucapAnimations }) export class MessagesComponent implements OnInit, OnDestroy, AfterViewInit { @Output() openProfile = new EventEmitter(); @ViewChild('messageBoxContainer', { static: true }) private messageBoxContainer: ElementRef; @ViewChild('chatForm', { static: false }) private chatForm: UCapUiChatFormComponent; @ViewChild('messageContextMenuTrigger', { static: true }) messageContextMenuTrigger: MatMenuTrigger; messageContextMenuPosition = { x: '0px', y: '0px' }; @ViewChild('psChatContent', { static: true }) psChatContent: PerfectScrollbarComponent; @ViewChild('fileUploadQueue', { static: true }) fileUploadQueue: FileUploadQueueComponent; environmentsInfo: EnvironmentsInfo; loginRes: LoginResponse; loginResSubscription: Subscription; eventList: Info[]; eventListNew: Info[]; eventListSubscription: Subscription; baseEventSeq = 0; roomInfo: RoomInfo; roomInfoSubscription: Subscription; userInfoList: UserInfo[]; userInfoListSubscription: Subscription; eventListProcessing$: Observable; eventInfoStatus$: Observable; sessionVerInfo: VersionInfo2Response; eventRemain$: Observable; eventRemain = false; // 이전대화가 남아 있는지 여부 eventMorePosition = 0; // 이전대화를 불러올 경우 현재 스크롤 포지션 유지를 위한 값. 0 이면 초기로딩. scrollUpinit = false; // ps 에서 초기 로딩시 scroll reach start 이벤트 발생 버그를 우회하기 위한 init 값으로 scrollUp 에 의해 true 로 된다. isRecalledMessage = isRecalled; isCopyableMessage = isCopyable; isRecallableMessage = isRecallable; /** Timer 대화방의 대화 삭제를 위한 interval */ interval: any; isShowStickerSelector = false; snackBarPreviewEvent: MatSnackBarRef; constructor( private store: Store, private sessionStorageService: SessionStorageService, private commonApiService: CommonApiService, private clipboardService: ClipboardService, private dialogService: DialogService, private snackBarService: SnackBarService, private logger: NGXLogger ) { this.sessionVerInfo = this.sessionStorageService.get( KEY_VER_INFO ); this.environmentsInfo = this.sessionStorageService.get( KEY_ENVIRONMENTS_INFO ); } setEventMoreInit() { // 방정보가 바뀌면 이전대화 보기 관련 값들을 초기화 한다. this.scrollUpinit = false; this.eventMorePosition = 0; } ngOnInit() { this.loginResSubscription = this.store .pipe( select(AppStore.AccountSelector.AuthenticationSelector.loginRes), tap(loginRes => { this.loginRes = loginRes; }) ) .subscribe(); this.roomInfoSubscription = this.store .pipe( select(AppStore.MessengerSelector.RoomSelector.roomInfo), tap(roomInfo => { this.roomInfo = roomInfo; this.setEventMoreInit(); }) ) .subscribe(); this.userInfoListSubscription = this.store .pipe( select(AppStore.MessengerSelector.RoomSelector.selectUserinfolist), tap(userInfoList => { this.userInfoList = userInfoList; }) ) .subscribe(); this.eventListProcessing$ = this.store.pipe( select(AppStore.MessengerSelector.EventSelector.infoListProcessing) ); this.eventRemain$ = this.store.pipe( select(AppStore.MessengerSelector.EventSelector.remainInfo), tap(remainInfo => { this.eventRemain = remainInfo; }) ); this.eventListSubscription = this.store .pipe( select(AppStore.MessengerSelector.EventSelector.selectAllInfoList), tap(infoList => { if (!!this.eventList && this.eventList.length > 0) { this.eventListNew = infoList.filter(info => { if (info.seq <= this.eventList[this.eventList.length - 1].seq) { return false; } return true; }); } this.eventList = infoList; if (!!infoList && infoList.length > 0) { this.baseEventSeq = infoList[0].seq; this.readyToReply(); } }) ) .subscribe(); this.eventInfoStatus$ = this.store.pipe( select(AppStore.MessengerSelector.EventSelector.infoStatus) ); this.interval = setInterval(() => { if (!!this.roomInfo && !!this.roomInfo.isTimeRoom) { this.store.dispatch(EventStore.infoIntervalClear({})); } }, 1000); } ngOnDestroy(): void { if (!!this.loginResSubscription) { this.loginResSubscription.unsubscribe(); } if (!!this.roomInfoSubscription) { this.roomInfoSubscription.unsubscribe(); } if (!!this.userInfoListSubscription) { this.userInfoListSubscription.unsubscribe(); } if (!!this.eventListSubscription) { this.eventListSubscription.unsubscribe(); } clearInterval(this.interval); } ngAfterViewInit(): void { // this.readyToReply(); } getRoomName() { if (!this.roomInfo || !this.userInfoList) { return '대화방명을 가져오고 있습니다..'; } switch (this.roomInfo.roomType) { case RoomType.Mytalk: return 'MyTalk'; case RoomType.Bot: case RoomType.Allim: return this.userInfoList .filter(user => user.seq !== this.loginRes.userSeq) .map(user => user.name); } if (!!this.roomInfo.roomName && '' !== this.roomInfo.roomName.trim()) { return this.roomInfo.roomName; } else { return this.userInfoList .filter(user => { if (this.roomInfo.roomType === RoomType.Single) { return user.seq !== this.loginRes.userSeq; } else { return true; } }) .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) .map(user => user.name) .join(','); } } /** 대화전송 가능한 방인지 판단 */ getEnableSend() { if (!this.roomInfo) { return false; } if ( this.roomInfo.roomType === RoomType.Bot || this.roomInfo.roomType === RoomType.Allim || this.roomInfo.roomType === RoomType.Link ) { return false; } return true; } getConvertTimer(timerInterval: number, unit: number = 1) { if (timerInterval >= 0 && timerInterval < 60 * unit) { return Math.floor((timerInterval / 1) * unit) + ' 초'; } else if (timerInterval >= 60 * unit && timerInterval < 3600 * unit) { return Math.floor(((timerInterval / 1) * unit) / 60) + ' 분'; } else if (timerInterval >= 3600 * unit && timerInterval <= 86400 * unit) { return Math.floor(((timerInterval / 1) * unit) / 60 / 60) + ' 시간'; } else { return ''; } } readyToReply(): void { setTimeout(() => { this.focusReplyInput(); this.scrollToBottom(); }); } focusReplyInput(): void { setTimeout(() => { if (!!this.chatForm) { this.chatForm.focus(); } }); } /** Scroll Handler */ scrollToBottom(speed?: number): void { if (this.eventMorePosition > 0) { if (this.psChatContent.directiveRef) { this.psChatContent.directiveRef.update(); setTimeout(() => { this.psChatContent.directiveRef.scrollToTop( this.psChatContent.directiveRef.elementRef.nativeElement .scrollHeight - this.eventMorePosition, speed ); this.eventMorePosition = 0; }); } } else if (this.scrollUpinit) { if (!!this.eventListNew && this.eventListNew.length > 0) { let message = ''; const info = this.eventListNew[this.eventListNew.length - 1]; const senderUser = this.userInfoList.filter( user => user.seq === info.senderSeq ); if (!!senderUser && senderUser.length > 0) { message += `${senderUser[0].name} : `; } message += StringUtil.convertFinalEventMessage( info.type, info.sentMessageJson || info.sentMessage ); this.snackBarPreviewEvent = this.snackBarService.open(message, 'GO', { // duration: 3000, verticalPosition: 'bottom', horizontalPosition: 'center', panelClass: ['chat-snackbar-class'] }); this.snackBarPreviewEvent.onAction().subscribe(() => { this.setEventMoreInit(); this.scrollToBottom(); this.snackBarPreviewEvent.dismiss(); }); } } else { speed = speed || 0; if (this.psChatContent.directiveRef) { this.psChatContent.directiveRef.update(); setTimeout(() => { this.psChatContent.directiveRef.scrollToBottom(0, speed); }); } } } onScrollup(event: any) { this.scrollUpinit = true; } onScrollReachStart(event: any) { this.onMoreEvent(this.baseEventSeq); } onScrollReachEnd(event: any) { this.setEventMoreInit(); if (!!this.snackBarPreviewEvent) { this.snackBarPreviewEvent.dismiss(); } } /** More Event */ onMoreEvent(seq: number) { if (this.scrollUpinit && this.eventRemain) { this.eventMorePosition = this.psChatContent.directiveRef.elementRef.nativeElement.scrollHeight; this.store.dispatch( EventStore.info({ roomSeq: this.roomInfo.roomSeq, baseSeq: seq, requestCount: CONST.EVENT_INFO_READ_COUNT }) ); } } async onSendMessage(message: string) { this.setEventMoreInit(); if (!message || message.trim().length === 0) { const result = await this.dialogService.open< AlertDialogComponent, AlertDialogData, AlertDialogResult >(AlertDialogComponent, { width: '360px', data: { title: 'Alert', message: `대화내용을 입력해주세요.` } }); return; } if (message.trim().length > CONST.MASSTEXT_LEN) { // MASS TEXT this.store.dispatch( EventStore.sendMass({ senderSeq: this.loginRes.userSeq, req: { roomSeq: this.roomInfo.roomSeq, eventType: EventType.MassText, // sentMessage: message.replace(/\n/gi, '\r\n') sentMessage: message } }) ); } else { this.store.dispatch( EventStore.send({ senderSeq: this.loginRes.userSeq, req: { roomSeq: this.roomInfo.roomSeq, eventType: EventType.Character, sentMessage: message } }) ); } } onClickReceiveAlarm() { this.store.dispatch(RoomStore.updateOnlyAlarm({ roomInfo: this.roomInfo })); } /** MassText Detail View */ onMassDetail(value: number) { this.store.dispatch( ChatStore.selectedMassDetail({ massEventSeq: value }) ); } onShowToggleStickerSelector() { this.isShowStickerSelector = !this.isShowStickerSelector; } async onFileViewer(fileInfo: FileEventJson) { const result = await this.dialogService.open< FileViewerDialogComponent, FileViewerDialogData, FileViewerDialogResult >(FileViewerDialogComponent, { position: { top: '30px' }, maxWidth: '100vw', maxHeight: '100vh', height: 'calc(100% - 30px)', width: '100%', hasBackdrop: false, panelClass: 'app-dialog-full', data: { fileInfo, downloadUrl: this.sessionVerInfo.downloadUrl, deviceType: this.environmentsInfo.deviceType, token: this.loginRes.tokenString, userSeq: this.loginRes.userSeq } }); } /** File Save, Save As */ onSave(value: { fileInfo: FileInfo; type: string }) { this.logger.debug('fileSave', value); } onFileDragEnter(items: DataTransferItemList) { this.logger.debug('onFileDragEnter', items); } onFileDragOver() { this.logger.debug('onFileDragOver'); } onFileDragLeave() { this.logger.debug('onFileDragLeave'); } async onFileSelected(fileUploadItems: FileUploadItem[]) { this.logger.debug('onFileSelected', fileUploadItems); const info = { senderSeq: this.loginRes.userSeq, roomSeq: this.roomInfo.roomSeq }; const allObservables: Observable[] = []; for (const fileUploadItem of fileUploadItems) { let thumbnail: File; if ( -1 !== [ '3gp', 'avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mpg', 'rv', 'ts', 'webm', 'wmv' ].indexOf(FileUtil.getExtension(fileUploadItem.file.name)) ) { thumbnail = await FileUtil.thumbnail(fileUploadItem.file); this.logger.debug('thumbnail', thumbnail); } const req: FileTalkSaveRequest = { userSeq: this.loginRes.userSeq, deviceType: this.environmentsInfo.deviceType, token: this.loginRes.tokenString, roomSeq: this.roomInfo.roomSeq, file: fileUploadItem.file, fileName: fileUploadItem.file.name, thumb: thumbnail, fileUploadItem }; allObservables.push( this.commonApiService .fileTalkSave(req, this.sessionVerInfo.uploadUrl) .pipe( map(res => { if (!res) { return; } if (StatusCode.Success === res.statusCode) { return res; } else { throw res; } }) ) ); } forkJoin(allObservables) .pipe(take(1)) .subscribe( resList => { for (const res of resList) { this.store.dispatch( EventStore.send({ senderSeq: info.senderSeq, req: { roomSeq: info.roomSeq, eventType: EventType.File, sentMessage: res.returnJson } }) ); } }, error => { this.logger.debug('onFileSelected error', error); this.fileUploadQueue.onUploadComplete(); this.dialogService.open< AlertDialogComponent, AlertDialogData, AlertDialogResult >(AlertDialogComponent, { data: { title: 'Alert', html: `파일전송에 실패하였습니다.
계속 문제 발생 시 관리자에게 문의하세요.` } }); }, () => { this.fileUploadQueue.onUploadComplete(); } ); } onContextMenuMessage(params: { event: MouseEvent; message: Info; }) { params.event.preventDefault(); params.event.stopPropagation(); this.messageContextMenuPosition.x = params.event.clientX + 'px'; this.messageContextMenuPosition.y = params.event.clientY + 'px'; this.messageContextMenuTrigger.menu.focusFirstItem('mouse'); this.messageContextMenuTrigger.menuData = { message: params.message, loginRes: this.loginRes }; this.messageContextMenuTrigger.openMenu(); } async onClickMessageContextMenu(menuType: string, message: Info) { switch (menuType) { case 'COPY': { switch (message.type) { case EventType.Character: { if ( this.clipboardService.copyFromContent( (message as Info).sentMessage ) ) { this.snackBarService.open('클립보드에 복사되었습니다.', '', { duration: 3000, verticalPosition: 'top', horizontalPosition: 'center' }); } } break; case EventType.MassText: { this.commonApiService .massTalkDownload({ userSeq: this.loginRes.userSeq, deviceType: this.environmentsInfo.deviceType, token: this.loginRes.tokenString, eventMassSeq: message.seq }) .pipe(take(1)) .subscribe(res => { if (this.clipboardService.copyFromContent(res.content)) { this.snackBarService.open( '클립보드에 복사되었습니다.', '', { duration: 3000, verticalPosition: 'top', horizontalPosition: 'center' } ); } }); } break; default: break; } } break; case 'REPLAY': { const result = await this.dialogService.open< CreateChatDialogComponent, CreateChatDialogData, CreateChatDialogResult >(CreateChatDialogComponent, { width: '600px', data: { type: UserSelectDialogType.MessageForward, title: 'MessageForward', ignoreRoom: [this.roomInfo] } }); if (!!result && !!result.choice && result.choice) { const userSeqs: number[] = []; let roomSeq = ''; if ( !!result.selectedUserList && result.selectedUserList.length > 0 ) { result.selectedUserList.map(user => userSeqs.push(user.seq)); } if (!!result.selectedRoom) { roomSeq = result.selectedRoom.roomSeq; } if (userSeqs.length > 0 || roomSeq.trim().length > 0) { this.store.dispatch( EventStore.forward({ senderSeq: this.loginRes.userSeq, req: { roomSeq: '-999', eventType: message.type, sentMessage: message.sentMessage }, trgtUserSeqs: userSeqs, trgtRoomSeq: roomSeq }) ); } } } break; case 'REPLAY_TO_ME': { if (this.loginRes.talkWithMeBotSeq > -1) { this.store.dispatch( EventStore.forward({ senderSeq: this.loginRes.userSeq, req: { roomSeq: '-999', eventType: message.type, sentMessage: message.sentMessage }, trgtUserSeqs: [this.loginRes.talkWithMeBotSeq] }) ); } } break; case 'DELETE': { const result = await this.dialogService.open< ConfirmDialogComponent, ConfirmDialogData, ConfirmDialogResult >(ConfirmDialogComponent, { width: '220px', data: { title: 'Delete', html: `선택한 메시지를 삭제하시겠습니까?
삭제된 메시지는 내 대화방에서만 적용되며 상대방의 대화방에서는 삭제되지 않습니다.` } }); if (!!result && !!result.choice && result.choice) { this.store.dispatch( EventStore.del({ roomSeq: this.roomInfo.roomSeq, eventSeq: message.seq }) ); } } break; case 'RECALL': { const result = await this.dialogService.open< ConfirmDialogComponent, ConfirmDialogData, ConfirmDialogResult >(ConfirmDialogComponent, { width: '220px', data: { title: 'ReCall', html: `해당 대화를 회수하시겠습니까?
상대방 대화창에서도 회수됩니다.` } }); if (!!result && !!result.choice && result.choice) { this.store.dispatch( EventStore.cancel({ roomSeq: this.roomInfo.roomSeq, eventSeq: message.seq, deviceType: this.environmentsInfo.deviceType }) ); } } break; default: break; } } async onClickContextMenu(menuType: string) { switch (menuType) { case 'OPEN_ALBUM_LIST': { this.store.dispatch( ChatStore.selectedRightDrawer({ req: RightDrawer.AlbumBox }) ); } break; case 'OPEN_FILE_LIST': { this.store.dispatch( ChatStore.selectedRightDrawer({ req: RightDrawer.FileBox }) ); } break; case 'OPEN_ROOM_USER': { this.store.dispatch( ChatStore.selectedRightDrawer({ req: RightDrawer.RoomUser }) ); } break; case 'ADD_MEMBER': { const result = await this.dialogService.open< CreateChatDialogComponent, CreateChatDialogData, CreateChatDialogResult >(CreateChatDialogComponent, { width: '600px', data: { type: UserSelectDialogType.EditChatMember, title: 'Edit Chat Member', curRoomUser: this.userInfoList.filter( user => user.seq !== this.loginRes.userSeq ) } }); if (!!result && !!result.choice && result.choice) { const userSeqs: number[] = []; if ( !!result.selectedUserList && result.selectedUserList.length > 0 ) { result.selectedUserList.map(user => { userSeqs.push(user.seq); }); } if (userSeqs.length > 0) { // include me userSeqs.push(this.loginRes.userSeq); this.store.dispatch( RoomStore.inviteOrOpen({ req: { divCd: 'Invite', userSeqs } }) ); } } } break; case 'ADD_GROUP': { const result = await this.dialogService.open< SelectGroupDialogComponent, SelectGroupDialogData, SelectGroupDialogResult >(SelectGroupDialogComponent, { width: '600px', data: { title: 'Group Select' } }); if (!!result && !!result.choice && result.choice) { if (!!result.group) { const oldGroup: GroupDetailData = result.group; const trgtUserSeq: number[] = []; result.group.userSeqs.map(seq => trgtUserSeq.push(seq)); this.userInfoList .filter(v => result.group.userSeqs.indexOf(v.seq) < 0) .forEach(user => { trgtUserSeq.push(user.seq); }); this.store.dispatch( SyncStore.updateGroupMember({ oldGroup, trgtUserSeq }) ); } } } break; case 'EDIT_ROOM': { const result = await this.dialogService.open< EditChatRoomDialogComponent, EditChatRoomDialogData, EditChatRoomDialogResult >(EditChatRoomDialogComponent, { width: '600px', data: { title: 'Edit Chat Room', roomInfo: this.roomInfo } }); if (!!result && !!result.choice && result.choice) { const roomName: string = result.roomName; const roomNameChangeTarget: string = result.roomNameChangeTarget; const timeRoomInterval: number = result.timeRoomInterval; const roomInfo: RoomInfo = result.roomInfo; // 방제목 업데이트. this.store.dispatch( RoomStore.update({ req: { roomSeq: roomInfo.roomSeq, roomName, receiveAlarm: roomInfo.receiveAlarm, syncAll: roomNameChangeTarget.toUpperCase() === 'ALL' ? true : false } }) ); if ( roomInfo.isTimeRoom && timeRoomInterval > 0 && roomInfo.timeRoomInterval !== timeRoomInterval ) { this.store.dispatch( RoomStore.updateTimeRoomInterval({ roomSeq: roomInfo.roomSeq, timerInterval: timeRoomInterval }) ); } } } break; case 'CLOSE_ROOM': { this.store.dispatch(ChatStore.clearSelectedRoom()); } break; default: break; } } onClickOpenProfile(userInfo: UserInfo) { if ( this.roomInfo.roomType !== RoomType.Allim && this.roomInfo.roomType !== RoomType.Bot ) { this.openProfile.emit(userInfo); } } }