MOBILE-2327 messages: Add contacts page

main
Pau Ferrer Ocaña 2018-03-02 12:57:02 +01:00
parent 11d052e659
commit dd49b78fb8
12 changed files with 471 additions and 14 deletions

View File

@ -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 {}

View File

@ -0,0 +1,31 @@
<ion-content>
<ion-refresher [enabled]="loaded" (ionRefresh)="refreshData($event)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-search-box (onSubmit)="search($event)" (onClear])="clearSearch($event)" [placeholder]=" 'addon.messages.contactname' | translate" autocorrect="off" spellcheck="false" lengthCheck="2"></core-search-box>
<core-loading [hideUntil]="loaded" [message]="loadingMessage">
<core-empty-box *ngIf="!hasContacts && searchString == ''" icon="person" [message]="'addon.messages.contactlistempty' | translate"></core-empty-box>
<core-empty-box *ngIf="!hasContacts && searchString != ''" icon="person" [message]="'addon.messages.nousersfound' | translate"></core-empty-box>
<ion-list *ngFor="let contactType of contactTypes" no-margin>
<ng-container *ngIf="contacts[contactType] && (contacts[contactType].length > 0 || contactType === searchType)">
<ion-item-divider color="light">
<h2>{{ 'addon.messages.type_' + contactType | translate }}</h2>
<ion-note item-end>{{ contacts[contactType].length }}</ion-note>
</ion-item-divider>
<ng-container *ngFor="let contact of contacts[contactType]">
<!-- Don't show deleted users -->
<ion-item text-wrap *ngIf="contact.profileimageurl || contact.profileimageurlsmall" [title]="contact.fullname" (click)="gotoDiscussion(contact.id)" [class.core-split-item-selected]="contact.id == discussionUserId" detail-none>
<ion-avatar item-start>
<img src="{{contact.profileimageurl || contact.profileimageurlsmall}}" [alt]="'core.pictureof' | translate:{$a: contact.fullname}" core-external-content onError="this.src='assets/img/user-avatar.png'">
</ion-avatar>
<h2><core-format-text [text]="contact.fullname"></core-format-text></h2>
</ion-item>
</ng-container>
</ng-container>
</ion-list>
</core-loading>
</ion-content>

View File

@ -0,0 +1,12 @@
addon-messages-discussions {
h2 {
display: flex;
justify-content: space-between;
.note {
margin: 0;
align-self: flex-end;
display: inline-flex;
}
}
}

View File

@ -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<any>} Promise resolved when done.
*/
refreshData(refresher?: any): Promise<any> {
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<any>} Promise resolved when done.
*/
protected fetchData(): Promise<any> {
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<any>} Resolved when done.
*/
search(query: string): Promise<any> {
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<any>} Resolved when done.
*/
protected performSearch(query: string): Promise<any> {
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);
}
}

View File

@ -3,7 +3,7 @@
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
</ion-refresher>
<core-search-box *ngIf="search.enabled" (onSubmit)="searchMessage($event)" [placeholder]=" 'addon.messages.message' | translate" [initialValue]="search.text" autocorrect="off" spellcheck="false" lengthCheck="2"></core-search-box>
<core-search-box *ngIf="search.enabled" (onSubmit)="searchMessage($event)" (onClear)="clearSearch($event)" [placeholder]=" 'addon.messages.message' | translate" autocorrect="off" spellcheck="false" lengthCheck="2"></core-search-box>
<core-loading [hideUntil]="loaded" [message]="loadingMessage">
<!-- Message telling there are no files. -->
@ -15,9 +15,6 @@
<ion-item-divider color="light">
<h2>{{ 'core.searchresults' | translate }}</h2>
<ion-note item-end>{{ search.results.length }}</ion-note>
<button item-end ion-button icon-only clear="true" class="addon-messages-clear-search" (click)="clearSearch()" [attr.aria-label]="'core.clearsearch' | translate">
<ion-icon name="close"></ion-icon>
</button>
</ion-item-divider>
<ion-item text-wrap *ngFor="let result of search.results" [title]="result.fullname" (click)="gotoDiscussion(result.userid, result.messageid)" [class.core-split-item-selected]="result.userid == discussionUserId" detail-none>
<ion-avatar item-start>

View File

@ -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;
});

View File

@ -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}}"
}

View File

@ -44,8 +44,8 @@
</button>
</ion-item>
</ng-container>
<core-empty-box *ngIf="!messages || messages.length <= 0" icon="chatbubbles" [message]="'addon.messages.nomessages' | translate"></core-empty-box>
</ion-list>
<core-empty-box *ngIf="!messages || messages.length <= 0" icon="chatbubbles" [message]="'addon.messages.nomessages' | translate"></core-empty-box>
</core-loading>
</ion-content>
<ion-footer color="light" class="footer-adjustable">

View File

@ -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<any>} Resolved with the WS data.
*/
getAllContacts(siteId?: string): Promise<any> {
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<any>} Resolved with the WS data.
*/
getBlockedContacts(siteId?: string): Promise<any> {
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<any>} Resolved with the WS data.
*/
getContacts(siteId?: string): Promise<any> {
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<any>} Resolved when done.
*/
invalidateAllContactsCache(userId: number, siteId?: string): Promise<any> {
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<any>}
*/
invalidateBlockedContactsCache(userId: number, siteId?: string): Promise<any> {
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<any>}
*/
searchContacts(query: string, limit: number = 100, siteId?: string): Promise<any> {
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.
*

View File

@ -1,10 +1,13 @@
<ion-card>
<form #f="ngForm" (ngSubmit)="submitForm(f.value.search)">
<form #f="ngForm" (ngSubmit)="submitForm()">
<ion-item>
<ion-input type="text" name="search" ngModel [placeholder]="placeholder" [autocorrect]="autocorrect" [spellcheck]="spellcheck" [core-auto-focus]="autoFocus"></ion-input>
<button item-end ion-button clear icon-only type="submit" class="button-small" [attr.aria-label]="searchLabel" [disabled]="!f.value.search || (f.value.search.length < lengthCheck)">
<ion-input type="text" name="search" [(ngModel)]="searchText" [placeholder]="placeholder" [autocorrect]="autocorrect" [spellcheck]="spellcheck" [core-auto-focus]="autoFocus"></ion-input>
<button item-end ion-button clear icon-only type="submit" class="button-small" [attr.aria-label]="searchLabel" [disabled]="!searchText || (searchText.length < lengthCheck)">
<ion-icon name="search"></ion-icon>
</button>
<button *ngIf="showClear" item-end ion-button clear icon-only class="button-small" [attr.aria-label]="'core.clearsearch' | translate" [disabled]="!searched" (click)="clearForm()">
<ion-icon name="close"></ion-icon>
</button>
</ion-item>
</form>
</ion-card>

View File

@ -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<string>; // Send data when submitting the search form.
@Output() onClear?: EventEmitter<void>; // 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<string>();
this.onClear = new EventEmitter<void>();
}
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();
}
}

View File

@ -4,7 +4,7 @@
</ion-navbar>
</ion-header>
<ion-content>
<core-search-box (onSubmit)="search($event)" [placeholder]="'core.courses.search' | translate" [searchLabel]="'core.courses.search' | translate" autoFocus="true"></core-search-box>
<core-search-box (onSubmit)="search($event)" [placeholder]="'core.courses.search' | translate" [searchLabel]="'core.courses.search' | translate" autoFocus="true" showClear="false"></core-search-box>
<div *ngIf="courses">
<ion-item-divider color="light">{{ 'core.courses.totalcoursesearchresults' | translate:{$a: total} }}</ion-item-divider>