MOBILE-2327 messages: Add discussion page
parent
8fbed26e98
commit
c1b4d435c2
|
@ -26,6 +26,7 @@
|
|||
"ionic:build:before": "gulp"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "^5.2.5",
|
||||
"@angular/common": "5.0.0",
|
||||
"@angular/compiler": "5.0.0",
|
||||
"@angular/compiler-cli": "5.0.0",
|
||||
|
|
|
@ -33,11 +33,13 @@
|
|||
<ion-avatar item-start>
|
||||
<img src="{{discussion.profileimageurl}}" [alt]="'core.pictureof' | translate:{$a: discussion.fullname}" core-external-content onError="this.src='assets/img/user-avatar.png'">
|
||||
</ion-avatar>
|
||||
<h2><core-format-text [text]="discussion.fullname"></core-format-text></h2>
|
||||
<ion-note item-end *ngIf="discussion.message.timecreated > 0 || discussion.unread">
|
||||
<div *ngIf="discussion.message.timecreated> 0">{{discussion.message.timecreated / 1000 | coreDateDayOrTime}}</div>
|
||||
<div text-right *ngIf="discussion.unread" class="core-primary-circle"></div>
|
||||
</ion-note>
|
||||
<h2>
|
||||
<core-format-text [text]="discussion.fullname"></core-format-text>
|
||||
<ion-note *ngIf="discussion.message.timecreated > 0 || discussion.unread">
|
||||
<div *ngIf="discussion.message.timecreated> 0">{{discussion.message.timecreated / 1000 | coreDateDayOrTime}}</div>
|
||||
<div text-right *ngIf="discussion.unread" class="core-primary-circle"></div>
|
||||
</ion-note>
|
||||
</h2>
|
||||
<p><core-format-text clean="true" singleLine="true" [text]="discussion.message.message"></core-format-text></p>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
addon-messages-discussions {
|
||||
h2 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
align-self: flex-end;
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -60,7 +60,9 @@ export class AddonMessagesDiscussionsComponent implements OnDestroy {
|
|||
// 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];
|
||||
const discussion = this.discussions.find((disc) => {
|
||||
return disc.message.user == data.userId;
|
||||
});
|
||||
|
||||
if (typeof discussion == 'undefined') {
|
||||
this.loaded = false;
|
||||
|
@ -78,7 +80,9 @@ export class AddonMessagesDiscussionsComponent implements OnDestroy {
|
|||
// 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];
|
||||
const discussion = this.discussions.find((disc) => {
|
||||
return disc.message.user == data.userId;
|
||||
});
|
||||
|
||||
if (typeof discussion != 'undefined') {
|
||||
// A discussion has been read reset counter.
|
||||
|
@ -160,7 +164,7 @@ export class AddonMessagesDiscussionsComponent implements OnDestroy {
|
|||
}
|
||||
|
||||
this.discussions = discussionsSorted.sort((a, b) => {
|
||||
return a.message.timecreated - b.message.timecreated;
|
||||
return b.message.timecreated - a.message.timecreated;
|
||||
});
|
||||
}).catch((error) => {
|
||||
this.domUtils.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingdiscussions', true);
|
||||
|
|
|
@ -1,6 +1,15 @@
|
|||
{
|
||||
"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.",
|
||||
"errordeletemessage": "Error while deleting the message.",
|
||||
"errorwhileretrievingdiscussions": "Error while retrieving discussions from the server.",
|
||||
"errorwhileretrievingmessages": "Error while retrieving messages from the server.",
|
||||
"messagenotsent": "The message was not sent. Please try again later.",
|
||||
"message": "Message",
|
||||
"messages": "Messages",
|
||||
"nomessages": "No messages"
|
||||
"newmessage": "New message",
|
||||
"newmessages": "New messages",
|
||||
"nomessages": "No messages",
|
||||
"warningmessagenotsent": "Couldn't send message(s) to user {{user}}. {{error}}"
|
||||
}
|
|
@ -15,7 +15,9 @@
|
|||
import { NgModule } from '@angular/core';
|
||||
import { AddonMessagesProvider } from './providers/messages';
|
||||
import { AddonMessagesOfflineProvider } from './providers/messages-offline';
|
||||
import { AddonMessagesSyncProvider } from './providers/sync';
|
||||
import { AddonMessagesMainMenuHandler } from './providers/mainmenu-handler';
|
||||
|
||||
import { CoreMainMenuDelegate } from '../../core/mainmenu/providers/delegate';
|
||||
|
||||
@NgModule({
|
||||
|
@ -26,6 +28,7 @@ import { CoreMainMenuDelegate } from '../../core/mainmenu/providers/delegate';
|
|||
providers: [
|
||||
AddonMessagesProvider,
|
||||
AddonMessagesOfflineProvider,
|
||||
AddonMessagesSyncProvider,
|
||||
AddonMessagesMainMenuHandler
|
||||
]
|
||||
})
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
<ion-header>
|
||||
<ion-navbar>
|
||||
<ion-title><core-format-text [text]="title"></core-format-text></ion-title>
|
||||
<ion-buttons end></ion-buttons>
|
||||
</ion-navbar>
|
||||
<core-navbar-buttons end>
|
||||
<button ion-button icon-only clear="true" (click)="toggleDelete()" [hidden]="!canDelete">
|
||||
<ion-icon name="trash"></ion-icon>
|
||||
</button>
|
||||
<a *ngIf="showProfileLink" core-user-link [userId]="userId" [attr.aria-label]=" 'core.user.viewprofile' | translate">
|
||||
<img class="button core-bar-button-image" [src]="profileLink" core-external-content onError="this.src='assets/img/user-avatar.png'">
|
||||
</a>
|
||||
</core-navbar-buttons>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<core-loading [hideUntil]="loaded">
|
||||
<!-- Load previous messages. -->
|
||||
<ion-infinite-scroll [enabled]="canLoadMore" (ionInfinite)="loadPrevious($event)" position="top">
|
||||
<ion-infinite-scroll-content></ion-infinite-scroll-content>
|
||||
</ion-infinite-scroll>
|
||||
<ion-list class="addon-messages-discussion-container" [attr.aria-live]="polite">
|
||||
<ng-container *ngFor="let message of messages; index as index; last as last">
|
||||
<ion-chip *ngIf="showDate(message, messages[index - 1])" class="addon-messages-date" color="light">
|
||||
<ion-label>{{ message.timecreated | coreFormatDate: "LL" }}</ion-label>
|
||||
</ion-chip>
|
||||
|
||||
<ion-chip class="addon-messages-unreadfrom" *ngIf="message.unreadFrom" color="light">
|
||||
<ion-label>{{ 'addon.messages.newmessages' | translate:{$a: title} }}</ion-label>
|
||||
<ion-icon name="arrow-round-down"></ion-icon>
|
||||
</ion-chip>
|
||||
|
||||
<ion-item text-wrap (longPress)="copyMessage(message.smallmessage)" class="addon-message" [class.addon-message-mine]="message.useridfrom == currentUserId" [@coreSlideInOut]="message.useridfrom == currentUserId ? '' : 'fromLeft'">
|
||||
<!-- Some messages have <p> and some others don't. Add a <p> so they all have same styles. -->
|
||||
<p class="addon-message-text">
|
||||
<core-format-text (afterRender)="last && scrollToBottom()" [text]="message.text"></core-format-text>
|
||||
</p>
|
||||
<ion-note *ngIf="!message.pending">
|
||||
{{ message.timecreated | coreFormatDate: "dftimedate" }}
|
||||
</ion-note>
|
||||
<ion-note *ngIf="message.pending"><ion-icon name="time"></ion-icon></ion-note>
|
||||
|
||||
<button ion-button icon-only clear="true" *ngIf="!message.sending && showDelete" (click)="deleteMessage(message, index)" class="addon-messages-delete-button" [@coreSlideInOut]="'fromRight'" [attr.aria-label]=" 'addon.messages.deletemessage' | translate">
|
||||
<ion-icon name="trash" color="danger"></ion-icon>
|
||||
</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-loading>
|
||||
</ion-content>
|
||||
<ion-footer color="light" class="footer-adjustable">
|
||||
<ion-toolbar color="light" position="bottom">
|
||||
<core-send-message-form (onSubmit)="sendMessage($event)" [showKeyboard]="showKeyboard" [placeholder]="'addon.messages.newmessage' | translate" (onResize)="resizeContent()"></core-send-message-form>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
|
@ -0,0 +1,35 @@
|
|||
// (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 { AddonMessagesDiscussionPage } from './discussion';
|
||||
import { CoreComponentsModule } from '../../../../components/components.module';
|
||||
import { CoreDirectivesModule } from '../../../../directives/directives.module';
|
||||
import { CorePipesModule } from '../../../../pipes/pipes.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AddonMessagesDiscussionPage,
|
||||
],
|
||||
imports: [
|
||||
CoreComponentsModule,
|
||||
CoreDirectivesModule,
|
||||
CorePipesModule,
|
||||
IonicPageModule.forChild(AddonMessagesDiscussionPage),
|
||||
TranslateModule.forChild()
|
||||
],
|
||||
})
|
||||
export class AddonMessagesDiscussionPageModule {}
|
|
@ -0,0 +1,109 @@
|
|||
// Messages.
|
||||
$item-message-bg: $gray-lighter !default;
|
||||
$item-message-note-text: $gray-dark !default;
|
||||
$item-message-note-font-size: 75% !default;
|
||||
$item-message-mine-bg: $blue-light !default;
|
||||
|
||||
|
||||
page-addon-messages-discussion {
|
||||
.addon-messages-discussion-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.addon-messages-date,
|
||||
.addon-messages-unreadfrom {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.addon-messages-unreadfrom {
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
// Message item.
|
||||
.addon-message {
|
||||
max-width: 80%;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
padding: 10px;
|
||||
margin: 4px;
|
||||
background-color: $item-message-bg;
|
||||
align-self: flex-start;
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
@include core-transition(width);
|
||||
|
||||
&.activated {
|
||||
background-color: darken($item-message-bg, 10%);
|
||||
}
|
||||
|
||||
&.item-block .item-inner {
|
||||
border-bottom: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.addon-message-text {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.note {
|
||||
align-self: flex-end;
|
||||
color: $item-message-note-text;
|
||||
font-size: $item-message-note-font-size;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.addon-messages-delete-button {
|
||||
min-height: initial;
|
||||
line-height: initial;
|
||||
margin: 0 0 0 10px;
|
||||
height: auto;
|
||||
-webkit-align-self: flex-end;
|
||||
-ms-flex-item-align: end;
|
||||
align-self: flex-end;
|
||||
vertical-align: middle;
|
||||
|
||||
.icon {
|
||||
font-size: 1.4em;
|
||||
line-height: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Defines when an item-message is the user's.
|
||||
.addon-message-mine {
|
||||
background-color: $item-message-mine-bg;
|
||||
align-self: flex-end;
|
||||
max-width: 80%;
|
||||
|
||||
&.activated {
|
||||
background-color: darken($item-message-mine-bg, 10%);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
float: right;
|
||||
margin-left: 5px;
|
||||
margin-right: -3px;
|
||||
margin-top: 2px;
|
||||
margin-bottom: -2px;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.addon-message .item-content,
|
||||
.addon-message-mine .item-content{
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,691 @@
|
|||
// (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, NavParams, NavController, Content } 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 { AddonMessagesSyncProvider } from '../../providers/sync';
|
||||
import { CoreUserProvider } from '../../../../core/user/providers/user';
|
||||
import { CoreDomUtilsProvider } from '../../../../providers/utils/dom';
|
||||
import { CoreUtilsProvider } from '../../../../providers/utils/utils';
|
||||
import { CoreLoggerProvider } from '../../../../providers/logger';
|
||||
import { CoreAppProvider } from '../../../../providers/app';
|
||||
import { coreSlideInOut } from '../../../../classes/animations';
|
||||
import { Md5 } from 'ts-md5/dist/md5';
|
||||
import * as moment from 'moment';
|
||||
|
||||
/**
|
||||
* Page that displays a message discussion page.
|
||||
*/
|
||||
@IonicPage({ segment: 'addon-messages-discussion' })
|
||||
@Component({
|
||||
selector: 'page-addon-messages-discussion',
|
||||
templateUrl: 'discussion.html',
|
||||
animations: [coreSlideInOut]
|
||||
})
|
||||
export class AddonMessagesDiscussionPage implements OnDestroy {
|
||||
@ViewChild(Content) content: Content;
|
||||
|
||||
protected siteId: string;
|
||||
protected fetching: boolean;
|
||||
protected polling;
|
||||
protected logger;
|
||||
|
||||
protected unreadMessageFrom = 0;
|
||||
protected messagesBeingSent = 0;
|
||||
protected pagesLoaded = 1;
|
||||
protected lastMessage = {text: '', timecreated: 0};
|
||||
protected keepMessageMap = {};
|
||||
protected syncObserver: any;
|
||||
protected oldContentHeight = 0;
|
||||
|
||||
userId: number;
|
||||
currentUserId: number;
|
||||
title: string;
|
||||
profileLink: string;
|
||||
showProfileLink: boolean;
|
||||
loaded = false;
|
||||
showKeyboard = false;
|
||||
canLoadMore = false;
|
||||
messages = [];
|
||||
showDelete = false;
|
||||
canDelete = false;
|
||||
scrollBottom = true;
|
||||
|
||||
constructor(private eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider, navParams: NavParams,
|
||||
private userProvider: CoreUserProvider, private navCtrl: NavController, private messagesSync: AddonMessagesSyncProvider,
|
||||
private domUtils: CoreDomUtilsProvider, private messagesProvider: AddonMessagesProvider, logger: CoreLoggerProvider,
|
||||
private utils: CoreUtilsProvider, private appProvider: CoreAppProvider, private translate: TranslateService) {
|
||||
this.siteId = sitesProvider.getCurrentSiteId();
|
||||
this.currentUserId = sitesProvider.getCurrentSiteUserId();
|
||||
|
||||
this.logger = logger.getInstance('AddonMessagesDiscussionPage');
|
||||
|
||||
this.userId = navParams.get('userId');
|
||||
this.showKeyboard = navParams.get('showKeyboard');
|
||||
|
||||
// Refresh data if this discussion is synchronized automatically.
|
||||
this.syncObserver = eventsProvider.on(AddonMessagesSyncProvider.AUTO_SYNCED, (data) => {
|
||||
if (data.userId == this.userId) {
|
||||
// Fetch messages.
|
||||
this.fetchData();
|
||||
|
||||
// Show first warning if any.
|
||||
if (data.warnings && data.warnings[0]) {
|
||||
this.domUtils.showErrorModal(data.warnings[0]);
|
||||
}
|
||||
}
|
||||
}, this.siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new message to the message list.
|
||||
*
|
||||
* @param {any} message Message to be added.
|
||||
* @param {boolean} [keep=true] If set the keep flag or not.
|
||||
*/
|
||||
protected addMessage(message: any, keep: boolean = true): void {
|
||||
// Use smallmessage instead of message ID because ID changes when a message is read.
|
||||
message.hash = Md5.hashStr(message.smallmessage) + '#' + message.timecreated + '#' + message.useridfrom;
|
||||
if (typeof this.keepMessageMap[message.hash] === 'undefined') {
|
||||
// Message not added to the list. Add it now.
|
||||
this.messages.push(message);
|
||||
}
|
||||
// Message needs to be kept in the list.
|
||||
this.keepMessageMap[message.hash] = keep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a message if it shouldn't be in the list anymore.
|
||||
*
|
||||
* @param {string} hash Hash of the message to be removed.
|
||||
*/
|
||||
protected removeMessage(hash: any): void {
|
||||
if (this.keepMessageMap[hash]) {
|
||||
// Selected to keep it, clear the flag.
|
||||
this.keepMessageMap[hash] = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
delete this.keepMessageMap[hash];
|
||||
|
||||
const position = this.messages.findIndex((message) => {
|
||||
return message.hash == hash;
|
||||
});
|
||||
if (position > 0) {
|
||||
this.messages.splice(position, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs when the page has loaded. This event only happens once per page being created.
|
||||
* If a page leaves but is cached, then this event will not fire again on a subsequent viewing.
|
||||
* Setup code for the page.
|
||||
*/
|
||||
ionViewDidLoad(): void {
|
||||
// Disable the profile button if we're already coming from a profile.
|
||||
const backViewPage = this.navCtrl.getPrevious() && this.navCtrl.getPrevious().component.name;
|
||||
this.showProfileLink = !backViewPage || backViewPage !== 'CoreUserProfilePage';
|
||||
|
||||
// Get the user profile to retrieve the user fullname and image.
|
||||
this.userProvider.getProfile(this.userId, undefined, true).then((user) => {
|
||||
if (!this.title) {
|
||||
this.title = user.fullname;
|
||||
}
|
||||
this.profileLink = user.profileimageurl;
|
||||
});
|
||||
|
||||
// Synchronize messages if needed.
|
||||
this.messagesSync.syncDiscussion(this.userId).catch(() => {
|
||||
// Ignore errors.
|
||||
}).then((warnings) => {
|
||||
if (warnings && warnings[0]) {
|
||||
this.domUtils.showErrorModal(warnings[0]);
|
||||
}
|
||||
|
||||
// Fetch the messages for the first time.
|
||||
return this.fetchData().then(() => {
|
||||
if (!this.title && this.messages.length) {
|
||||
// Didn't receive the fullname via argument. Try to get it from messages.
|
||||
// It's possible that name cannot be resolved when no messages were yet exchanged.
|
||||
if (this.messages[0].useridto != this.currentUserId) {
|
||||
this.title = this.messages[0].usertofullname || '';
|
||||
} else {
|
||||
this.title = this.messages[0].userfromfullname || '';
|
||||
}
|
||||
}
|
||||
}).catch((error) => {
|
||||
this.domUtils.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingmessages', true);
|
||||
}).finally(() => {
|
||||
this.triggerDiscussionLoadedEvent();
|
||||
this.resizeContent();
|
||||
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.setPolling();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs when the page is about to leave and no longer be the active page.
|
||||
*/
|
||||
ionViewWillLeave(): void {
|
||||
this.unsetPolling();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to fetch messages.
|
||||
* @return {Promise<any>} Resolved when done.
|
||||
*/
|
||||
protected fetchData(): Promise<any> {
|
||||
this.logger.debug(`Polling new messages for discussion with user '${this.userId}'`);
|
||||
if (this.messagesBeingSent > 0) {
|
||||
// We do not poll while a message is being sent or we could confuse the user
|
||||
// as his message would disappear from the list, and he'd have to wait for the
|
||||
// interval to check for new messages.
|
||||
return Promise.reject(null);
|
||||
} else if (this.fetching) {
|
||||
// Already fetching.
|
||||
return Promise.reject(null);
|
||||
}
|
||||
|
||||
this.fetching = true;
|
||||
|
||||
// Wait for synchronization process to finish.
|
||||
return this.messagesSync.waitForSync(this.userId).then(() => {
|
||||
// Fetch messages. Invalidate the cache before fetching.
|
||||
return this.messagesProvider.invalidateDiscussionCache(this.userId).catch(() => {
|
||||
// Ignore errors.
|
||||
});
|
||||
}).then(() => {
|
||||
return this.getDiscussion(this.pagesLoaded);
|
||||
}).then((messages) => {
|
||||
// Check if we are at the bottom to scroll it after render.
|
||||
this.scrollBottom = this.content.scrollHeight - this.content.scrollTop === this.content.contentHeight;
|
||||
|
||||
if (this.messagesBeingSent > 0) {
|
||||
// Ignore polling due to a race condition.
|
||||
return Promise.reject(null);
|
||||
}
|
||||
|
||||
// Add new messages to the list and mark the messages that should still be displayed.
|
||||
messages.forEach((message) => {
|
||||
this.addMessage(message);
|
||||
});
|
||||
|
||||
// Remove messages that shouldn't be in the list anymore.
|
||||
for (const hash in this.keepMessageMap) {
|
||||
this.removeMessage(hash);
|
||||
}
|
||||
|
||||
// Sort the messages.
|
||||
this.messagesProvider.sortMessages(this.messages);
|
||||
|
||||
// Notify that there can be a new message.
|
||||
this.notifyNewMessage();
|
||||
|
||||
// Mark retrieved messages as read if they are not.
|
||||
this.markMessagesAsRead();
|
||||
}).finally(() => {
|
||||
this.fetching = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a discussion. Can load several "pages".
|
||||
*
|
||||
* @param {number} pagesToLoad Number of pages to load.
|
||||
* @param {number} [lfReceivedUnread=0] Number of unread received messages already fetched, so fetch will be done from this.
|
||||
* @param {number} [lfReceivedRead=0] Number of read received messages already fetched, so fetch will be done from this.
|
||||
* @param {number} [lfSentUnread=0] Number of unread sent messages already fetched, so fetch will be done from this.
|
||||
* @param {number} [lfSentRead=0] Number of read sent messages already fetched, so fetch will be done from this.
|
||||
* @return {Promise<any>} Resolved when done.
|
||||
*/
|
||||
protected getDiscussion(pagesToLoad: number, lfReceivedUnread: number = 0, lfReceivedRead: number = 0, lfSentUnread: number = 0,
|
||||
lfSentRead: number = 0): Promise<any> {
|
||||
|
||||
// Only get offline messages if we're loading the first "page".
|
||||
const excludePending = lfReceivedUnread > 0 || lfReceivedRead > 0 || lfSentUnread > 0 || lfSentRead > 0;
|
||||
|
||||
// Get next messages.
|
||||
return this.messagesProvider.getDiscussion(this.userId, excludePending, lfReceivedUnread, lfReceivedRead, lfSentUnread,
|
||||
lfSentRead).then((result) => {
|
||||
|
||||
pagesToLoad--;
|
||||
if (pagesToLoad > 0 && result.canLoadMore) {
|
||||
// More pages to load. Calculate new limit froms.
|
||||
result.messages.forEach((message) => {
|
||||
if (!message.pending) {
|
||||
if (message.useridfrom == this.userId) {
|
||||
if (message.read) {
|
||||
lfReceivedRead++;
|
||||
} else {
|
||||
lfReceivedUnread++;
|
||||
}
|
||||
} else {
|
||||
if (message.read) {
|
||||
lfSentRead++;
|
||||
} else {
|
||||
lfSentUnread++;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get next messages.
|
||||
return this.getDiscussion(pagesToLoad, lfReceivedUnread, lfReceivedRead, lfSentUnread, lfSentRead)
|
||||
.then((nextMessages) => {
|
||||
return result.messages.concat(nextMessages);
|
||||
});
|
||||
} else {
|
||||
// No more messages to load, return them.
|
||||
this.canLoadMore = result.canLoadMore;
|
||||
|
||||
return result.messages;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark messages as read.
|
||||
*/
|
||||
protected markMessagesAsRead(): void {
|
||||
let readChanged = false;
|
||||
const promises = [];
|
||||
|
||||
if (this.messagesProvider.isMarkAllMessagesReadEnabled()) {
|
||||
let messageUnreadFound = false;
|
||||
// Mark all messages at a time if one messages is unread.
|
||||
for (const x in this.messages) {
|
||||
const message = this.messages[x];
|
||||
// If an unread message is found, mark all messages as read.
|
||||
if (message.useridfrom != this.currentUserId && message.read == 0) {
|
||||
messageUnreadFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (messageUnreadFound) {
|
||||
this.setUnreadLabelPosition();
|
||||
promises.push(this.messagesProvider.markAllMessagesRead(this.userId).then(() => {
|
||||
readChanged = true;
|
||||
// Mark all messages as read.
|
||||
this.messages.forEach((message) => {
|
||||
message.read = 1;
|
||||
});
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
this.setUnreadLabelPosition();
|
||||
// Mark each message as read one by one.
|
||||
this.messages.forEach((message) => {
|
||||
// If the message is unread, call this.messagesProvider.markMessageRead.
|
||||
if (message.useridfrom != this.currentUserId && message.read == 0) {
|
||||
promises.push(this.messagesProvider.markMessageRead(message.id).then(() => {
|
||||
readChanged = true;
|
||||
message.read = 1;
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Promise.all(promises).finally(() => {
|
||||
if (readChanged) {
|
||||
this.eventsProvider.trigger(AddonMessagesProvider.READ_CHANGED_EVENT, {
|
||||
userId: this.userId
|
||||
}, this.siteId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the last message found so discussions list controller can tell if last message should be updated.
|
||||
*/
|
||||
protected notifyNewMessage(): void {
|
||||
const last = this.messages[this.messages.length - 1];
|
||||
|
||||
let trigger = false;
|
||||
if (!last) {
|
||||
this.lastMessage = {text: '', timecreated: 0};
|
||||
trigger = true;
|
||||
} else if (last.text !== this.lastMessage.text || last.timecreated !== this.lastMessage.timecreated) {
|
||||
this.lastMessage = {text: last.text, timecreated: last.timecreated};
|
||||
trigger = true;
|
||||
}
|
||||
|
||||
if (trigger) {
|
||||
// Update discussions last message.
|
||||
this.eventsProvider.trigger(AddonMessagesProvider.NEW_MESSAGE_EVENT, {
|
||||
userId: this.userId,
|
||||
message: this.lastMessage.text,
|
||||
timecreated: this.lastMessage.timecreated
|
||||
}, this.siteId);
|
||||
|
||||
// Update navBar links and buttons.
|
||||
const newCanDelete = (last && last.id && this.messages.length == 1) || this.messages.length > 1;
|
||||
if (this.canDelete != newCanDelete) {
|
||||
this.triggerDiscussionLoadedEvent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the place where the unread label position has to be.
|
||||
*/
|
||||
protected setUnreadLabelPosition(): void {
|
||||
if (this.unreadMessageFrom != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let previousMessageRead = false;
|
||||
|
||||
for (const x in this.messages) {
|
||||
const message = this.messages[x];
|
||||
if (message.useridfrom != this.currentUserId) {
|
||||
// Place unread from message label only once.
|
||||
message.unreadFrom = message.read == 0 && previousMessageRead;
|
||||
|
||||
if (message.unreadFrom) {
|
||||
// Save where the label is placed.
|
||||
this.unreadMessageFrom = parseInt(message.id, 10);
|
||||
break;
|
||||
}
|
||||
|
||||
previousMessageRead = message.read != 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Do not update the message unread from label on next refresh.
|
||||
if (this.unreadMessageFrom == 0) {
|
||||
// Using negative to indicate the label is not placed but should not be placed.
|
||||
this.unreadMessageFrom = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's any message in the list that can be deleted.
|
||||
*/
|
||||
protected triggerDiscussionLoadedEvent(): void {
|
||||
// All messages being sent should be at the end of the list.
|
||||
const first = this.messages[0];
|
||||
this.canDelete = first && !first.sending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide unread label when sending messages.
|
||||
*/
|
||||
protected hideUnreadLabel(): void {
|
||||
if (this.unreadMessageFrom > 0) {
|
||||
for (const x in this.messages) {
|
||||
const message = this.messages[x];
|
||||
if (message.id == this.unreadMessageFrom) {
|
||||
message.unreadFrom = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Label hidden.
|
||||
this.unreadMessageFrom = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until fetching is false.
|
||||
* @return {Promise<void>} Resolved when done.
|
||||
*/
|
||||
protected waitForFetch(): Promise<void> {
|
||||
if (!this.fetching) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const deferred = this.utils.promiseDefer();
|
||||
|
||||
setTimeout(() => {
|
||||
return this.waitForFetch().finally(() => {
|
||||
deferred.resolve();
|
||||
});
|
||||
}, 400);
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a polling to get new messages every certain time.
|
||||
*/
|
||||
protected setPolling(): void {
|
||||
if (!this.polling) {
|
||||
// Start polling.
|
||||
this.polling = setInterval(() => {
|
||||
this.fetchData().catch(() => {
|
||||
// Ignore errors.
|
||||
});
|
||||
}, AddonMessagesProvider.POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unset polling.
|
||||
*/
|
||||
protected unsetPolling(): void {
|
||||
if (this.polling) {
|
||||
this.logger.debug(`Cancelling polling for conversation with user '${this.userId}'`);
|
||||
clearInterval(this.polling);
|
||||
this.polling = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy message to clipboard
|
||||
*
|
||||
* @param {string} text Message text to be copied.
|
||||
*/
|
||||
copyMessage(text: string): void {
|
||||
this.utils.copyToClipboard(text);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Function to delete a message.
|
||||
*
|
||||
* @param {any} message Message object to delete.
|
||||
* @param {number} index Index where the mesasge is to delete it from the view.
|
||||
*/
|
||||
deleteMessage(message: any, index: number): void {
|
||||
const langKey = message.pending ? 'core.areyousure' : 'addon.messages.deletemessageconfirmation';
|
||||
this.domUtils.showConfirm(this.translate.instant(langKey)).then(() => {
|
||||
const modal = this.domUtils.showModalLoading('core.deleting', true);
|
||||
|
||||
this.messagesProvider.deleteMessage(message).then(() => {
|
||||
// Remove message from the list without having to wait for re-fetch.
|
||||
this.messages.splice(index, 1);
|
||||
this.removeMessage(message.hash);
|
||||
this.notifyNewMessage();
|
||||
|
||||
this.fetchData(); // Re-fetch messages to update cached data.
|
||||
}).catch((error) => {
|
||||
this.domUtils.showErrorModalDefault(error, 'addon.messages.errordeletemessage', true);
|
||||
}).finally(() => {
|
||||
modal.dismiss();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to load previous messages.
|
||||
*
|
||||
* @param {any} [infiniteScroll] Infinite scroll object.
|
||||
* @return {Promise<any>} Resolved when done.
|
||||
*/
|
||||
loadPrevious(infiniteScroll: any): Promise<any> {
|
||||
// If there is an ongoing fetch, wait for it to finish.
|
||||
return this.waitForFetch().finally(() => {
|
||||
this.pagesLoaded++;
|
||||
|
||||
this.fetchData().catch((error) => {
|
||||
this.pagesLoaded--;
|
||||
this.domUtils.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingmessages', true);
|
||||
}).finally(() => {
|
||||
infiniteScroll.complete();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Content or scroll has been resized. For content, only call it if it's been added on top.
|
||||
*/
|
||||
resizeContent(): void {
|
||||
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.content.contentHeight != this.oldContentHeight) {
|
||||
if (!top) {
|
||||
top = this.content.getContentDimensions().scrollTop;
|
||||
}
|
||||
|
||||
top += this.oldContentHeight - this.content.contentHeight;
|
||||
this.oldContentHeight = this.content.contentHeight;
|
||||
|
||||
this.content.scrollTo(0, top, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll bottom when render has finished.
|
||||
*/
|
||||
scrollToBottom(): void {
|
||||
// Check if scroll is at bottom. If so, scroll bottom after rendering since there might be something new.
|
||||
if (this.scrollBottom) {
|
||||
// Need a timeout to leave time to the view to be rendered.
|
||||
setTimeout(() => {
|
||||
this.content.scrollToBottom(0);
|
||||
});
|
||||
this.scrollBottom = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to the server.
|
||||
* @param {string} text Message text.
|
||||
*/
|
||||
sendMessage(text: string): void {
|
||||
let message;
|
||||
|
||||
this.hideUnreadLabel();
|
||||
|
||||
this.showDelete = false;
|
||||
this.scrollBottom = true;
|
||||
|
||||
message = {
|
||||
pending: true,
|
||||
sending: true,
|
||||
useridfrom: this.currentUserId,
|
||||
smallmessage: text,
|
||||
text: text,
|
||||
timecreated: new Date().getTime()
|
||||
};
|
||||
this.addMessage(message, false);
|
||||
|
||||
this.messagesBeingSent++;
|
||||
|
||||
// If there is an ongoing fetch, wait for it to finish.
|
||||
// Otherwise, if a message is sent while fetching it could disappear until the next fetch.
|
||||
this.waitForFetch().finally(() => {
|
||||
this.messagesProvider.sendMessage(this.userId, text).then((data) => {
|
||||
let promise;
|
||||
|
||||
this.messagesBeingSent--;
|
||||
|
||||
if (data.sent) {
|
||||
// Message was sent, fetch messages right now.
|
||||
promise = this.fetchData();
|
||||
} else {
|
||||
promise = Promise.reject(null);
|
||||
}
|
||||
|
||||
promise.catch(() => {
|
||||
// Fetch failed or is offline message, mark the message as sent. If fetch is successful there's no need
|
||||
// to mark it because the fetch will already show the message received from the server.
|
||||
message.sending = false;
|
||||
if (data.sent) {
|
||||
// Message sent to server, not pending anymore.
|
||||
message.pending = false;
|
||||
} else if (data.message) {
|
||||
message.timecreated = data.message.timecreated;
|
||||
}
|
||||
|
||||
this.notifyNewMessage();
|
||||
});
|
||||
}).catch((error) => {
|
||||
this.messagesBeingSent--;
|
||||
|
||||
// Only close the keyboard if an error happens, we want the user to be able to send multiple
|
||||
// messages without the keyboard being closed.
|
||||
this.appProvider.closeKeyboard();
|
||||
|
||||
this.domUtils.showErrorModalDefault(error, 'addon.messages.messagenotsent', true);
|
||||
this.removeMessage(message.hash);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check date should be shown on message list for the current message.
|
||||
* If date has changed from previous to current message it should be shown.
|
||||
*
|
||||
* @param {any} message Current message where to show the date.
|
||||
* @param {any} [prevMessage] Previous message where to compare the date with.
|
||||
* @return {boolean} If date has changed and should be shown.
|
||||
*/
|
||||
showDate(message: any, prevMessage?: any): boolean {
|
||||
if (!prevMessage) {
|
||||
// First message, show it.
|
||||
return true;
|
||||
} else if (message.pending) {
|
||||
// If pending, it has no date, not show.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if day has changed.
|
||||
return !moment(message.timecreated).isSame(prevMessage.timecreated, 'day');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles delete state.
|
||||
*/
|
||||
toggleDelete(): void {
|
||||
this.showDelete = !this.showDelete;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page destroyed.
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
// Unset again, just in case.
|
||||
this.unsetPolling();
|
||||
this.syncObserver && this.syncObserver.off();
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
<ion-header>
|
||||
<ion-navbar>
|
||||
<ion-title>{{ 'addon.messages.messages' | translate }}</ion-title>
|
||||
<ion-buttons end></ion-buttons>
|
||||
</ion-navbar>
|
||||
</ion-header>
|
||||
<core-split-view>
|
||||
|
|
|
@ -54,7 +54,7 @@ export class AddonMessagesIndexPage implements OnDestroy {
|
|||
*/
|
||||
gotoDiscussion(discussionUserId: number, messageId?: number): void {
|
||||
const params = {
|
||||
id: discussionUserId
|
||||
userId: discussionUserId
|
||||
};
|
||||
if (messageId) {
|
||||
params['message'] = messageId;
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { CoreLoggerProvider } from '../../../providers/logger';
|
||||
import { CoreSitesProvider } from '../../../providers/sites';
|
||||
import { CoreAppProvider } from '../../../providers/app';
|
||||
|
||||
/**
|
||||
* Service to handle Offline messages.
|
||||
|
@ -55,11 +56,55 @@ export class AddonMessagesOfflineProvider {
|
|||
}
|
||||
];
|
||||
|
||||
constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider) {
|
||||
constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private appProvider: CoreAppProvider) {
|
||||
this.logger = logger.getInstance('AddonMessagesOfflineProvider');
|
||||
this.sitesProvider.createTablesFromSchema(this.tablesSchema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message.
|
||||
*
|
||||
* @param {number} toUserId User ID to send the message to.
|
||||
* @param {string} message The message.
|
||||
* @param {number} timeCreated The time the message was created.
|
||||
* @param {string} [siteId] Site ID. If not defined, current site.
|
||||
* @return {Promise<any>} Promise resolved if stored, rejected if failure.
|
||||
*/
|
||||
deleteMessage(toUserId: number, message: string, timeCreated: number, siteId?: string): Promise<any> {
|
||||
return this.sitesProvider.getSite(siteId).then((site) => {
|
||||
return site.getDb().deleteRecords(this.MESSAGES_TABLE, {
|
||||
touserid: toUserId,
|
||||
smallmessage: message,
|
||||
timecreated: timeCreated
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages where deviceoffline is set to 1.
|
||||
*
|
||||
* @param {string} [siteId] Site ID. If not defined, current site.
|
||||
* @return {Promise<any>} Promise resolved with messages.
|
||||
*/
|
||||
getAllDeviceOfflineMessages(siteId?: string): Promise<any> {
|
||||
return this.sitesProvider.getSite(siteId).then((site) => {
|
||||
return site.getDb().getRecords(this.MESSAGES_TABLE, {deviceoffline: 1});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get offline messages to send to a certain user.
|
||||
*
|
||||
* @param {number} toUserId User ID to get messages to.
|
||||
* @param {string} [siteId] Site ID. If not defined, current site.
|
||||
* @return {Promise<any>} Promise resolved with messages.
|
||||
*/
|
||||
getMessages(toUserId: number, siteId?: string): Promise<any> {
|
||||
return this.sitesProvider.getSite(siteId).then((site) => {
|
||||
return site.getDb().getRecords(this.MESSAGES_TABLE, {touserid: toUserId});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all offline messages.
|
||||
*
|
||||
|
@ -71,4 +116,71 @@ export class AddonMessagesOfflineProvider {
|
|||
return site.getDb().getAllRecords(this.MESSAGES_TABLE);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are offline messages to send to a certain user.
|
||||
*
|
||||
* @param {number} toUserId User ID to check.
|
||||
* @param {string} [siteId] Site ID. If not defined, current site.
|
||||
* @return {Promise<any>} Promise resolved with boolean: true if has offline messages, false otherwise.
|
||||
*/
|
||||
hasMessages(toUserId: number, siteId?: string): Promise<any> {
|
||||
return this.getMessages(toUserId, siteId).then((messages) => {
|
||||
return !!messages.length;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a message to be sent later.
|
||||
*
|
||||
* @param {number} toUserId User ID recipient of the message.
|
||||
* @param {string} message The message to send.
|
||||
* @param {string} [siteId] Site ID. If not defined, current site.
|
||||
* @return {Promise<any>} Promise resolved if stored, rejected if failure.
|
||||
*/
|
||||
saveMessage(toUserId: number, message: string, siteId?: string): Promise<any> {
|
||||
return this.sitesProvider.getSite(siteId).then((site) => {
|
||||
const entry = {
|
||||
touserid: toUserId,
|
||||
useridfrom: site.getUserId(),
|
||||
smallmessage: message,
|
||||
timecreated: new Date().getTime(),
|
||||
deviceoffline: this.appProvider.isOnline() ? 0 : 1
|
||||
};
|
||||
|
||||
return site.getDb().insertOrUpdateRecord(this.MESSAGES_TABLE, entry, {
|
||||
touserid: toUserId,
|
||||
smallmessage: message,
|
||||
timecreated: entry.timecreated
|
||||
}).then(() => {
|
||||
return entry;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set deviceoffline for a group of messages.
|
||||
*
|
||||
* @param {any} messages Messages to update. Should be the same entry as retrieved from the DB.
|
||||
* @param {boolean} value Value to set.
|
||||
* @param {string} [siteId] Site ID. If not defined, current site.
|
||||
* @return {Promise<any>} Promise resolved if stored, rejected if failure.
|
||||
*/
|
||||
setMessagesDeviceOffline(messages: any, value: boolean, siteId?: string): Promise<any> {
|
||||
return this.sitesProvider.getSite(siteId).then((site) => {
|
||||
const db = site.getDb(),
|
||||
promises = [],
|
||||
data = { deviceoffline: value ? 1 : 0 };
|
||||
|
||||
messages.forEach((message) => {
|
||||
promises.push(db.insertOrUpdateRecord(this.MESSAGES_TABLE, data, {
|
||||
touserid: message.touserid,
|
||||
smallmessage: message.smallmessage,
|
||||
timecreated: message.timecreated
|
||||
}));
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,8 @@ import { CoreSitesProvider } from '../../../providers/sites';
|
|||
import { CoreAppProvider } from '../../../providers/app';
|
||||
import { CoreUserProvider } from '../../../core/user/providers/user';
|
||||
import { AddonMessagesOfflineProvider } from './messages-offline';
|
||||
import { CoreUtilsProvider } from '../../../providers/utils/utils';
|
||||
import { CoreTimeUtilsProvider } from '../../../providers/utils/time';
|
||||
|
||||
/**
|
||||
* Service to handle messages.
|
||||
|
@ -30,14 +32,53 @@ export class AddonMessagesProvider {
|
|||
static READ_CHANGED_EVENT = 'read_changed_event';
|
||||
static READ_CRON_EVENT = 'read_cron_event';
|
||||
static SPLIT_VIEW_LOAD_EVENT = 'split_view_load_event';
|
||||
static POLL_INTERVAL = 10000;
|
||||
|
||||
protected logger;
|
||||
|
||||
constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private appProvider: CoreAppProvider,
|
||||
private userProvider: CoreUserProvider, private messagesOffline: AddonMessagesOfflineProvider) {
|
||||
private userProvider: CoreUserProvider, private messagesOffline: AddonMessagesOfflineProvider,
|
||||
private utils: CoreUtilsProvider, private timeUtils: CoreTimeUtilsProvider) {
|
||||
this.logger = logger.getInstance('AddonMessagesProvider');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message (online or offline).
|
||||
*
|
||||
* @param {any} message Message to delete.
|
||||
* @return {Promise<any>} Promise resolved when the message has been deleted.
|
||||
*/
|
||||
deleteMessage(message: any): Promise<any> {
|
||||
if (message.id) {
|
||||
// Message has ID, it means it has been sent to the server.
|
||||
return this.deleteMessageOnline(message.id, message.read);
|
||||
}
|
||||
|
||||
// It's an offline message.
|
||||
return this.messagesOffline.deleteMessage(message.touserid, message.smallmessage, message.timecreated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message from the server.
|
||||
*
|
||||
* @param {number} id Message ID.
|
||||
* @param {number} read 1 if message is read, 0 otherwise.
|
||||
* @param {number} [userId] User we want to delete the message for. If not defined, use current user.
|
||||
* @return {Promise<any>} Promise resolved when the message has been deleted.
|
||||
*/
|
||||
deleteMessageOnline(id: number, read: number, userId?: number): Promise<any> {
|
||||
userId = userId || this.sitesProvider.getCurrentSiteUserId();
|
||||
const params = {
|
||||
messageid: id,
|
||||
userid: userId,
|
||||
read: read
|
||||
};
|
||||
|
||||
return this.sitesProvider.getCurrentSite().write('core_message_delete_message', params).then(() => {
|
||||
return this.invalidateDiscussionCache(userId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cache key for contacts.
|
||||
*
|
||||
|
@ -47,6 +88,16 @@ export class AddonMessagesProvider {
|
|||
return this.ROOT_CACHE_KEY + 'contacts';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cache key for a discussion.
|
||||
*
|
||||
* @param {number} userId The other person with whom the current user is having the discussion.
|
||||
* @return {string} Cache key.
|
||||
*/
|
||||
protected getCacheKeyForDiscussion(userId: number): string {
|
||||
return this.ROOT_CACHE_KEY + 'discussion:' + userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cache key for the list of discussions.
|
||||
*
|
||||
|
@ -56,6 +107,88 @@ export class AddonMessagesProvider {
|
|||
return this.ROOT_CACHE_KEY + 'discussions';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current user's discussion with another user.
|
||||
*
|
||||
* @param {number} userId The ID of the other user.
|
||||
* @param {boolean} excludePending True to exclude messages pending to be sent.
|
||||
* @param {number} [lfReceivedUnread=0] Number of unread received messages already fetched, so fetch will be done from this.
|
||||
* @param {number} [lfReceivedRead=0] Number of read received messages already fetched, so fetch will be done from this.
|
||||
* @param {number} [lfSentUnread=0] Number of unread sent messages already fetched, so fetch will be done from this.
|
||||
* @param {number} [lfSentRead=0] Number of read sent messages already fetched, so fetch will be done from this.
|
||||
* @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<any>} Promise resolved with messages and a boolean telling if can load more messages.
|
||||
*/
|
||||
getDiscussion(userId: number, excludePending: boolean, lfReceivedUnread: number = 0, lfReceivedRead: number = 0,
|
||||
lfSentUnread: number = 0, lfSentRead: number = 0, toDisplay: boolean = true, siteId?: string): Promise<any> {
|
||||
|
||||
return this.sitesProvider.getSite(siteId).then((site) => {
|
||||
const result = {},
|
||||
preSets = {
|
||||
cacheKey: this.getCacheKeyForDiscussion(userId)
|
||||
},
|
||||
params = {
|
||||
useridto: site.getUserId(),
|
||||
useridfrom: userId,
|
||||
limitnum: this.LIMIT_MESSAGES
|
||||
};
|
||||
|
||||
let hasReceived,
|
||||
hasSent;
|
||||
|
||||
if (lfReceivedUnread > 0 || lfReceivedRead > 0 || lfSentUnread > 0 || lfSentRead > 0) {
|
||||
// Do not use cache when retrieving older messages. This is to prevent storing too much data
|
||||
// and to prevent inconsistencies between "pages" loaded.
|
||||
preSets['getFromCache'] = 0;
|
||||
preSets['saveToCache'] = 0;
|
||||
preSets['emergencyCache'] = 0;
|
||||
}
|
||||
|
||||
// Get message received by current user.
|
||||
return this.getRecentMessages(params, preSets, lfReceivedUnread, lfReceivedRead, toDisplay, site.getId())
|
||||
.then((response) => {
|
||||
result['messages'] = response;
|
||||
params.useridto = userId;
|
||||
params.useridfrom = site.getUserId();
|
||||
hasReceived = response.length > 0;
|
||||
|
||||
// Get message sent by current user.
|
||||
return this.getRecentMessages(params, preSets, lfSentUnread, lfSentRead, toDisplay, siteId);
|
||||
}).then((response) => {
|
||||
result['messages'] = result['messages'].concat(response);
|
||||
hasSent = response.length > 0;
|
||||
|
||||
if (result['messages'].length > this.LIMIT_MESSAGES) {
|
||||
// Sort messages and get the more recent ones.
|
||||
result['canLoadMore'] = true;
|
||||
result['messages'] = this.sortMessages(result['messages']);
|
||||
result['messages'] = result['messages'].slice(-this.LIMIT_MESSAGES);
|
||||
} else {
|
||||
result['canLoadMore'] = result['messages'].length == this.LIMIT_MESSAGES && (!hasReceived || !hasSent);
|
||||
}
|
||||
|
||||
if (excludePending) {
|
||||
// No need to get offline messages, return the ones we have.
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get offline messages.
|
||||
return this.messagesOffline.getMessages(userId).then((offlineMessages) => {
|
||||
// Mark offline messages as pending.
|
||||
offlineMessages.forEach((message) => {
|
||||
message.pending = true;
|
||||
message.text = message.smallmessage;
|
||||
});
|
||||
|
||||
result['messages'] = result['messages'].concat(offlineMessages);
|
||||
|
||||
return result;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the discussions of the current user.
|
||||
*
|
||||
|
@ -101,7 +234,7 @@ export class AddonMessagesProvider {
|
|||
user: userId,
|
||||
message: message.text,
|
||||
timecreated: message.timecreated,
|
||||
pending: message.pending
|
||||
pending: !!message.pending
|
||||
};
|
||||
}
|
||||
};
|
||||
|
@ -197,8 +330,7 @@ export class AddonMessagesProvider {
|
|||
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());
|
||||
this.storeLastReceivedMessageIfNeeded(params.useridfrom, response.messages[0], site.getId());
|
||||
}
|
||||
|
||||
return response;
|
||||
|
@ -265,6 +397,19 @@ export class AddonMessagesProvider {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate discussion cache.
|
||||
*
|
||||
* @param {number} userId The user ID with whom the current user is having the discussion.
|
||||
* @param {string} [siteId] Site ID. If not defined, current site.
|
||||
* @return {Promise<any>} Resolved when done.
|
||||
*/
|
||||
invalidateDiscussionCache(userId: number, siteId?: string): Promise<any> {
|
||||
return this.sitesProvider.getSite(siteId).then((site) => {
|
||||
return site.invalidateWsCacheForKey(this.getCacheKeyForDiscussion(userId));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate discussions cache.
|
||||
*
|
||||
|
@ -281,6 +426,16 @@ export class AddonMessagesProvider {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not we can mark all messages as read.
|
||||
*
|
||||
* @return {boolean} If related WS is avalaible on current site.
|
||||
* @since 3.2
|
||||
*/
|
||||
isMarkAllMessagesReadEnabled(): boolean {
|
||||
return this.sitesProvider.getCurrentSite().wsAvailable('core_message_mark_all_messages_as_read');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the plugin is enabled in a certain site.
|
||||
*
|
||||
|
@ -303,14 +458,47 @@ export class AddonMessagesProvider {
|
|||
return this.sitesProvider.getCurrentSite().wsAvailable('core_message_data_for_messagearea_search_messages');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark message as read.
|
||||
*
|
||||
* @param {number} messageId ID of message to mark as read
|
||||
* @returns {Promise<any>} Promise resolved with boolean marking success or not.
|
||||
*/
|
||||
markMessageRead(messageId: number): Promise<any> {
|
||||
const params = {
|
||||
messageid: messageId,
|
||||
timeread: this.timeUtils.timestamp()
|
||||
};
|
||||
|
||||
return this.sitesProvider.getCurrentSite().write('core_message_mark_message_read', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all messages of a discussion as read.
|
||||
*
|
||||
* @param {number} userIdFrom User Id for the sender.
|
||||
* @returns {Promise<any>} Promise resolved with boolean marking success or not.
|
||||
*/
|
||||
markAllMessagesRead(userIdFrom?: number): Promise<any> {
|
||||
const params = {
|
||||
useridto: this.sitesProvider.getCurrentSiteUserId(),
|
||||
useridfrom: userIdFrom
|
||||
},
|
||||
preSets = {
|
||||
typeExpected: 'boolean'
|
||||
};
|
||||
|
||||
return this.sitesProvider.getCurrentSite().write('core_message_mark_all_messages_as_read', params, preSets);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {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.
|
||||
* @param {number} [limit] Number of results to get. Defaults to LIMIT_MESSAGES.
|
||||
* @param {string} [siteId] Site ID. If not defined, current site.
|
||||
* @return {Promise<any>} Promise resolved with the results.
|
||||
*/
|
||||
searchMessages(query: string, userId?: number, from: number = 0, limit: number = this.LIMIT_MESSAGES, siteId?: string):
|
||||
|
@ -332,6 +520,175 @@ export class AddonMessagesProvider {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to someone.
|
||||
*
|
||||
* @param {number} userIdTo User ID to send the message to.
|
||||
* @param {string} message The message to send
|
||||
* @param {string} [siteId] Site ID. If not defined, current site.
|
||||
* @return {Promise<any>} Promise resolved with:
|
||||
* - sent (Boolean) True if message was sent to server, false if stored in device.
|
||||
* - message (Object) If sent=false, contains the stored message.
|
||||
*/
|
||||
sendMessage(toUserId: number, message: string, siteId?: string): Promise<any> {
|
||||
// Convenience function to store a message to be synchronized later.
|
||||
const storeOffline = (): Promise<any> => {
|
||||
return this.messagesOffline.saveMessage(toUserId, message, siteId).then((entry) => {
|
||||
return {
|
||||
sent: false,
|
||||
message: entry
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
siteId = siteId || this.sitesProvider.getCurrentSiteId();
|
||||
|
||||
if (!this.appProvider.isOnline()) {
|
||||
// App is offline, store the message.
|
||||
return storeOffline();
|
||||
}
|
||||
|
||||
// Check if this conversation already has offline messages.
|
||||
// If so, store this message since they need to be sent in order.
|
||||
return this.messagesOffline.hasMessages(toUserId, siteId).catch(() => {
|
||||
// Error, it's safer to assume it has messages.
|
||||
return true;
|
||||
}).then((hasStoredMessages) => {
|
||||
if (hasStoredMessages) {
|
||||
return storeOffline();
|
||||
}
|
||||
|
||||
// Online and no messages stored. Send it to server.
|
||||
return this.sendMessageOnline(toUserId, message).then(() => {
|
||||
return { sent: true };
|
||||
}).catch((data) => {
|
||||
if (data.wserror) {
|
||||
// It's a WebService error, the user cannot send the message so don't store it.
|
||||
return Promise.reject(data.error);
|
||||
} else {
|
||||
// Error sending message, store it to retry later.
|
||||
return storeOffline();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to someone. It will fail if offline or cannot connect.
|
||||
*
|
||||
* @param {number} toUserId User ID to send the message to.
|
||||
* @param {string} message The message to send
|
||||
* @param {string} [siteId] Site ID. If not defined, current site.
|
||||
* @return {Promise<any>} Promise resolved if success, rejected if failure. Reject param is an object with:
|
||||
* - error: The error message.
|
||||
* - wserror: True if it's an error returned by the WebService, false otherwise.
|
||||
*/
|
||||
sendMessageOnline(toUserId: number, message: string, siteId?: string): Promise<any> {
|
||||
siteId = siteId || this.sitesProvider.getCurrentSiteId();
|
||||
|
||||
const messages = [
|
||||
{
|
||||
touserid: toUserId,
|
||||
text: message,
|
||||
textformat: 1
|
||||
}
|
||||
];
|
||||
|
||||
return this.sendMessagesOnline(messages, siteId).catch((error) => {
|
||||
return Promise.reject({
|
||||
error: error,
|
||||
wserror: this.utils.isWebServiceError(error)
|
||||
});
|
||||
}).then((response) => {
|
||||
if (response && response[0] && response[0].msgid === -1) {
|
||||
// There was an error, and it should be translated already.
|
||||
return Promise.reject({
|
||||
error: response[0].errormessage,
|
||||
wserror: true
|
||||
});
|
||||
}
|
||||
|
||||
return this.invalidateDiscussionCache(toUserId, siteId).catch(() => {
|
||||
// Ignore errors.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send some messages. It will fail if offline or cannot connect.
|
||||
* IMPORTANT: Sending several messages at once for the same discussions can cause problems with display order,
|
||||
* since messages with same timecreated aren't ordered by ID.
|
||||
*
|
||||
* @param {any} messages Messages to send. Each message must contain touserid, text and textformat.
|
||||
* @param {string} [siteId] Site ID. If not defined, current site.
|
||||
* @return {Promise<any>} Promise resolved if success, rejected if failure. Promise resolved doesn't mean that messages
|
||||
* have been sent, the resolve param can contain errors for messages not sent.
|
||||
*/
|
||||
sendMessagesOnline(messages: any, siteId?: string): Promise<any> {
|
||||
return this.sitesProvider.getSite(siteId).then((site) => {
|
||||
const data = {
|
||||
messages: messages
|
||||
};
|
||||
|
||||
return site.write('core_message_send_instant_messages', data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to sort messages by time.
|
||||
*
|
||||
* @param {any} messages Array of messages containing the key 'timecreated'.
|
||||
* @return {any} Messages sorted with most recent last.
|
||||
*/
|
||||
sortMessages(messages: any): any {
|
||||
return messages.sort((a, b) => {
|
||||
// Pending messages last.
|
||||
if (a.pending && !b.pending) {
|
||||
return 1;
|
||||
} else if (!a.pending && b.pending) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const timecreatedA = parseInt(a.timecreated, 10),
|
||||
timecreatedB = parseInt(b.timecreated, 10);
|
||||
if (timecreatedA == timecreatedB && a.id) {
|
||||
// Same time, sort by ID.
|
||||
return a.id >= b.id ? 1 : -1;
|
||||
}
|
||||
|
||||
return timecreatedA >= timecreatedB ? 1 : -1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the last received message if it's newer than the last stored.
|
||||
* @todo
|
||||
*
|
||||
* @param {number} userIdFrom ID of the useridfrom retrieved, 0 for all users.
|
||||
* @param {any} message Last message received.
|
||||
* @param {string} [siteId] Site ID. If not defined, current site.
|
||||
* @return {Promise<any>} Promise resolved when done.
|
||||
*/
|
||||
protected storeLastReceivedMessageIfNeeded(userIdFrom: number, message: any, siteId?: string): Promise<any> {
|
||||
/*let component = mmaMessagesPushSimulationComponent;
|
||||
|
||||
// Get the last received message.
|
||||
return $mmEmulatorHelper.getLastReceivedNotification(component, siteId).then((lastMessage) => {
|
||||
if (userIdFrom > 0 && (!message || !lastMessage)) {
|
||||
// Seeing a single discussion. No received message or cannot know if it really is the last received message. Stop.
|
||||
return;
|
||||
}
|
||||
|
||||
if (message && lastMessage && message.timecreated <= lastMessage.timecreated) {
|
||||
// The message isn't newer than the stored message, don't store it.
|
||||
return;
|
||||
}
|
||||
|
||||
return $mmEmulatorHelper.storeLastReceivedNotification(component, message, siteId);
|
||||
});*/
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store user data from discussions in local DB.
|
||||
*
|
||||
|
|
|
@ -0,0 +1,188 @@
|
|||
// (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 { CoreSyncBaseProvider } from '../../../classes/base-sync';
|
||||
import { CoreAppProvider } from '../../../providers/app';
|
||||
import { AddonMessagesOfflineProvider } from './messages-offline';
|
||||
import { AddonMessagesProvider } from './messages';
|
||||
import { CoreUserProvider } from '../../../core/user/providers/user';
|
||||
import { CoreEventsProvider } from '../../../providers/events';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
/**
|
||||
* Service to sync messages.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AddonMessagesSyncProvider extends CoreSyncBaseProvider {
|
||||
|
||||
static AUTO_SYNCED = 'addon_messages_autom_synced';
|
||||
|
||||
constructor(protected sitesProvider: CoreSitesProvider, protected loggerProvider: CoreLoggerProvider,
|
||||
protected appProvider: CoreAppProvider, private messagesOffline: AddonMessagesOfflineProvider,
|
||||
private eventsProvider: CoreEventsProvider, private messagesProvider: AddonMessagesProvider,
|
||||
private userProvider: CoreUserProvider, private translate: TranslateService) {
|
||||
super('AddonMessagesSync', sitesProvider, loggerProvider, appProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to synchronize all the discussions in a certain site or in all sites.
|
||||
*
|
||||
* @param {string} [siteId] Site ID to sync. If not defined, sync all sites.
|
||||
* @param {boolean} [onlyDeviceOffline=false] True to only sync discussions that failed because device was offline,
|
||||
* false to sync all.
|
||||
* @return {Promise<any>} Promise resolved if sync is successful, rejected if sync fails.
|
||||
*/
|
||||
syncAllDiscussions(siteId?: string, onlyDeviceOffline: boolean = false): Promise<any> {
|
||||
const syncFunctionLog = 'all discussions' + (onlyDeviceOffline ? ' (Only offline)' : '');
|
||||
|
||||
return this.syncOnSites(syncFunctionLog, 'syncAllDiscussionsFunc', {onlyDeviceOffline: onlyDeviceOffline}, siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages pending to be sent in the site.
|
||||
* @param {boolean} [onlyDeviceOffline=false] True to only sync discussions that failed because device was offline,
|
||||
* @param {string} [siteId] Site ID to sync. If not defined, sync all sites.
|
||||
* @param {Promise<any>} Promise resolved if sync is successful, rejected if sync fails.
|
||||
*/
|
||||
protected syncAllDiscussionsFunc(onlyDeviceOffline: boolean = false, siteId?: string): Promise<any> {
|
||||
const fn = onlyDeviceOffline ? this.messagesOffline.getAllDeviceOfflineMessages : this.messagesOffline.getAllMessages;
|
||||
|
||||
return fn(siteId).then((messages) => {
|
||||
const userIds = [],
|
||||
promises = [];
|
||||
|
||||
// Get all the discussions to be synced.
|
||||
messages.forEach((message) => {
|
||||
if (userIds.indexOf(message.touserid) == -1) {
|
||||
userIds.push(message.touserid);
|
||||
}
|
||||
});
|
||||
|
||||
// Sync all discussions.
|
||||
userIds.forEach((userId) => {
|
||||
promises.push(this.syncDiscussion(userId, siteId).then((warnings) => {
|
||||
if (typeof warnings != 'undefined') {
|
||||
// Sync successful, send event.
|
||||
this.eventsProvider.trigger(AddonMessagesSyncProvider.AUTO_SYNCED, {
|
||||
userid: userId,
|
||||
warnings: warnings
|
||||
}, siteId);
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize a discussion.
|
||||
*
|
||||
* @param {number} userId User ID of the discussion.
|
||||
* @param {string} [siteId] Site ID. If not defined, current site.
|
||||
* @return {Promise<any>} Promise resolved if sync is successful, rejected otherwise.
|
||||
*/
|
||||
syncDiscussion(userId: number, siteId?: string): Promise<any> {
|
||||
siteId = siteId || this.sitesProvider.getCurrentSiteId();
|
||||
|
||||
if (this.isSyncing(userId, siteId)) {
|
||||
// There's already a sync ongoing for this SCORM, return the promise.
|
||||
return this.getOngoingSync(userId, siteId);
|
||||
}
|
||||
|
||||
let syncPromise;
|
||||
const warnings = [];
|
||||
|
||||
this.logger.debug(`Try to sync discussion with user '${userId}'`);
|
||||
|
||||
// Get offline messages to be sent.
|
||||
syncPromise = this.messagesOffline.getMessages(userId, siteId).then((messages) => {
|
||||
if (!messages.length) {
|
||||
// Nothing to sync.
|
||||
return [];
|
||||
} else if (!this.appProvider.isOnline()) {
|
||||
// Cannot sync in offline. Mark messages as device offline.
|
||||
this.messagesOffline.setMessagesDeviceOffline(messages, true);
|
||||
|
||||
return Promise.reject(null);
|
||||
}
|
||||
|
||||
let promise: Promise<any>;
|
||||
promise = Promise.resolve();
|
||||
const errors = [];
|
||||
|
||||
// Order message by timecreated.
|
||||
messages = this.messagesProvider.sortMessages(messages);
|
||||
|
||||
// Send the messages. We don't use $mmaMessages#sendMessagesOnline because there's a problem with display order.
|
||||
// @todo Use $mmaMessages#sendMessagesOnline once the display order is fixed.
|
||||
messages.forEach((message, index) => {
|
||||
// Chain message sending. If 1 message fails to be sent we'll stop sending.
|
||||
promise = promise.then(() => {
|
||||
return this.messagesProvider.sendMessageOnline(userId, message.smallmessage, siteId).catch((data) => {
|
||||
if (data.wserror) {
|
||||
// Error returned by WS. Store the error to show a warning but keep sending messages.
|
||||
if (errors.indexOf(data.error) == -1) {
|
||||
errors.push(data.error);
|
||||
}
|
||||
} else {
|
||||
// Error sending, stop execution.
|
||||
if (this.appProvider.isOnline()) {
|
||||
// App is online, unmark deviceoffline if marked.
|
||||
this.messagesOffline.setMessagesDeviceOffline(messages, false);
|
||||
}
|
||||
|
||||
return Promise.reject(data.error);
|
||||
}
|
||||
}).then(() => {
|
||||
// Message was sent, delete it from local DB.
|
||||
return this.messagesOffline.deleteMessage(userId, message.smallmessage, message.timecreated, siteId);
|
||||
}).then(() => {
|
||||
// All done. Wait 1 second to ensure timecreated of messages is different.
|
||||
if (index < messages.length - 1) {
|
||||
return setTimeout(() => {return; }, 1000);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return promise.then(() => {
|
||||
return errors;
|
||||
});
|
||||
}).then((errors) => {
|
||||
if (errors && errors.length) {
|
||||
// At least an error occurred, get user full name and add errors to warnings array.
|
||||
return this.userProvider.getProfile(userId, undefined, true).catch(() => {
|
||||
// Ignore errors.
|
||||
return {};
|
||||
}).then((user) => {
|
||||
errors.forEach((error) => {
|
||||
warnings.push(this.translate.instant('addon.messages.warningmessagenotsent', {
|
||||
user: user.fullname ? user.fullname : userId,
|
||||
error: error
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
}).then(() => {
|
||||
// All done, return the warnings.
|
||||
return warnings;
|
||||
});
|
||||
|
||||
return this.addOngoingSync(userId, syncPromise, siteId);
|
||||
}
|
||||
}
|
|
@ -13,6 +13,7 @@
|
|||
// limitations under the License.
|
||||
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { IonicApp, IonicModule, Platform } from 'ionic-angular';
|
||||
import { HttpModule } from '@angular/http';
|
||||
|
@ -82,6 +83,7 @@ export function createTranslateLoader(http: HttpClient): TranslateHttpLoader {
|
|||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
BrowserAnimationsModule,
|
||||
HttpClientModule, // HttpClient is used to make JSON requests. It fails for HEAD requests because there is no content.
|
||||
HttpModule,
|
||||
IonicModule.forRoot(MoodleMobileApp, {
|
||||
|
|
|
@ -15,7 +15,6 @@
|
|||
// for the .md, .ios, or .wp mode classes. The mode class is
|
||||
// automatically applied to the <body> element in the app.
|
||||
|
||||
|
||||
// Alignment
|
||||
// -------------------------
|
||||
|
||||
|
@ -236,6 +235,11 @@ core-format-text[ng-reflect-max-height], *[core-format-text][ng-reflect-max-heig
|
|||
}
|
||||
}
|
||||
|
||||
core-format-text[singleLine="true"], *[core-format-text][singleLine="true"],
|
||||
core-format-text[ng-reflect-single-line="true"], *[core-format-text][ng-reflect-single-line="true"] {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.core-media-adapt-width {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
@ -432,6 +436,36 @@ ion-toast.core-toast-alert .toast-wrapper{
|
|||
background: $red-dark;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
resize: none;
|
||||
|
||||
&[core-auto-rows] {
|
||||
height: auto;
|
||||
line-height: 18px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
&:not([core-auto-rows]) {
|
||||
height: 200px;
|
||||
min-height: $core-rte-min-height;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar .core-bar-button-image {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: $core-toolbar-button-image-width - 1;
|
||||
max-height: $core-toolbar-button-image-width - 1;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
// Footer with auto height.
|
||||
.footer.footer-adjustable {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
// Message cards
|
||||
@each $color-name, $color-base, $color-contrast in get-colors($colors) {
|
||||
.core-#{$color-name}-card {
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
// (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 { trigger, style, transition, animate, keyframes } from '@angular/animations';
|
||||
|
||||
export const coreShowHideAnimation = trigger('coreShowHideAnimation', [
|
||||
transition(':enter', [
|
||||
style({opacity: 0}),
|
||||
animate('500ms ease-in-out', style({opacity: 1}))
|
||||
]),
|
||||
transition(':leave', [
|
||||
style({opacity: 1}),
|
||||
animate('500ms ease-in-out', style({opacity: 0}))
|
||||
])
|
||||
]);
|
||||
|
||||
export const coreSlideInOut = trigger('coreSlideInOut', [
|
||||
// Enter animation.
|
||||
transition('void => fromLeft', [
|
||||
style({transform: 'translateX(0)', opacity: 1}),
|
||||
animate(300, keyframes([
|
||||
style({opacity: 0, transform: 'translateX(-100%)', offset: 0}),
|
||||
style({opacity: 1, transform: 'translateX(5%)', offset: 0.7}),
|
||||
style({opacity: 1, transform: 'translateX(0)', offset: 1.0})
|
||||
]))
|
||||
]),
|
||||
// Leave animation.
|
||||
transition('fromLeft => void', [
|
||||
style({transform: 'translateX(-100%)', opacity: 0}),
|
||||
animate(300, keyframes([
|
||||
style({opacity: 1, transform: 'translateX(0)', offset: 0}),
|
||||
style({opacity: 1, transform: 'translateX(5%)', offset: 0.3}),
|
||||
style({opacity: 0, transform: 'translateX(-100%)', offset: 1.0})
|
||||
]))
|
||||
]),
|
||||
// Enter animation.
|
||||
transition('void => fromRight', [
|
||||
style({transform: 'translateX(0)', opacity: 1}),
|
||||
animate(300, keyframes([
|
||||
style({opacity: 0, transform: 'translateX(100%)', offset: 0}),
|
||||
style({opacity: 1, transform: 'translateX(-5%)', offset: 0.7}),
|
||||
style({opacity: 1, transform: 'translateX(0)', offset: 1.0})
|
||||
]))
|
||||
]),
|
||||
// Leave animation.
|
||||
transition('fromRight => void', [
|
||||
style({transform: 'translateX(-100%)', opacity: 0}),
|
||||
animate(300, keyframes([
|
||||
style({opacity: 1, transform: 'translateX(0)', offset: 0}),
|
||||
style({opacity: 1, transform: 'translateX(-5%)', offset: 0.3}),
|
||||
style({opacity: 0, transform: 'translateX(100%)', offset: 1.0})
|
||||
]))
|
||||
])
|
||||
]);
|
|
@ -14,11 +14,20 @@
|
|||
|
||||
import { CoreSitesProvider } from '../providers/sites';
|
||||
import { CoreSyncProvider } from '../providers/sync';
|
||||
import { CoreLoggerProvider } from '../providers/logger';
|
||||
import { CoreAppProvider } from '../providers/app';
|
||||
|
||||
/**
|
||||
* Base class to create sync providers. It provides some common functions.
|
||||
*/
|
||||
export class CoreSyncBaseProvider {
|
||||
|
||||
/**
|
||||
* Logger instance get from CoreLoggerProvider.
|
||||
* @type {any}
|
||||
*/
|
||||
protected logger;
|
||||
|
||||
/**
|
||||
* Component of the sync provider.
|
||||
* @type {string}
|
||||
|
@ -34,7 +43,11 @@ export class CoreSyncBaseProvider {
|
|||
// Store sync promises.
|
||||
protected syncPromises: { [siteId: string]: { [uniqueId: string]: Promise<any> } } = {};
|
||||
|
||||
constructor(private sitesProvider: CoreSitesProvider) { }
|
||||
constructor(component: string, protected sitesProvider: CoreSitesProvider, protected loggerProvider: CoreLoggerProvider,
|
||||
protected appProvider: CoreAppProvider) {
|
||||
this.logger = this.loggerProvider.getInstance(component);
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an ongoing sync to the syncPromises list. On finish the promise will be removed.
|
||||
|
@ -187,6 +200,53 @@ export class CoreSyncBaseProvider {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a sync function on selected sites.
|
||||
*
|
||||
* @param {string} syncFunctionLog Log message to explain the sync function purpose.
|
||||
* @param {string} syncFunction Sync function to execute.
|
||||
* @param {any} [params] Object that defines the params that admit the funcion.
|
||||
* @param {string} [siteId] Site ID to sync. If not defined, sync all sites.
|
||||
* @return {Promise<any>} Resolved with siteIds selected. Rejected if offline.
|
||||
*/
|
||||
syncOnSites(syncFunctionLog: string, syncFunction: string, params?: any, siteId?: string): Promise<any> {
|
||||
if (!this.appProvider.isOnline()) {
|
||||
this.logger.debug(`Cannot sync '${syncFunctionLog}' because device is offline.`);
|
||||
|
||||
return Promise.reject(null);
|
||||
}
|
||||
|
||||
if (this[syncFunction]) {
|
||||
this.logger.debug(`Cannot sync '${syncFunctionLog}' function '${syncFunction}' does not exist.`);
|
||||
|
||||
return Promise.reject(null);
|
||||
}
|
||||
params = params || {};
|
||||
|
||||
let promise;
|
||||
if (!siteId) {
|
||||
// No site ID defined, sync all sites.
|
||||
this.logger.debug(`Try to sync '${syncFunctionLog}' in all sites.`);
|
||||
promise = this.sitesProvider.getSitesIds();
|
||||
} else {
|
||||
this.logger.debug(`Try to sync '${syncFunctionLog}' in site '${siteId}'.`);
|
||||
promise = Promise.resolve([siteId]);
|
||||
}
|
||||
|
||||
return promise.then((siteIds) => {
|
||||
const sitePromises = [];
|
||||
siteIds.forEach((siteId) => {
|
||||
params['siteId'] = siteId;
|
||||
// Execute function for every site selected.
|
||||
if (this[syncFunction]) {
|
||||
sitePromises.push(this[syncFunction].apply(this, params));
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all(sitePromises);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* If there's an ongoing sync for a certain identifier, wait for it to end.
|
||||
* If there's no sync ongoing the promise will be resolved right away.
|
||||
|
|
|
@ -39,6 +39,7 @@ import { CoreTabComponent } from './tabs/tab';
|
|||
import { CoreRichTextEditorComponent } from './rich-text-editor/rich-text-editor';
|
||||
import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons';
|
||||
import { CoreDynamicComponent } from './dynamic-component/dynamic-component';
|
||||
import { CoreSendMessageFormComponent } from './send-message-form/send-message-form';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
|
@ -63,7 +64,8 @@ import { CoreDynamicComponent } from './dynamic-component/dynamic-component';
|
|||
CoreTabComponent,
|
||||
CoreRichTextEditorComponent,
|
||||
CoreNavBarButtonsComponent,
|
||||
CoreDynamicComponent
|
||||
CoreDynamicComponent,
|
||||
CoreSendMessageFormComponent
|
||||
],
|
||||
entryComponents: [
|
||||
CoreContextMenuPopoverComponent,
|
||||
|
@ -95,7 +97,8 @@ import { CoreDynamicComponent } from './dynamic-component/dynamic-component';
|
|||
CoreTabComponent,
|
||||
CoreRichTextEditorComponent,
|
||||
CoreNavBarButtonsComponent,
|
||||
CoreDynamicComponent
|
||||
CoreDynamicComponent,
|
||||
CoreSendMessageFormComponent
|
||||
]
|
||||
})
|
||||
export class CoreComponentsModule {}
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
<!-- @TODO: Add show hide animation -->
|
||||
<div class="core-loading-container" *ngIf="!hideUntil">
|
||||
<div [@coreShowHideAnimation] class="core-loading-container" *ngIf="!hideUntil">
|
||||
<span class="core-loading-spinner">
|
||||
<ion-spinner></ion-spinner>
|
||||
<p class="core-loading-message" *ngIf="message">{{message}}</p>
|
||||
</span>
|
||||
</div>
|
||||
<ng-content class="core-loading-content" *ngIf="hideUntil">
|
||||
<ng-content [@coreShowHideAnimation] class="core-loading-content" *ngIf="hideUntil">
|
||||
</ng-content>
|
|
@ -13,6 +13,7 @@ core-loading {
|
|||
&.core-loading-noheight .core-loading-content {
|
||||
height: auto;
|
||||
}
|
||||
@include core-transition(core-show-animation);
|
||||
}
|
||||
|
||||
.scroll-content > core-loading > .core-loading-container,
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { coreShowHideAnimation } from '../../classes/animations';
|
||||
|
||||
/**
|
||||
* Component to show a loading spinner and message while data is being loaded.
|
||||
|
@ -37,7 +38,8 @@ import { TranslateService } from '@ngx-translate/core';
|
|||
*/
|
||||
@Component({
|
||||
selector: 'core-loading',
|
||||
templateUrl: 'loading.html'
|
||||
templateUrl: 'loading.html',
|
||||
animations: [coreShowHideAnimation]
|
||||
})
|
||||
export class CoreLoadingComponent implements OnInit {
|
||||
@Input() hideUntil: boolean; // Determine when should the contents be shown.
|
||||
|
|
|
@ -151,7 +151,7 @@ export class CoreNavBarButtonsComponent implements OnInit {
|
|||
if (parentPage) {
|
||||
// Check if the page has a header. If it doesn't, search the next parent page.
|
||||
const header = this.searchHeaderInPage(parentPage);
|
||||
if (header) {
|
||||
if (header && getComputedStyle(header, null).display != 'none') {
|
||||
return Promise.resolve(header);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
<form (ngSubmit)="submitForm($event)">
|
||||
<textarea class="core-send-message-input" [core-auto-focus]="showKeyboard" [placeholder]="placeholder" rows="1" core-auto-rows [(ngModel)]="message" name="message" (onResize)="textareaResized()"></textarea>
|
||||
<ion-buttons end>
|
||||
<button ion-button icon-only clear="true" type="submit" [disabled]="!message" [attr.aria-label]="'core.send' | translate">
|
||||
<ion-icon name="send" color="dark"></ion-icon>
|
||||
</button>
|
||||
</ion-buttons>
|
||||
</form>
|
|
@ -0,0 +1,36 @@
|
|||
$core-send-message-input-background: $gray;
|
||||
$core-send-message-input-color: $black;
|
||||
|
||||
core-send-message-form {
|
||||
background: $white;
|
||||
|
||||
form {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
flex-shrink: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.core-send-message-input {
|
||||
@include appearance(none);
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
font-family: inherit;
|
||||
align-self: self-start;
|
||||
background: $core-send-message-input-background;
|
||||
color: $core-send-message-input-color;
|
||||
border-radius: 5px;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.core-send-message-button {
|
||||
@include margin(0);
|
||||
@include padding(0);
|
||||
display: none;
|
||||
min-height: 0;
|
||||
align-self: self-end;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
// (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, Input, Output, EventEmitter, OnInit } from '@angular/core';
|
||||
import { CoreUtilsProvider } from '../../providers/utils/utils';
|
||||
import { CoreTextUtilsProvider } from '../../providers/utils/text';
|
||||
|
||||
/**
|
||||
* Component to display a "send message form".
|
||||
*
|
||||
* @description
|
||||
* This component will display a standalone send message form in order to have a better UX.
|
||||
*
|
||||
* Example usage:
|
||||
* <core-send-message-form (onSubmit)="sendMessage($event)" [placeholder]="'core.messages.newmessage' | translate"
|
||||
* [show-keyboard]="showKeyboard"></core-send-message-form>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'core-send-message-form',
|
||||
templateUrl: 'send-message-form.html'
|
||||
})
|
||||
export class CoreSendMessageFormComponent implements OnInit {
|
||||
@Input() placeholder = ''; // Placeholder for the input area.
|
||||
@Input() showKeyboard = false; // If keyboard is shown or not.
|
||||
@Output() onSubmit: EventEmitter<string>; // Send data when submitting the message form.
|
||||
@Output() onResize: EventEmitter<void>; // Emit when resizing the textarea.
|
||||
|
||||
message: string;
|
||||
|
||||
constructor(private utils: CoreUtilsProvider, private textUtils: CoreTextUtilsProvider) {
|
||||
this.onSubmit = new EventEmitter();
|
||||
this.onResize = new EventEmitter();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.showKeyboard = this.utils.isTrueOrOne(this.showKeyboard);
|
||||
}
|
||||
|
||||
/**
|
||||
* Form submitted.
|
||||
* @param {any} $event Form submit
|
||||
*/
|
||||
submitForm($event: any): void {
|
||||
let value = this.message.trim();
|
||||
$event.target.reset();
|
||||
|
||||
// Focus again on textarea.
|
||||
$event.target[0].focus();
|
||||
|
||||
if (!value) {
|
||||
// Silent error.
|
||||
return;
|
||||
}
|
||||
|
||||
value = this.textUtils.replaceNewLines(value, '<br>');
|
||||
this.onSubmit.emit(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Textarea resized.
|
||||
*/
|
||||
textareaResized(): void {
|
||||
this.onResize.emit();
|
||||
}
|
||||
}
|
|
@ -19,12 +19,8 @@
|
|||
</ion-refresher>
|
||||
|
||||
<core-loading [hideUntil]="coursesLoaded">
|
||||
<div no-padding padding-bottom *ngIf="showFilter">
|
||||
<ion-item class="item-transparent">
|
||||
<ion-label item-start><ion-icon name="funnel" class="placeholder-icon"></ion-icon></ion-label>
|
||||
<ion-input type="text" name="filter" clearInput [(ngModel)]="filter" (ngModelChange)="filterChanged($event)" [placeholder]="'core.courses.filtermycourses' | translate"></ion-input>
|
||||
</ion-item>
|
||||
</div>
|
||||
<ion-searchbar *ngIf="showFilter" [(ngModel)]="filter" (ionInput)="filterChanged($event)" (ionCancel)="filterChanged()" [placeholder]="'core.courses.filtermycourses' | translate">
|
||||
</ion-searchbar>
|
||||
<ion-grid no-padding>
|
||||
<ion-row no-padding>
|
||||
<ion-col *ngFor="let course of filteredCourses" no-padding col-12 col-sm-6 col-md-6 col-lg-4 col-xl-4 align-self-stretch>
|
||||
|
|
|
@ -134,14 +134,15 @@ export class CoreCoursesMyCoursesPage implements OnDestroy {
|
|||
/**
|
||||
* The filter has changed.
|
||||
*
|
||||
* @param {string} newValue New filter value.
|
||||
* @param {any} Received Event.
|
||||
*/
|
||||
filterChanged(newValue: string): void {
|
||||
filterChanged(event: any): void {
|
||||
const newValue = event.target.value && event.target.value.trim().toLowerCase();
|
||||
if (!newValue || !this.courses) {
|
||||
this.filteredCourses = this.courses;
|
||||
} else {
|
||||
this.filteredCourses = this.courses.filter((course) => {
|
||||
return course.fullname.indexOf(newValue) > -1;
|
||||
return course.fullname.toLowerCase().indexOf(newValue) > -1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -80,12 +80,8 @@
|
|||
</div>
|
||||
</div>
|
||||
<!-- Filter courses. -->
|
||||
<div no-padding padding-bottom [hidden]="!showFilter">
|
||||
<ion-item>
|
||||
<ion-label item-start><ion-icon name="funnel" class="placeholder-icon"></ion-icon></ion-label>
|
||||
<ion-input type="text" name="filter" clearInput [(ngModel)]="courses.filter" (ngModelChange)="filterChanged($event)" [placeholder]="'core.courses.filtermycourses' | translate"></ion-input>
|
||||
</ion-item>
|
||||
</div>
|
||||
<ion-searchbar *ngIf="showFilter" [(ngModel)]="courses.filter" (ionInput)="filterChanged($event)" (ionCancel)="filterChanged()" [placeholder]="'core.courses.filtermycourses' | translate">
|
||||
</ion-searchbar>
|
||||
<!-- List of courses. -->
|
||||
<div>
|
||||
<ion-grid no-padding>
|
||||
|
|
|
@ -212,14 +212,15 @@ export class CoreCoursesMyOverviewPage implements OnDestroy {
|
|||
/**
|
||||
* The filter has changed.
|
||||
*
|
||||
* @param {string} newValue New filter value.
|
||||
* @param {any} Received Event.
|
||||
*/
|
||||
filterChanged(newValue: string): void {
|
||||
filterChanged(event: any): void {
|
||||
const newValue = event.target.value && event.target.value.trim().toLowerCase();
|
||||
if (!newValue || !this.courses[this.courses.selected]) {
|
||||
this.filteredCourses = this.courses[this.courses.selected];
|
||||
} else {
|
||||
this.filteredCourses = this.courses[this.courses.selected].filter((course) => {
|
||||
return course.fullname.toLowerCase().indexOf(newValue.toLowerCase()) > -1;
|
||||
return course.fullname.toLowerCase().indexOf(newValue) > -1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
<ion-item text-center *ngIf="(!handlers || !handlers.length) && !handlersLoaded">
|
||||
<ion-spinner></ion-spinner>
|
||||
</ion-item>
|
||||
<ion-item *ngFor="let handler of handlers" [ngClass]="['core-moremenu-handler', handler.class]" (click)="openHandler(handler)" title="{{ handler.title | translate }}">
|
||||
<ion-item *ngFor="let handler of handlers" [ngClass]="['core-moremenu-handler', handler.class]" (click)="openHandler(handler)" title="{{ handler.title | translate }}" detail-push>
|
||||
<ion-icon [name]="handler.icon" item-start></ion-icon>
|
||||
<p>{{ handler.title | translate}}</p>
|
||||
<!-- @todo: Badge. -->
|
||||
|
|
|
@ -97,7 +97,7 @@ export class CoreMainMenuMorePage implements OnDestroy {
|
|||
* @param {CoreMainMenuHandlerData} handler Handler to open.
|
||||
*/
|
||||
openHandler(handler: CoreMainMenuHandlerData): void {
|
||||
// @todo.
|
||||
this.navCtrl.push(handler.page);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
// (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 { Directive, ElementRef, HostListener, Output, EventEmitter } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Directive to adapt a textarea rows depending on the input text. It's based on Moodle's data-auto-rows.
|
||||
*
|
||||
* @description
|
||||
* Usage:
|
||||
* <textarea class="core-textarea" [(ngModel)]="message" rows="1" core-auto-rows></textarea>
|
||||
*/
|
||||
@Directive({
|
||||
selector: 'textarea[core-auto-rows]'
|
||||
})
|
||||
export class CoreAutoRowsDirective {
|
||||
protected element: HTMLTextAreaElement;
|
||||
protected height = 0;
|
||||
|
||||
@Output() onResize: EventEmitter<void>; // Emit when resizing the textarea.
|
||||
|
||||
constructor(element: ElementRef) {
|
||||
this.element = element.nativeElement || element;
|
||||
this.height = this.element.scrollHeight;
|
||||
this.onResize = new EventEmitter();
|
||||
}
|
||||
|
||||
@HostListener('input') onInput(): void {
|
||||
this.resize();
|
||||
}
|
||||
|
||||
@HostListener('change') onChange(): void {
|
||||
// Fired on reset. Wait to the change to be finished.
|
||||
setTimeout(() => {
|
||||
this.resize();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize after init.
|
||||
*/
|
||||
ngAfterViewInit(): void {
|
||||
this.resize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the textarea.
|
||||
* @param {any} $event Event fired.
|
||||
*/
|
||||
protected resize($event?: any): void {
|
||||
// Set height to 1px to force scroll height to calculate correctly.
|
||||
this.element.style.height = '1px';
|
||||
this.element.style.height = this.element.scrollHeight + 'px';
|
||||
|
||||
// Emit event when resizing.
|
||||
if (this.height != this.element.scrollHeight) {
|
||||
this.height = this.element.scrollHeight;
|
||||
this.onResize.emit();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -19,6 +19,8 @@ import { CoreFormatTextDirective } from './format-text';
|
|||
import { CoreLinkDirective } from './link';
|
||||
import { CoreKeepKeyboardDirective } from './keep-keyboard';
|
||||
import { CoreUserLinkDirective } from './user-link';
|
||||
import { CoreAutoRowsDirective } from './auto-rows';
|
||||
import { CoreLongPressDirective } from './long-press';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
|
@ -27,7 +29,9 @@ import { CoreUserLinkDirective } from './user-link';
|
|||
CoreFormatTextDirective,
|
||||
CoreKeepKeyboardDirective,
|
||||
CoreLinkDirective,
|
||||
CoreUserLinkDirective
|
||||
CoreUserLinkDirective,
|
||||
CoreAutoRowsDirective,
|
||||
CoreLongPressDirective
|
||||
],
|
||||
imports: [],
|
||||
exports: [
|
||||
|
@ -36,7 +40,9 @@ import { CoreUserLinkDirective } from './user-link';
|
|||
CoreFormatTextDirective,
|
||||
CoreKeepKeyboardDirective,
|
||||
CoreLinkDirective,
|
||||
CoreUserLinkDirective
|
||||
CoreUserLinkDirective,
|
||||
CoreAutoRowsDirective,
|
||||
CoreLongPressDirective
|
||||
]
|
||||
})
|
||||
export class CoreDirectivesModule {}
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
// (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.
|
||||
|
||||
// Based on http://roblouie.com/article/198/using-gestures-in-the-ionic-2-beta/
|
||||
|
||||
import { Directive, ElementRef, OnInit, OnDestroy, Output, EventEmitter } from '@angular/core';
|
||||
import { Gesture } from 'ionic-angular/gestures/gesture';
|
||||
|
||||
/**
|
||||
* Directive to add long press actions to html elements.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[longPress]'
|
||||
})
|
||||
export class CoreLongPressDirective implements OnInit, OnDestroy {
|
||||
el: HTMLElement;
|
||||
pressGesture: Gesture;
|
||||
|
||||
@Output() longPress = new EventEmitter();
|
||||
|
||||
constructor(el: ElementRef) {
|
||||
this.el = el.nativeElement;
|
||||
this.el.setAttribute('tappable', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize gesture listening.
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.pressGesture = new Gesture(this.el);
|
||||
this.pressGesture.listen();
|
||||
this.pressGesture.on('press', (e) => {
|
||||
this.longPress.emit(e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy gesture listening.
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.pressGesture.destroy();
|
||||
}
|
||||
}
|
|
@ -59,9 +59,10 @@ export class CoreDateDayOrTimePipe implements PipeTransform {
|
|||
}
|
||||
|
||||
return moment(timestamp * 1000).calendar(null, {
|
||||
sameDay: this.translate.instant('core.dftimedate'),
|
||||
sameDay: 'LT', //this.translate.instant('core.dftimedate'),
|
||||
lastDay: this.translate.instant('core.dflastweekdate'),
|
||||
lastWeek: this.translate.instant('core.dflastweekdate')
|
||||
lastWeek: this.translate.instant('core.dflastweekdate'),
|
||||
sameElse: 'L'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,10 +52,12 @@ export class CoreFormatDatePipe implements PipeTransform {
|
|||
timestamp = numberTimestamp;
|
||||
}
|
||||
|
||||
if (format.indexOf('.') == -1) {
|
||||
format = 'core.' + format;
|
||||
if (format.indexOf('df') == 0) {
|
||||
format = this.translate.instant('core.' + format);
|
||||
} else if (format.indexOf('.') > 0) {
|
||||
format = this.translate.instant(format);
|
||||
}
|
||||
|
||||
return moment(timestamp).format(this.translate.instant(format));
|
||||
return moment(timestamp).format(format);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,7 +50,6 @@ $core-color: $orange;
|
|||
$core-color-light: lighten($core-color, 10%);
|
||||
$core-color-dark: darken($core-color, 10%);
|
||||
|
||||
|
||||
// Shared Variables
|
||||
// --------------------------------------------------
|
||||
// To customize the look and feel of this app, you can override
|
||||
|
@ -204,3 +203,17 @@ $core-top-tabs-border: $gray;
|
|||
$core-top-tabs-color-active: $core-color;
|
||||
|
||||
$core-user-profile-communication-icons-color: $core-color;
|
||||
|
||||
$core-rte-min-height: 80px;
|
||||
|
||||
$core-toolbar-button-image-width: 32px;
|
||||
|
||||
// Mixins
|
||||
// -------------------------
|
||||
@mixin core-transition($where: all, $time: 500ms) {
|
||||
-webkit-transition: $where $time ease-in-out;
|
||||
-moz-transition: $where $time ease-in-out;
|
||||
-ms-transition: $where $time ease-in-out;
|
||||
-o-transition: $where $time ease-in-out;
|
||||
transition: $where $time ease-in-out;
|
||||
}
|
Loading…
Reference in New Issue