From dd49b78fb8572e1b2c9d7f3e21337d8ab102e611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Fri, 2 Mar 2018 12:57:02 +0100 Subject: [PATCH] MOBILE-2327 messages: Add contacts page --- .../messages/components/components.module.ts | 3 + .../components/contacts/contacts.html | 31 +++ .../components/contacts/contacts.scss | 12 + .../messages/components/contacts/contacts.ts | 225 ++++++++++++++++++ .../components/discussions/discussions.html | 5 +- .../components/discussions/discussions.ts | 3 +- src/addon/messages/lang/en.json | 8 + .../messages/pages/discussion/discussion.html | 2 +- src/addon/messages/providers/messages.ts | 161 +++++++++++++ src/components/search-box/search-box.html | 9 +- src/components/search-box/search-box.ts | 24 +- src/core/courses/pages/search/search.html | 2 +- 12 files changed, 471 insertions(+), 14 deletions(-) create mode 100644 src/addon/messages/components/contacts/contacts.html create mode 100644 src/addon/messages/components/contacts/contacts.scss create mode 100644 src/addon/messages/components/contacts/contacts.ts diff --git a/src/addon/messages/components/components.module.ts b/src/addon/messages/components/components.module.ts index 5d81ea357..270ed0e3a 100644 --- a/src/addon/messages/components/components.module.ts +++ b/src/addon/messages/components/components.module.ts @@ -20,10 +20,12 @@ import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives'; import { CorePipesModule } from '@pipes'; import { AddonMessagesDiscussionsComponent } from '../components/discussions/discussions'; +import { AddonMessagesContactsComponent } from '../components/contacts/contacts'; @NgModule({ declarations: [ AddonMessagesDiscussionsComponent, + AddonMessagesContactsComponent ], imports: [ CommonModule, @@ -37,6 +39,7 @@ import { AddonMessagesDiscussionsComponent } from '../components/discussions/dis ], exports: [ AddonMessagesDiscussionsComponent, + AddonMessagesContactsComponent ] }) export class AddonMessagesComponentsModule {} diff --git a/src/addon/messages/components/contacts/contacts.html b/src/addon/messages/components/contacts/contacts.html new file mode 100644 index 000000000..67cf29fc9 --- /dev/null +++ b/src/addon/messages/components/contacts/contacts.html @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + +

{{ 'addon.messages.type_' + contactType | translate }}

+ {{ contacts[contactType].length }} +
+ + + + + + +

