diff --git a/src/addon/messageoutput/airnotifier/airnotifier.module.ts b/src/addon/messageoutput/airnotifier/airnotifier.module.ts new file mode 100644 index 000000000..1a060c0a4 --- /dev/null +++ b/src/addon/messageoutput/airnotifier/airnotifier.module.ts @@ -0,0 +1,34 @@ +// (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 { AddonMessageOutputDelegate } from '@addon/messageoutput/providers/delegate'; +import { AddonMessageOutputAirnotifierProvider } from './providers/airnotifier'; +import { AddonMessageOutputAirnotifierHandler } from './providers/handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + ], + providers: [ + AddonMessageOutputAirnotifierProvider, + AddonMessageOutputAirnotifierHandler, + ] +}) +export class AddonMessageOutputAirnotifierModule { + constructor(messageOutputDelegate: AddonMessageOutputDelegate, airnotifierHandler: AddonMessageOutputAirnotifierHandler) { + messageOutputDelegate.registerHandler(airnotifierHandler); + } +} diff --git a/src/addon/messageoutput/airnotifier/lang/en.json b/src/addon/messageoutput/airnotifier/lang/en.json new file mode 100644 index 000000000..a6f460bbb --- /dev/null +++ b/src/addon/messageoutput/airnotifier/lang/en.json @@ -0,0 +1,3 @@ +{ + "processorsettingsdesc": "Configure devices" +} \ No newline at end of file diff --git a/src/addon/messageoutput/airnotifier/pages/devices/devices.html b/src/addon/messageoutput/airnotifier/pages/devices/devices.html new file mode 100644 index 000000000..16a4876cd --- /dev/null +++ b/src/addon/messageoutput/airnotifier/pages/devices/devices.html @@ -0,0 +1,22 @@ + + + {{ 'addon.messageoutput_airnotifier.processorsettingsdesc' | translate }} + + + + + + + + + + + {{ device.model }} + ({{ 'core.currentdevice' | translate }}) + + + + + + + diff --git a/src/addon/messageoutput/airnotifier/pages/devices/devices.module.ts b/src/addon/messageoutput/airnotifier/pages/devices/devices.module.ts new file mode 100644 index 000000000..8f16f9e4c --- /dev/null +++ b/src/addon/messageoutput/airnotifier/pages/devices/devices.module.ts @@ -0,0 +1,31 @@ +// (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 { CoreComponentsModule } from '@components/components.module'; +import { AddonMessageOutputAirnotifierDevicesPage } from './devices'; + +@NgModule({ + declarations: [ + AddonMessageOutputAirnotifierDevicesPage, + ], + imports: [ + CoreComponentsModule, + IonicPageModule.forChild(AddonMessageOutputAirnotifierDevicesPage), + TranslateModule.forChild() + ], +}) +export class AddonMessageOutputAirnotifierDevicesPageModule {} diff --git a/src/addon/messageoutput/airnotifier/pages/devices/devices.ts b/src/addon/messageoutput/airnotifier/pages/devices/devices.ts new file mode 100644 index 000000000..0bea819b4 --- /dev/null +++ b/src/addon/messageoutput/airnotifier/pages/devices/devices.ts @@ -0,0 +1,137 @@ +// (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 } from '@angular/core'; +import { IonicPage } from 'ionic-angular'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { AddonPushNotificationsProvider } from '@addon/pushnotifications/providers/pushnotifications'; +import { AddonMessageOutputAirnotifierProvider } from '../../providers/airnotifier'; + +/** + * Page that displays the list of devices. + */ +@IonicPage({ segment: 'addon-message-output-airnotifier-devices' }) +@Component({ + selector: 'page-addon-message-output-airnotifier-devices', + templateUrl: 'devices.html', +}) +export class AddonMessageOutputAirnotifierDevicesPage implements OnDestroy { + + devices = []; + devicesLoaded = false; + + protected updateTimeout: any; + + constructor(private domUtils: CoreDomUtilsProvider, private airnotifierProivder: AddonMessageOutputAirnotifierProvider, + private pushNotificationsProvider: AddonPushNotificationsProvider ) { + } + + /** + * View loaded. + */ + ionViewDidLoad(): void { + this.fetchDevices(); + } + + /** + * Fetches the list of devices. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchDevices(): Promise { + return this.airnotifierProivder.getUserDevices().then((devices) => { + const pushId = this.pushNotificationsProvider.getPushId(); + + // Convert enabled to boolean and search current device. + devices.forEach((device) => { + device.enable = !!device.enable; + device.current = pushId && pushId == device.pushid; + }); + + this.devices = devices; + }).catch((message) => { + this.domUtils.showErrorModal(message); + }).finally(() => { + this.devicesLoaded = true; + }); + } + + /** + * Update list of devices after a certain time. The purpose is to store the updated data, it won't be reflected in the view. + */ + protected updateDevicesAfterDelay(): void { + // Cancel pending updates. + if (this.updateTimeout) { + clearTimeout(this.updateTimeout); + } + + this.updateTimeout = setTimeout(() => { + this.updateTimeout = null; + this.updateDevices(); + }, 5000); + } + + /** + * Fetch devices. The purpose is to store the updated data, it won't be reflected in the view. + */ + protected updateDevices(): void { + this.airnotifierProivder.invalidateUserDevices().finally(() => { + this.airnotifierProivder.getUserDevices(); + }); + } + + /** + * Refresh the list of devices. + * + * @param {any} refresher Refresher. + */ + refreshDevices(refresher: any): void { + this.airnotifierProivder.invalidateUserDevices().finally(() => { + this.fetchDevices().finally(() => { + refresher.complete(); + }); + }); + } + + /** + * Enable or disable a certain device. + * + * @param {any} device The device object. + * @param {boolean} enable True to enable the device, false to disable it. + */ + enableDevice(device: any, enable: boolean): void { + device.updating = true; + this.airnotifierProivder.enableDevice(device.id, enable).then(() => { + // Update the list of devices since it was modified. + this.updateDevicesAfterDelay(); + }).catch((message) => { + // Show error and revert change. + this.domUtils.showErrorModal(message); + device.enable = !device.enable; + }).finally(() => { + device.updating = false; + }); + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + // If there is a pending action to update devices, execute it right now. + if (this.updateTimeout) { + clearTimeout(this.updateTimeout); + this.updateDevices(); + } + } +} diff --git a/src/addon/messageoutput/airnotifier/providers/airnotifier.ts b/src/addon/messageoutput/airnotifier/providers/airnotifier.ts new file mode 100644 index 000000000..f8ca5be71 --- /dev/null +++ b/src/addon/messageoutput/airnotifier/providers/airnotifier.ts @@ -0,0 +1,115 @@ +// (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 { CoreConfigConstants } from '../../../../configconstants'; + +/** + * Service to handle Airnotifier message output. + */ +@Injectable() +export class AddonMessageOutputAirnotifierProvider { + + protected ROOT_CACHE_KEY = 'mmaMessageOutputAirnotifier:'; + protected logger: any; + + constructor(loggerProvider: CoreLoggerProvider, private sitesProvider: CoreSitesProvider) { + this.logger = loggerProvider.getInstance('AddonMessageOutputAirnotifier'); + } + + /** + * Enables or disables a device. + * + * @param {number} deviceId Device ID. + * @param {boolean} enable True to enable, false to disable. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if success. + */ + enableDevice(deviceId: number, enable: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const data = { + deviceid: deviceId, + enable: enable ? 1 : 0 + }; + + return site.write('message_airnotifier_enable_device', data).then((result) => { + if (!result.success) { + // Fail. Reject with warning message if any. + if (result.warnings && result.warnings.length) { + return Promise.reject(result.warnings[0].message); + } + + return Promise.reject(null); + } + }); + }); + } + + /** + * Get the cache key for the get user devices call. + * + * @return {string} Cache key. + */ + protected getUserDevicesCacheKey(): string { + return this.ROOT_CACHE_KEY + 'userDevices'; + } + + /** + * Get user devices. + * + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise} Promise resolved with the devices. + */ + getUserDevices(siteId?: string): Promise { + this.logger.debug('Get user devices'); + + return this.sitesProvider.getSite(siteId).then((site) => { + const data = { + appid: CoreConfigConstants.app_id + }; + const preSets = { + cacheKey: this.getUserDevicesCacheKey() + }; + + return site.read('message_airnotifier_get_user_devices', data, preSets).then((data) => { + return data.devices; + }); + }); + } + + /** + * Invalidate get user devices. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when data is invalidated. + */ + invalidateUserDevices(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getUserDevicesCacheKey()); + }); + } + + /** + * Returns whether or not the plugin is enabled for the current site. + * + * @return {boolean} True if enabled, false otherwise. + * @since 3.2 + */ + isEnabled(): boolean { + return this.sitesProvider.wsAvailableInCurrentSite('message_airnotifier_enable_device') && + this.sitesProvider.wsAvailableInCurrentSite('message_airnotifier_get_user_devices'); + } +} diff --git a/src/addon/messageoutput/airnotifier/providers/handler.ts b/src/addon/messageoutput/airnotifier/providers/handler.ts new file mode 100644 index 000000000..8d5cc15ae --- /dev/null +++ b/src/addon/messageoutput/airnotifier/providers/handler.ts @@ -0,0 +1,52 @@ +// (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 { AddonMessageOutputHandler, AddonMessageOutputHandlerData } from '@addon/messageoutput/providers/delegate'; +import { AddonMessageOutputAirnotifierProvider } from './airnotifier'; + +/** + * Airnotifier message output handler. + */ +@Injectable() +export class AddonMessageOutputAirnotifierHandler implements AddonMessageOutputHandler { + name = 'AddonMessageOutputAirnotifier'; + processorName = 'airnotifier'; + + constructor(private airnotifierProvider: AddonMessageOutputAirnotifierProvider) {} + + /** + * Whether or not the module is enabled for the site. + * + * @return {boolean} True if enabled, false otherwise. + */ + isEnabled(): boolean { + return this.airnotifierProvider.isEnabled(); + } + + /** + * Returns the data needed to render the handler. + * + * @param {any} processor The processor object. + * @return {CoreMainMenuHandlerData} Data. + */ + getDisplayData(processor: any): AddonMessageOutputHandlerData { + return { + priority: 600, + label: 'addon.messageoutput_airnotifier.processorsettingsdesc', + icon: 'settings', + page: 'AddonMessageOutputAirnotifierDevicesPage', + }; + } +} diff --git a/src/addon/messageoutput/messageoutput.module.ts b/src/addon/messageoutput/messageoutput.module.ts new file mode 100644 index 000000000..87552dc1d --- /dev/null +++ b/src/addon/messageoutput/messageoutput.module.ts @@ -0,0 +1,27 @@ +// (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 { AddonMessageOutputDelegate } from './providers/delegate'; + +@NgModule({ + declarations: [ + ], + imports: [ + ], + providers: [ + AddonMessageOutputDelegate + ] +}) +export class AddonMessageOutputModule {} diff --git a/src/addon/messageoutput/providers/delegate.ts b/src/addon/messageoutput/providers/delegate.ts new file mode 100644 index 000000000..cd1f96698 --- /dev/null +++ b/src/addon/messageoutput/providers/delegate.ts @@ -0,0 +1,97 @@ +// (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 { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; + +/** + * Interface that all message output handlers must implement. + */ +export interface AddonMessageOutputHandler extends CoreDelegateHandler { + /** + * The name of the processor. E.g. 'airnotifier'. + * @type {string} + */ + processorName: string; + + /** + * Returns the data needed to render the handler. + * + * @param {any} processor The processor object. + * @return {CoreMainMenuHandlerData} Data. + */ + getDisplayData(processor: any): AddonMessageOutputHandlerData; +} + +/** + * Data needed to render a message output handler. It's returned by the handler. + */ +export interface AddonMessageOutputHandlerData { + /** + * Handler's priority. + * @type {number} + */ + priority: number; + + /** + * Name of the page to load for the handler. + * @type {string} + */ + page: string; + + /** + * Label to display for the handler. + * @type {string} + */ + label: string; + + /** + * Name of the icon to display for the handler. + * @type {string} + */ + icon: string; + + /** + * Params to pass to the page. + * @type {any} + */ + pageParams?: any; +} + +/** + * Delegate to register processors (message/output) to be used in places like notification preferences. + */ + @Injectable() + export class AddonMessageOutputDelegate extends CoreDelegate { + + protected handlerNameProperty = 'processorName'; + + constructor(protected loggerProvider: CoreLoggerProvider, protected sitesProvider: CoreSitesProvider, + protected eventsProvider: CoreEventsProvider) { + super('CoreSettingsDelegate', loggerProvider, sitesProvider, eventsProvider); + } + + /** + * Get the display data of the handler. + * + * @param {string} processor The processor object. + * @return {AddonMessageOutputHandlerData} Data. + */ + getDisplayData(processor: any): AddonMessageOutputHandlerData { + return this.executeFunctionOnEnabled(processor.name, 'getDisplayData', processor); + } +} diff --git a/src/addon/messages/providers/mainmenu-handler.ts b/src/addon/messages/providers/mainmenu-handler.ts index a30ac88a4..f934224be 100644 --- a/src/addon/messages/providers/mainmenu-handler.ts +++ b/src/addon/messages/providers/mainmenu-handler.ts @@ -24,6 +24,7 @@ import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; import { AddonPushNotificationsProvider } from '@addon/pushnotifications/providers/pushnotifications'; import { AddonPushNotificationsDelegate } from '@addon/pushnotifications/providers/delegate'; +import { CoreEmulatorHelperProvider } from '@core/emulator/providers/helper'; /** * Handler to inject an option into main menu. @@ -46,7 +47,7 @@ export class AddonMessagesMainMenuHandler implements CoreMainMenuHandler, CoreCr private eventsProvider: CoreEventsProvider, private appProvider: CoreAppProvider, private localNotificationsProvider: CoreLocalNotificationsProvider, private textUtils: CoreTextUtilsProvider, private pushNotificationsProvider: AddonPushNotificationsProvider, utils: CoreUtilsProvider, - pushNotificationsDelegate: AddonPushNotificationsDelegate) { + pushNotificationsDelegate: AddonPushNotificationsDelegate, private emulatorHelper: CoreEmulatorHelperProvider) { eventsProvider.on(AddonMessagesProvider.READ_CHANGED_EVENT, (data) => { this.updateBadge(data.siteId); @@ -132,9 +133,9 @@ export class AddonMessagesMainMenuHandler implements CoreMainMenuHandler, CoreCr } if (this.appProvider.isDesktop() && this.localNotificationsProvider.isAvailable()) { - // @todo - /*$mmEmulatorHelper.checkNewNotifications( - AddonMessagesProvider.PUSH_SIMULATION_COMPONENT, this.fetchMessages, this.getTitleAndText, siteId);*/ + this.emulatorHelper.checkNewNotifications( + AddonMessagesProvider.PUSH_SIMULATION_COMPONENT, + this.fetchMessages.bind(this), this.getTitleAndText.bind(this), siteId); } return Promise.resolve(); diff --git a/src/addon/messages/providers/messages.ts b/src/addon/messages/providers/messages.ts index da898e64b..19ece296b 100644 --- a/src/addon/messages/providers/messages.ts +++ b/src/addon/messages/providers/messages.ts @@ -20,6 +20,7 @@ import { CoreUserProvider } from '@core/user/providers/user'; import { AddonMessagesOfflineProvider } from './messages-offline'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreEmulatorHelperProvider } from '@core/emulator/providers/helper'; /** * Service to handle messages. @@ -40,7 +41,8 @@ export class AddonMessagesProvider { constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private appProvider: CoreAppProvider, private userProvider: CoreUserProvider, private messagesOffline: AddonMessagesOfflineProvider, - private utils: CoreUtilsProvider, private timeUtils: CoreTimeUtilsProvider) { + private utils: CoreUtilsProvider, private timeUtils: CoreTimeUtilsProvider, + private emulatorHelper: CoreEmulatorHelperProvider) { this.logger = logger.getInstance('AddonMessagesProvider'); } @@ -1088,7 +1090,6 @@ export class AddonMessagesProvider { /** * 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. @@ -1096,10 +1097,10 @@ export class AddonMessagesProvider { * @return {Promise} Promise resolved when done. */ protected storeLastReceivedMessageIfNeeded(userIdFrom: number, message: any, siteId?: string): Promise { - /*let component = mmaMessagesPushSimulationComponent; + const component = AddonMessagesProvider.PUSH_SIMULATION_COMPONENT; // Get the last received message. - return $mmEmulatorHelper.getLastReceivedNotification(component, siteId).then((lastMessage) => { + return this.emulatorHelper.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; @@ -1110,9 +1111,8 @@ export class AddonMessagesProvider { return; } - return $mmEmulatorHelper.storeLastReceivedNotification(component, message, siteId); - });*/ - return Promise.resolve(); + return this.emulatorHelper.storeLastReceivedNotification(component, message, siteId); + }); } /** diff --git a/src/addon/notifications/components/actions/actions.html b/src/addon/notifications/components/actions/actions.html new file mode 100644 index 000000000..7d69e94bd --- /dev/null +++ b/src/addon/notifications/components/actions/actions.html @@ -0,0 +1,8 @@ + + + + + diff --git a/src/addon/notifications/components/actions/actions.ts b/src/addon/notifications/components/actions/actions.ts new file mode 100644 index 000000000..b72a529c9 --- /dev/null +++ b/src/addon/notifications/components/actions/actions.ts @@ -0,0 +1,41 @@ +// (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, OnInit } from '@angular/core'; +import { CoreContentLinksDelegate, CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; + +/** + * Component that displays the actions for a notification. + */ +@Component({ + selector: 'addon-notifications-actions', + templateUrl: 'actions.html', +}) +export class AddonNotificationsActionsComponent implements OnInit { + @Input() contextUrl: string; + @Input() courseId: number; + + actions: CoreContentLinksAction[] = []; + + constructor(private contentLinksDelegate: CoreContentLinksDelegate) {} + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.contentLinksDelegate.getActionsFor(this.contextUrl, this.courseId).then((actions) => { + this.actions = actions; + }); + } +} diff --git a/src/addon/notifications/components/components.module.ts b/src/addon/notifications/components/components.module.ts new file mode 100644 index 000000000..a1eaa049a --- /dev/null +++ b/src/addon/notifications/components/components.module.ts @@ -0,0 +1,36 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { AddonNotificationsActionsComponent } from './actions/actions'; + +@NgModule({ + declarations: [ + AddonNotificationsActionsComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + ], + providers: [ + ], + exports: [ + AddonNotificationsActionsComponent + ], +}) +export class AddonNotificationsComponentsModule {} diff --git a/src/addon/notifications/lang/en.json b/src/addon/notifications/lang/en.json new file mode 100644 index 000000000..b1582bf1a --- /dev/null +++ b/src/addon/notifications/lang/en.json @@ -0,0 +1,7 @@ +{ + "errorgetnotifications": "Error getting notifications.", + "notificationpreferences": "Notification preferences", + "notifications": "Notifications", + "playsound": "Play sound", + "therearentnotificationsyet": "There are no notifications." +} \ No newline at end of file diff --git a/src/addon/notifications/notifications.module.ts b/src/addon/notifications/notifications.module.ts new file mode 100644 index 000000000..9591fef71 --- /dev/null +++ b/src/addon/notifications/notifications.module.ts @@ -0,0 +1,80 @@ +// (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 { AddonNotificationsProvider } from './providers/notifications'; +import { AddonNotificationsMainMenuHandler } from './providers/mainmenu-handler'; +import { AddonNotificationsSettingsHandler } from './providers/settings-handler'; +import { AddonNotificationsCronHandler } from './providers/cron-handler'; +import { CoreAppProvider } from '@providers/app'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { CoreMainMenuDelegate } from '@core/mainmenu/providers/delegate'; +import { CoreSettingsDelegate } from '@core/settings/providers/delegate'; +import { CoreCronDelegate } from '@providers/cron'; +import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { AddonPushNotificationsDelegate } from '@addon/pushnotifications/providers/delegate'; + +@NgModule({ + declarations: [ + ], + imports: [ + ], + providers: [ + AddonNotificationsProvider, + AddonNotificationsMainMenuHandler, + AddonNotificationsSettingsHandler, + AddonNotificationsCronHandler, + ] +}) +export class AddonNotificationsModule { + constructor(mainMenuDelegate: CoreMainMenuDelegate, mainMenuHandler: AddonNotificationsMainMenuHandler, + settingsDelegate: CoreSettingsDelegate, settingsHandler: AddonNotificationsSettingsHandler, + cronDelegate: CoreCronDelegate, cronHandler: AddonNotificationsCronHandler, + appProvider: CoreAppProvider, utils: CoreUtilsProvider, sitesProvider: CoreSitesProvider, + notificationsProvider: AddonNotificationsProvider, localNotifications: CoreLocalNotificationsProvider, + linkHelper: CoreContentLinksHelperProvider, pushNotificationsDelegate: AddonPushNotificationsDelegate) { + mainMenuDelegate.registerHandler(mainMenuHandler); + settingsDelegate.registerHandler(settingsHandler); + cronDelegate.register(cronHandler); + + const notificationClicked = (notification: any): void => { + sitesProvider.isFeatureDisabled('CoreMainMenuDelegate_AddonNotifications', notification.site).then((disabled) => { + if (disabled) { + // Notifications are disabled, stop. + return; + } + + notificationsProvider.invalidateNotificationsList().finally(() => { + linkHelper.goInSite(undefined, 'AddonNotificationsListPage', undefined, notification.site); + }); + }); + }; + + if (appProvider.isDesktop()) { + // Listen for clicks in simulated push notifications. + localNotifications.registerClick(AddonNotificationsProvider.PUSH_SIMULATION_COMPONENT, notificationClicked); + } + + // Register push notification clicks. + pushNotificationsDelegate.on('click').subscribe((notification) => { + if (utils.isTrueOrOne(notification.notif)) { + notificationClicked(notification); + + return true; + } + }); + } +} diff --git a/src/addon/notifications/pages/list/list.html b/src/addon/notifications/pages/list/list.html new file mode 100644 index 000000000..9d53f2f02 --- /dev/null +++ b/src/addon/notifications/pages/list/list.html @@ -0,0 +1,30 @@ + + + {{ 'addon.notifications.notifications' | translate }} + + + + + + + + + + + + +

