From e180445b96be896451a4402e0546bed83d66230d Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 14 Apr 2021 15:23:44 +0200 Subject: [PATCH] MOBILE-3638 chat: Migrate chat page --- .../conversation-info/conversation-info.html | 2 +- .../messages/pages/discussion/discussion.scss | 2 +- src/addons/mod/chat/chat-lazy.module.ts | 4 + .../mod/chat/components/components.module.ts | 3 + src/addons/mod/chat/components/index/index.ts | 1 + .../components/users-modal/users-modal.html | 35 ++ .../components/users-modal/users-modal.ts | 106 +++++ src/addons/mod/chat/pages/chat/chat.html | 126 ++++++ src/addons/mod/chat/pages/chat/chat.scss | 6 + src/addons/mod/chat/pages/chat/chat.ts | 403 ++++++++++++++++++ 10 files changed, 686 insertions(+), 2 deletions(-) create mode 100644 src/addons/mod/chat/components/users-modal/users-modal.html create mode 100644 src/addons/mod/chat/components/users-modal/users-modal.ts create mode 100644 src/addons/mod/chat/pages/chat/chat.html create mode 100644 src/addons/mod/chat/pages/chat/chat.scss create mode 100644 src/addons/mod/chat/pages/chat/chat.ts diff --git a/src/addons/messages/components/conversation-info/conversation-info.html b/src/addons/messages/components/conversation-info/conversation-info.html index 38add1ddc..42e3f3a6f 100644 --- a/src/addons/messages/components/conversation-info/conversation-info.html +++ b/src/addons/messages/components/conversation-info/conversation-info.html @@ -3,7 +3,7 @@ {{ 'addon.messages.groupinfo' | translate }} - + diff --git a/src/addons/messages/pages/discussion/discussion.scss b/src/addons/messages/pages/discussion/discussion.scss index 67086643d..477aa2305 100644 --- a/src/addons/messages/pages/discussion/discussion.scss +++ b/src/addons/messages/pages/discussion/discussion.scss @@ -1,6 +1,6 @@ :host { ion-content { - background-color: var(--background-lighter); + --background: var(--background-lighter); &::part(scroll) { padding-bottom: 0 !important; diff --git a/src/addons/mod/chat/chat-lazy.module.ts b/src/addons/mod/chat/chat-lazy.module.ts index e190a1860..6338bace3 100644 --- a/src/addons/mod/chat/chat-lazy.module.ts +++ b/src/addons/mod/chat/chat-lazy.module.ts @@ -23,6 +23,10 @@ const routes: Routes = [ path: ':courseId/:cmId', component: AddonModChatIndexPage, }, + { + path: ':courseId/:cmId/chat', + loadChildren: () => import('./pages/chat/chat.module').then(m => m.AddonModChatChatPageModule), + }, ]; @NgModule({ diff --git a/src/addons/mod/chat/components/components.module.ts b/src/addons/mod/chat/components/components.module.ts index 5c340aab9..954e3a8b4 100644 --- a/src/addons/mod/chat/components/components.module.ts +++ b/src/addons/mod/chat/components/components.module.ts @@ -16,10 +16,12 @@ import { NgModule } from '@angular/core'; import { AddonModChatIndexComponent } from './index/index'; import { CoreSharedModule } from '@/core/shared.module'; import { CoreCourseComponentsModule } from '@features/course/components/components.module'; +import { AddonModChatUsersModalComponent } from './users-modal/users-modal'; @NgModule({ declarations: [ AddonModChatIndexComponent, + AddonModChatUsersModalComponent, ], imports: [ CoreSharedModule, @@ -29,6 +31,7 @@ import { CoreCourseComponentsModule } from '@features/course/components/componen ], exports: [ AddonModChatIndexComponent, + AddonModChatUsersModalComponent, ], }) export class AddonModChatComponentsModule {} diff --git a/src/addons/mod/chat/components/index/index.ts b/src/addons/mod/chat/components/index/index.ts index bd70985d2..65b73d838 100644 --- a/src/addons/mod/chat/components/index/index.ts +++ b/src/addons/mod/chat/components/index/index.ts @@ -107,6 +107,7 @@ export class AddonModChatIndexComponent extends CoreCourseModuleMainActivityComp { params: { title, + chatId: this.chat!.id, }, }, ); diff --git a/src/addons/mod/chat/components/users-modal/users-modal.html b/src/addons/mod/chat/components/users-modal/users-modal.html new file mode 100644 index 000000000..050985a8b --- /dev/null +++ b/src/addons/mod/chat/components/users-modal/users-modal.html @@ -0,0 +1,35 @@ + + + + + + {{ 'addon.mod_chat.currentusers' | translate }} + + + + + + + + + + + + + +

{{ user.fullname }}

+ + + + {{ 'addon.mod_chat.talk' | translate }} + + + + {{ 'addon.mod_chat.beep' | translate }} + + +
+
+
+
diff --git a/src/addons/mod/chat/components/users-modal/users-modal.ts b/src/addons/mod/chat/components/users-modal/users-modal.ts new file mode 100644 index 000000000..456ed87d1 --- /dev/null +++ b/src/addons/mod/chat/components/users-modal/users-modal.ts @@ -0,0 +1,106 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { ModalController, Network, NgZone } from '@singletons'; +import { Subscription } from 'rxjs'; +import { AddonModChat, AddonModChatUser } from '../../services/chat'; + +/** + * MMdal that displays the chat session users. + */ +@Component({ + selector: 'addon-mod-chat-users-modal', + templateUrl: 'users-modal.html', +}) +export class AddonModChatUsersModalComponent implements OnInit, OnDestroy { + + @Input() sessionId!: string; + @Input() cmId!: number; + + users: AddonModChatUser[] = []; + usersLoaded = false; + currentUserId: number; + isOnline: boolean; + + protected onlineSubscription: Subscription; + + constructor() { + this.isOnline = CoreApp.isOnline(); + this.currentUserId = CoreSites.getCurrentSiteUserId(); + this.onlineSubscription = Network.onChange().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + NgZone.run(() => { + this.isOnline = CoreApp.isOnline(); + }); + }); + } + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + try { + const data = await AddonModChat.getChatUsers(this.sessionId, { cmId: this.cmId }); + + this.users = data.users; + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'addon.mod_chat.errorwhilegettingchatusers', true); + } finally { + this.usersLoaded = true; + } + } + + /** + * Close the chat users modal. + */ + closeModal(): void { + ModalController.dismiss( { users: this.users }); + } + + /** + * Add "To user:". + * + * @param user User object. + */ + talkTo(user: AddonModChatUser): void { + ModalController.dismiss( { talkTo: user.fullname, users: this.users }); + } + + /** + * Beep a user. + * + * @param user User object. + */ + beepTo(user: AddonModChatUser): void { + ModalController.dismiss( { beepTo: user.id, users: this.users }); + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.onlineSubscription.unsubscribe(); + } + +} + +export type AddonModChatUsersModalResult = { + users: AddonModChatUser[]; + talkTo?: string; + beepTo?: number; +}; diff --git a/src/addons/mod/chat/pages/chat/chat.html b/src/addons/mod/chat/pages/chat/chat.html new file mode 100644 index 000000000..5e1980a91 --- /dev/null +++ b/src/addons/mod/chat/pages/chat/chat.html @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + +
+ {{ message.timestamp * 1000 | coreFormatDate: "strftimedayshort" }} +
+ +
+ + + + {{ message.timestamp * 1000 | coreFormatDate:"strftimetime" }} + {{ 'addon.mod_chat.messageenter' | translate:{$a: message.userfullname} }} + + + + + + + {{ message.timestamp * 1000 | coreFormatDate:"strftimetime" }} + {{ 'addon.mod_chat.messageexit' | translate:{$a: message.userfullname} }} + + + + + + + {{ message.timestamp * 1000 | coreFormatDate:"strftimetime" }} + {{ 'addon.mod_chat.messagebeepseveryone' | translate:{$a: message.userfullname} }} + + + + + + + {{ message.timestamp * 1000 | coreFormatDate:"strftimetime" }} + {{ 'addon.mod_chat.messagebeepsyou' | translate:{$a: message.userfullname} }} + + + + + + + {{ message.timestamp * 1000 | coreFormatDate:"strftimetime" }} + {{ 'addon.mod_chat.messageyoubeep' | translate:{$a: message.beepWho} }} + + + + + + + {{ message.timestamp * 1000 | coreFormatDate:"strftimetime" }} + + {{ message.userfullname }} + + + + +
+ + + + +

+ + +
{{ message.userfullname }}
+ {{ message.timestamp * 1000 | coreFormatDate: "strftimetime" }} +

+ +

+ + +

+
+
+
+
+
+ + + +
+
+ + +

+ {{ 'addon.mod_chat.mustbeonlinetosendmessages' | translate }} +

+ + + + + + {{ 'core.login.reconnect' | translate }} + +
+
diff --git a/src/addons/mod/chat/pages/chat/chat.scss b/src/addons/mod/chat/pages/chat/chat.scss new file mode 100644 index 000000000..2ee6b3c8c --- /dev/null +++ b/src/addons/mod/chat/pages/chat/chat.scss @@ -0,0 +1,6 @@ +:host { + .addon-mod_chat-notice { + margin-top: 8px; + margin-bottom: 8px; + } +} diff --git a/src/addons/mod/chat/pages/chat/chat.ts b/src/addons/mod/chat/pages/chat/chat.ts new file mode 100644 index 000000000..1fe1d3d0e --- /dev/null +++ b/src/addons/mod/chat/pages/chat/chat.ts @@ -0,0 +1,403 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, ViewChild, OnInit, OnDestroy } from '@angular/core'; +import { CoreAnimations } from '@components/animations'; +import { CoreSendMessageFormComponent } from '@components/send-message-form/send-message-form'; +import { IonContent } from '@ionic/angular'; +import { CoreApp } from '@services/app'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { ModalController, Network, NgZone } from '@singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { Subscription } from 'rxjs'; +import { AddonModChatUsersModalComponent, AddonModChatUsersModalResult } from '../../components/users-modal/users-modal'; +import { AddonModChat, AddonModChatProvider, AddonModChatUser } from '../../services/chat'; +import { AddonModChatFormattedMessage, AddonModChatHelper } from '../../services/chat-helper'; + +/** + * Page that displays a chat session. + */ +@Component({ + selector: 'page-addon-mod-chat-chat', + templateUrl: 'chat.html', + animations: [CoreAnimations.SLIDE_IN_OUT], + styleUrls: ['chat.scss', '../../../../messages/pages/discussion/discussion.scss'], +}) +export class AddonModChatChatPage implements OnInit, OnDestroy { + + @ViewChild(IonContent) content?: IonContent; + @ViewChild(CoreSendMessageFormComponent) sendMessageForm?: CoreSendMessageFormComponent; + + loaded = false; + title = ''; + messages: AddonModChatFormattedMessage[] = []; + newMessage?: string; + polling?: number; + isOnline: boolean; + currentUserId: number; + sending = false; + courseId!: number; + cmId!: number; + + protected logger; + protected chatId!: number; + protected sessionId?: string; + protected lastTime = 0; + protected oldContentHeight = 0; + protected onlineSubscription: Subscription; + protected keyboardObserver: CoreEventObserver; + protected viewDestroyed = false; + protected pollingRunning = false; + protected users: AddonModChatUser[] = []; + + constructor() { + this.currentUserId = CoreSites.getCurrentSiteUserId(); + this.isOnline = CoreApp.isOnline(); + this.onlineSubscription = Network.onChange().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + NgZone.run(() => { + this.isOnline = CoreApp.isOnline(); + }); + }); + + // Recalculate footer position when keyboard is shown or hidden. + this.keyboardObserver = CoreEvents.on(CoreEvents.KEYBOARD_CHANGE, () => { + // @todo probably not needed. + // this.content.resize(); + }); + } + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + this.courseId = CoreNavigator.getRouteNumberParam('courseId')!; + this.cmId = CoreNavigator.getRouteNumberParam('cmId')!; + this.chatId = CoreNavigator.getRouteNumberParam('chatId')!; + this.title = CoreNavigator.getRouteParam('title') || ''; + + try { + await this.loginUser(); + + await this.fetchMessages(); + + this.startPolling(); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'addon.mod_chat.errorwhileconnecting', true); + CoreNavigator.back(); + } finally { + this.loaded = true; + } + + } + + /** + * Runs when the page has fully entered and is now the active page. + * This event will fire, whether it was the first load or a cached page. + */ + ionViewDidEnter(): void { + this.startPolling(); + } + + /** + * Runs when the page is about to leave and no longer be the active page. + */ + ionViewWillLeave(): void { + CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'chat' }); + this.stopPolling(); + } + + /** + * Convenience function to login the user. + * + * @return Promise resolved when done. + */ + protected async loginUser(): Promise { + this.sessionId = await AddonModChat.loginUser(this.chatId); + } + + /** + * Convenience function to fetch chat messages. + * + * @return Promise resolved when done. + */ + protected async fetchMessages(): Promise { + const messagesInfo = await AddonModChat.getLatestMessages(this.sessionId!, this.lastTime); + + this.lastTime = messagesInfo.chatnewlasttime || 0; + + const messages = await AddonModChat.getMessagesUserData(messagesInfo.messages, this.courseId); + + if (!messages.length) { + // No messages yet, nothing else to do. + return; + } + + const previousLength = this.messages.length; + this.messages = this.messages.concat(messages); + + // Calculate which messages need to display the date or user data. + for (let index = previousLength; index < this.messages.length; index++) { + const prevMessage = index > 0 ? this.messages[index - 1] : undefined; + + this.messages[index] = AddonModChatHelper.formatMessage(this.currentUserId, this.messages[index], prevMessage); + + const message = this.messages[index]; + + if (message.beep && message.beep != String(this.currentUserId)) { + this.loadMessageBeepWho(message); + } + } + + this.messages[this.messages.length - 1].showTail = true; + + // New messages or beeps, scroll to bottom. + setTimeout(() => this.scrollToBottom()); + } + + protected async loadMessageBeepWho(message: AddonModChatFormattedMessage): Promise { + message.beepWho = await this.getUserFullname(message.beep!); + } + + /** + * Display the chat users modal. + */ + async showChatUsers(): Promise { + // Create the toc modal. + const modal = await ModalController.create({ + component: AddonModChatUsersModalComponent, + componentProps: { + sessionId: this.sessionId, + cmId: this.cmId, + }, + cssClass: 'core-modal-lateral', + showBackdrop: true, + backdropDismiss: true, + // @todo enterAnimation: 'core-modal-lateral-transition', + // @todo leaveAnimation: 'core-modal-lateral-transition', + }); + + await modal.present(); + + const result = await modal.onDidDismiss(); + + if (result.data) { + if (result.data.talkTo) { + this.newMessage = `To ${result.data.talkTo}: ` + (this.sendMessageForm?.message || ''); + } + if (result.data.beepTo) { + this.sendMessage('', result.data.beepTo); + } + + this.users = result.data.users; + } + } + + /** + * Get the user fullname for a beep. + * + * @param id User Id before parsing. + * @return User fullname. + */ + protected async getUserFullname(id: string): Promise { + const idNumber = parseInt(id, 10); + + if (isNaN(idNumber)) { + return id; + } + + const user = this.users.find((user) => user.id == idNumber); + + if (user) { + return user.fullname; + } + + try { + const data = await AddonModChat.getChatUsers(this.sessionId!, { cmId: this.cmId }); + + this.users = data.users; + const user = this.users.find((user) => user.id == idNumber); + + if (user) { + return user.fullname; + } + + return id; + } catch (error) { + // Ignore errors. + return id; + } + } + + /** + * Start the polling to get chat messages periodically. + */ + protected startPolling(): void { + // We already have the polling in place. + if (this.polling) { + return; + } + + // Start polling. + this.polling = window.setInterval(() => { + CoreUtils.ignoreErrors(this.fetchMessagesInterval()); + }, AddonModChatProvider.POLL_INTERVAL); + } + + /** + * Stop polling for messages. + */ + protected stopPolling(): void { + clearInterval(this.polling); + this.polling = undefined; + } + + /** + * Convenience function to be called every certain time to fetch chat messages. + * + * @return Promise resolved when done. + */ + protected async fetchMessagesInterval(): Promise { + if (!this.isOnline || this.pollingRunning) { + // Obviously we cannot check for new messages when the app is offline. + return; + } + + this.pollingRunning = true; + + try { + await this.fetchMessages(); + } catch { + try { + // Try to login, it might have failed because the session expired. + await this.loginUser(); + + await this.fetchMessages(); + } catch (error) { + // Fail again. Stop polling if needed. + this.stopPolling(); + CoreDomUtils.showErrorModalDefault(error, 'addon.mod_chat.errorwhileretrievingmessages', true); + + throw error; + } + } finally { + this.pollingRunning = false; + } + } + + /** + * Send a message to the chat. + * + * @param text Text of the nessage. + * @param beep ID of the user to beep. + */ + async sendMessage(text: string, beep: number = 0): Promise { + if (!this.isOnline) { + // Silent error, the view should prevent this. + return; + } else if (beep === 0 && !text.trim()) { + // Silent error. + return; + } + + this.sending = true; + + try { + await AddonModChat.sendMessage(this.sessionId!, text, beep); + + // Update messages to show the sent message. + CoreUtils.ignoreErrors(this.fetchMessagesInterval()); + } catch (error) { + // Only close the keyboard if an error happens, we want the user to be able to send multiple + // messages without the keyboard being closed. + CoreApp.closeKeyboard(); + + this.newMessage = text; + CoreDomUtils.showErrorModalDefault(error, 'addon.mod_chat.errorwhilesendingmessage', true); + } finally { + this.sending = false; + } + } + + /** + * Try to reconnect. + * + * @return Promise resolved when done. + */ + async reconnect(): Promise { + const modal = await CoreDomUtils.showModalLoading(); + + try { + // Call startPolling would take a while for the first execution, so we'll execute it manually to check if it works now. + await this.fetchMessagesInterval(); + + // It works, start the polling again. + this.startPolling(); + } catch { + // Ignore errors. + } finally { + modal.dismiss(); + } + } + + /** + * Scroll bottom when render has finished. + */ + scrollToBottom(): void { + // Need a timeout to leave time to the view to be rendered. + setTimeout(() => { + if (!this.viewDestroyed) { + this.content?.scrollToBottom(); + } + }); + } + + /** + * Content or scroll has been resized. For content, only call it if it's been added on top. + */ + resizeContent(): void { + // @todo probably not needed. + // let top = this.content.getContentDimensions().scrollTop; + // this.content.resize(); + + // // Wait for new content height to be calculated. + // setTimeout(() => { + // // Visible content size changed, maintain the bottom position. + // if (!this.viewDestroyed && this.content && this.domUtils.getContentHeight(this.content) != this.oldContentHeight) { + // if (!top) { + // top = this.content.getContentDimensions().scrollTop; + // } + + // top += this.oldContentHeight - this.domUtils.getContentHeight(this.content); + // this.oldContentHeight = this.domUtils.getContentHeight(this.content); + + // this.content.scrollTo(0, top, 0); + // } + // }); + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.onlineSubscription && this.onlineSubscription.unsubscribe(); + this.keyboardObserver && this.keyboardObserver.off(); + this.stopPolling(); + this.viewDestroyed = true; + } + +}