From 8fbed26e9866be32f4b5f94942dd250ca29b6d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Fri, 9 Feb 2018 10:02:14 +0100 Subject: [PATCH] MOBILE-2327 messages: Add messages discussions page --- .../messages/components/components.module.ts | 42 +++ .../components/discussions/discussions.html | 45 +++ .../components/discussions/discussions.ts | 234 ++++++++++++ src/addon/messages/lang/en.json | 6 + src/addon/messages/messages.module.ts | 37 ++ src/addon/messages/pages/index/index.html | 21 ++ .../messages/pages/index/index.module.ts | 37 ++ src/addon/messages/pages/index/index.ts | 71 ++++ .../messages/providers/mainmenu-handler.ts | 51 +++ .../messages/providers/messages-offline.ts | 74 ++++ src/addon/messages/providers/messages.ts | 354 ++++++++++++++++++ src/app/app.module.ts | 4 +- src/app/app.scss | 14 +- src/components/tabs/tabs.scss | 9 +- src/core/emulator/classes/sqlitedb.ts | 1 + src/core/login/pages/reconnect/reconnect.html | 2 +- src/core/login/pages/sites/sites.html | 2 +- src/core/mainmenu/pages/more/more.html | 2 +- .../components/participants/participants.html | 2 +- src/core/user/pages/profile/profile.html | 3 +- 20 files changed, 1002 insertions(+), 9 deletions(-) create mode 100644 src/addon/messages/components/components.module.ts create mode 100644 src/addon/messages/components/discussions/discussions.html create mode 100644 src/addon/messages/components/discussions/discussions.ts create mode 100644 src/addon/messages/lang/en.json create mode 100644 src/addon/messages/messages.module.ts create mode 100644 src/addon/messages/pages/index/index.html create mode 100644 src/addon/messages/pages/index/index.module.ts create mode 100644 src/addon/messages/pages/index/index.ts create mode 100644 src/addon/messages/providers/mainmenu-handler.ts create mode 100644 src/addon/messages/providers/messages-offline.ts create mode 100644 src/addon/messages/providers/messages.ts diff --git a/src/addon/messages/components/components.module.ts b/src/addon/messages/components/components.module.ts new file mode 100644 index 000000000..ffe6a06d5 --- /dev/null +++ b/src/addon/messages/components/components.module.ts @@ -0,0 +1,42 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '../../../components/components.module'; +import { CoreDirectivesModule } from '../../../directives/directives.module'; +import { CorePipesModule } from '../../../pipes/pipes.module'; +import { AddonMessagesDiscussionsComponent } from '../components/discussions/discussions'; + +@NgModule({ + declarations: [ + AddonMessagesDiscussionsComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule + ], + providers: [ + ], + exports: [ + AddonMessagesDiscussionsComponent + ] +}) +export class AddonMessagesComponentsModule {} diff --git a/src/addon/messages/components/discussions/discussions.html b/src/addon/messages/components/discussions/discussions.html new file mode 100644 index 000000000..94017873e --- /dev/null +++ b/src/addon/messages/components/discussions/discussions.html @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + +

{{ 'core.searchresults' | translate }}

+ {{ search.results.length }} + +
+ + + + +

+

+
+
+ + + + + + +

+ +
{{discussion.message.timecreated / 1000 | coreDateDayOrTime}}
+
+
+