+
+
+
+
+
+
diff --git a/src/addon/messages/components/contacts/contacts.scss b/src/addon/messages/components/contacts/contacts.scss new file mode 100644 index 000000000..ff39202e3 --- /dev/null +++ b/src/addon/messages/components/contacts/contacts.scss @@ -0,0 +1,12 @@ +addon-messages-discussions { + h2 { + display: flex; + justify-content: space-between; + + .note { + margin: 0; + align-self: flex-end; + display: inline-flex; + } + } +} \ No newline at end of file diff --git a/src/addon/messages/components/contacts/contacts.ts b/src/addon/messages/components/contacts/contacts.ts new file mode 100644 index 000000000..781c20b29 --- /dev/null +++ b/src/addon/messages/components/contacts/contacts.ts @@ -0,0 +1,225 @@ +// (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 } from '@angular/core'; +import { NavParams } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSitesProvider } from '@providers/sites'; +import { AddonMessagesProvider } from '../../providers/messages'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreAppProvider } from '@providers/app'; +import { CoreEventsProvider } from '@providers/events'; + +/** + * Component that displays the list of contacts. + */ +@Component({ + selector: 'addon-messages-contacts', + templateUrl: 'contacts.html', +}) +export class AddonMessagesContactsComponent { + + protected currentUserId: number; + protected searchingMessages: string; + protected loadingMessages: string; + protected siteId: string; + protected noSearchTypes = ['online', 'offline', 'blocked', 'strangers']; + + loaded = false; + discussionUserId: number; + contactTypes = this.noSearchTypes; + searchType = 'search'; + loadingMessage = ''; + hasContacts = false; + contacts = { + search: [] + }; + searchString = ''; + + constructor(sitesProvider: CoreSitesProvider, translate: TranslateService, private appProvider: CoreAppProvider, + private messagesProvider: AddonMessagesProvider, private domUtils: CoreDomUtilsProvider, navParams: NavParams, + private eventsProvider: CoreEventsProvider) { + + this.currentUserId = sitesProvider.getCurrentSiteUserId(); + this.siteId = sitesProvider.getCurrentSiteId(); + this.searchingMessages = translate.instant('core.searching'); + this.loadingMessages = translate.instant('core.loading'); + this.loadingMessage = this.loadingMessages; + + 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.hasContacts) { + let contact; + for (const x in this.contacts) { + if (this.contacts[x].length > 0) { + contact = this.contacts[x][0]; + break; + } + } + + if (contact) { + // Take first and load it. + this.gotoDiscussion(contact.id, true); + } + } + }).finally(() => { + this.loaded = true; + }); + } + + /** + * Refresh the data. + * + * @param {any} [refresher] Refresher. + * @return {Promise} Promise resolved when done. + */ + refreshData(refresher?: any): Promise { + let promise; + + if (this.searchString) { + // User has searched, update the search. + promise = this.performSearch(this.searchString); + } else { + // Update contacts. + promise = this.messagesProvider.invalidateAllContactsCache(this.currentUserId).then(() => { + return this.fetchData(); + }); + } + + return promise.finally(() => { + refresher.complete(); + }); + } + + /** + * Fetch contacts. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchData(): Promise { + this.loadingMessage = this.loadingMessages; + + return this.messagesProvider.getAllContacts().then((contacts) => { + for (const x in contacts) { + if (contacts[x].length > 0) { + this.contacts[x] = this.sortUsers(contacts[x]); + } else { + this.contacts[x] = []; + } + } + + this.clearSearch(); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingcontacts', true); + + return Promise.reject(null); + }); + } + + /** + * Sort user list by fullname + * @param {any[]} list List to sort. + * @return {any[]} Sorted list. + */ + protected sortUsers(list: any[]): any[] { + return list.sort((a, b) => { + const compareA = a.fullname.toLowerCase(), + compareB = b.fullname.toLowerCase(); + + return compareA.localeCompare(compareB); + }); + } + + /** + * Clear search and show all contacts again. + */ + clearSearch(): void { + this.searchString = ''; // Reset searched string. + this.contactTypes = this.noSearchTypes; + + this.hasContacts = false; + for (const x in this.contacts) { + if (this.contacts[x].length > 0) { + this.hasContacts = true; + + return; + } + } + } + + /** + * Search users from the UI. + * + * @param {string} query Text to search for. + * @return {Promise} Resolved when done. + */ + search(query: string): Promise { + this.appProvider.closeKeyboard(); + + this.loaded = false; + this.loadingMessage = this.searchingMessages; + + return this.performSearch(query).finally(() => { + this.loaded = true; + }); + } + + /** + * Perform the search of users. + * + * @param {string} query Text to search for. + * @return {Promise} Resolved when done. + */ + protected performSearch(query: string): Promise { + return this.messagesProvider.searchContacts(query).then((result) => { + this.hasContacts = result.length > 0; + this.searchString = query; + this.contactTypes = ['search']; + + this.contacts['search'] = this.sortUsers(result); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingcontacts', true); + + return Promise.reject(null); + }); + } + + /** + * Navigate to a particular discussion. + * + * @param {number} discussionUserId Discussion Id to load. + * @param {boolean} [onlyWithSplitView=false] Only go to Discussion if split view is on. + */ + gotoDiscussion(discussionUserId: number, onlyWithSplitView: boolean = false): void { + this.discussionUserId = discussionUserId; + + const params = { + discussion: discussionUserId, + onlyWithSplitView: onlyWithSplitView + }; + this.eventsProvider.trigger(AddonMessagesProvider.SPLIT_VIEW_LOAD_EVENT, params, this.siteId); + } +} diff --git a/src/addon/messages/components/discussions/discussions.html b/src/addon/messages/components/discussions/discussions.html index d7ef201cd..c33cb5ef0 100644 --- a/src/addon/messages/components/discussions/discussions.html +++ b/src/addon/messages/components/discussions/discussions.html @@ -3,7 +3,7 @@ - + @@ -15,9 +15,6 @@

{{ 'core.searchresults' | translate }}