{{notification.userfromfullname}}

+
+

{{notification.timecreated | coreDateDayOrTime}}

+
+ +

+
+ +
+ + + + +
+
diff --git a/src/addon/notifications/pages/list/list.module.ts b/src/addon/notifications/pages/list/list.module.ts new file mode 100644 index 000000000..56369935d --- /dev/null +++ b/src/addon/notifications/pages/list/list.module.ts @@ -0,0 +1,37 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CorePipesModule } from '@pipes/pipes.module'; +import { AddonNotificationsComponentsModule } from '../../components/components.module'; +import { AddonNotificationsListPage } from './list'; + +@NgModule({ + declarations: [ + AddonNotificationsListPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + IonicPageModule.forChild(AddonNotificationsListPage), + TranslateModule.forChild(), + AddonNotificationsComponentsModule, + ], +}) +export class AddonNotificationsListPageModule {} diff --git a/src/addon/notifications/pages/list/list.ts b/src/addon/notifications/pages/list/list.ts new file mode 100644 index 000000000..8bcf66f5d --- /dev/null +++ b/src/addon/notifications/pages/list/list.ts @@ -0,0 +1,194 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { Subscription } from 'rxjs'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreEventsProvider, CoreEventObserver } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { AddonNotificationsProvider } from '../../providers/notifications'; +import { AddonPushNotificationsDelegate } from '@addon/pushnotifications/providers/delegate'; + +/** + * Page that displays the list of notifications. + */ +@IonicPage({ segment: 'addon-notifications-list' }) +@Component({ + selector: 'page-addon-notifications-list', + templateUrl: 'list.html', +}) +export class AddonNotificationsListPage { + + notifications = []; + notificationsLoaded = false; + canLoadMore = false; + + protected readCount = 0; + protected unreadCount = 0; + protected cronObserver: CoreEventObserver; + protected pushObserver: Subscription; + + constructor(navParams: NavParams, private domUtils: CoreDomUtilsProvider, private eventsProvider: CoreEventsProvider, + private sitesProvider: CoreSitesProvider, private textUtils: CoreTextUtilsProvider, + private utils: CoreUtilsProvider, private notificationsProvider: AddonNotificationsProvider, + private pushNotificationsDelegate: AddonPushNotificationsDelegate) { + } + + /** + * View loaded. + */ + ionViewDidLoad(): void { + this.fetchNotifications().finally(() => { + this.notificationsLoaded = true; + }); + + this.cronObserver = this.eventsProvider.on(AddonNotificationsProvider.READ_CRON_EVENT, () => this.refreshNotifications(), + this.sitesProvider.getCurrentSiteId()); + + this.pushObserver = this.pushNotificationsDelegate.on('receive').subscribe((notification) => { + // New notification received. If it's from current site, refresh the data. + if (this.utils.isTrueOrOne(notification.notif) && this.sitesProvider.isCurrentSite(notification.site)) { + this.refreshNotifications(); + } + }); + } + + /** + * Convenience function to get notifications. Gets unread notifications first. + * + * @param {boolean} refreh Whether we're refreshing data. + * @return {Promise} Resolved when done. + */ + protected fetchNotifications(refresh?: boolean): Promise { + if (refresh) { + this.readCount = 0; + this.unreadCount = 0; + } + + const limit = AddonNotificationsProvider.LIST_LIMIT; + + return this.notificationsProvider.getUnreadNotifications(this.unreadCount, limit).then((unread) => { + let promise; + + unread.forEach(this.formatText.bind(this)); + + /* Don't add the unread notifications to this.notifications yet. If there are no unread notifications + that causes that the "There are no notifications" message is shown in pull to refresh. */ + this.unreadCount += unread.length; + + if (unread.length < limit) { + // Limit not reached. Get read notifications until reach the limit. + const readLimit = limit - unread.length; + promise = this.notificationsProvider.getReadNotifications(this.readCount, readLimit).then((read) => { + read.forEach(this.formatText.bind(this)); + this.readCount += read.length; + if (refresh) { + this.notifications = unread.concat(read); + } else { + this.notifications = this.notifications.concat(unread, read); + } + this.canLoadMore = read.length >= readLimit; + }).catch((error) => { + if (unread.length == 0) { + this.domUtils.showErrorModalDefault(error, 'addon.notifications.errorgetnotifications', true); + this.canLoadMore = false; // Set to false to prevent infinite calls with infinite-loading. + } + }); + } else { + promise = Promise.resolve(); + if (refresh) { + this.notifications = unread; + } else { + this.notifications = this.notifications.concat(unread); + } + this.canLoadMore = true; + } + + return promise.then(() => { + // Mark retrieved notifications as read if they are not. + this.markNotificationsAsRead(unread); + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.notifications.errorgetnotifications', true); + this.canLoadMore = false; // Set to false to prevent infinite calls with infinite-loading. + }); + } + + /** + * Mark notifications as read. + * + * @param {any[]} notifications Array of notification objects. + */ + protected markNotificationsAsRead(notifications: any[]): void { + if (notifications.length > 0) { + const promises = notifications.map((notification) => { + return this.notificationsProvider.markNotificationRead(notification.id); + }); + + Promise.all(promises).finally(() => { + this.notificationsProvider.invalidateNotificationsList().finally(() => { + const siteId = this.sitesProvider.getCurrentSiteId(); + this.eventsProvider.trigger(AddonNotificationsProvider.READ_CHANGED_EVENT, null, siteId); + }); + }); + } + } + + /** + * Refresh notifications. + * + * @param {any} [refresher] Refresher. + */ + refreshNotifications(refresher?: any): void { + this.notificationsProvider.invalidateNotificationsList().finally(() => { + return this.fetchNotifications(true).finally(() => { + if (refresher) { + refresher.complete(); + } + }); + }); + } + + /** + * Load more results. + * + * @param {any} infiniteScroll The infinit scroll instance. + */ + loadMoreNotifications(infiniteScroll: any): void { + this.fetchNotifications().finally(() => { + infiniteScroll.complete(); + }); + } + + /** + * Formats the text of a notification. + * + * @param {any} notification The notification object. + */ + protected formatText(notification: any): void { + const text = notification.mobiletext.replace(/-{4,}/ig, ''); + notification.mobiletext = this.textUtils.replaceNewLines(text, '
'); + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.cronObserver && this.cronObserver.off(); + this.pushObserver && this.pushObserver.unsubscribe(); + } +} diff --git a/src/addon/notifications/pages/settings/settings.html b/src/addon/notifications/pages/settings/settings.html new file mode 100644 index 000000000..9bf7a66f0 --- /dev/null +++ b/src/addon/notifications/pages/settings/settings.html @@ -0,0 +1,76 @@ + + + {{ 'addon.notifications.notificationpreferences' | translate }} + + + + + + + + + + + + + + + + + {{ 'addon.notifications.playsound' | translate }} + + + + + + + {{ 'core.settings.disableall' | translate }} + + + + {{ 'addon.notifications.playsound' | translate }} + + + + + + + {{ processor.displayname }} + + + + + + {{ component.displayname }} + {{ 'core.settings.loggedin' | translate }} + {{ 'core.settings.loggedoff' | translate }} + + + + + + {{ notification.displayname }} + + + + + + + {{ 'core.settings.disabled' | translate }} + + + + {{ notification.displayname }} + + + {{ 'core.settings.' + state | translate }} + + + + {{ 'core.settings.disabled' | translate }} + + + + + + diff --git a/src/addon/notifications/pages/settings/settings.module.ts b/src/addon/notifications/pages/settings/settings.module.ts new file mode 100644 index 000000000..2b6e9e500 --- /dev/null +++ b/src/addon/notifications/pages/settings/settings.module.ts @@ -0,0 +1,33 @@ +// (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 { AddonNotificationsSettingsPage } from './settings'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +@NgModule({ + declarations: [ + AddonNotificationsSettingsPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(AddonNotificationsSettingsPage), + TranslateModule.forChild() + ], +}) +export class AddonNotificationsSettingsPageModule {} diff --git a/src/addon/notifications/pages/settings/settings.scss b/src/addon/notifications/pages/settings/settings.scss new file mode 100644 index 000000000..354425bb6 --- /dev/null +++ b/src/addon/notifications/pages/settings/settings.scss @@ -0,0 +1,10 @@ +page-addon-notifications-settings { + .list-header { + margin-bottom: 0; + border-top: 0; + } + + .toggle { + display: inline-block; + } +} diff --git a/src/addon/notifications/pages/settings/settings.ts b/src/addon/notifications/pages/settings/settings.ts new file mode 100644 index 000000000..cf1d19ee3 --- /dev/null +++ b/src/addon/notifications/pages/settings/settings.ts @@ -0,0 +1,265 @@ +// (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, Optional } from '@angular/core'; +import { IonicPage, NavController } from 'ionic-angular'; +import { AddonNotificationsProvider } from '../../providers/notifications'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreSettingsHelper } from '@core/settings/providers/helper'; +import { AddonMessageOutputDelegate, AddonMessageOutputHandlerData } from '@addon/messageoutput/providers/delegate'; +import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; +import { CoreConfigProvider } from '@providers/config'; +import { CoreAppProvider } from '@providers/app'; +import { CoreConstants } from '@core/constants'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; + +/** + * Page that displays notifications settings. + */ +@IonicPage({ segment: 'addon-notifications-settings' }) +@Component({ + selector: 'page-addon-notifications-settings', + templateUrl: 'settings.html', +}) +export class AddonNotificationsSettingsPage implements OnDestroy { + protected updateTimeout: any; + + components: any[]; + preferences: any; + preferencesLoaded: boolean; + currentProcessor: any; + notifPrefsEnabled: boolean; + canChangeSound: boolean; + notificationSound: boolean; + processorHandlers = []; + + constructor(private notificationsProvider: AddonNotificationsProvider, private domUtils: CoreDomUtilsProvider, + private settingsHelper: CoreSettingsHelper, private userProvider: CoreUserProvider, + private navCtrl: NavController, private messageOutputDelegate: AddonMessageOutputDelegate, + appProvider: CoreAppProvider, private configProvider: CoreConfigProvider, private eventsProvider: CoreEventsProvider, + private localNotificationsProvider: CoreLocalNotificationsProvider, private sitesProvider: CoreSitesProvider, + @Optional() private svComponent: CoreSplitViewComponent) { + + this.notifPrefsEnabled = notificationsProvider.isNotificationPreferencesEnabled(); + this.canChangeSound = localNotificationsProvider.isAvailable() && !appProvider.isDesktop(); + if (this.canChangeSound) { + configProvider.get(CoreConstants.SETTINGS_NOTIFICATION_SOUND, true).then((enabled) => { + this.notificationSound = enabled; + }); + } + } + + /** + * View loaded. + */ + ionViewDidLoad(): void { + if (this.notifPrefsEnabled) { + this.fetchPreferences(); + } else { + this.preferencesLoaded = true; + } + } + + /** + * Fetches preference data. + * + * @return {Promise} Resolved when done. + */ + protected fetchPreferences(): Promise { + return this.notificationsProvider.getNotificationPreferences().then((preferences) => { + if (!this.currentProcessor) { + // Initialize current processor. Load "Mobile" (airnotifier) if available. + this.currentProcessor = this.settingsHelper.getProcessor(preferences.processors, 'airnotifier'); + } + + if (!this.currentProcessor) { + // Shouldn't happen. + return Promise.reject('No processor found'); + } + + preferences.disableall = !!preferences.disableall; // Convert to boolean. + this.preferences = preferences; + this.loadProcessor(this.currentProcessor); + + // Get display data of message output handlers (thery are displayed in the context menu), + this.processorHandlers = []; + if (preferences.processors) { + preferences.processors.forEach((processor) => { + processor.supported = this.messageOutputDelegate.hasHandler(processor.name, true); + if (processor.hassettings && processor.supported) { + this.processorHandlers.push(this.messageOutputDelegate.getDisplayData(processor)); + } + }); + } + }).catch((message) => { + this.domUtils.showErrorModal(message); + }).finally(() => { + this.preferencesLoaded = true; + }); + } + + /** + * Load a processor. + * + * @param {any} processor Processor object. + */ + protected loadProcessor(processor: any): void { + if (!processor) { + return; + } + this.currentProcessor = processor; + this.components = this.settingsHelper.getProcessorComponents(processor.name, this.preferences.components); + } + + /** + * Update preferences after a certain time. The purpose is to store the updated data, it won't be reflected in the view. + */ + protected updatePreferencesAfterDelay(): void { + // Cancel pending updates. + clearTimeout(this.updateTimeout); + + this.updateTimeout = setTimeout(() => { + this.updateTimeout = null; + this.updatePreferences(); + }, 5000); + } + + /** + * Update preferences. The purpose is to store the updated data, it won't be reflected in the view. + */ + protected updatePreferences(): void { + this.notificationsProvider.invalidateNotificationPreferences().finally(() => { + this.notificationsProvider.getNotificationPreferences(); + }); + } + + /** + * The selected processor was changed. + * + * @param {string} name Name of the selected processor. + */ + changeProcessor(name: string): void { + this.preferences.processors.forEach((processor) => { + if (processor.name == name) { + this.loadProcessor(processor); + } + }); + } + + /** + * Refresh the list of preferences. + * + * @param {any} [refresher] Refresher. + */ + refreshPreferences(refresher?: any): void { + this.notificationsProvider.invalidateNotificationPreferences().finally(() => { + this.fetchPreferences().finally(() => { + refresher && refresher.complete(); + }); + }); + } + + /** + * Open extra preferences. + * + * @param {AddonMessageOutputHandlerData} handlerData + */ + openExtraPreferences(handlerData: AddonMessageOutputHandlerData): void { + // Decide which navCtrl to use. If this page is inside a split view, use the split view's master nav. + const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; + navCtrl.push(handlerData.page, handlerData.pageParams); + } + + /** + * Change the value of a certain preference. + * + * @param {any} notification Notification object. + * @param {string} state State name, ['loggedin', 'loggedoff']. + */ + changePreference(notification: any, state: string): void { + const processorState = notification.currentProcessor[state]; + const preferenceName = notification.preferencekey + '_' + processorState.name; + let value; + + notification.processors.forEach((processor) => { + if (processor[state].checked) { + if (!value) { + value = processor.name; + } else { + value += ',' + processor.name; + } + } + }); + + if (!value) { + value = 'none'; + } + + processorState.updating = true; + this.userProvider.updateUserPreference(preferenceName, value).then(() => { + // Update the preferences since they were modified. + this.updatePreferencesAfterDelay(); + }).catch((message) => { + // Show error and revert change. + this.domUtils.showErrorModal(message); + notification.currentProcessor[state].checked = !notification.currentProcessor[state].checked; + }).finally(() => { + processorState.updating = false; + }); + } + + /** + * Disable all notifications changed. + */ + disableAll(disable: boolean): void { + const modal = this.domUtils.showModalLoading('core.sending', true); + this.userProvider.updateUserPreferences([], disable).then(() => { + // Update the preferences since they were modified. + this.updatePreferencesAfterDelay(); + }).catch((message) => { + // Show error and revert change. + this.domUtils.showErrorModal(message); + this.preferences.disableall = !this.preferences.disableall; + }).finally(() => { + modal.dismiss(); + }); + } + + /** + * Change the notification sound setting. + * + * @param {enabled} enabled True to enable the notification sound, false to disable it. + */ + changeNotificationSound(enabled: boolean): void { + this.configProvider.set(CoreConstants.SETTINGS_NOTIFICATION_SOUND, enabled).finally(() => { + const siteId = this.sitesProvider.getCurrentSiteId(); + this.eventsProvider.trigger(CoreEventsProvider.NOTIFICATION_SOUND_CHANGED, {enabled}, siteId); + this.localNotificationsProvider.rescheduleAll(); + }); + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + // If there is a pending action to update preferences, execute it right now. + if (this.updateTimeout) { + clearTimeout(this.updateTimeout); + this.updatePreferences(); + } + } +} diff --git a/src/addon/notifications/providers/cron-handler.ts b/src/addon/notifications/providers/cron-handler.ts new file mode 100644 index 000000000..78f317c41 --- /dev/null +++ b/src/addon/notifications/providers/cron-handler.ts @@ -0,0 +1,112 @@ +// (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 { CoreAppProvider } from '@providers/app'; +import { CoreCronHandler } from '@providers/cron'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreEmulatorHelperProvider } from '@core/emulator/providers/helper'; +import { AddonNotificationsProvider } from './notifications'; + +/** + * Notifications cron handler. + */ +@Injectable() +export class AddonNotificationsCronHandler implements CoreCronHandler { + name = 'AddonNotificationsCronHandler'; + + constructor(private appProvider: CoreAppProvider, private eventsProvider: CoreEventsProvider, + private sitesProvider: CoreSitesProvider, private localNotifications: CoreLocalNotificationsProvider, + private notificationsProvider: AddonNotificationsProvider, private textUtils: CoreTextUtilsProvider, + private emulatorHelper: CoreEmulatorHelperProvider) {} + + /** + * Get the time between consecutive executions. + * + * @return {number} Time between consecutive executions (in ms). + */ + getInterval(): number { + return this.appProvider.isDesktop() ? 60000 : 600000; // 1 or 10 minutes. + } + + /** + * Check whether it's a synchronization process or not. True if not defined. + * + * @return {boolean} Whether it's a synchronization process or not. + */ + isSync(): boolean { + // This is done to use only wifi if using the fallback function. + // In desktop it is always sync, since it fetches notification to see if there's a new one. + return !this.notificationsProvider.isPreciseNotificationCountEnabled() || this.appProvider.isDesktop(); + } + + /** + * Check whether the sync can be executed manually. Call isSync if not defined. + * + * @return {boolean} Whether the sync can be executed manually. + */ + canManualSync(): boolean { + return true; + } + + /** + * Execute the process. + * + * @param {string} [siteId] ID of the site affected. If not defined, all sites. + * @return {Promise} Promise resolved when done. If the promise is rejected, this function will be called again often, + * it shouldn't be abused. + */ + execute(siteId?: string): Promise { + if (this.sitesProvider.isCurrentSite(siteId)) { + this.eventsProvider.trigger(AddonNotificationsProvider.READ_CRON_EVENT, {}, this.sitesProvider.getCurrentSiteId()); + } + + if (this.appProvider.isDesktop() && this.localNotifications.isAvailable()) { + this.emulatorHelper.checkNewNotifications( + AddonNotificationsProvider.PUSH_SIMULATION_COMPONENT, + this.fetchNotifications.bind(this), this.getTitleAndText.bind(this), siteId); + } + + return Promise.resolve(null); + } + + /** + * Get the latest unread notifications from a site. + * + * @param {string} siteId Site ID. + * @return {Promise} Promise resolved with the notifications. + */ + protected fetchNotifications(siteId: string): Promise { + return this.notificationsProvider.getUnreadNotifications(0, undefined, true, false, true, siteId); + } + + /** + * Given a notification, return the title and the text for the notification. + * + * @param {any} notification Notification. + * @return {Promise} Promise resvoled with an object with title and text. + */ + protected getTitleAndText(notification: any): Promise { + const data = { + title: notification.userfromfullname, + text: notification.mobiletext.replace(/-{4,}/ig, '') + }; + data.text = this.textUtils.replaceNewLines(data.text, '
'); + + return Promise.resolve(data); + } +} diff --git a/src/addon/notifications/providers/mainmenu-handler.ts b/src/addon/notifications/providers/mainmenu-handler.ts new file mode 100644 index 000000000..8866d14a8 --- /dev/null +++ b/src/addon/notifications/providers/mainmenu-handler.ts @@ -0,0 +1,115 @@ +// (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 { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreMainMenuHandler, CoreMainMenuHandlerData } from '@core/mainmenu/providers/delegate'; +import { AddonNotificationsProvider } from './notifications'; +import { AddonPushNotificationsProvider } from '@addon/pushnotifications/providers/pushnotifications'; +import { AddonPushNotificationsDelegate } from '@addon/pushnotifications/providers/delegate'; + +/** + * Handler to inject an option into main menu. + */ +@Injectable() +export class AddonNotificationsMainMenuHandler implements CoreMainMenuHandler { + name = 'AddonNotifications'; + priority = 700; + + protected handler: CoreMainMenuHandlerData = { + icon: 'notifications', + title: 'addon.notifications.notifications', + page: 'AddonNotificationsListPage', + class: 'addon-notifications-handler', + showBadge: true, + badge: '', + loading: true, + }; + + constructor(eventsProvider: CoreEventsProvider, private sitesProvider: CoreSitesProvider, + utils: CoreUtilsProvider, private notificationsProvider: AddonNotificationsProvider, + private pushNotificationsProvider: AddonPushNotificationsProvider, + pushNotificationsDelegate: AddonPushNotificationsDelegate) { + + eventsProvider.on(AddonNotificationsProvider.READ_CHANGED_EVENT, (data) => { + this.updateBadge(data.siteId); + }); + + eventsProvider.on(AddonNotificationsProvider.READ_CRON_EVENT, (data) => { + this.updateBadge(data.siteId); + }); + + // Reset info on logout. + eventsProvider.on(CoreEventsProvider.LOGOUT, (data) => { + this.handler.badge = ''; + this.handler.loading = true; + }); + + // If a push notification is received, refresh the count. + pushNotificationsDelegate.on('receive').subscribe((notification) => { + // New notification received. If it's from current site, refresh the data. + if (utils.isTrueOrOne(notification.notif) && this.sitesProvider.isCurrentSite(notification.site)) { + this.updateBadge(notification.site); + } + }); + + // Register Badge counter. + pushNotificationsDelegate.registerCounterHandler('AddonNotifications'); + } + + /** + * Check if the handler is enabled on a site level. + * + * @return {boolean} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return true; + } + + /** + * Returns the data needed to render the handler. + * + * @return {CoreMainMenuHandlerData} Data needed to render the handler. + */ + getDisplayData(): CoreMainMenuHandlerData { + if (this.handler.loading) { + this.updateBadge(); + } + + return this.handler; + } + + /** + * Triggers an update for the badge number and loading status. Mandatory if showBadge is enabled. + * + * @param {string} [siteId] Site ID or current Site if undefined. + */ + updateBadge(siteId?: string): void { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + if (!siteId) { + return; + } + + this.notificationsProvider.getUnreadNotificationsCount(null, siteId).then((unread) => { + this.handler.badge = unread > 0 ? String(unread) : ''; + this.pushNotificationsProvider.updateAddonCounter('AddonNotifications', unread, siteId); + }).catch(() => { + this.handler.badge = ''; + }).finally(() => { + this.handler.loading = false; + }); + } +} diff --git a/src/addon/notifications/providers/notifications.ts b/src/addon/notifications/providers/notifications.ts new file mode 100644 index 000000000..3fefc469c --- /dev/null +++ b/src/addon/notifications/providers/notifications.ts @@ -0,0 +1,291 @@ +// (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 { CoreAppProvider } from '@providers/app'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUserProvider } from '@core/user/providers/user'; +import { CoreEmulatorHelperProvider } from '@core/emulator/providers/helper'; + +/** + * Service to handle notifications. + */ +@Injectable() +export class AddonNotificationsProvider { + + static READ_CHANGED_EVENT = 'addon_notifications_read_changed_event'; + static READ_CRON_EVENT = 'addon_notifications_read_cron_event'; + static PUSH_SIMULATION_COMPONENT = 'AddonNotificationsPushSimulation'; + static LIST_LIMIT = 20; + + protected ROOT_CACHE_KEY = 'mmaNotifications:'; + protected logger; + + constructor(logger: CoreLoggerProvider, private appProvider: CoreAppProvider, private sitesProvider: CoreSitesProvider, + private timeUtils: CoreTimeUtilsProvider, private userProvider: CoreUserProvider, + private emulatorHelper: CoreEmulatorHelperProvider) { + this.logger = logger.getInstance('AddonNotificationsProvider'); + } + + /** + * Function to format notification data. + * + * @param {any[]} notifications List of notifications. + */ + protected formatNotificationsData(notifications: any[]): void { + notifications.forEach((notification) => { + // Set message to show. + if (notification.contexturl && notification.contexturl.indexOf('/mod/forum/')) { + notification.mobiletext = notification.smallmessage; + } else { + notification.mobiletext = notification.fullmessage; + } + // Try to set courseid the notification belongs to. + const cid = notification.fullmessagehtml.match(/course\/view\.php\?id=([^"]*)/); + if (cid && cid[1]) { + notification.courseid = cid[1]; + } + // Try to get the profile picture of the user. + this.userProvider.getProfile(notification.useridfrom, notification.courseid, true).then((user) => { + notification.profileimageurlfrom = user.profileimageurl; + }); + }); + } + + /** + * Get the cache key for the get notification preferences call. + * + * @return {string} Cache key. + */ + protected getNotificationPreferencesCacheKey(): string { + return this.ROOT_CACHE_KEY + 'notificationPreferences'; + } + + /** + * Get notification preferences. + * + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise} Promise resolved with the notification preferences. + */ + getNotificationPreferences(siteId?: string): Promise { + this.logger.debug('Get notification preferences'); + + return this.sitesProvider.getSite(siteId).then((site) => { + const preSets = { + cacheKey: this.getNotificationPreferencesCacheKey() + }; + + return site.read('core_message_get_user_notification_preferences', {}, preSets).then((data) => { + return data.preferences; + }); + }); + } + + /** + * Get cache key for notification list WS calls. + * + * @return {string} Cache key. + */ + protected getNotificationsCacheKey(): string { + return this.ROOT_CACHE_KEY + 'list'; + } + + /** + * Get notifications from site. + * + * @param {boolean} read True if should get read notifications, false otherwise. + * @param {number} limitFrom Position of the first notification to get. + * @param {number} limitNumber Number of notifications to get or 0 to use the default limit. + * @param {boolean} [toDisplay=true] True if notifications will be displayed to the user, either in view or in a notification. + * @param {boolean} [forceCache] True if it should return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise} Promise resolved with notifications. + */ + getNotifications(read: boolean, limitFrom: number, limitNumber: number = 0, toDisplay: boolean = true, + forceCache?: boolean, ignoreCache?: boolean, siteId?: string): Promise { + limitNumber = limitNumber || AddonNotificationsProvider.LIST_LIMIT; + this.logger.debug('Get ' + (read ? 'read' : 'unread') + ' notifications from ' + limitFrom + '. Limit: ' + limitNumber); + + return this.sitesProvider.getSite(siteId).then((site) => { + const data = { + useridto: site.getUserId(), + useridfrom: 0, + type: 'notifications', + read: read ? 1 : 0, + newestfirst: 1, + limitfrom: limitFrom, + limitnum: limitNumber + }; + const preSets: object = { + cacheKey: this.getNotificationsCacheKey(), + omitExpires: forceCache, + getFromCache: forceCache || !ignoreCache, + emergencyCache: forceCache || !ignoreCache, + }; + + // Get unread notifications. + return site.read('core_message_get_messages', data, preSets).then((response) => { + if (response.messages) { + const notifications = response.messages; + this.formatNotificationsData(notifications); + if (this.appProvider.isDesktop() && toDisplay && !read && limitFrom === 0) { + // Store the last received notification. Don't block the user for this. + this.emulatorHelper.storeLastReceivedNotification( + AddonNotificationsProvider.PUSH_SIMULATION_COMPONENT, notifications[0], siteId); + } + + return notifications; + } else { + return Promise.reject(null); + } + }); + }); + } + + /** + * Get read notifications from site. + * + * @param {number} limitFrom Position of the first notification to get. + * @param {number} limitNumber Number of notifications to get. + * @param {boolean} [toDisplay=true] True if notifications will be displayed to the user, either in view or in a notification. + * @param {boolean} [forceCache] True if it should return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise} Promise resolved with notifications. + */ + getReadNotifications(limitFrom: number, limitNumber: number, toDisplay: boolean = true, + forceCache?: boolean, ignoreCache?: boolean, siteId?: string): Promise { + return this.getNotifications(true, limitFrom, limitNumber, toDisplay, forceCache, ignoreCache, siteId); + } + + /** + * Get unread notifications from site. + * + * @param {number} limitFrom Position of the first notification to get. + * @param {number} limitNumber Number of notifications to get. + * @param {boolean} [toDisplay=true] True if notifications will be displayed to the user, either in view or in a notification. + * @param {boolean} [forceCache] True if it should return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise} Promise resolved with notifications. + */ + getUnreadNotifications(limitFrom: number, limitNumber: number, toDisplay: boolean = true, + forceCache?: boolean, ignoreCache?: boolean, siteId?: string): Promise { + return this.getNotifications(false, limitFrom, limitNumber, toDisplay, forceCache, ignoreCache, siteId); + } + + /** + * Get unread notifications count. Do not cache calls. + * + * @param {number} [userId] The user id who received the notification. If not defined, use current user. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @return {Promise} Promise resolved with the message notifications count. + */ + getUnreadNotificationsCount(userId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + // @since 3.2 + if (site.wsAvailable('message_popup_get_unread_popup_notification_count')) { + userId = userId || site.getUserId(); + const params = { + useridto: userId + }; + const preSets = { + getFromCache: false, + emergencyCache: false, + saveToCache: false, + typeExpected: 'number' + }; + + return site.read('message_popup_get_unread_popup_notification_count', params, preSets).catch(() => { + // Return no messages if the call fails. + return 0; + }); + } + + // Fallback call. + const limit = AddonNotificationsProvider.LIST_LIMIT + 1; + + return this.getUnreadNotifications(0, limit, false, false, false, siteId).then((unread) => { + // Add + sign if there are more than the limit reachable. + return (unread.length > AddonNotificationsProvider.LIST_LIMIT) ? + AddonNotificationsProvider.LIST_LIMIT + '+' : unread.length; + }).catch(() => { + // Return no messages if the call fails. + return 0; + }); + }); + } + + /** + * Mark message notification as read. + * + * @param {number} notificationId ID of notification to mark as read + * @returns {Promise} Resolved when done. + */ + markNotificationRead(notificationId: number): Promise { + const params = { + messageid: notificationId, + timeread: this.timeUtils.timestamp() + }; + + return this.sitesProvider.getCurrentSite().write('core_message_mark_message_read', params); + } + + /** + * Invalidate get notification preferences. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when data is invalidated. + */ + invalidateNotificationPreferences(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getNotificationPreferencesCacheKey()); + }); + } + + /** + * Invalidates notifications list WS calls. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the list is invalidated. + */ + invalidateNotificationsList(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getNotificationsCacheKey()); + }); + } + + /** + * Returns whether or not we can count unread notifications precisely. + * + * @return {boolean} True if enabled, false otherwise. + * @since 3.2 + */ + isPreciseNotificationCountEnabled(): boolean { + return this.sitesProvider.wsAvailableInCurrentSite('message_popup_get_unread_popup_notification_count'); + } + + /** + * Returns whether or not the notification preferences are enabled for the current site. + * + * @return {boolean} True if enabled, false otherwise. + * @since 3.2 + */ + isNotificationPreferencesEnabled(): boolean { + return this.sitesProvider.wsAvailableInCurrentSite('core_message_get_user_notification_preferences'); + } +} diff --git a/src/addon/notifications/providers/settings-handler.ts b/src/addon/notifications/providers/settings-handler.ts new file mode 100644 index 000000000..1bb8002f7 --- /dev/null +++ b/src/addon/notifications/providers/settings-handler.ts @@ -0,0 +1,57 @@ +// (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 { AddonNotificationsProvider } from './notifications'; +import { CoreAppProvider } from '@providers/app'; +import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; +import { CoreSettingsHandler, CoreSettingsHandlerData } from '@core/settings/providers/delegate'; + +/** + * Notifications settings handler. + */ +@Injectable() +export class AddonNotificationsSettingsHandler implements CoreSettingsHandler { + name = 'AddonNotifications'; + priority = 500; + + constructor(private appProvider: CoreAppProvider, private localNotificationsProvider: CoreLocalNotificationsProvider, + private notificationsProvider: AddonNotificationsProvider) { + } + + /** + * Check if the handler is enabled on a site level. + * + * @return {boolean | Promise} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + // Preferences or notification sound setting available. + return (this.notificationsProvider.isNotificationPreferencesEnabled() || + this.localNotificationsProvider.isAvailable() && !this.appProvider.isDesktop()); + } + + /** + * Returns the data needed to render the handler. + * + * @return {CoreSettingsHandlerData} Data needed to render the handler. + */ + getDisplayData(): CoreSettingsHandlerData { + return { + icon: 'notifications', + title: 'addon.notifications.notificationpreferences', + page: 'AddonNotificationsSettingsPage', + class: 'addon-notifications-settings-handler' + }; + } +} diff --git a/src/addon/pushnotifications/providers/pushnotifications.ts b/src/addon/pushnotifications/providers/pushnotifications.ts index eec83e6ff..98e1e266e 100644 --- a/src/addon/pushnotifications/providers/pushnotifications.ts +++ b/src/addon/pushnotifications/providers/pushnotifications.ts @@ -25,6 +25,7 @@ import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreConfigProvider } from '@providers/config'; +import { CoreConstants } from '@core/constants'; import { CoreConfigConstants } from '../../../configconstants'; /** @@ -89,8 +90,7 @@ export class AddonPushNotificationsProvider { * @return {Promise} Promise with the push options resolved when done. */ protected getOptions(): Promise { - // @todo: CoreSettingsProvider.NOTIFICATION_SOUND - return this.configProvider.get('CoreSettingsProvider.NOTIFICATION_SOUND', true).then((soundEnabled) => { + return this.configProvider.get(CoreConstants.SETTINGS_NOTIFICATION_SOUND, true).then((soundEnabled) => { return { android: { senderID: CoreConfigConstants.gcmpn, diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 03f660c6a..fc46e8adf 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -82,9 +82,12 @@ import { AddonModFolderModule } from '@addon/mod/folder/folder.module'; import { AddonModPageModule } from '@addon/mod/page/page.module'; import { AddonModUrlModule } from '@addon/mod/url/url.module'; import { AddonModSurveyModule } from '@addon/mod/survey/survey.module'; +import { AddonMessageOutputModule } from '@addon/messageoutput/messageoutput.module'; +import { AddonMessageOutputAirnotifierModule } from '@addon/messageoutput/airnotifier/airnotifier.module'; import { AddonMessagesModule } from '@addon/messages/messages.module'; import { AddonNotesModule } from '../addon/notes/notes.module'; import { AddonPushNotificationsModule } from '@addon/pushnotifications/pushnotifications.module'; +import { AddonNotificationsModule } from '@addon/notifications/notifications.module'; import { AddonRemoteThemesModule } from '@addon/remotethemes/remotethemes.module'; import { AddonQbehaviourModule } from '@addon/qbehaviour/qbehaviour.module'; import { AddonQtypeModule } from '@addon/qtype/qtype.module'; @@ -171,8 +174,11 @@ export const CORE_PROVIDERS: any[] = [ AddonModPageModule, AddonModUrlModule, AddonModSurveyModule, + AddonMessageOutputModule, + AddonMessageOutputAirnotifierModule, AddonMessagesModule, AddonNotesModule, + AddonNotificationsModule, AddonPushNotificationsModule, AddonRemoteThemesModule, AddonQbehaviourModule, diff --git a/src/core/emulator/emulator.module.ts b/src/core/emulator/emulator.module.ts index 8229d4bec..9afd878fc 100644 --- a/src/core/emulator/emulator.module.ts +++ b/src/core/emulator/emulator.module.ts @@ -35,6 +35,7 @@ import { SQLite } from '@ionic-native/sqlite'; import { Zip } from '@ionic-native/zip'; // Services that Mock Ionic Native in browser an desktop. +import { BadgeMock } from './providers/badge'; import { CameraMock } from './providers/camera'; import { ClipboardMock } from './providers/clipboard'; import { FileMock } from './providers/file'; @@ -44,6 +45,7 @@ import { InAppBrowserMock } from './providers/inappbrowser'; import { LocalNotificationsMock } from './providers/local-notifications'; import { MediaCaptureMock } from './providers/media-capture'; import { NetworkMock } from './providers/network'; +import { PushMock } from './providers/push'; import { ZipMock } from './providers/zip'; import { CoreEmulatorHelperProvider } from './providers/helper'; @@ -89,7 +91,14 @@ export const IONIC_NATIVE_PROVIDERS = [ imports: [ ], providers: [ - Badge, // @todo: Mock + { + provide: Badge, + deps: [CoreAppProvider], + useFactory: (appProvider: CoreAppProvider): Badge => { + // Use platform instead of CoreAppProvider to prevent circular dependencies. + return appProvider.isMobile() ? new Badge() : new BadgeMock(appProvider); + } + }, CoreEmulatorHelperProvider, CoreEmulatorCaptureHelperProvider, { @@ -162,7 +171,14 @@ export const IONIC_NATIVE_PROVIDERS = [ return platform.is('cordova') ? new Network() : new NetworkMock(); } }, - Push, // @todo: Mock + { + provide: Push, + deps: [CoreAppProvider], + useFactory: (appProvider: CoreAppProvider): Push => { + // Use platform instead of CoreAppProvider to prevent circular dependencies. + return appProvider.isMobile() ? new Push() : new PushMock(appProvider); + } + }, SplashScreen, StatusBar, SQLite, diff --git a/src/core/emulator/providers/badge.ts b/src/core/emulator/providers/badge.ts new file mode 100644 index 000000000..713ef67a1 --- /dev/null +++ b/src/core/emulator/providers/badge.ts @@ -0,0 +1,123 @@ +// (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 { Badge } from '@ionic-native/badge'; +import { CoreAppProvider } from '@providers/app'; + +/** + * Emulates the Cordova Push plugin in desktop apps and in browser. + */ +@Injectable() +export class BadgeMock implements Badge { + + constructor(private appProvider: CoreAppProvider) {} + + /** + * Clear the badge of the app icon. + * + * @returns {Promise} + */ + clear(): Promise { + return Promise.reject('clear is only supported in mobile devices'); + } + + /** + * Set the badge of the app icon. + * @param {number} badgeNumber The new badge number. + * @returns {Promise} + */ + set(badgeNumber: number): Promise { + if (!this.appProvider.isDesktop()) { + return Promise.reject('set is not supported in browser'); + } + + try { + const app = require('electron').remote.app; + if (app.setBadgeCount(badgeNumber)) { + return Promise.resolve(); + } else { + return Promise.reject(null); + } + } catch (ex) { + return Promise.reject(ex); + } + } + + /** + * Get the badge of the app icon. + * + * @returns {Promise} + */ + get(): Promise { + if (!this.appProvider.isDesktop()) { + return Promise.reject('get is not supported in browser'); + } + + try { + const app = require('electron').remote.app; + + return Promise.resolve(app.getBadgeCount()); + } catch (ex) { + return Promise.reject(ex); + } + } + + /** + * Increase the badge number. + * + * @param {number} increaseBy Count to add to the current badge number + * @returns {Promise} + */ + increase(increaseBy: number): Promise { + return Promise.reject('increase is only supported in mobile devices'); + } + + /** + * Decrease the badge number. + * + * @param {number} decreaseBy Count to subtract from the current badge number + * @returns {Promise} + */ + decrease(decreaseBy: number): Promise { + return Promise.reject('decrease is only supported in mobile devices'); + } + + /** + * Check support to show badges. + * + * @returns {Promise} + */ + isSupported(): Promise { + return Promise.reject('isSupported is only supported in mobile devices'); + } + + /** + * Determine if the app has permission to show badges. + * + * @returns {Promise} + */ + hasPermission(): Promise { + return Promise.reject('hasPermission is only supported in mobile devices'); + } + + /** + * Register permission to set badge notifications + * + * @returns {Promise} + */ + requestPermission(): Promise { + return Promise.reject('requestPermission is only supported in mobile devices'); + } +} diff --git a/src/core/emulator/providers/helper.ts b/src/core/emulator/providers/helper.ts index 7314ded11..53317da62 100644 --- a/src/core/emulator/providers/helper.ts +++ b/src/core/emulator/providers/helper.ts @@ -17,9 +17,15 @@ import { CoreFileProvider } from '@providers/file'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { File } from '@ionic-native/file'; import { LocalNotifications } from '@ionic-native/local-notifications'; +import { CoreAppProvider } from '@providers/app'; import { CoreInitDelegate, CoreInitHandler } from '@providers/init'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { FileTransferErrorMock } from './file-transfer'; import { CoreEmulatorCaptureHelperProvider } from './capture-helper'; +import { CoreConstants } from '../../constants'; /** * Helper service for the emulator feature. It also acts as an init handler. @@ -30,9 +36,38 @@ export class CoreEmulatorHelperProvider implements CoreInitHandler { priority = CoreInitDelegate.MAX_RECOMMENDED_PRIORITY + 500; blocking = true; + protected logger; + + // Variables for database. + protected LAST_RECEIVED_NOTIFICATION_TABLE = 'core_emulator_last_received_notification'; + protected tablesSchema = [ + { + name: this.LAST_RECEIVED_NOTIFICATION_TABLE, + columns: [ + { + name: 'component', + type: 'TEXT' + }, + { + name: 'id', + type: 'INTEGER', + }, + { + name: 'timecreated', + type: 'INTEGER', + }, + ], + primaryKeys: ['component'] + } + ]; + constructor(private file: File, private fileProvider: CoreFileProvider, private utils: CoreUtilsProvider, - initDelegate: CoreInitDelegate, private localNotif: LocalNotifications, - private captureHelper: CoreEmulatorCaptureHelperProvider) { } + logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private localNotif: LocalNotifications, + private captureHelper: CoreEmulatorCaptureHelperProvider, private timeUtils: CoreTimeUtilsProvider, + private appProvider: CoreAppProvider, private localNotifProvider: CoreLocalNotificationsProvider) { + this.logger = logger.getInstance('CoreEmulatorHelper'); + sitesProvider.createTablesFromSchema(this.tablesSchema); + } /** * Load the Mocks that need it. @@ -52,4 +87,130 @@ export class CoreEmulatorHelperProvider implements CoreInitHandler { return this.utils.allPromises(promises); } + + /** + * Check if there are new notifications, triggering a local notification if found. + * Only for desktop apps since they don't support push notifications. + * + * @param {string} component Component to check. + * @param {Function} fetchFn Function that receives a site ID and returns a Promise resolved with an array of notifications. + * @param {Function} getDataFn Function that receives a notification and returns a promise resolved with the title and text. + * @param {string} [siteId] Site ID to check. If not defined, check all sites. + * @return {Promise} Promise resolved when done. + */ + checkNewNotifications(component: string, fetchFn: Function, getDataFn: Function, siteId?: string): Promise { + if (!this.appProvider.isDesktop() || !this.localNotifProvider.isAvailable()) { + return Promise.resolve(null); + } + + if (!this.appProvider.isOnline()) { + this.logger.debug('Cannot check push notifications because device is offline.'); + + return Promise.reject(null); + } + + let promise: Promise; + if (!siteId) { + // No site ID defined, check all sites. + promise = this.sitesProvider.getSitesIds(); + } else { + promise = Promise.resolve([siteId]); + } + + return promise.then((siteIds) => { + const sitePromises = siteIds.map((siteId) => { + // Check new notifications for each site. + return this.checkNewNotificationsForSite(component, fetchFn, getDataFn, siteId); + }); + + return Promise.all(sitePromises); + }); + } + + /** + * Check if there are new notifications for a certain site, triggering a local notification if found. + * + * @param {string} component Component to check. + * @param {Function} fetchFn Function that receives a site ID and returns a Promise resolved with an array of notifications. + * @param {Function} getDataFn Function that receives a notification and returns a promise resolved with the title and text. + * @param {string} siteId Site ID to check. + * @return {Promise} Promise resolved when done. + */ + protected checkNewNotificationsForSite(component: string, fetchFn: Function, getDataFn: Function, siteId: string) + : Promise { + // Get the last received notification in the app. + return this.getLastReceivedNotification(component, siteId).then((lastNotification) => { + // Now fetch the latest notifications from the server. + return fetchFn(siteId).then((notifications) => { + if (!lastNotification || !notifications.length) { + // No last notification stored (first call) or no new notifications. Stop. + return; + } + + const notification = notifications[0]; + + if (notification.id == lastNotification.id || notification.timecreated <= lastNotification.timecreated || + this.timeUtils.timestamp() - notification.timecreated > CoreConstants.SECONDS_DAY) { + // There are no new notifications or the newest one happened more than a day ago, stop. + return; + } + + // There is a new notification, show it. + return getDataFn(notification).then((titleAndText) => { + const localNotif = { + id: 1, + at: new Date(), + title: titleAndText.title, + text: titleAndText.text, + data: { + notif: notification, + site: siteId + } + }; + + return this.localNotifProvider.schedule(localNotif, component, siteId); + }); + }); + }); + } + + /** + * Get the last notification received in a certain site for a certain component. + * + * @param {string} component Component of the notification to get. + * @param {string} siteId Site ID of the notification. + * @return {Promise} Promise resolved with the notification or false if not found. + */ + getLastReceivedNotification(component: string, siteId: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecord(this.LAST_RECEIVED_NOTIFICATION_TABLE, {component: component}); + }).catch(() => { + return false; + }); + } + + /** + * Store the last notification received in a certain site. + * + * @param {string} component Component of the notification to store. + * @param {any} notification Notification to store. + * @param {string} siteId Site ID of the notification. + * @return {Promise} Promise resolved when done. + */ + storeLastReceivedNotification(component: string, notification: any, siteId: string): Promise { + if (!notification) { + // No notification, store a fake one. + notification = {id: -1, timecreated: 0}; + } + + return this.sitesProvider.getSite(siteId).then((site) => { + const entry = { + component: component, + id: notification.id, + timecreated: notification.timecreated, + }; + + return site.getDb().insertRecord(this.LAST_RECEIVED_NOTIFICATION_TABLE, entry); + }); + } } diff --git a/src/core/emulator/providers/push.ts b/src/core/emulator/providers/push.ts new file mode 100644 index 000000000..e801b01bf --- /dev/null +++ b/src/core/emulator/providers/push.ts @@ -0,0 +1,184 @@ +// (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 { Observable } from 'rxjs/Observable'; +import { Channel, EventResponse, Push, PushEvent, PushObject, PushOptions } from '@ionic-native/push'; +import { CoreAppProvider } from '@providers/app'; + +/** + * Emulates the Cordova Push plugin in desktop apps and in browser. + */ +@Injectable() +export class PushMock implements Push { + + constructor(private appProvider: CoreAppProvider) { + } + + /** + * Init push notifications + * + * @param {PushOptions} options + * @return {PushObject} + */ + init(options: PushOptions): PushObject { + return new PushObjectMock(this.appProvider); + } + + /** + * Check whether the push notification permission has been granted. + * + * @return {Promise<{isEnabled: boolean}>} Returns a Promise that resolves with an object with one property: isEnabled, a + * boolean that indicates if permission has been granted. + */ + hasPermission(): Promise<{isEnabled: boolean}> { + return Promise.reject('hasPermission is only supported in mobile devices'); + } + + /** + * Create a new notification channel for Android O and above. + * + * @param {Channel} channel + */ + createChannel(channel?: Channel): Promise { + return Promise.reject('createChannel is only supported in mobile devices'); + } + + /** + * Delete a notification channel for Android O and above. + * + * @param {string} id + */ + deleteChannel(id?: string): Promise { + return Promise.reject('deleteChannel is only supported in mobile devices'); + } + + /** + * Returns a list of currently configured channels. + * + * @return {Promise} + */ + listChannels(): Promise { + return Promise.reject('listChannels is only supported in mobile devices'); + } +} + +/** + * Emulates the PushObject class in desktop apps and in browser. + */ +export class PushObjectMock extends PushObject { + + constructor(private appProvider: CoreAppProvider) { + super({}); + } + + /** + * Adds an event listener + * @param event {string} + * @return {Observable} + */ + on(event: PushEvent): Observable { + return Observable.empty(); + } + + /** + * The unregister method is used when the application no longer wants to receive push notifications. + * Beware that this cleans up all event handlers previously registered, + * so you will need to re-register them if you want them to function again without an application reload. + */ + unregister(): Promise { + return Promise.reject('unregister is only supported in mobile devices'); + } + + /** + * Set the badge count visible when the app is not running + * + * The count is an integer indicating what number should show up in the badge. + * Passing 0 will clear the badge. + * Each notification event contains a data.count value which can be used to set the badge to correct number. + * + * @param count + */ + setApplicationIconBadgeNumber(count?: number): Promise { + if (!this.appProvider.isDesktop()) { + return Promise.reject('setApplicationIconBadgeNumber is not supported in browser'); + } + + try { + const app = require('electron').remote.app; + if (app.setBadgeCount(count)) { + return Promise.resolve(); + } else { + return Promise.reject(null); + } + } catch (ex) { + return Promise.reject(ex); + } + } + + /** + * Get the current badge count visible when the app is not running + * successHandler gets called with an integer which is the current badge count + */ + getApplicationIconBadgeNumber(): Promise { + if (!this.appProvider.isDesktop()) { + return Promise.reject('getApplicationIconBadgeNumber is not supported in browser'); + } + + try { + const app = require('electron').remote.app; + + return Promise.resolve(app.getBadgeCount()); + } catch (ex) { + return Promise.reject(ex); + } + } + + /** + * iOS only + * Tells the OS that you are done processing a background push notification. + * successHandler gets called when background push processing is successfully completed. + * @param [id] + */ + finish(id?: string): Promise { + return Promise.reject('finish is only supported in mobile devices'); + } + + /** + * Tells the OS to clear all notifications from the Notification Center + */ + clearAllNotifications(): Promise { + return Promise.reject('clearAllNotifications is only supported in mobile devices'); + } + + /** + * The subscribe method is used when the application wants to subscribe a new topic to receive push notifications. + * @param topic {string} Topic to subscribe to. + * @return {Promise} + */ + subscribe(topic: string): Promise { + return Promise.reject('subscribe is only supported in mobile devices'); + } + + /** + * The unsubscribe method is used when the application no longer wants to receive push notifications from a specific topic but + * continue to receive other push messages. + * + * @param topic {string} Topic to unsubscribe from. + * @return {Promise} + */ + unsubscribe(topic: string): Promise { + return Promise.reject('unsubscribe is only supported in mobile devices'); + } +} diff --git a/src/core/settings/lang/en.json b/src/core/settings/lang/en.json index 6e7ad59e0..0999dce8c 100644 --- a/src/core/settings/lang/en.json +++ b/src/core/settings/lang/en.json @@ -1,5 +1,7 @@ { "about": "About", + "disableall": "Disable notifications", + "disabled": "Disabled", "general": "General", "loggedin": "Online", "loggedoff": "Offline", @@ -7,4 +9,4 @@ "sites": "Sites", "spaceusage": "Space usage", "synchronization": "Synchronisation" -} \ No newline at end of file +} diff --git a/src/core/settings/pages/list/list.html b/src/core/settings/pages/list/list.html index b26591fe8..d24df5cb8 100644 --- a/src/core/settings/pages/list/list.html +++ b/src/core/settings/pages/list/list.html @@ -1,6 +1,8 @@ {{ 'core.settings.settings' | translate}} + + diff --git a/src/core/settings/providers/helper.ts b/src/core/settings/providers/helper.ts new file mode 100644 index 000000000..c4fb77b71 --- /dev/null +++ b/src/core/settings/providers/helper.ts @@ -0,0 +1,94 @@ +// (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 { CoreUtilsProvider } from '@providers/utils/utils'; + +/** + * Settings helper service. + */ +@Injectable() +export class CoreSettingsHelper { + protected logger; + + constructor(loggerProvider: CoreLoggerProvider, private utils: CoreUtilsProvider) { + this.logger = loggerProvider.getInstance('CoreSettingsHelper'); + } + + /** + * Get a certain processor from a list of processors. + * + * @param {any[]} processors List of processors. + * @param {string} name Name of the processor to get. + * @param {boolean} [fallback=true] True to return first processor if not found, false to not return any. Defaults to true. + * @return {any} Processor. + */ + getProcessor(processors: any[], name: string, fallback: boolean = true): any { + if (!processors || !processors.length) { + return; + } + for (let i = 0; i < processors.length; i++) { + if (processors[i].name == name) { + return processors[i]; + } + } + + // Processor not found, return first if requested. + if (fallback) { + return processors[0]; + } + } + + /** + * Return the components and notifications that have a certain processor. + * + * @param {string} processor Name of the processor to filter. + * @param {any[]} components Array of components. + * @return {any[]} Filtered components. + */ + getProcessorComponents(processor: string, components: any[]): any[] { + const result = []; + + components.forEach((component) => { + // Create a copy of the component with an empty list of notifications. + const componentCopy = this.utils.clone(component); + componentCopy.notifications = []; + + component.notifications.forEach((notification) => { + let hasProcessor = false; + for (let i = 0; i < notification.processors.length; i++) { + const proc = notification.processors[i]; + if (proc.name == processor) { + hasProcessor = true; + notification.currentProcessor = proc; + break; + } + } + + if (hasProcessor) { + // Add the notification. + componentCopy.notifications.push(notification); + } + }); + + if (componentCopy.notifications.length) { + // At least 1 notification added, add the component to the result. + result.push(componentCopy); + } + }); + + return result; + } +} diff --git a/src/core/settings/settings.module.ts b/src/core/settings/settings.module.ts index 91efeb1b1..e658b90f5 100644 --- a/src/core/settings/settings.module.ts +++ b/src/core/settings/settings.module.ts @@ -14,6 +14,7 @@ import { NgModule } from '@angular/core'; import { CoreSettingsDelegate } from './providers/delegate'; +import { CoreSettingsHelper } from './providers/helper'; @NgModule({ declarations: [ @@ -21,7 +22,8 @@ import { CoreSettingsDelegate } from './providers/delegate'; imports: [ ], providers: [ - CoreSettingsDelegate + CoreSettingsDelegate, + CoreSettingsHelper ] }) export class CoreSettingsModule {}