+
+
+
+
diff --git a/src/addon/messages/components/discussions/discussions.ts b/src/addon/messages/components/discussions/discussions.ts new file mode 100644 index 000000000..fbe12f6a1 --- /dev/null +++ b/src/addon/messages/components/discussions/discussions.ts @@ -0,0 +1,234 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, OnDestroy, ViewChild } from '@angular/core'; +import { Platform, NavParams } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreEventsProvider } from '../../../../providers/events'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { AddonMessagesProvider } from '../../providers/messages'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreAppProvider } from '../../../../providers/app'; + +/** + * Component that displays the list of discussions. + */ +@Component({ + selector: 'addon-messages-discussions', + templateUrl: 'discussions.html', +}) +export class AddonMessagesDiscussionsComponent implements OnDestroy { + protected newMessagesObserver: any; + protected readChangedObserver: any; + protected cronObserver: any; + protected appResumeSubscription: any; + protected loadingMessages: string; + protected siteId: string; + + loaded = false; + loadingMessage: string; + discussions: any; + discussionUserId: number; + messageId: number; + search = { + enabled: false, + showResults: false, + results: [], + loading: '', + text: '' + }; + + constructor(private eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider, translate: TranslateService, + private messagesProvider: AddonMessagesProvider, private domUtils: CoreDomUtilsProvider, navParams: NavParams, + private appProvider: CoreAppProvider, platform: Platform) { + + this.search.loading = translate.instant('core.searching'); + this.loadingMessages = translate.instant('core.loading'); + this.siteId = sitesProvider.getCurrentSiteId(); + + // Update discussions when new message is received. + this.newMessagesObserver = eventsProvider.on(AddonMessagesProvider.NEW_MESSAGE_EVENT, (data) => { + if (data.userId) { + const discussion = this.discussions[data.userId]; + + if (typeof discussion == 'undefined') { + this.loaded = false; + this.refreshData().finally(() => { + this.loaded = true; + }); + } else { + // An existing discussion has a new message, update the last message. + discussion.message.message = data.message; + discussion.message.timecreated = data.timecreated; + } + } + }, this.siteId); + + // Update discussions when a message is read. + this.readChangedObserver = eventsProvider.on(AddonMessagesProvider.READ_CHANGED_EVENT, (data) => { + if (data.userId) { + const discussion = this.discussions[data.userId]; + + if (typeof discussion != 'undefined') { + // A discussion has been read reset counter. + discussion.unread = false; + + // Discussions changed, invalidate them. + this.messagesProvider.invalidateDiscussionsCache(); + } + } + }, this.siteId); + + // Update discussions when cron read is executed. + this.cronObserver = eventsProvider.on(AddonMessagesProvider.READ_CRON_EVENT, (data) => { + this.refreshData(); + }, this.siteId); + + // Refresh the view when the app is resumed. + this.appResumeSubscription = platform.resume.subscribe(() => { + if (!this.loaded) { + return; + } + this.loaded = false; + this.refreshData(); + }); + + this.discussionUserId = navParams.get('discussionUserId') || false; + } + + /** + * Component loaded. + */ + ngOnInit(): void { + if (this.discussionUserId) { + // There is a discussion to load, open the discussion in a new state. + this.gotoDiscussion(this.discussionUserId); + } + + this.fetchData().then(() => { + if (!this.discussionUserId && this.discussions.length > 0) { + // Take first and load it. + this.gotoDiscussion(this.discussions[0].message.user, undefined, true); + } + }); + } + + /** + * Refresh the data. + * + * @param {any} [refresher] Refresher. + * @return {Promise} Promise resolved when done. + */ + refreshData(refresher?: any): Promise { + return this.messagesProvider.invalidateDiscussionsCache().then(() => { + return this.fetchData().finally(() => { + if (refresher) { + // Actions to take if refresh comes from the user. + this.eventsProvider.trigger(AddonMessagesProvider.READ_CHANGED_EVENT, undefined, this.siteId); + refresher.complete(); + } + }); + }); + } + + /** + * Fetch discussions. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchData(): Promise { + this.loadingMessage = this.loadingMessages; + this.search.enabled = this.messagesProvider.isSearchMessagesEnabled(); + + return this.messagesProvider.getDiscussions().then((discussions) => { + // Convert to an array for sorting. + const discussionsSorted = []; + for (const userId in discussions) { + discussions[userId].unread = !!discussions[userId].unread; + discussionsSorted.push(discussions[userId]); + } + + this.discussions = discussionsSorted.sort((a, b) => { + return a.message.timecreated - b.message.timecreated; + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingdiscussions', true); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Clear search and show discussions again. + */ + clearSearch(): void { + this.loaded = false; + this.search.showResults = false; + this.fetchData().finally(() => { + this.loaded = true; + }); + } + + /** + * Search messages cotaining text. + * + * @param {string} query Text to search for. + * @return {Promise} Resolved when done. + */ + searchMessage(query: string): Promise { + this.appProvider.closeKeyboard(); + this.loaded = false; + this.loadingMessage = this.search.loading; + + return this.messagesProvider.searchMessages(query).then((searchResults) => { + this.search.showResults = true; + this.search.results = searchResults; + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'mma.messages.errorwhileretrievingmessages', true); + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Navigate to a particular discussion. + * + * @param {number} discussionUserId Discussion Id to load. + * @param {number} [messageId] Message to scroll after loading the discussion. Used when searching. + * @param {boolean} [onlyWithSplitView=false] Only go to Discussion if split view is on. + */ + gotoDiscussion(discussionUserId: number, messageId?: number, onlyWithSplitView: boolean = false): void { + this.discussionUserId = discussionUserId; + + const params = { + discussion: discussionUserId, + onlyWithSplitView: onlyWithSplitView + }; + if (messageId) { + params['message'] = messageId; + this.messageId = messageId; + } + this.eventsProvider.trigger(AddonMessagesProvider.SPLIT_VIEW_LOAD_EVENT, params, this.siteId); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.newMessagesObserver && this.newMessagesObserver.off(); + this.readChangedObserver && this.readChangedObserver.off(); + this.cronObserver && this.cronObserver.off(); + this.appResumeSubscription && this.appResumeSubscription.unsubscribe(); + } +} diff --git a/src/addon/messages/lang/en.json b/src/addon/messages/lang/en.json new file mode 100644 index 000000000..80a590607 --- /dev/null +++ b/src/addon/messages/lang/en.json @@ -0,0 +1,6 @@ +{ + "errorwhileretrievingdiscussions": "Error while retrieving discussions from the server.", + "message": "Message", + "messages": "Messages", + "nomessages": "No messages" +} \ No newline at end of file diff --git a/src/addon/messages/messages.module.ts b/src/addon/messages/messages.module.ts new file mode 100644 index 000000000..078287599 --- /dev/null +++ b/src/addon/messages/messages.module.ts @@ -0,0 +1,37 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { AddonMessagesProvider } from './providers/messages'; +import { AddonMessagesOfflineProvider } from './providers/messages-offline'; +import { AddonMessagesMainMenuHandler } from './providers/mainmenu-handler'; +import { CoreMainMenuDelegate } from '../../core/mainmenu/providers/delegate'; + +@NgModule({ + declarations: [ + ], + imports: [ + ], + providers: [ + AddonMessagesProvider, + AddonMessagesOfflineProvider, + AddonMessagesMainMenuHandler + ] +}) +export class AddonMessagesModule { + constructor(mainMenuDelegate: CoreMainMenuDelegate, mainmenuHandler: AddonMessagesMainMenuHandler) { + // Register handlers. + mainMenuDelegate.registerHandler(mainmenuHandler); + } +} diff --git a/src/addon/messages/pages/index/index.html b/src/addon/messages/pages/index/index.html new file mode 100644 index 000000000..ad2929844 --- /dev/null +++ b/src/addon/messages/pages/index/index.html @@ -0,0 +1,21 @@ + + + {{ 'addon.messages.messages' | translate }} + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/addon/messages/pages/index/index.module.ts b/src/addon/messages/pages/index/index.module.ts new file mode 100644 index 000000000..b2b1cdf17 --- /dev/null +++ b/src/addon/messages/pages/index/index.module.ts @@ -0,0 +1,37 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { AddonMessagesIndexPage } from './index'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreDirectivesModule } from '../../../../directives/directives.module'; +import { CorePipesModule } from '../../../../pipes/pipes.module'; +import { AddonMessagesComponentsModule } from '../../components/components.module'; + +@NgModule({ + declarations: [ + AddonMessagesIndexPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + AddonMessagesComponentsModule, + IonicPageModule.forChild(AddonMessagesIndexPage), + TranslateModule.forChild() + ], +}) +export class AddonMessagesIndexPageModule {} diff --git a/src/addon/messages/pages/index/index.ts b/src/addon/messages/pages/index/index.ts new file mode 100644 index 000000000..4e7bc88e7 --- /dev/null +++ b/src/addon/messages/pages/index/index.ts @@ -0,0 +1,71 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, OnDestroy, ViewChild } from '@angular/core'; +import { IonicPage } from 'ionic-angular'; +import { CoreEventsProvider } from '../../../../providers/events'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { AddonMessagesProvider } from '../../providers/messages'; +import { CoreSplitViewComponent } from '../../../../components/split-view/split-view'; + +/** + * Page that displays the messages index page. + */ +@IonicPage({ segment: 'addon-messages-index' }) +@Component({ + selector: 'page-addon-messages-index', + templateUrl: 'index.html', +}) +export class AddonMessagesIndexPage implements OnDestroy { + @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent; + + protected loadSplitViewObserver: any; + protected siteId: string; + + constructor(private eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider, + private messagesProvider: AddonMessagesProvider) { + + this.siteId = sitesProvider.getCurrentSiteId(); + + // Update split view or navigate. + this.loadSplitViewObserver = eventsProvider.on(AddonMessagesProvider.SPLIT_VIEW_LOAD_EVENT, (data) => { + if (data.discussion && (this.splitviewCtrl.isOn() || !data.onlyWithSplitView)) { + this.gotoDiscussion(data.discussion, data.message); + } + }, this.siteId); + } + + /** + * Navigate to a particular discussion. + * + * @param {number} discussionUserId Discussion Id to load. + * @param {number} [messageId] Message to scroll after loading the discussion. Used when searching. + */ + gotoDiscussion(discussionUserId: number, messageId?: number): void { + const params = { + id: discussionUserId + }; + if (messageId) { + params['message'] = messageId; + } + this.splitviewCtrl.push('AddonMessagesDiscussionPage', params); + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.loadSplitViewObserver && this.loadSplitViewObserver.off(); + } +} diff --git a/src/addon/messages/providers/mainmenu-handler.ts b/src/addon/messages/providers/mainmenu-handler.ts new file mode 100644 index 000000000..3bd98ae39 --- /dev/null +++ b/src/addon/messages/providers/mainmenu-handler.ts @@ -0,0 +1,51 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Injectable } from '@angular/core'; +import { AddonMessagesProvider } from './messages'; +import { CoreMainMenuHandler, CoreMainMenuHandlerData } from '../../../core/mainmenu/providers/delegate'; + +/** + * Handler to inject an option into main menu. + */ +@Injectable() +export class AddonMessagesMainMenuHandler implements CoreMainMenuHandler { + name = 'AddonMessages'; + priority = 600; + + constructor(private messagesProvider: AddonMessagesProvider) { } + + /** + * Check if the handler is enabled on a site level. + * + * @return {boolean} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return this.messagesProvider.isPluginEnabled(); + } + + /** + * Returns the data needed to render the handler. + * + * @return {CoreMainMenuHandlerData} Data needed to render the handler. + */ + getDisplayData(): CoreMainMenuHandlerData { + return { + icon: 'chatbubbles', + title: 'addon.messages.messages', + page: 'AddonMessagesIndexPage', + class: 'addon-messages-handler' + }; + } +} diff --git a/src/addon/messages/providers/messages-offline.ts b/src/addon/messages/providers/messages-offline.ts new file mode 100644 index 000000000..652b187de --- /dev/null +++ b/src/addon/messages/providers/messages-offline.ts @@ -0,0 +1,74 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Injectable } from '@angular/core'; +import { CoreLoggerProvider } from '../../../providers/logger'; +import { CoreSitesProvider } from '../../../providers/sites'; + +/** + * Service to handle Offline messages. + */ +@Injectable() +export class AddonMessagesOfflineProvider { + + protected logger; + + // Variables for database. + protected MESSAGES_TABLE = 'mma_messages_offline_messages'; + protected tablesSchema = [ + { + name: this.MESSAGES_TABLE, + columns: [ + { + name: 'touserid', + type: 'INTEGER' + }, + { + name: 'useridfrom', + type: 'INTEGER' + }, + { + name: 'smallmessage', + type: 'TEXT' + }, + { + name: 'timecreated', + type: 'INTEGER' + }, + { + name: 'deviceoffline', // If message was stored because device was offline. + type: 'INTEGER' + } + ], + primaryKeys: ['touserid', 'smallmessage', 'timecreated'] + } + ]; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider) { + this.logger = logger.getInstance('AddonMessagesOfflineProvider'); + this.sitesProvider.createTablesFromSchema(this.tablesSchema); + } + + /** + * Get all offline messages. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with messages. + */ + getAllMessages(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getAllRecords(this.MESSAGES_TABLE); + }); + } +} diff --git a/src/addon/messages/providers/messages.ts b/src/addon/messages/providers/messages.ts new file mode 100644 index 000000000..23d2c5ee9 --- /dev/null +++ b/src/addon/messages/providers/messages.ts @@ -0,0 +1,354 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Injectable } from '@angular/core'; +import { CoreLoggerProvider } from '../../../providers/logger'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreAppProvider } from '../../../providers/app'; +import { CoreUserProvider } from '../../../core/user/providers/user'; +import { AddonMessagesOfflineProvider } from './messages-offline'; + +/** + * Service to handle messages. + */ +@Injectable() +export class AddonMessagesProvider { + protected ROOT_CACHE_KEY = 'mmaMessages:'; + protected LIMIT_MESSAGES = 50; + static NEW_MESSAGE_EVENT = 'new_message_event'; + static READ_CHANGED_EVENT = 'read_changed_event'; + static READ_CRON_EVENT = 'read_cron_event'; + static SPLIT_VIEW_LOAD_EVENT = 'split_view_load_event'; + + protected logger; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private appProvider: CoreAppProvider, + private userProvider: CoreUserProvider, private messagesOffline: AddonMessagesOfflineProvider) { + this.logger = logger.getInstance('AddonMessagesProvider'); + } + + /** + * Get the cache key for contacts. + * + * @return {string} Cache key. + */ + protected getCacheKeyForContacts(): string { + return this.ROOT_CACHE_KEY + 'contacts'; + } + + /** + * Get the cache key for the list of discussions. + * + * @return {string} Cache key. + */ + protected getCacheKeyForDiscussions(): string { + return this.ROOT_CACHE_KEY + 'discussions'; + } + + /** + * Get the discussions of the current user. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved with an object where the keys are the user ID of the other user. + */ + getDiscussions(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const discussions = {}, + currentUserId = site.getUserId(), + params = { + useridto: currentUserId, + useridfrom: 0, + limitnum: this.LIMIT_MESSAGES + }, + preSets = { + cacheKey: this.getCacheKeyForDiscussions() + }; + + /** + * Convenience function to treat a recent message, adding it to discussions list if needed. + */ + const treatRecentMessage = (message: any, userId: number, userFullname: string): void => { + if (typeof discussions[userId] === 'undefined') { + discussions[userId] = { + fullname: userFullname, + profileimageurl: '' + }; + + if (!message.timeread && !message.pending && message.useridfrom != currentUserId) { + discussions[userId].unread = true; + } + } + + // Extract the most recent message. Pending messages are considered more recent than messages already sent. + const discMessage = discussions[userId].message; + if (typeof discMessage === 'undefined' || (!discMessage.pending && message.pending) || + (discMessage.pending == message.pending && (discMessage.timecreated < message.timecreated || + (discMessage.timecreated == message.timecreated && discMessage.id < message.id)))) { + + discussions[userId].message = { + id: message.id, + user: userId, + message: message.text, + timecreated: message.timecreated, + pending: message.pending + }; + } + }; + + // Get recent messages sent to current user. + return this.getRecentMessages(params, preSets, undefined, undefined, undefined, site.getId()).then((messages) => { + + // Extract the discussions by filtering same senders. + messages.forEach((message) => { + treatRecentMessage(message, message.useridfrom, message.userfromfullname); + }); + + // Now get the last messages sent by the current user. + params.useridfrom = params.useridto; + params.useridto = 0; + + return this.getRecentMessages(params, preSets); + }).then((messages) => { + + // Extract the discussions by filtering same senders. + messages.forEach((message) => { + treatRecentMessage(message, message.useridto, message.usertofullname); + }); + + // Now get unsent messages. + return this.messagesOffline.getAllMessages(site.getId()); + }).then((offlineMessages) => { + offlineMessages.forEach((message) => { + message.pending = true; + message.text = message.smallmessage; + treatRecentMessage(message, message.touserid, ''); + }); + + return this.getDiscussionsUserImg(discussions, site.getId()).then((discussions) => { + this.storeUsersFromDiscussions(discussions); + + return discussions; + }); + }); + }); + } + + /** + * Get user images for all the discussions that don't have one already. + * + * @param {any} discussions List of discussions. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise always resolved. Resolve param is the formatted discussions. + */ + protected getDiscussionsUserImg(discussions: any, siteId?: string): Promise { + const promises = []; + + for (const userId in discussions) { + if (!discussions[userId].profileimageurl) { + // We don't have the user image. Try to retrieve it. + promises.push(this.userProvider.getProfile(discussions[userId].message.user, 0, true, siteId).then((user) => { + discussions[userId].profileimageurl = user.profileimageurl; + }).catch(() => { + // Error getting profile, resolve promise without adding any extra data. + })); + } + } + + return Promise.all(promises).then(() => { + return discussions; + }); + } + + /** + * Get messages according to the params. + * + * @param {any} params Parameters to pass to the WS. + * @param {any} preSets Set of presets for the WS. + * @param {boolean} [toDisplay=true] True if messages will be displayed to the user, either in view or in a notification. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise} + */ + protected getMessages(params: any, preSets: any, toDisplay: boolean = true, siteId?: string): Promise { + params['type'] = 'conversations'; + params['newestfirst'] = 1; + + return this.sitesProvider.getSite(siteId).then((site) => { + const userId = site.getUserId(); + + return site.read('core_message_get_messages', params, preSets).then((response) => { + response.messages.forEach((message) => { + message.read = params.read == 0 ? 0 : 1; + // Convert times to milliseconds. + message.timecreated = message.timecreated ? message.timecreated * 1000 : 0; + message.timeread = message.timeread ? message.timeread * 1000 : 0; + }); + + if (toDisplay && this.appProvider.isDesktop() && !params.read && params.useridto == userId && + params.limitfrom === 0) { + // Store the last unread received messages. Don't block the user for this. + // @todo + // this.storeLastReceivedMessageIfNeeded(params.useridfrom, response.messages[0], site.getId()); + } + + return response; + }); + }); + } + + /** + * Get the most recent messages. + * + * @param {any} params Parameters to pass to the WS. + * @param {any} preSets Set of presets for the WS. + * @param {number} [limitFromUnread=0] Number of read messages already fetched, so fetch will be done from this number. + * @param {number} [limitFromRead=0] Number of unread messages already fetched, so fetch will be done from this number. + * @param {boolean} [toDisplay=true] True if messages will be displayed to the user, either in view or in a notification. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise} + */ + protected getRecentMessages(params: any, preSets: any, limitFromUnread: number = 0, limitFromRead: number = 0, + toDisplay: boolean = true, siteId?: string): Promise { + limitFromUnread = limitFromUnread || 0; + limitFromRead = limitFromRead || 0; + + params['read'] = 0; + params['limitfrom'] = limitFromUnread; + + return this.getMessages(params, preSets, toDisplay, siteId).then((response) => { + let messages = response.messages; + if (messages) { + if (messages.length >= params.limitnum) { + return messages; + } + + // We need to fetch more messages. + params.limitnum = params.limitnum - messages.length; + params.read = 1; + params.limitfrom = limitFromRead; + + return this.getMessages(params, preSets, toDisplay, siteId).then((response) => { + if (response.messages) { + messages = messages.concat(response.messages); + } + + return messages; + }).catch(() => { + return messages; + }); + + } else { + return Promise.reject(null); + } + }); + } + + /** + * Invalidate contacts cache. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved when done. + */ + invalidateContactsCache(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getCacheKeyForContacts()); + }); + } + + /** + * Invalidate discussions cache. + * + * Note that {@link this.getDiscussions} uses the contacts, so we need to invalidate contacts too. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved when done. + */ + invalidateDiscussionsCache(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getCacheKeyForDiscussions()).then(() => { + return this.invalidateContactsCache(site.getId()); + }); + }); + } + + /** + * Returns whether or not the plugin is enabled in a certain site. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with true if enabled, rejected or resolved with false otherwise. + */ + isPluginEnabled(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.canUseAdvancedFeature('messaging'); + }); + } + + /** + * Returns whether or not we can search messages. + * + * @return {boolean} + * @since 3.2 + */ + isSearchMessagesEnabled(): boolean { + return this.sitesProvider.getCurrentSite().wsAvailable('core_message_data_for_messagearea_search_messages'); + } + + /** + * Search for all the messges with a specific text. + * + * @param {string} query The query string + * @param {number} [userId] The user ID. If not defined, current user. + * @param {number} [from=0] Position of the first result to get. Defaults to 0. + * @param {number} [limit] Number of results to get. Defaults to LIMIT_MESSAGES. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the results. + */ + searchMessages(query: string, userId?: number, from: number = 0, limit: number = this.LIMIT_MESSAGES, siteId?: string): + Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const param = { + userid: userId || site.getUserId(), + search: query, + limitfrom: from, + limitnum: limit + }, + preSets = { + getFromCache: false // Always try to get updated data. If it fails, it will get it from cache. + }; + + return site.read('core_message_data_for_messagearea_search_messages', param, preSets).then((searchResults) => { + return searchResults.contacts; + }); + }); + } + + /** + * Store user data from discussions in local DB. + * + * @param {any} discussions List of discussions. + * @param {string} [siteId] Site ID. If not defined, current site. + */ + protected storeUsersFromDiscussions(discussions: any, siteId?: string): void { + const users = []; + for (const userId in discussions) { + if (typeof userId != 'undefined' && !isNaN(parseInt(userId))) { + users.push({ + id: userId, + fullname: discussions[userId].fullname, + profileimageurl: discussions[userId].profileimageurl + }); + } + } + this.userProvider.storeUsers(users, siteId); + } +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 188240118..cba32cae4 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -69,6 +69,7 @@ import { AddonUserProfileFieldModule } from '../addon/userprofilefield/userprofi import { AddonFilesModule } from '../addon/files/files.module'; import { AddonModBookModule } from '../addon/mod/book/book.module'; import { AddonModLabelModule } from '../addon/mod/label/label.module'; +import { AddonMessagesModule } from '../addon/messages/messages.module'; // For translate loader. AoT requires an exported function for factories. export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { @@ -109,7 +110,8 @@ export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { AddonUserProfileFieldModule, AddonFilesModule, AddonModBookModule, - AddonModLabelModule + AddonModLabelModule, + AddonMessagesModule ], bootstrap: [IonicApp], entryComponents: [ diff --git a/src/app/app.scss b/src/app/app.scss index e21adbc2e..f345874a7 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -438,4 +438,16 @@ ion-toast.core-toast-alert .toast-wrapper{ @extend ion-card; border-bottom: 3px solid $color-base; } -} \ No newline at end of file +} + +.core-circle:before { + content: ' \25CF'; + font-size: 20px; +} + +@each $color-name, $color-base, $color-contrast in get-colors($colors) { + .core-#{$color-name}-circle:before { + @extend .core-circle:before; + color: $color-base; + } +} diff --git a/src/components/tabs/tabs.scss b/src/components/tabs/tabs.scss index b7e3916fa..3872a5055 100644 --- a/src/components/tabs/tabs.scss +++ b/src/components/tabs/tabs.scss @@ -76,4 +76,11 @@ core-tabs { .core-tabs-bar::after { @extend .header-md::after; } -} \ No newline at end of file +} + +.ios, .md, .wp { + .core-avoid-header ion-content core-tabs core-tab ion-content { + top: 0; + height: 100%; + } +} diff --git a/src/core/emulator/classes/sqlitedb.ts b/src/core/emulator/classes/sqlitedb.ts index 60e72126b..b39b2bbd5 100644 --- a/src/core/emulator/classes/sqlitedb.ts +++ b/src/core/emulator/classes/sqlitedb.ts @@ -93,6 +93,7 @@ export class SQLiteDBMock extends SQLiteDB { tx.executeSql(sql, params, (tx, results) => { resolve(results); }, (tx, error) => { + console.error(sql, params, error); reject(error); }); }); diff --git a/src/core/login/pages/reconnect/reconnect.html b/src/core/login/pages/reconnect/reconnect.html index d3c6b5903..ee94ea5e6 100644 --- a/src/core/login/pages/reconnect/reconnect.html +++ b/src/core/login/pages/reconnect/reconnect.html @@ -8,7 +8,7 @@
- {{ 'core.pictureof' | translate:{$a: site.fullname} }} + {{ 'core.pictureof' | translate:{$a: site.fullname} }} diff --git a/src/core/login/pages/sites/sites.html b/src/core/login/pages/sites/sites.html index f4fd0baf9..4e57f2aa5 100644 --- a/src/core/login/pages/sites/sites.html +++ b/src/core/login/pages/sites/sites.html @@ -13,7 +13,7 @@ - {{ 'core.pictureof' | translate:{$a: site.fullname} }} + {{ 'core.pictureof' | translate:{$a: site.fullname} }}

{{site.fullName}}

diff --git a/src/core/mainmenu/pages/more/more.html b/src/core/mainmenu/pages/more/more.html index 089e4ad3c..f3dd8ee3c 100644 --- a/src/core/mainmenu/pages/more/more.html +++ b/src/core/mainmenu/pages/more/more.html @@ -15,7 +15,7 @@ - +

{{ handler.title | translate}}

diff --git a/src/core/user/components/participants/participants.html b/src/core/user/components/participants/participants.html index f089dd40f..266a79210 100644 --- a/src/core/user/components/participants/participants.html +++ b/src/core/user/components/participants/participants.html @@ -10,7 +10,7 @@ - +

{{ 'core.lastaccess' | translate }}: {{ participant.lastaccess * 1000 | coreFormatDate:"dfmediumdate"}}

diff --git a/src/core/user/pages/profile/profile.html b/src/core/user/pages/profile/profile.html index ae17d5f1d..8ea83b59f 100644 --- a/src/core/user/pages/profile/profile.html +++ b/src/core/user/pages/profile/profile.html @@ -11,8 +11,7 @@
- {{ 'core.pictureof' | translate:{$a: user.fullname} }} - {{ 'core.pictureof' | translate:{$a: user.fullname} }} + {{ 'core.pictureof' | translate:{$a: user.fullname} }}