{{ search.results.length }} -
diff --git a/src/addon/messages/components/discussions/discussions.ts b/src/addon/messages/components/discussions/discussions.ts index ec31793dc..0d5a1fb4e 100644 --- a/src/addon/messages/components/discussions/discussions.ts +++ b/src/addon/messages/components/discussions/discussions.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnDestroy, ViewChild } from '@angular/core'; +import { Component, OnDestroy } from '@angular/core'; import { Platform, NavParams } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreEventsProvider } from '@providers/events'; @@ -190,6 +190,7 @@ export class AddonMessagesDiscussionsComponent implements OnDestroy { clearSearch(): void { this.loaded = false; this.search.showResults = false; + this.search.text = ''; // Reset searched string. this.fetchData().finally(() => { this.loaded = true; }); diff --git a/src/addon/messages/lang/en.json b/src/addon/messages/lang/en.json index 1972d107d..c8c5a4985 100644 --- a/src/addon/messages/lang/en.json +++ b/src/addon/messages/lang/en.json @@ -1,5 +1,7 @@ { "blocknoncontacts": "Prevent non-contacts from messaging me", + "contactlistempty": "The contact list is empty", + "contactname": "Contact name", "contacts": "Contacts", "deletemessage": "Delete message", "deletemessageconfirmation": "Are you sure you want to delete this message? It will only be deleted from your messaging history and will still be viewable by the user who sent or received the message.", @@ -13,5 +15,11 @@ "newmessage": "New message", "newmessages": "New messages", "nomessages": "No messages", + "nousersfound": "No users found", + "type_blocked": "Blocked", + "type_offline": "Offline", + "type_online": "Online", + "type_search": "Search results", + "type_strangers": "Others", "warningmessagenotsent": "Couldn't send message(s) to user {{user}}. {{error}}" } \ No newline at end of file diff --git a/src/addon/messages/pages/discussion/discussion.html b/src/addon/messages/pages/discussion/discussion.html index 3b2e2db08..f41c4d1de 100644 --- a/src/addon/messages/pages/discussion/discussion.html +++ b/src/addon/messages/pages/discussion/discussion.html @@ -44,8 +44,8 @@ - +
diff --git a/src/addon/messages/providers/messages.ts b/src/addon/messages/providers/messages.ts index 3373e28ff..c9d05095f 100644 --- a/src/addon/messages/providers/messages.ts +++ b/src/addon/messages/providers/messages.ts @@ -80,6 +80,16 @@ export class AddonMessagesProvider { }); } + /** + * Get the cache key for blocked contacts. + * + * @param {number} userId The user who's contacts we're looking for. + * @return {string} Cache key. + */ + protected getCacheKeyForBlockedContacts(userId: number): string { + return this.ROOT_CACHE_KEY + 'blockedContacts:' + userId; + } + /** * Get the cache key for contacts. * @@ -108,6 +118,85 @@ export class AddonMessagesProvider { return this.ROOT_CACHE_KEY + 'discussions'; } + /** + * Get all the contacts of the current user. + * + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise} Resolved with the WS data. + */ + getAllContacts(siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.getContacts(siteId).then((contacts) => { + return this.getBlockedContacts(siteId).then((blocked) => { + contacts.blocked = blocked.users; + this.storeUsersFromAllContacts(contacts); + + return contacts; + }).catch(() => { + // The WS for blocked contacts might fail, but we still want the contacts. + contacts.blocked = []; + this.storeUsersFromAllContacts(contacts); + + return contacts; + }); + }); + } + + /** + * Get all the blocked contacts of the current user. + * + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise} Resolved with the WS data. + */ + getBlockedContacts(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const userId = site.getUserId(), + params = { + userid: userId + }, + preSets = { + cacheKey: this.getCacheKeyForBlockedContacts(userId) + }; + + return site.read('core_message_get_blocked_users', params, preSets); + }); + } + + /** + * Get the contacts of the current user. + * + * This excludes the blocked users. + * + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise} Resolved with the WS data. + */ + getContacts(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const preSets = { + cacheKey: this.getCacheKeyForContacts() + }; + + return site.read('core_message_get_contacts', undefined, preSets).then((contacts) => { + // Filter contacts with negative ID, they are notifications. + const validContacts = {}; + for (const typeName in contacts) { + if (!validContacts[typeName]) { + validContacts[typeName] = []; + } + + contacts[typeName].forEach((contact) => { + if (contact.id > 0) { + validContacts[typeName].push(contact); + } + }); + } + + return validContacts; + }); + }); + } + /** * Return the current user's discussion with another user. * @@ -509,6 +598,34 @@ export class AddonMessagesProvider { }); } + /** + * Invalidate all contacts cache. + * + * @param {number} userId The user ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Resolved when done. + */ + invalidateAllContactsCache(userId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.invalidateContactsCache(siteId).then(() => { + return this.invalidateBlockedContactsCache(userId, siteId); + }); + } + + /** + * Invalidate blocked contacts cache. + * + * @param {number} userId The user ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} + */ + invalidateBlockedContactsCache(userId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getCacheKeyForBlockedContacts(userId)); + }); + } + /** * Invalidate contacts cache. * @@ -665,6 +782,39 @@ export class AddonMessagesProvider { return this.sitesProvider.getCurrentSite().write('core_message_mark_all_messages_as_read', params, preSets); } + /** + * Search for contacts. + * + * By default this only returns the first 100 contacts, but note that the WS can return thousands + * of results which would take a while to process. The limit here is just a convenience to + * prevent viewed to crash because too many DOM elements are created. + * + * @param {string} query The query string. + * @param {number} [limit=100] The number of results to return, 0 for none. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} + */ + searchContacts(query: string, limit: number = 100, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const data = { + searchtext: query, + onlymycourses: 0 + }, + preSets = { + getFromCache: false // Always try to get updated data. If it fails, it will get it from cache. + }; + + return site.read('core_message_search_contacts', data, preSets).then((contacts) => { + if (limit && contacts.length > limit) { + contacts = contacts.splice(0, limit); + } + this.userProvider.storeUsers(contacts); + + return contacts; + }); + }); + } + /** * Search for all the messges with a specific text. * @@ -863,6 +1013,17 @@ export class AddonMessagesProvider { return Promise.resolve(); } + /** + * Store user data from contacts in local DB. + * + * @param {any} contactTypes List of contacts grouped in types. + */ + protected storeUsersFromAllContacts(contactTypes: any): void { + for (const x in contactTypes) { + this.userProvider.storeUsers(contactTypes[x]); + } + } + /** * Store user data from discussions in local DB. * diff --git a/src/components/search-box/search-box.html b/src/components/search-box/search-box.html index 7772b67e8..dfac40da9 100644 --- a/src/components/search-box/search-box.html +++ b/src/components/search-box/search-box.html @@ -1,10 +1,13 @@ -
+ - - +
diff --git a/src/components/search-box/search-box.ts b/src/components/search-box/search-box.ts index f8c9b5ec3..6100ea1d6 100644 --- a/src/components/search-box/search-box.ts +++ b/src/components/search-box/search-box.ts @@ -31,23 +31,29 @@ import { CoreUtilsProvider } from '@providers/utils/utils'; templateUrl: 'search-box.html' }) export class CoreSearchBoxComponent implements OnInit { - @Input() initialValue? = ''; // Initial value for search text. @Input() searchLabel?: string; // Label to be used on action button. @Input() placeholder?: string; // Placeholder text for search text input. @Input() autocorrect? = 'on'; // Enables/disable Autocorrection on search text input. @Input() spellcheck?: string | boolean = true; // Enables/disable Spellchecker on search text input. @Input() autoFocus?: string | boolean; // Enables/disable Autofocus when entering view. @Input() lengthCheck? = 3; // Check value length before submit. If 0, any string will be submitted. + @Input() showClear? = true; // Show/hide clear button. @Output() onSubmit: EventEmitter; // Send data when submitting the search form. + @Output() onClear?: EventEmitter; // Send event when clearing the search form. + + searched = false; + searchText = ''; constructor(private translate: TranslateService, private utils: CoreUtilsProvider) { - this.onSubmit = new EventEmitter(); + this.onSubmit = new EventEmitter(); + this.onClear = new EventEmitter(); } ngOnInit(): void { this.searchLabel = this.searchLabel || this.translate.instant('core.search'); this.placeholder = this.placeholder || this.translate.instant('core.search'); this.spellcheck = this.utils.isTrueOrOne(this.spellcheck); + this.showClear = this.utils.isTrueOrOne(this.showClear); } /** @@ -56,11 +62,21 @@ export class CoreSearchBoxComponent implements OnInit { * @param {string} value Entered value. */ submitForm(value: string): void { - if (value.length < this.lengthCheck) { + if (this.searchText.length < this.lengthCheck) { // The view should handle this case, but we check it here too just in case. return; } - this.onSubmit.emit(value); + this.searched = true; + this.onSubmit.emit(this.searchText); + } + + /** + * Form submitted. + */ + clearForm(): void { + this.searched = false; + this.searchText = ''; + this.onClear.emit(); } } diff --git a/src/core/courses/pages/search/search.html b/src/core/courses/pages/search/search.html index 4df0a3845..7e1d94fcb 100644 --- a/src/core/courses/pages/search/search.html +++ b/src/core/courses/pages/search/search.html @@ -4,7 +4,7 @@ - +
{{ 'core.courses.totalcoursesearchresults' | translate:{$a: total} }}