diff --git a/.travis.yml b/.travis.yml index cedbc149d..c75db89dc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,14 +3,14 @@ dist: xenial group: edge language: node_js -node_js: stable +node_js: 11 before_cache: - rm -rf $HOME/.cache/electron-builder/wine cache: directories: - - node_modules + - $HOME/.npm - $HOME/.cache/electron - $HOME/.cache/electron-builder diff --git a/Dockerfile b/Dockerfile index d884d988f..d7a27002c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,15 +11,15 @@ EXPOSE 35729 # Port 53703 is the Chrome dev logger port. EXPOSE 53703 -# MoodleMobile uses Cordova, Ionic, and Gulp. -RUN npm install -g cordova ionic gulp && rm -rf /root/.npm +# MoodleMobile uses Ionic and Gulp. +RUN npm i -g ionic gulp && rm -rf /root/.npm WORKDIR /app COPY . /app -# The setup script will handle npm installation, cordova setup, and gulp setup. -RUN npm run setup && rm -rf /root/.npm +# Install npm libraries and run gulp to initialize the project. +RUN npm install && gulp && rm -rf /root/.npm # Provide a Healthcheck command for easier use in CI. HEALTHCHECK --interval=10s --timeout=3s --start-period=30s CMD curl -f http://localhost:8100 || exit 1 diff --git a/config.xml b/config.xml index 0a1343541..186608e98 100644 --- a/config.xml +++ b/config.xml @@ -1,5 +1,5 @@ - + Moodle Moodle official app Moodle Mobile team @@ -37,6 +37,7 @@ + @@ -112,14 +113,14 @@ - - + + - + @@ -129,7 +130,7 @@ - + @@ -139,13 +140,19 @@ - + - + + + + + + YES + diff --git a/desktop/assets/windows/AppXManifest.xml b/desktop/assets/windows/AppXManifest.xml index f674c51dc..4d53ac2d3 100644 --- a/desktop/assets/windows/AppXManifest.xml +++ b/desktop/assets/windows/AppXManifest.xml @@ -6,7 +6,7 @@ + Version="3.7.0.0" /> Moodle Desktop Moodle Pty Ltd. diff --git a/desktop/electron.js b/desktop/electron.js index 343f81a5b..a526fa466 100644 --- a/desktop/electron.js +++ b/desktop/electron.js @@ -6,6 +6,7 @@ const url = require('url'); const fs = require('fs'); const os = require('os'); const userAgent = 'MoodleMobile'; +const isMac = os.platform().indexOf('darwin') != -1; // Keep a global reference of the window object, if you don't, the window will // be closed automatically when the JavaScript object is garbage collected. @@ -68,6 +69,14 @@ function createWindow() { // Append some text to the user agent. mainWindow.webContents.setUserAgent(mainWindow.webContents.getUserAgent() + ' ' + userAgent); + + // Add shortcut to open dev tools: Cmd + Option + I in MacOS, Ctrl + Shift + I in Windows/Linux. + mainWindow.webContents.on('before-input-event', function(e, input) { + if (input.type == 'keyDown' && !input.isAutoRepeat && input.code == 'KeyI' && + ((isMac && input.alt && input.meta) || (!isMac && input.shift && input.control))) { + mainWindow.webContents.toggleDevTools(); + } + }, true) } // Make sure that only a single instance of the app is running. @@ -75,7 +84,7 @@ function createWindow() { // See https://github.com/electron/electron/issues/15958 var gotTheLock = app.requestSingleInstanceLock(); -if (!gotTheLock && os.platform().indexOf('darwin') == -1) { +if (!gotTheLock && !isMac) { // It's not the main instance of the app, kill it. app.exit(); return; @@ -221,22 +230,18 @@ function setAppMenu() { submenu: [ { label: 'Cut', - accelerator: 'CmdOrCtrl+X', role: 'cut' }, { label: 'Copy', - accelerator: 'CmdOrCtrl+C', role: 'copy' }, { label: 'Paste', - accelerator: 'CmdOrCtrl+V', role: 'paste' }, { label: 'Select All', - accelerator: 'CmdOrCtrl+A', role: 'selectall' } ] diff --git a/gulpfile.js b/gulpfile.js index c4deb448f..f0bc12a16 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -8,6 +8,8 @@ var gulp = require('gulp'), gutil = require('gulp-util'), flatten = require('gulp-flatten'), npmPath = require('path'), + concat = require('gulp-concat'), + bufferFrom = require('buffer-from') File = gutil.File, exec = require('child_process').exec, license = '' + @@ -113,7 +115,7 @@ function treatMergedData(data) { mergedOrdered[k] = merged[k]; }); - return new Buffer(JSON.stringify(mergedOrdered, null, 4)); + return bufferFrom(JSON.stringify(mergedOrdered, null, 4)); } /** @@ -257,7 +259,7 @@ gulp.task('config', function(done) { contents += '}\n'; - file.contents = new Buffer(contents); + file.contents = bufferFrom(contents); this.emit('data', file); })) .pipe(rename('configconstants.ts')) @@ -296,3 +298,129 @@ gulp.task('copy-component-templates', function(done) { .on('end', done); }); +/** + * Finds the file and returns its content. + * + * @param {string} capture Import file path. + * @param {string} baseDir Directory where the file was found. + * @param {string} paths Alternative paths where to find the imports. + * @param {Array} parsedFiles Yet parsed files to reduce size of the result. + * @return {string} Partially combined scss. + */ +function getReplace(capture, baseDir, paths, parsedFiles) { + var parse = path.parse(path.resolve(baseDir, capture + '.scss')); + var file = parse.dir + '/' + parse.name; + + + if (!fs.existsSync(file + '.scss')) { + // File not found, might be a partial file. + file = parse.dir + '/_' + parse.name; + } + + // If file still not found, try to find the file in the alternative paths. + var x = 0; + while (!fs.existsSync(file + '.scss') && paths.length > x) { + parse = path.parse(path.resolve(paths[x], capture + '.scss')); + file = parse.dir + '/' + parse.name; + + x++; + } + + file = file + '.scss'; + + if (!fs.existsSync(file)) { + // File not found. Leave the import there. + console.log('File "' + capture + '" not found'); + return '@import "' + capture + '";'; + } + + if (parsedFiles.indexOf(file) >= 0) { + console.log('File "' + capture + '" already parsed'); + // File was already parsed, leave the import commented. + return '// @import "' + capture + '";'; + } + + parsedFiles.push(file); + var text = fs.readFileSync(file); + + // Recursive call. + return scssCombine(text, parse.dir, paths, parsedFiles); +} + +/** + * Combine scss files with its imports + * + * @param {string} content Scss string to read. + * @param {string} baseDir Directory where the file was found. + * @param {string} paths Alternative paths where to find the imports. + * @param {Array} parsedFiles Yet parsed files to reduce size of the result. + * @return {string} Scss string with the replaces done. + */ +function scssCombine(content, baseDir, paths, parsedFiles) { + + // Content is a Buffer, convert to string. + if (typeof content != "string") { + content = content.toString(); + } + + // Search of single imports. + var regex = /@import[ ]*['"](.*)['"][ ]*;/g; + + if (regex.test(content)) { + return content.replace(regex, function(m, capture) { + if (capture == "bmma") { + return m; + } + + return getReplace(capture, baseDir, paths, parsedFiles); + }); + } + + // Search of multiple imports. + regex = /@import(?:[ \n]+['"](.*)['"][,]?[ \n]*)+;/gm; + if (regex.test(content)) { + return content.replace(regex, function(m, capture) { + var text = ""; + + // Divide the import into multiple files. + regex = /['"]([^'"]*)['"]/g; + var captures = m.match(regex); + for (var x in captures) { + text += getReplace(captures[x].replace(/['"]+/g, ''), baseDir, paths, parsedFiles) + "\n"; + } + + return text; + }); + } + + return content; +} + +gulp.task('combine-scss', function(done) { + var paths = [ + 'node_modules/ionic-angular/themes/', + 'node_modules/font-awesome/scss/', + 'node_modules/ionicons/dist/scss/' + ]; + + var parsedFiles = []; + + gulp.src([ + './src/theme/variables.scss', + './node_modules/ionic-angular/themes/ionic.globals.*.scss', + './node_modules/ionic-angular/themes/ionic.components.scss', + './src/**/*.scss']) // define a source files + .pipe(through(function(file, encoding, callback) { + if (file.isNull()) { + return; + } + + parsedFiles.push(file); + file.contents = bufferFrom(scssCombine(file.contents, path.dirname(file.path), paths, parsedFiles)); + + this.emit('data', file); + })) // combine them based on @import and save it to stream + .pipe(concat('combined.scss')) // concat the stream output in single file + .pipe(gulp.dest('.')) // save file to destination. + .on('end', done); +}); diff --git a/package-lock.json b/package-lock.json index d970a674d..24f38ee3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "moodlemobile", - "version": "3.6.1", + "version": "3.7.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -2473,9 +2473,9 @@ "dev": true }, "com-darryncampbell-cordova-plugin-intent": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/com-darryncampbell-cordova-plugin-intent/-/com-darryncampbell-cordova-plugin-intent-1.1.1.tgz", - "integrity": "sha512-h+V54+qCFY1h5csX8lAKTxBn5DdbP/8/sm7vS6X0WZPI+OTKycxeoJC+oGtPHhlvTh4gSEVW5/MkDqANRcmaug==" + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/com-darryncampbell-cordova-plugin-intent/-/com-darryncampbell-cordova-plugin-intent-1.1.7.tgz", + "integrity": "sha512-e+CIaOTpZ7r178tmCijZcm/o5nJIWVnQaUrwm5xwX1zc5zutVCtz1oH3xqq6gzNk05C9i7n96xdenODHMYpiMw==" }, "combined-stream": { "version": "1.0.6", @@ -2521,6 +2521,15 @@ "typedarray": "^0.0.6" } }, + "concat-with-sourcemaps": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz", + "integrity": "sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==", + "dev": true, + "requires": { + "source-map": "^0.6.1" + } + }, "console-browserify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", @@ -2808,18 +2817,18 @@ } }, "cordova-android-support-gradle-release": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/cordova-android-support-gradle-release/-/cordova-android-support-gradle-release-2.0.1.tgz", - "integrity": "sha512-HlX75PN8b9y3LIlAFLQspSbO7dr7hTRi2/n4A2Hz4AHb7NxiVt6VU+6j+JcseDveVdddh1sKMZd0xPtFMVNjXA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cordova-android-support-gradle-release/-/cordova-android-support-gradle-release-3.0.0.tgz", + "integrity": "sha512-vyiqQ6N9Qb+4xRizWSpUX/LyJ1HaDN0piWc8xoS9Hx9YodIS3vyi1UpQyfLQmCixoeLVcRieKXjuSMXnUrv1dw==", "requires": { - "semver": "5.1.0", - "xml2js": "~0.4.19" + "q": "^1.4.1", + "semver": "5.6.0" }, "dependencies": { "semver": { - "version": "5.1.0", - "resolved": "http://registry.npmjs.org/semver/-/semver-5.1.0.tgz", - "integrity": "sha1-hfLPhVBGXE3wAM99hvawVBBqueU=" + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz", + "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==" } } }, @@ -3173,9 +3182,8 @@ "integrity": "sha512-6ucQ6FdlLdBm8kJfFnzozmBTjru/0xekHP/dAhjoCZggkGRlgs8TsUJFkxa/bV+qi7Dlo50JjmpE4UMWAO+aOQ==" }, "cordova-plugin-local-notification": { - "version": "0.9.0-beta.3", - "resolved": "https://registry.npmjs.org/cordova-plugin-local-notification/-/cordova-plugin-local-notification-0.9.0-beta.3.tgz", - "integrity": "sha512-L3Z1velxrkm9nHFcvLnMgBPZjKFt6hwM6hn1lA+JFwIR26Yw6UF72z+/lRMBclAcOxBIDYCqeaLgvezmajjuEg==" + "version": "git+https://github.com/moodlemobile/cordova-plugin-local-notification.git#5b2f3073a1c1fb39cad3566be792445c343db2c6", + "from": "git+https://github.com/moodlemobile/cordova-plugin-local-notification.git#moodle" }, "cordova-plugin-media-capture": { "version": "3.0.2", @@ -4082,9 +4090,9 @@ } }, "esprima": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.0.tgz", - "integrity": "sha512-oftTcaMu/EGrEIu904mWteKIv8vMuOgGYo7EhVJJN00R/EED9DCua/xxHRdYnKtcECzVg7xOWhflvJMnqcFZjw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true }, "esrecurse": { @@ -5253,9 +5261,9 @@ } }, "fstream": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.11.tgz", - "integrity": "sha1-XB+x8RdHcRTwYyoOtLcbPLD9MXE=", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", "dev": true, "requires": { "graceful-fs": "^4.1.2", @@ -5870,6 +5878,17 @@ "through2": "~2.0.1" } }, + "gulp-concat": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/gulp-concat/-/gulp-concat-2.6.1.tgz", + "integrity": "sha1-Yz0WyV2IUEYorQJmVmPO5aR5M1M=", + "dev": true, + "requires": { + "concat-with-sourcemaps": "^1.0.0", + "through2": "^2.0.0", + "vinyl": "^2.0.0" + } + }, "gulp-flatten": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/gulp-flatten/-/gulp-flatten-0.4.0.tgz", @@ -6672,9 +6691,9 @@ "dev": true }, "js-yaml": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.12.0.tgz", - "integrity": "sha512-PIt2cnwmPfL4hKNwqeiuz4bKfnzHTBv6HyVgjahA6mPLwPDzjDWrplJBMjHUFxku/N3FlmrbyPclad+I+4mJ3A==", + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", "dev": true, "requires": { "argparse": "^1.0.7", @@ -8560,7 +8579,7 @@ }, "pegjs": { "version": "0.10.0", - "resolved": "http://registry.npmjs.org/pegjs/-/pegjs-0.10.0.tgz", + "resolved": "https://registry.npmjs.org/pegjs/-/pegjs-0.10.0.tgz", "integrity": "sha1-z4uvrm7d/0tafvsYUmnqr0YQ3b0=" }, "performance-now": { @@ -8575,8 +8594,8 @@ "integrity": "sha512-1wvc3iQOQpEBaQbXgLxA2JUiLSQ2azdF/bF29ghXDiQJWSpQ1BF8gSuqttM8WZoj081Ps8OKL0gYxdDBkFNPqA==" }, "phonegap-plugin-push": { - "version": "git+https://github.com/moodlemobile/phonegap-plugin-push.git#9b1d9fe575d1f21b517327c480e7fe0f73280e7a", - "from": "git+https://github.com/moodlemobile/phonegap-plugin-push.git#moodle-v2", + "version": "git+https://github.com/moodlemobile/phonegap-plugin-push.git#cf8101e86adb774ae1d7ad6b65fb9d8802673f4b", + "from": "git+https://github.com/moodlemobile/phonegap-plugin-push.git#moodle-v3", "requires": { "babel-plugin-add-header-comment": "^1.0.3", "install": "^0.8.2" @@ -8796,6 +8815,11 @@ "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", "dev": true }, + "q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=" + }, "qs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", @@ -9588,7 +9612,8 @@ "sax": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true }, "scss-tokenizer": { "version": "0.2.3", @@ -10179,14 +10204,28 @@ "dev": true }, "tar": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz", - "integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.2.tgz", + "integrity": "sha512-FCEhQ/4rE1zYv9rYXJw/msRqsnmlje5jHP6huWeBZ704jUTy02c5AZyWujpMR1ax6mVw9NyJMfuK2CMDWVIfgA==", "dev": true, "requires": { "block-stream": "*", - "fstream": "^1.0.2", + "fstream": "^1.0.12", "inherits": "2" + }, + "dependencies": { + "fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + } + } } }, "temp-file": { @@ -12174,6 +12213,7 @@ "version": "0.4.19", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", + "dev": true, "requires": { "sax": ">=0.6.0", "xmlbuilder": "~9.0.1" @@ -12182,7 +12222,8 @@ "xmlbuilder": { "version": "9.0.7", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", - "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" + "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=", + "dev": true }, "xmldom": { "version": "0.1.27", diff --git a/package.json b/package.json index 221a31a47..91b804947 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moodlemobile", - "version": "3.6.1", + "version": "3.7.0", "description": "The official app for Moodle.", "author": { "name": "Moodle Pty Ltd.", @@ -28,6 +28,7 @@ "build": "ionic-app-scripts build", "lint": "ionic-app-scripts lint", "ionic:build": "node --max-old-space-size=16384 ./node_modules/@ionic/app-scripts/bin/ionic-app-scripts.js build", + "ionic:serve:before": "gulp", "ionic:serve": "gulp watch | ionic-app-scripts serve", "ionic:build:before": "gulp", "ionic:watch:before": "gulp", @@ -78,9 +79,9 @@ "@types/node": "8.10.19", "@types/promise.prototype.finally": "2.0.2", "chart.js": "2.7.2", - "com-darryncampbell-cordova-plugin-intent": "1.1.1", + "com-darryncampbell-cordova-plugin-intent": "1.1.7", "cordova-android": "7.1.2", - "cordova-android-support-gradle-release": "2.0.1", + "cordova-android-support-gradle-release": "3.0.0", "cordova-clipboard": "1.2.1", "cordova-ios": "4.5.5", "cordova-plugin-badge": "0.8.8", @@ -93,7 +94,7 @@ "cordova-plugin-globalization": "1.11.0", "cordova-plugin-inappbrowser": "3.0.0", "cordova-plugin-ionic-keyboard": "2.1.3", - "cordova-plugin-local-notification": "0.9.0-beta.3", + "cordova-plugin-local-notification": "git+https://github.com/moodlemobile/cordova-plugin-local-notification.git#moodle", "cordova-plugin-media-capture": "3.0.2", "cordova-plugin-network-information": "2.0.1", "cordova-plugin-screen-orientation": "3.0.1", @@ -111,7 +112,7 @@ "moment": "2.22.2", "nl.kingsquare.cordova.background-audio": "1.0.1", "phonegap-plugin-multidex": "1.0.0", - "phonegap-plugin-push": "git+https://github.com/moodlemobile/phonegap-plugin-push.git#moodle-v2", + "phonegap-plugin-push": "git+https://github.com/moodlemobile/phonegap-plugin-push.git#moodle-v3", "promise.prototype.finally": "3.1.0", "rxjs": "5.5.11", "sw-toolbox": "3.6.0", @@ -125,6 +126,7 @@ "electron-rebuild": "1.8.1", "gulp": "4.0.0", "gulp-clip-empty-files": "0.1.2", + "gulp-concat": "2.6.1", "gulp-flatten": "0.4.0", "gulp-rename": "1.3.0", "gulp-slash": "1.1.3", @@ -227,6 +229,9 @@ }, "snap": { "confinement": "classic" + }, + "nsis": { + "deleteAppDataOnUninstall": true } } } diff --git a/resources/android/icon.png b/resources/android/icon.png old mode 100755 new mode 100644 index bb6b82176..59ea30d89 Binary files a/resources/android/icon.png and b/resources/android/icon.png differ diff --git a/resources/android/icon.png.md5 b/resources/android/icon.png.md5 index c36f96a17..baafd389e 100644 --- a/resources/android/icon.png.md5 +++ b/resources/android/icon.png.md5 @@ -1 +1 @@ -35bf4a4bbe8ec8e40270338abd041adc \ No newline at end of file +5e8ac0ef8768e0fad3284434d24064f8 \ No newline at end of file diff --git a/resources/android/icon/drawable-hdpi-icon.png b/resources/android/icon/drawable-hdpi-icon.png index 0b8ce8e6a..b155f74e0 100644 Binary files a/resources/android/icon/drawable-hdpi-icon.png and b/resources/android/icon/drawable-hdpi-icon.png differ diff --git a/resources/android/icon/drawable-ldpi-icon.png b/resources/android/icon/drawable-ldpi-icon.png index ed0faa889..4d607a119 100644 Binary files a/resources/android/icon/drawable-ldpi-icon.png and b/resources/android/icon/drawable-ldpi-icon.png differ diff --git a/resources/android/icon/drawable-mdpi-icon.png b/resources/android/icon/drawable-mdpi-icon.png index 9952bcbb5..33e7a52b1 100644 Binary files a/resources/android/icon/drawable-mdpi-icon.png and b/resources/android/icon/drawable-mdpi-icon.png differ diff --git a/resources/android/icon/drawable-xhdpi-icon.png b/resources/android/icon/drawable-xhdpi-icon.png index e169ccfe8..1503f3377 100644 Binary files a/resources/android/icon/drawable-xhdpi-icon.png and b/resources/android/icon/drawable-xhdpi-icon.png differ diff --git a/resources/android/icon/drawable-xxhdpi-icon.png b/resources/android/icon/drawable-xxhdpi-icon.png index 324b21968..2c5af19f9 100644 Binary files a/resources/android/icon/drawable-xxhdpi-icon.png and b/resources/android/icon/drawable-xxhdpi-icon.png differ diff --git a/resources/android/icon/drawable-xxxhdpi-icon.png b/resources/android/icon/drawable-xxxhdpi-icon.png index 5d4d57eb4..b19a28d9f 100644 Binary files a/resources/android/icon/drawable-xxxhdpi-icon.png and b/resources/android/icon/drawable-xxxhdpi-icon.png differ diff --git a/resources/android/splash/drawable-land-hdpi-screen.png b/resources/android/splash/drawable-land-hdpi-screen.png index 075093575..2bbe24c29 100644 Binary files a/resources/android/splash/drawable-land-hdpi-screen.png and b/resources/android/splash/drawable-land-hdpi-screen.png differ diff --git a/resources/android/splash/drawable-land-ldpi-screen.png b/resources/android/splash/drawable-land-ldpi-screen.png index 4c5494c7e..c043d3597 100644 Binary files a/resources/android/splash/drawable-land-ldpi-screen.png and b/resources/android/splash/drawable-land-ldpi-screen.png differ diff --git a/resources/android/splash/drawable-land-mdpi-screen.png b/resources/android/splash/drawable-land-mdpi-screen.png index f40758507..d5eab5c1d 100644 Binary files a/resources/android/splash/drawable-land-mdpi-screen.png and b/resources/android/splash/drawable-land-mdpi-screen.png differ diff --git a/resources/android/splash/drawable-land-xhdpi-screen.png b/resources/android/splash/drawable-land-xhdpi-screen.png index 20ab9081d..0398df2bd 100644 Binary files a/resources/android/splash/drawable-land-xhdpi-screen.png and b/resources/android/splash/drawable-land-xhdpi-screen.png differ diff --git a/resources/android/splash/drawable-land-xxhdpi-screen.png b/resources/android/splash/drawable-land-xxhdpi-screen.png index b9183d9b5..bcb36cc12 100644 Binary files a/resources/android/splash/drawable-land-xxhdpi-screen.png and b/resources/android/splash/drawable-land-xxhdpi-screen.png differ diff --git a/resources/android/splash/drawable-land-xxxhdpi-screen.png b/resources/android/splash/drawable-land-xxxhdpi-screen.png index 99d09c9f4..686f13132 100644 Binary files a/resources/android/splash/drawable-land-xxxhdpi-screen.png and b/resources/android/splash/drawable-land-xxxhdpi-screen.png differ diff --git a/resources/android/splash/drawable-port-hdpi-screen.png b/resources/android/splash/drawable-port-hdpi-screen.png index c8b75602a..8256b58de 100644 Binary files a/resources/android/splash/drawable-port-hdpi-screen.png and b/resources/android/splash/drawable-port-hdpi-screen.png differ diff --git a/resources/android/splash/drawable-port-ldpi-screen.png b/resources/android/splash/drawable-port-ldpi-screen.png index 1546693de..d41cda737 100644 Binary files a/resources/android/splash/drawable-port-ldpi-screen.png and b/resources/android/splash/drawable-port-ldpi-screen.png differ diff --git a/resources/android/splash/drawable-port-mdpi-screen.png b/resources/android/splash/drawable-port-mdpi-screen.png index bcd6c707f..ac8009538 100644 Binary files a/resources/android/splash/drawable-port-mdpi-screen.png and b/resources/android/splash/drawable-port-mdpi-screen.png differ diff --git a/resources/android/splash/drawable-port-xhdpi-screen.png b/resources/android/splash/drawable-port-xhdpi-screen.png index 93a5badd2..3defde516 100644 Binary files a/resources/android/splash/drawable-port-xhdpi-screen.png and b/resources/android/splash/drawable-port-xhdpi-screen.png differ diff --git a/resources/android/splash/drawable-port-xxhdpi-screen.png b/resources/android/splash/drawable-port-xxhdpi-screen.png index 728d9f4c1..00c8a8055 100644 Binary files a/resources/android/splash/drawable-port-xxhdpi-screen.png and b/resources/android/splash/drawable-port-xxhdpi-screen.png differ diff --git a/resources/android/splash/drawable-port-xxxhdpi-screen.png b/resources/android/splash/drawable-port-xxxhdpi-screen.png index 1760cec92..53b5b8661 100644 Binary files a/resources/android/splash/drawable-port-xxxhdpi-screen.png and b/resources/android/splash/drawable-port-xxxhdpi-screen.png differ diff --git a/resources/desktop/Square150x150Logo.png b/resources/desktop/Square150x150Logo.png index 9d9206792..95de043c2 100644 Binary files a/resources/desktop/Square150x150Logo.png and b/resources/desktop/Square150x150Logo.png differ diff --git a/resources/desktop/Square44x44Logo.png b/resources/desktop/Square44x44Logo.png index 6db08c208..df2f21b2d 100644 Binary files a/resources/desktop/Square44x44Logo.png and b/resources/desktop/Square44x44Logo.png differ diff --git a/resources/desktop/StoreLogo.png b/resources/desktop/StoreLogo.png index de5b0f4c6..f41b1f803 100644 Binary files a/resources/desktop/StoreLogo.png and b/resources/desktop/StoreLogo.png differ diff --git a/resources/desktop/Wide310x150Logo.png b/resources/desktop/Wide310x150Logo.png index 0b7e6c110..05b810bed 100644 Binary files a/resources/desktop/Wide310x150Logo.png and b/resources/desktop/Wide310x150Logo.png differ diff --git a/resources/desktop/icon.icns b/resources/desktop/icon.icns index 6e02dbf03..2799114b2 100644 Binary files a/resources/desktop/icon.icns and b/resources/desktop/icon.icns differ diff --git a/resources/desktop/icon.ico b/resources/desktop/icon.ico index 4c516e7b5..483c9647c 100644 Binary files a/resources/desktop/icon.ico and b/resources/desktop/icon.ico differ diff --git a/resources/ios/icon.png b/resources/ios/icon.png index de5b0f4c6..f41b1f803 100644 Binary files a/resources/ios/icon.png and b/resources/ios/icon.png differ diff --git a/resources/ios/icon.png.md5 b/resources/ios/icon.png.md5 index 5b1a29b92..d951fb13a 100644 --- a/resources/ios/icon.png.md5 +++ b/resources/ios/icon.png.md5 @@ -1 +1 @@ -3ac2bf0bded2c5da7d213095c12ead29 \ No newline at end of file +5225afcaf865b3e218501903bef688e0 \ No newline at end of file diff --git a/resources/ios/icon/icon-1024.png b/resources/ios/icon/icon-1024.png index 10123998d..377e167bf 100644 Binary files a/resources/ios/icon/icon-1024.png and b/resources/ios/icon/icon-1024.png differ diff --git a/resources/ios/icon/icon-40.png b/resources/ios/icon/icon-40.png index 7f59b6edc..d257a5303 100644 Binary files a/resources/ios/icon/icon-40.png and b/resources/ios/icon/icon-40.png differ diff --git a/resources/ios/icon/icon-40@2x.png b/resources/ios/icon/icon-40@2x.png index 4b1180523..64ab98ba1 100644 Binary files a/resources/ios/icon/icon-40@2x.png and b/resources/ios/icon/icon-40@2x.png differ diff --git a/resources/ios/icon/icon-40@3x.png b/resources/ios/icon/icon-40@3x.png index e7b0b937c..f9c0c0490 100644 Binary files a/resources/ios/icon/icon-40@3x.png and b/resources/ios/icon/icon-40@3x.png differ diff --git a/resources/ios/icon/icon-50.png b/resources/ios/icon/icon-50.png index e58aed988..0fefa6608 100644 Binary files a/resources/ios/icon/icon-50.png and b/resources/ios/icon/icon-50.png differ diff --git a/resources/ios/icon/icon-50@2x.png b/resources/ios/icon/icon-50@2x.png index 53567db5f..503c6de51 100644 Binary files a/resources/ios/icon/icon-50@2x.png and b/resources/ios/icon/icon-50@2x.png differ diff --git a/resources/ios/icon/icon-60.png b/resources/ios/icon/icon-60.png index 193525361..f3ae32330 100644 Binary files a/resources/ios/icon/icon-60.png and b/resources/ios/icon/icon-60.png differ diff --git a/resources/ios/icon/icon-60@2x.png b/resources/ios/icon/icon-60@2x.png index e7b0b937c..83acef985 100644 Binary files a/resources/ios/icon/icon-60@2x.png and b/resources/ios/icon/icon-60@2x.png differ diff --git a/resources/ios/icon/icon-60@3x.png b/resources/ios/icon/icon-60@3x.png index 8a525d68c..2be3dc5ca 100644 Binary files a/resources/ios/icon/icon-60@3x.png and b/resources/ios/icon/icon-60@3x.png differ diff --git a/resources/ios/icon/icon-72.png b/resources/ios/icon/icon-72.png index d530e8e1f..8ebd26a51 100644 Binary files a/resources/ios/icon/icon-72.png and b/resources/ios/icon/icon-72.png differ diff --git a/resources/ios/icon/icon-72@2x.png b/resources/ios/icon/icon-72@2x.png index ce2a7efda..42c444fa8 100644 Binary files a/resources/ios/icon/icon-72@2x.png and b/resources/ios/icon/icon-72@2x.png differ diff --git a/resources/ios/icon/icon-76.png b/resources/ios/icon/icon-76.png index 849905a6d..5b4184494 100644 Binary files a/resources/ios/icon/icon-76.png and b/resources/ios/icon/icon-76.png differ diff --git a/resources/ios/icon/icon-76@2x.png b/resources/ios/icon/icon-76@2x.png index 3a454bba5..d50c660f4 100644 Binary files a/resources/ios/icon/icon-76@2x.png and b/resources/ios/icon/icon-76@2x.png differ diff --git a/resources/ios/icon/icon-83.5@2x.png b/resources/ios/icon/icon-83.5@2x.png index 2e7f699ab..ccc01673f 100644 Binary files a/resources/ios/icon/icon-83.5@2x.png and b/resources/ios/icon/icon-83.5@2x.png differ diff --git a/resources/ios/icon/icon-small.png b/resources/ios/icon/icon-small.png index 6ae30e55b..f7978170f 100644 Binary files a/resources/ios/icon/icon-small.png and b/resources/ios/icon/icon-small.png differ diff --git a/resources/ios/icon/icon-small@2x.png b/resources/ios/icon/icon-small@2x.png index 849c7fd2e..1ab363c66 100644 Binary files a/resources/ios/icon/icon-small@2x.png and b/resources/ios/icon/icon-small@2x.png differ diff --git a/resources/ios/icon/icon-small@3x.png b/resources/ios/icon/icon-small@3x.png index da4210202..8ded4e1ea 100644 Binary files a/resources/ios/icon/icon-small@3x.png and b/resources/ios/icon/icon-small@3x.png differ diff --git a/resources/ios/icon/icon.png b/resources/ios/icon/icon.png index da6598385..9c018d104 100644 Binary files a/resources/ios/icon/icon.png and b/resources/ios/icon/icon.png differ diff --git a/resources/ios/icon/icon@2x.png b/resources/ios/icon/icon@2x.png index eb647caa6..e3bc95e6a 100644 Binary files a/resources/ios/icon/icon@2x.png and b/resources/ios/icon/icon@2x.png differ diff --git a/resources/ios/splash/Default-568h@2x~iphone.png b/resources/ios/splash/Default-568h@2x~iphone.png index f6f96fe2c..9e7f3dde5 100644 Binary files a/resources/ios/splash/Default-568h@2x~iphone.png and b/resources/ios/splash/Default-568h@2x~iphone.png differ diff --git a/resources/ios/splash/Default-667h.png b/resources/ios/splash/Default-667h.png index bb7d2b3d9..9b6ee14a5 100644 Binary files a/resources/ios/splash/Default-667h.png and b/resources/ios/splash/Default-667h.png differ diff --git a/resources/ios/splash/Default-736h.png b/resources/ios/splash/Default-736h.png index c4694479f..1773d666e 100644 Binary files a/resources/ios/splash/Default-736h.png and b/resources/ios/splash/Default-736h.png differ diff --git a/resources/ios/splash/Default-Landscape-736h.png b/resources/ios/splash/Default-Landscape-736h.png index c5eabae52..7cfc62946 100644 Binary files a/resources/ios/splash/Default-Landscape-736h.png and b/resources/ios/splash/Default-Landscape-736h.png differ diff --git a/resources/ios/splash/Default-Landscape@2x~ipad.png b/resources/ios/splash/Default-Landscape@2x~ipad.png index d79da749a..c7a2b2494 100644 Binary files a/resources/ios/splash/Default-Landscape@2x~ipad.png and b/resources/ios/splash/Default-Landscape@2x~ipad.png differ diff --git a/resources/ios/splash/Default-Landscape@~ipadpro.png b/resources/ios/splash/Default-Landscape@~ipadpro.png index 806b39872..ab00ad820 100644 Binary files a/resources/ios/splash/Default-Landscape@~ipadpro.png and b/resources/ios/splash/Default-Landscape@~ipadpro.png differ diff --git a/resources/ios/splash/Default-Landscape~ipad.png b/resources/ios/splash/Default-Landscape~ipad.png index 75d69a3c2..995decfa1 100644 Binary files a/resources/ios/splash/Default-Landscape~ipad.png and b/resources/ios/splash/Default-Landscape~ipad.png differ diff --git a/resources/ios/splash/Default-Portrait@2x~ipad.png b/resources/ios/splash/Default-Portrait@2x~ipad.png index f933400cc..5a9b09090 100644 Binary files a/resources/ios/splash/Default-Portrait@2x~ipad.png and b/resources/ios/splash/Default-Portrait@2x~ipad.png differ diff --git a/resources/ios/splash/Default-Portrait@~ipadpro.png b/resources/ios/splash/Default-Portrait@~ipadpro.png index 633baac60..dd8141a79 100644 Binary files a/resources/ios/splash/Default-Portrait@~ipadpro.png and b/resources/ios/splash/Default-Portrait@~ipadpro.png differ diff --git a/resources/ios/splash/Default-Portrait~ipad.png b/resources/ios/splash/Default-Portrait~ipad.png index 69d44bc2c..a430930f2 100644 Binary files a/resources/ios/splash/Default-Portrait~ipad.png and b/resources/ios/splash/Default-Portrait~ipad.png differ diff --git a/resources/ios/splash/Default@2x~iphone.png b/resources/ios/splash/Default@2x~iphone.png index acc36d760..dfd140f53 100644 Binary files a/resources/ios/splash/Default@2x~iphone.png and b/resources/ios/splash/Default@2x~iphone.png differ diff --git a/resources/ios/splash/Default@2x~universal~anyany.png b/resources/ios/splash/Default@2x~universal~anyany.png index 05ab2d173..5d21a832c 100644 Binary files a/resources/ios/splash/Default@2x~universal~anyany.png and b/resources/ios/splash/Default@2x~universal~anyany.png differ diff --git a/resources/ios/splash/Default~iphone.png b/resources/ios/splash/Default~iphone.png index 4eb0e5831..ff0484e50 100644 Binary files a/resources/ios/splash/Default~iphone.png and b/resources/ios/splash/Default~iphone.png differ diff --git a/resources/splash.png b/resources/splash.png index c597baed2..e7889ccf9 100644 Binary files a/resources/splash.png and b/resources/splash.png differ diff --git a/resources/splash.png.md5 b/resources/splash.png.md5 index 74038fbe1..808167787 100644 --- a/resources/splash.png.md5 +++ b/resources/splash.png.md5 @@ -1 +1 @@ -32dca3a9cd3c8e9d241f68a0850d2ace \ No newline at end of file +4d2128e5cc9659b321956c1178057980 \ No newline at end of file diff --git a/scripts/aot.sh b/scripts/aot.sh index f10a70790..5fa706e64 100755 --- a/scripts/aot.sh +++ b/scripts/aot.sh @@ -6,7 +6,7 @@ if [ $TRAVIS_BRANCH == 'integration' ] || [ $TRAVIS_BRANCH == 'master' ] || [ $T ./build_lang.sh cd .. - if [ $TRAVIS_BRANCH == 'master' ] && [ ! -z $GIT_TOKEN ] ; then + if [ "$TRAVIS_BRANCH" == 'master' ] && [ ! -z $GIT_TOKEN ] ; then git remote set-url origin https://$GIT_TOKEN@github.com/$TRAVIS_REPO_SLUG.git git fetch -q origin git add src/assets/lang @@ -17,7 +17,7 @@ if [ $TRAVIS_BRANCH == 'integration' ] || [ $TRAVIS_BRANCH == 'master' ] || [ $T version=`grep versionname src/config.json| cut -d: -f2|cut -d'"' -f2` date=`date +%Y%m%d`'00' - pushd ../../moodle-local_moodlemobileapp + pushd ../moodle-local_moodlemobileapp sed -ie "s/release[ ]*=[ ]*'[^']*';/release = '$version';/1" version.php sed -ie "s/version[ ]*=[ ]*[0-9]*;/version = $date;/1" version.php rm version.phpe diff --git a/scripts/langindex.json b/scripts/langindex.json index d86efa26d..7381c6fc8 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -111,9 +111,11 @@ "addon.competency.myplans": "tool_lp", "addon.competency.noactivities": "tool_lp", "addon.competency.nocompetencies": "local_moodlemobileapp", + "addon.competency.nocompetenciesincourse": "tool_lp", "addon.competency.nocrossreferencedcompetencies": "tool_lp", "addon.competency.noevidence": "tool_lp", "addon.competency.noplanswerecreated": "tool_lp", + "addon.competency.nouserplanswithcompetency": "competency", "addon.competency.path": "tool_lp", "addon.competency.planstatusactive": "competency", "addon.competency.planstatuscomplete": "competency", @@ -126,6 +128,7 @@ "addon.competency.reviewstatus": "tool_lp", "addon.competency.status": "tool_lp", "addon.competency.template": "tool_lp", + "addon.competency.uponcoursecompletion": "tool_lp", "addon.competency.usercompetencystatus_idle": "competency", "addon.competency.usercompetencystatus_inreview": "competency", "addon.competency.usercompetencystatus_waitingforreview": "competency", @@ -177,9 +180,12 @@ "addon.messages.contactname": "local_moodlemobileapp", "addon.messages.contactrequestsent": "message", "addon.messages.contacts": "message", + "addon.messages.conversationactions": "message", "addon.messages.decline": "message", "addon.messages.deleteallconfirm": "message", + "addon.messages.deleteallselfconfirm": "message", "addon.messages.deleteconversation": "message", + "addon.messages.deleteforeveryone": "message", "addon.messages.deletemessage": "local_moodlemobileapp", "addon.messages.deletemessageconfirmation": "local_moodlemobileapp", "addon.messages.errordeletemessage": "local_moodlemobileapp", @@ -196,6 +202,8 @@ "addon.messages.messagenotsent": "local_moodlemobileapp", "addon.messages.messagepreferences": "message", "addon.messages.messages": "message", + "addon.messages.muteconversation": "message", + "addon.messages.mutedconversation": "message", "addon.messages.newmessage": "message", "addon.messages.newmessages": "local_moodlemobileapp", "addon.messages.nocontactrequests": "message", @@ -214,9 +222,8 @@ "addon.messages.requests": "moodle", "addon.messages.requirecontacttomessage": "message", "addon.messages.searchcombined": "message", - "addon.messages.searchnocontactsfound": "message", - "addon.messages.searchnomessagesfound": "message", - "addon.messages.searchnononcontactsfound": "message", + "addon.messages.selfconversation": "message", + "addon.messages.selfconversationdefaultmessage": "message", "addon.messages.sendcontactrequest": "message", "addon.messages.showdeletemessages": "local_moodlemobileapp", "addon.messages.type_blocked": "local_moodlemobileapp", @@ -227,6 +234,7 @@ "addon.messages.unabletomessage": "message", "addon.messages.unblockuser": "message", "addon.messages.unblockuserconfirm": "message", + "addon.messages.unmuteconversation": "message", "addon.messages.useentertosend": "message", "addon.messages.useentertosenddescdesktop": "local_moodlemobileapp", "addon.messages.useentertosenddescmac": "local_moodlemobileapp", @@ -448,6 +456,8 @@ "addon.mod_feedback.feedbackclose": "feedback", "addon.mod_feedback.feedbackopen": "feedback", "addon.mod_feedback.mapcourses": "feedback", + "addon.mod_feedback.maximal": "feedback", + "addon.mod_feedback.minimal": "feedback", "addon.mod_feedback.mode": "feedback", "addon.mod_feedback.modulenameplural": "feedback", "addon.mod_feedback.next_page": "feedback", @@ -474,11 +484,20 @@ "addon.mod_forum.addanewdiscussion": "forum", "addon.mod_forum.addanewquestion": "forum", "addon.mod_forum.addanewtopic": "forum", + "addon.mod_forum.addtofavourites": "forum", + "addon.mod_forum.advanced": "moodle", "addon.mod_forum.cannotadddiscussion": "forum", "addon.mod_forum.cannotadddiscussionall": "forum", "addon.mod_forum.cannotcreatediscussion": "forum", "addon.mod_forum.couldnotadd": "forum", + "addon.mod_forum.cutoffdatereached": "forum", "addon.mod_forum.discussion": "forum", + "addon.mod_forum.discussionlistsortbycreatedasc": "forum", + "addon.mod_forum.discussionlistsortbycreateddesc": "forum", + "addon.mod_forum.discussionlistsortbylastpostasc": "forum", + "addon.mod_forum.discussionlistsortbylastpostdesc": "forum", + "addon.mod_forum.discussionlistsortbyrepliesasc": "forum", + "addon.mod_forum.discussionlistsortbyrepliesdesc": "forum", "addon.mod_forum.discussionlocked": "forum", "addon.mod_forum.discussionpinned": "forum", "addon.mod_forum.discussionsubscription": "forum", @@ -487,8 +506,12 @@ "addon.mod_forum.erroremptysubject": "forum", "addon.mod_forum.errorgetforum": "local_moodlemobileapp", "addon.mod_forum.errorgetgroups": "local_moodlemobileapp", + "addon.mod_forum.errorposttoallgroups": "local_moodlemobileapp", + "addon.mod_forum.favouriteupdated": "forum", "addon.mod_forum.forumnodiscussionsyet": "local_moodlemobileapp", "addon.mod_forum.group": "local_moodlemobileapp", + "addon.mod_forum.lockdiscussion": "forum", + "addon.mod_forum.lockupdated": "forum", "addon.mod_forum.message": "forum", "addon.mod_forum.modeflatnewestfirst": "forum", "addon.mod_forum.modeflatoldestfirst": "forum", @@ -496,12 +519,23 @@ "addon.mod_forum.modulenameplural": "forum", "addon.mod_forum.numdiscussions": "local_moodlemobileapp", "addon.mod_forum.numreplies": "local_moodlemobileapp", + "addon.mod_forum.pindiscussion": "forum", + "addon.mod_forum.pinupdated": "forum", + "addon.mod_forum.postisprivatereply": "forum", "addon.mod_forum.posttoforum": "forum", + "addon.mod_forum.posttomygroups": "forum", + "addon.mod_forum.privatereply": "forum", "addon.mod_forum.re": "forum", "addon.mod_forum.refreshdiscussions": "local_moodlemobileapp", "addon.mod_forum.refreshposts": "local_moodlemobileapp", + "addon.mod_forum.removefromfavourites": "forum", "addon.mod_forum.reply": "forum", + "addon.mod_forum.replyplaceholder": "forum", "addon.mod_forum.subject": "forum", + "addon.mod_forum.thisforumhasduedate": "forum", + "addon.mod_forum.thisforumisdue": "forum", + "addon.mod_forum.unlockdiscussion": "forum", + "addon.mod_forum.unpindiscussion": "forum", "addon.mod_forum.unread": "forum", "addon.mod_forum.unreadpostsnumber": "forum", "addon.mod_glossary.addentry": "glossary", @@ -632,6 +666,7 @@ "addon.mod_quiz.attemptquiznow": "quiz", "addon.mod_quiz.attemptstate": "quiz", "addon.mod_quiz.cannotsubmitquizdueto": "local_moodlemobileapp", + "addon.mod_quiz.clearchoice": "qtype_multichoice", "addon.mod_quiz.comment": "quiz", "addon.mod_quiz.completedon": "quiz", "addon.mod_quiz.confirmclose": "quiz", @@ -878,6 +913,11 @@ "addon.notifications.notifications": "local_moodlemobileapp", "addon.notifications.playsound": "local_moodlemobileapp", "addon.notifications.therearentnotificationsyet": "local_moodlemobileapp", + "addon.storagemanager.deletecourse": "local_moodlemobileapp", + "addon.storagemanager.deletedatafrom": "local_moodlemobileapp", + "addon.storagemanager.info": "local_moodlemobileapp", + "addon.storagemanager.managestorage": "local_moodlemobileapp", + "addon.storagemanager.storageused": "local_moodlemobileapp", "assets.countries.AD": "countries", "assets.countries.AE": "countries", "assets.countries.AF": "countries", @@ -1184,6 +1224,7 @@ "core.agelocationverification": "moodle", "core.ago": "message", "core.all": "moodle", + "core.allgroups": "moodle", "core.allparticipants": "moodle", "core.android": "local_moodlemobileapp", "core.answer": "moodle", @@ -1229,6 +1270,7 @@ "core.contentlinks.confirmurlothersite": "local_moodlemobileapp", "core.contentlinks.errornoactions": "local_moodlemobileapp", "core.contentlinks.errornosites": "local_moodlemobileapp", + "core.contentlinks.errorredirectothersite": "local_moodlemobileapp", "core.continue": "moodle", "core.copiedtoclipboard": "local_moodlemobileapp", "core.course": "moodle", @@ -1237,9 +1279,11 @@ "core.course.activitynotyetviewablesiteupgradeneeded": "local_moodlemobileapp", "core.course.allsections": "local_moodlemobileapp", "core.course.askadmintosupport": "local_moodlemobileapp", + "core.course.availablespace": "local_moodlemobileapp", "core.course.confirmdeletemodulefiles": "local_moodlemobileapp", "core.course.confirmdownload": "local_moodlemobileapp", "core.course.confirmdownloadunknownsize": "local_moodlemobileapp", + "core.course.confirmlimiteddownload": "local_moodlemobileapp", "core.course.confirmpartialdownloadsize": "local_moodlemobileapp", "core.course.contents": "local_moodlemobileapp", "core.course.couldnotloadsectioncontent": "local_moodlemobileapp", @@ -1251,6 +1295,8 @@ "core.course.errorgetmodule": "local_moodlemobileapp", "core.course.hiddenfromstudents": "moodle", "core.course.hiddenoncoursepage": "moodle", + "core.course.insufficientavailablequota": "local_moodlemobileapp", + "core.course.insufficientavailablespace": "local_moodlemobileapp", "core.course.manualcompletionnotsynced": "local_moodlemobileapp", "core.course.nocontentavailable": "local_moodlemobileapp", "core.course.overriddennotice": "grades", @@ -1319,6 +1365,7 @@ "core.dismiss": "local_moodlemobileapp", "core.done": "survey", "core.download": "moodle", + "core.downloaded": "local_moodlemobileapp", "core.downloading": "local_moodlemobileapp", "core.edit": "moodle", "core.emptysplit": "local_moodlemobileapp", @@ -1342,6 +1389,7 @@ "core.favourites": "moodle", "core.filename": "repository", "core.filenameexist": "local_moodlemobileapp", + "core.filenotfound": "resource", "core.fileuploader.addfiletext": "repository", "core.fileuploader.audio": "local_moodlemobileapp", "core.fileuploader.camera": "local_moodlemobileapp", @@ -1555,6 +1603,7 @@ "core.nopermissions": "error", "core.noresults": "moodle", "core.notapplicable": "local_moodlemobileapp", + "core.notenrolledprofile": "moodle", "core.notice": "moodle", "core.notingroup": "moodle", "core.notsent": "local_moodlemobileapp", @@ -1596,14 +1645,14 @@ "core.question.questionno": "question", "core.question.requiresgrading": "question", "core.quotausage": "moodle", - "core.rating.aggregateavg": "moodle", - "core.rating.aggregatecount": "moodle", - "core.rating.aggregatemax": "moodle", - "core.rating.aggregatemin": "moodle", - "core.rating.aggregatesum": "moodle", - "core.rating.noratings": "moodle", - "core.rating.rating": "moodle", - "core.rating.ratings": "moodle", + "core.rating.aggregateavg": "rating", + "core.rating.aggregatecount": "rating", + "core.rating.aggregatemax": "rating", + "core.rating.aggregatemin": "rating", + "core.rating.aggregatesum": "rating", + "core.rating.noratings": "rating", + "core.rating.rating": "rating", + "core.rating.ratings": "rating", "core.redirectingtosite": "local_moodlemobileapp", "core.refresh": "moodle", "core.remove": "moodle", @@ -1612,6 +1661,7 @@ "core.resourcedisplayopen": "moodle", "core.resources": "moodle", "core.restore": "moodle", + "core.restricted": "moodle", "core.retry": "local_moodlemobileapp", "core.save": "moodle", "core.search": "moodle", @@ -1648,6 +1698,7 @@ "core.settings.enablerichtexteditor": "local_moodlemobileapp", "core.settings.enablerichtexteditordescription": "local_moodlemobileapp", "core.settings.enablesyncwifi": "local_moodlemobileapp", + "core.settings.entriesincache": "local_moodlemobileapp", "core.settings.errordeletesitefiles": "local_moodlemobileapp", "core.settings.errorsyncsite": "local_moodlemobileapp", "core.settings.estimatedfreespace": "local_moodlemobileapp", @@ -1698,6 +1749,7 @@ "core.sizemb": "moodle", "core.sizetb": "local_moodlemobileapp", "core.sorry": "local_moodlemobileapp", + "core.sort": "moodle", "core.sortby": "moodle", "core.start": "grouptool", "core.strftimedate": "langconfig", diff --git a/scripts/linux.sh b/scripts/linux.sh index ef76cd0a5..571102f5b 100755 --- a/scripts/linux.sh +++ b/scripts/linux.sh @@ -27,6 +27,10 @@ if [ ! -z $GIT_ORG_PRIVATE ] && [ ! -z $GIT_TOKEN ] ; then mv *i386.AppImage linux-ia32.AppImage mv Moodle*.AppImage linux-x64.AppImage ls + + tar -czvf MoodleDesktop32.tar.gz linux-ia32.AppImage + tar -czvf MoodleDesktop64.tar.gz linux-x64.AppImage + rm *.AppImage git add . git commit -m "Linux desktop versions from Travis build $TRAVIS_BUILD_NUMBER" diff --git a/scripts/moodle_to_json.php b/scripts/moodle_to_json.php index 2eccc8c1d..bc6d128e5 100644 --- a/scripts/moodle_to_json.php +++ b/scripts/moodle_to_json.php @@ -34,9 +34,11 @@ $config_langs = array_keys(get_object_vars($config['languages'])); // Set languages to do. If script is called using a language it will be used as unique. if (isset($argv[1]) && !empty($argv[1])) { $forcedetect = false; + define('TOTRANSLATE', true); $languages = explode(',', $argv[1]); } else { $forcedetect = true; + define('TOTRANSLATE', false); $languages = $config_langs; } @@ -160,16 +162,19 @@ function build_lang($lang, $keys, $total) { $file = LANGPACKSFOLDER.'/'.$langfoldername.'/'.$value->file.'.php'; // Apply translations. if (!file_exists($file)) { + if (TOTRANSLATE) { + echo "\n\t\To translate $value->string on $value->file"; + } continue; } $string = []; include($file); - if (!isset($string[$value->string])) { + if (!isset($string[$value->string]) || ($lang == 'en' && $value->file == 'local_moodlemobileapp')) { // Not yet translated. Do not override. if (!$langFile) { - // Load lang fils just once. + // Load lang files just once. $langFile = file_get_contents(ASSETSPATH.$lang.'.json'); $langFile = (array) json_decode($langFile); } @@ -177,6 +182,9 @@ function build_lang($lang, $keys, $total) { $translations[$key] = $langFile[$key]; $local++; } + if (TOTRANSLATE) { + echo "\n\t\tTo translate $value->string on $value->file"; + } continue; } else { $text = $string[$value->string]; diff --git a/src/addon/badges/badges.module.ts b/src/addon/badges/badges.module.ts index 380fe0046..20163a208 100644 --- a/src/addon/badges/badges.module.ts +++ b/src/addon/badges/badges.module.ts @@ -17,8 +17,10 @@ import { AddonBadgesProvider } from './providers/badges'; import { AddonBadgesUserHandler } from './providers/user-handler'; import { AddonBadgesMyBadgesLinkHandler } from './providers/mybadges-link-handler'; import { AddonBadgesBadgeLinkHandler } from './providers/badge-link-handler'; +import { AddonBadgesPushClickHandler } from './providers/push-click-handler'; import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; import { CoreUserDelegate } from '@core/user/providers/user-delegate'; +import { CorePushNotificationsDelegate } from '@core/pushnotifications/providers/delegate'; // List of providers (without handlers). export const ADDON_BADGES_PROVIDERS: any[] = [ @@ -34,16 +36,19 @@ export const ADDON_BADGES_PROVIDERS: any[] = [ AddonBadgesProvider, AddonBadgesUserHandler, AddonBadgesMyBadgesLinkHandler, - AddonBadgesBadgeLinkHandler + AddonBadgesBadgeLinkHandler, + AddonBadgesPushClickHandler ] }) export class AddonBadgesModule { constructor(userDelegate: CoreUserDelegate, userHandler: AddonBadgesUserHandler, contentLinksDelegate: CoreContentLinksDelegate, myBadgesLinkHandler: AddonBadgesMyBadgesLinkHandler, - badgeLinkHandler: AddonBadgesBadgeLinkHandler) { + badgeLinkHandler: AddonBadgesBadgeLinkHandler, + pushNotificationsDelegate: CorePushNotificationsDelegate, pushClickHandler: AddonBadgesPushClickHandler) { userDelegate.registerHandler(userHandler); contentLinksDelegate.registerHandler(myBadgesLinkHandler); contentLinksDelegate.registerHandler(badgeLinkHandler); + pushNotificationsDelegate.registerClickHandler(pushClickHandler); } } diff --git a/src/addon/badges/providers/badges.ts b/src/addon/badges/providers/badges.ts index 2245ad1fb..019341e39 100644 --- a/src/addon/badges/providers/badges.ts +++ b/src/addon/badges/providers/badges.ts @@ -15,6 +15,7 @@ import { Injectable } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; +import { CoreSite } from '@classes/site'; /** * Service to handle badges. @@ -79,11 +80,12 @@ export class AddonBadgesProvider { courseid : courseId, userid : userId }, - presets = { - cacheKey: this.getBadgesCacheKey(courseId, userId) + preSets = { + cacheKey: this.getBadgesCacheKey(courseId, userId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; - return site.read('core_badges_get_user_badges', data, presets).then((response) => { + return site.read('core_badges_get_user_badges', data, preSets).then((response) => { if (response && response.badges) { return response.badges; } else { diff --git a/src/addon/badges/providers/push-click-handler.ts b/src/addon/badges/providers/push-click-handler.ts new file mode 100644 index 000000000..1aabf4f28 --- /dev/null +++ b/src/addon/badges/providers/push-click-handler.ts @@ -0,0 +1,71 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CorePushNotificationsClickHandler } from '@core/pushnotifications/providers/delegate'; +import { CoreLoginHelperProvider } from '@core/login/providers/helper'; +import { AddonBadgesProvider } from './badges'; + +/** + * Handler for badges push notifications clicks. + */ +@Injectable() +export class AddonBadgesPushClickHandler implements CorePushNotificationsClickHandler { + name = 'AddonBadgesPushClickHandler'; + priority = 200; + featureName = 'CoreUserDelegate_AddonBadges'; + + constructor(private utils: CoreUtilsProvider, private badgesProvider: AddonBadgesProvider, + private loginHelper: CoreLoginHelperProvider) {} + + /** + * Check if a notification click is handled by this handler. + * + * @param {any} notification The notification to check. + * @return {boolean} Whether the notification click is handled by this handler + */ + handles(notification: any): boolean | Promise { + const data = notification.customdata || {}; + + if (this.utils.isTrueOrOne(notification.notif) && notification.moodlecomponent == 'moodle' && + (notification.name == 'badgerecipientnotice' || (notification.name == 'badgecreatornotice' && data.hash))) { + return this.badgesProvider.isPluginEnabled(notification.site); + } + + return false; + } + + /** + * Handle the notification click. + * + * @param {any} notification The notification to check. + * @return {Promise} Promise resolved when done. + */ + handleClick(notification: any): Promise { + const data = notification.customdata || {}; + + if (data.hash) { + // We have the hash, open the badge directly. + return this.loginHelper.redirect('AddonBadgesIssuedBadgePage', {courseId: 0, badgeHash: data.hash}, notification.site); + } + + // No hash, open the list of user badges. + return this.badgesProvider.invalidateUserBadges(0, Number(notification.usertoid), notification.site).catch(() => { + // Ignore errors. + }).then(() => { + return this.loginHelper.redirect('AddonBadgesUserBadgesPage', {}, notification.site); + }); + } +} diff --git a/src/addon/block/myoverview/components/myoverview/addon-block-myoverview.html b/src/addon/block/myoverview/components/myoverview/addon-block-myoverview.html index 9b3cfa183..598c204f0 100644 --- a/src/addon/block/myoverview/components/myoverview/addon-block-myoverview.html +++ b/src/addon/block/myoverview/components/myoverview/addon-block-myoverview.html @@ -1,7 +1,7 @@

{{ 'addon.block_myoverview.pluginname' | translate }}

-
+
@@ -36,7 +36,7 @@ - + diff --git a/src/addon/block/myoverview/components/myoverview/myoverview.ts b/src/addon/block/myoverview/components/myoverview/myoverview.ts index e577c5161..b36202f7e 100644 --- a/src/addon/block/myoverview/components/myoverview/myoverview.ts +++ b/src/addon/block/myoverview/components/myoverview/myoverview.ts @@ -62,11 +62,14 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem showHidden = false; showSelectorFilter = false; showSortFilter = false; + downloadCourseEnabled: boolean; + downloadCoursesEnabled: boolean; protected prefetchIconsInitialized = false; protected isDestroyed; protected downloadButtonObserver; protected coursesObserver; + protected updateSiteObserver; protected courseIds = []; protected fetchContentDefaultError = 'Error getting my overview data.'; @@ -96,6 +99,16 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem } }); + this.downloadCourseEnabled = !this.coursesProvider.isDownloadCourseDisabledInSite(); + this.downloadCoursesEnabled = !this.coursesProvider.isDownloadCoursesDisabledInSite(); + + // Refresh the enabled flags if site is updated. + this.updateSiteObserver = this.eventsProvider.on(CoreEventsProvider.SITE_UPDATED, () => { + this.downloadCourseEnabled = !this.coursesProvider.isDownloadCourseDisabledInSite(); + this.downloadCoursesEnabled = !this.coursesProvider.isDownloadCoursesDisabledInSite(); + + }, this.sitesProvider.getCurrentSiteId()); + this.coursesObserver = this.eventsProvider.on(CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, () => { this.refreshContent(); }, this.sitesProvider.getCurrentSiteId()); @@ -336,6 +349,7 @@ export class AddonBlockMyOverviewComponent extends CoreBlockBaseComponent implem ngOnDestroy(): void { this.isDestroyed = true; this.coursesObserver && this.coursesObserver.off(); + this.updateSiteObserver && this.updateSiteObserver.off(); this.downloadButtonObserver && this.downloadButtonObserver.off(); } } diff --git a/src/addon/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts b/src/addon/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts index 02c044771..0f3355219 100644 --- a/src/addon/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts +++ b/src/addon/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts @@ -75,7 +75,7 @@ export class AddonBlockSiteMainMenuComponent extends CoreBlockBaseComponent impl */ protected fetchContent(): Promise { return this.courseProvider.getSections(this.siteHomeId, false, true).then((sections) => { - this.block = sections[0]; + this.block = sections.find((section) => section.section == 0); if (this.block) { this.block.hasContent = this.courseHelper.sectionHasContent(this.block); diff --git a/src/addon/block/timeline/providers/timeline.ts b/src/addon/block/timeline/providers/timeline.ts index 942ba4ba5..148aed267 100644 --- a/src/addon/block/timeline/providers/timeline.ts +++ b/src/addon/block/timeline/providers/timeline.ts @@ -14,6 +14,7 @@ import { Injectable } from '@angular/core'; import { CoreSitesProvider } from '@providers/sites'; +import { CoreCoursesDashboardProvider } from '@core/courses/providers/dashboard'; import * as moment from 'moment'; /** @@ -26,7 +27,7 @@ export class AddonBlockTimelineProvider { // Cache key was maintained when moving the functions to this file. It comes from core myoverview. protected ROOT_CACHE_KEY = 'myoverview:'; - constructor(private sitesProvider: CoreSitesProvider) { } + constructor(private sitesProvider: CoreSitesProvider, private dashboardProvider: CoreCoursesDashboardProvider) { } /** * Get calendar action events for the given course. @@ -218,6 +219,11 @@ export class AddonBlockTimelineProvider { */ isAvailable(siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { + // First check if dashboard is disabled. + if (this.dashboardProvider.isDisabledInSite(site)) { + return false; + } + return site.wsAvailable('core_calendar_get_action_events_by_courses') && site.wsAvailable('core_calendar_get_action_events_by_timesort'); }); diff --git a/src/addon/blog/components/entries/addon-blog-entries.html b/src/addon/blog/components/entries/addon-blog-entries.html index 9312a00cd..670e4d276 100644 --- a/src/addon/blog/components/entries/addon-blog-entries.html +++ b/src/addon/blog/components/entries/addon-blog-entries.html @@ -29,7 +29,7 @@ - + diff --git a/src/addon/blog/components/entries/entries.ts b/src/addon/blog/components/entries/entries.ts index 54f84d30a..b66db02a3 100644 --- a/src/addon/blog/components/entries/entries.ts +++ b/src/addon/blog/components/entries/entries.ts @@ -18,6 +18,7 @@ import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreSitesProvider } from '@providers/sites'; import { CoreUserProvider } from '@core/user/providers/user'; import { AddonBlogProvider } from '../../providers/blog'; +import { CoreCommentsProvider } from '@core/comments/providers/comments'; /** * Component that displays the blog entries. @@ -47,9 +48,11 @@ export class AddonBlogEntriesComponent implements OnInit { showMyIssuesToggle = false; onlyMyEntries = false; component = AddonBlogProvider.COMPONENT; + commentsEnabled: boolean; constructor(protected blogProvider: AddonBlogProvider, protected domUtils: CoreDomUtilsProvider, - protected userProvider: CoreUserProvider, sitesProvider: CoreSitesProvider) { + protected userProvider: CoreUserProvider, sitesProvider: CoreSitesProvider, + protected commentsProvider: CoreCommentsProvider) { this.currentUserId = sitesProvider.getCurrentSiteUserId(); } @@ -81,6 +84,8 @@ export class AddonBlogEntriesComponent implements OnInit { this.filter['tagid'] = this.tagId; } + this.commentsEnabled = !this.commentsProvider.areCommentsDisabledInSite(); + this.fetchEntries().then(() => { this.blogProvider.logView(this.filter).catch(() => { // Ignore errors. diff --git a/src/addon/blog/providers/blog.ts b/src/addon/blog/providers/blog.ts index adc15b1f2..90b875283 100644 --- a/src/addon/blog/providers/blog.ts +++ b/src/addon/blog/providers/blog.ts @@ -16,6 +16,8 @@ import { Injectable } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CorePushNotificationsProvider } from '@core/pushnotifications/providers/pushnotifications'; +import { CoreSite } from '@classes/site'; /** * Service to handle blog entries. @@ -27,7 +29,8 @@ export class AddonBlogProvider { protected ROOT_CACHE_KEY = 'addonBlog:'; protected logger; - constructor(logger: CoreLoggerProvider, protected sitesProvider: CoreSitesProvider, protected utils: CoreUtilsProvider) { + constructor(logger: CoreLoggerProvider, protected sitesProvider: CoreSitesProvider, protected utils: CoreUtilsProvider, + protected pushNotificationsProvider: CorePushNotificationsProvider) { this.logger = logger.getInstance('AddonBlogProvider'); } @@ -74,7 +77,8 @@ export class AddonBlogProvider { }; const preSets = { - cacheKey: this.getEntriesCacheKey(filter) + cacheKey: this.getEntriesCacheKey(filter), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; return site.read('core_blog_get_entries', data, preSets); @@ -102,6 +106,8 @@ export class AddonBlogProvider { * @return {Promise} Promise to be resolved when done. */ logView(filter: any = {}, siteId?: string): Promise { + this.pushNotificationsProvider.logViewListEvent('blog', 'core_blog_view_entries', filter, siteId); + return this.sitesProvider.getSite(siteId).then((site) => { const data = { filters: this.utils.objectToArrayOfObjects(filter, 'name', 'value') diff --git a/src/addon/blog/providers/index-link-handler.ts b/src/addon/blog/providers/index-link-handler.ts index 176aec76e..189fad7a3 100644 --- a/src/addon/blog/providers/index-link-handler.ts +++ b/src/addon/blog/providers/index-link-handler.ts @@ -24,7 +24,7 @@ import { AddonBlogProvider } from './blog'; @Injectable() export class AddonBlogIndexLinkHandler extends CoreContentLinksHandlerBase { name = 'AddonBlogIndexLinkHandler'; - featureName = 'CoreUserDelegate_AddonBlog'; + featureName = 'CoreUserDelegate_AddonBlog:blogs'; pattern = /\/blog\/index\.php/; constructor(private blogProvider: AddonBlogProvider, private loginHelper: CoreLoginHelperProvider) { diff --git a/src/addon/calendar/pages/event/event.html b/src/addon/calendar/pages/event/event.html index c00376085..3c40c931a 100644 --- a/src/addon/calendar/pages/event/event.html +++ b/src/addon/calendar/pages/event/event.html @@ -57,7 +57,7 @@

{{ 'addon.calendar.reminders' | translate }}

- +

{{ 'core.defaultvalue' | translate :{$a: ((event.timestart - defaultTime) * 1000) | coreFormatDate } }}

{{ reminder.time * 1000 | coreFormatDate }}

+
diff --git a/src/addon/calendar/providers/calendar.ts b/src/addon/calendar/providers/calendar.ts index 14ad8d69b..40dcd2208 100644 --- a/src/addon/calendar/providers/calendar.ts +++ b/src/addon/calendar/providers/calendar.ts @@ -292,7 +292,8 @@ export class AddonCalendarProvider { getEvent(id: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { const preSets = { - cacheKey: this.getEventCacheKey(id) + cacheKey: this.getEventCacheKey(id), + updateFrequency: CoreSite.FREQUENCY_RARELY }, data = { options: { @@ -329,7 +330,8 @@ export class AddonCalendarProvider { getEventById(id: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { const preSets = { - cacheKey: this.getEventCacheKey(id) + cacheKey: this.getEventCacheKey(id), + updateFrequency: CoreSite.FREQUENCY_RARELY }, data = { eventid: id @@ -469,7 +471,8 @@ export class AddonCalendarProvider { // We need to retrieve cached data using cache key because we have timestamp in the params. const preSets = { cacheKey: this.getEventsListCacheKey(daysToStart, daysInterval), - getCacheUsingCacheKey: true + getCacheUsingCacheKey: true, + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; return site.read('core_calendar_get_calendar_events', data, preSets).then((response) => { @@ -646,6 +649,7 @@ export class AddonCalendarProvider { id: reminderId, title: event.name, text: this.timeUtils.userDate(event.timestart * 1000, 'core.strftimedaydatetime', true), + icon: 'file://assets/img/icons/calendar.png', trigger: { at: new Date(time) }, diff --git a/src/addon/competency/competency.module.ts b/src/addon/competency/competency.module.ts index c321e8a20..b7aecb757 100644 --- a/src/addon/competency/competency.module.ts +++ b/src/addon/competency/competency.module.ts @@ -18,10 +18,17 @@ import { AddonCompetencyHelperProvider } from './providers/helper'; import { AddonCompetencyCourseOptionHandler } from './providers/course-option-handler'; import { AddonCompetencyMainMenuHandler } from './providers/mainmenu-handler'; import { AddonCompetencyUserHandler } from './providers/user-handler'; +import { AddonCompetencyCompetencyLinkHandler } from './providers/competency-link-handler'; +import { AddonCompetencyPlanLinkHandler } from './providers/plan-link-handler'; +import { AddonCompetencyPlansLinkHandler } from './providers/plans-link-handler'; +import { AddonCompetencyUserCompetencyLinkHandler } from './providers/user-competency-link-handler'; +import { AddonCompetencyPushClickHandler } from './providers/push-click-handler'; import { AddonCompetencyComponentsModule } from './components/components.module'; import { CoreCourseOptionsDelegate } from '@core/course/providers/options-delegate'; import { CoreMainMenuDelegate } from '@core/mainmenu/providers/delegate'; import { CoreUserDelegate } from '@core/user/providers/user-delegate'; +import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; +import { CorePushNotificationsDelegate } from '@core/pushnotifications/providers/delegate'; // List of providers (without handlers). export const ADDON_COMPETENCY_PROVIDERS: any[] = [ @@ -40,16 +47,30 @@ export const ADDON_COMPETENCY_PROVIDERS: any[] = [ AddonCompetencyHelperProvider, AddonCompetencyCourseOptionHandler, AddonCompetencyMainMenuHandler, - AddonCompetencyUserHandler + AddonCompetencyUserHandler, + AddonCompetencyCompetencyLinkHandler, + AddonCompetencyPlanLinkHandler, + AddonCompetencyPlansLinkHandler, + AddonCompetencyUserCompetencyLinkHandler, + AddonCompetencyPushClickHandler ] }) export class AddonCompetencyModule { constructor(mainMenuDelegate: CoreMainMenuDelegate, mainMenuHandler: AddonCompetencyMainMenuHandler, courseOptionsDelegate: CoreCourseOptionsDelegate, courseOptionHandler: AddonCompetencyCourseOptionHandler, - userDelegate: CoreUserDelegate, userHandler: AddonCompetencyUserHandler) { + userDelegate: CoreUserDelegate, userHandler: AddonCompetencyUserHandler, + contentLinksDelegate: CoreContentLinksDelegate, competencyLinkHandler: AddonCompetencyCompetencyLinkHandler, + planLinkHandler: AddonCompetencyPlanLinkHandler, plansLinkHandler: AddonCompetencyPlansLinkHandler, + userComptencyLinkHandler: AddonCompetencyUserCompetencyLinkHandler, + pushNotificationsDelegate: CorePushNotificationsDelegate, pushClickHandler: AddonCompetencyPushClickHandler) { mainMenuDelegate.registerHandler(mainMenuHandler); courseOptionsDelegate.registerHandler(courseOptionHandler); userDelegate.registerHandler(userHandler); + contentLinksDelegate.registerHandler(competencyLinkHandler); + contentLinksDelegate.registerHandler(planLinkHandler); + contentLinksDelegate.registerHandler(plansLinkHandler); + contentLinksDelegate.registerHandler(userComptencyLinkHandler); + pushNotificationsDelegate.registerClickHandler(pushClickHandler); } } diff --git a/src/addon/competency/components/course/addon-competency-course.html b/src/addon/competency/components/course/addon-competency-course.html index c8636ff41..041646947 100644 --- a/src/addon/competency/components/course/addon-competency-course.html +++ b/src/addon/competency/components/course/addon-competency-course.html @@ -4,18 +4,19 @@ - - {{ 'addon.competency.coursecompetencyratingsarepushedtouserplans' | translate }} - - - {{ 'addon.competency.coursecompetencyratingsarenotpushedtouserplans' | translate }} - - - {{ 'addon.competency.progress' | translate }}: - {{ 'addon.competency.xcompetenciesproficientoutofyincourse' | translate:{$a: {x: competencies.statistics.proficientcompetencycount, y: competencies.statistics.competencycount} } }} ({{ competencies.statistics.proficientcompetencypercentageformatted }}%) + + + {{ 'addon.competency.coursecompetencyratingsarepushedtouserplans' | translate }} + + + {{ 'addon.competency.coursecompetencyratingsarenotpushedtouserplans' | translate }} + + + + {{ 'addon.competency.xcompetenciesproficientoutofyincourse' | translate:{$a: {x: competencies.statistics.proficientcompetencycount, y: competencies.statistics.competencycount} } }} - + {{ 'addon.competency.competenciesmostoftennotproficientincourse' | translate }}:

@@ -25,42 +26,63 @@ -

{{ 'addon.competency.competencies' | translate }}

+

{{ 'addon.competency.coursecompetencies' | translate }}

- +
- - {{competency.competency.shortname}} {{competency.competency.idnumber}} + +

{{competency.competency.shortname}} {{competency.competency.idnumber}}

{{ competency.usercompetencycourse.gradename }} -
+ -
+

-

+

{{ 'addon.competency.path' | translate }} - {{ competency.comppath.framework.name }} + {{ competency.comppath.framework.name }} + {{ competency.comppath.framework.name }} +  /  -  / {{ ancestor.name }} + {{ ancestor.name }} + {{ ancestor.name }} +  / 
+
+ {{ 'addon.competency.uponcoursecompletion' | translate }} + + + + + +
- {{ 'addon.competency.activities' | translate }}: - + {{ 'addon.competency.activities' | translate }} +

{{ 'addon.competency.noactivities' | translate }} - - +

+
+
+ {{ 'addon.competency.userplans' | translate }} +

+ {{ 'addon.competency.nouserplanswithcompetency' | translate }} +

+ + + +
diff --git a/src/addon/competency/lang/en.json b/src/addon/competency/lang/en.json index a36bacbd7..e09256cb0 100644 --- a/src/addon/competency/lang/en.json +++ b/src/addon/competency/lang/en.json @@ -23,9 +23,11 @@ "myplans": "My learning plans", "noactivities": "No activities", "nocompetencies": "No competencies", + "nocompetenciesincourse": "No competencies have been linked to this course.", "nocrossreferencedcompetencies": "No other competencies have been cross-referenced to this competency.", "noevidence": "No evidence", "noplanswerecreated": "No learning plans were created.", + "nouserplanswithcompetency": "No learning plans contain this competency.", "path": "Path:", "planstatusactive": "Active", "planstatuscomplete": "Complete", @@ -38,6 +40,7 @@ "reviewstatus": "Review status", "status": "Status", "template": "Learning plan template", + "uponcoursecompletion": "Upon course completion:", "usercompetencystatus_idle": "Idle", "usercompetencystatus_inreview": "In review", "usercompetencystatus_waitingforreview": "Waiting for review", diff --git a/src/addon/competency/pages/competencies/competencies.html b/src/addon/competency/pages/competencies/competencies.html index 6319eafd3..b89d3a0e0 100644 --- a/src/addon/competency/pages/competencies/competencies.html +++ b/src/addon/competency/pages/competencies/competencies.html @@ -11,7 +11,7 @@ - {{ competency.competency.shortname }} {{competency.competency.idnumber}} +

{{ competency.competency.shortname }} {{competency.competency.idnumber}}

{{ competency.usercompetency.gradename }} {{ competency.usercompetencycourse.gradename }}
diff --git a/src/addon/competency/pages/competency/competency.html b/src/addon/competency/pages/competency/competency.html index 34bf134cc..dc048e446 100644 --- a/src/addon/competency/pages/competency/competency.html +++ b/src/addon/competency/pages/competency/competency.html @@ -21,9 +21,13 @@
{{ 'addon.competency.path' | translate }} - {{ competency.competency.comppath.framework.name }} + {{ competency.competency.comppath.framework.name }} + {{ competency.competency.comppath.framework.name }} +  /  -  / {{ ancestor.name }} + {{ ancestor.name }} + {{ ancestor.name }} +  /  @@ -38,21 +42,21 @@
- {{ 'addon.competency.activities' | translate }}: - + {{ 'addon.competency.activities' | translate }} +

{{ 'addon.competency.noactivities' | translate }} - +

- {{ 'addon.competency.reviewstatus' | translate }}: + {{ 'addon.competency.reviewstatus' | translate }} {{ competency.usercompetency.statusname }} - {{ 'addon.competency.proficient' | translate }}: + {{ 'addon.competency.proficient' | translate }} {{ 'core.yes' | translate }} @@ -61,7 +65,7 @@ - {{ 'addon.competency.rating' | translate }}: + {{ 'addon.competency.rating' | translate }} {{ competency.usercompetency.gradename }} diff --git a/src/addon/competency/pages/competency/competency.ts b/src/addon/competency/pages/competency/competency.ts index 9552e75a6..dc7e76c9d 100644 --- a/src/addon/competency/pages/competency/competency.ts +++ b/src/addon/competency/pages/competency/competency.ts @@ -55,13 +55,16 @@ export class AddonCompetencyCompetencyPage { */ ionViewDidLoad(): void { this.fetchCompetency().then(() => { + const name = this.competency && this.competency.competency && this.competency.competency.competency && + this.competency.competency.competency.shortname; + if (this.planId) { - this.competencyProvider.logCompetencyInPlanView(this.planId, this.competencyId, this.planStatus, this.userId) - .catch(() => { + this.competencyProvider.logCompetencyInPlanView(this.planId, this.competencyId, this.planStatus, name, + this.userId).catch(() => { // Ignore errors. }); } else { - this.competencyProvider.logCompetencyInCourseView(this.courseId, this.competencyId, this.userId).catch(() => { + this.competencyProvider.logCompetencyInCourseView(this.courseId, this.competencyId, name, this.userId).catch(() => { // Ignore errors. }); } diff --git a/src/addon/competency/pages/competencysummary/competencysummary.ts b/src/addon/competency/pages/competencysummary/competencysummary.ts index 3045f44f3..db4c7a32d 100644 --- a/src/addon/competency/pages/competencysummary/competencysummary.ts +++ b/src/addon/competency/pages/competencysummary/competencysummary.ts @@ -41,7 +41,10 @@ export class AddonCompetencyCompetencySummaryPage { */ ionViewDidLoad(): void { this.fetchCompetency().then(() => { - this.competencyProvider.logCompetencyView(this.competencyId).catch(() => { + const name = this.competency.competency && this.competency.competency.competency && + this.competency.competency.competency.shortname; + + this.competencyProvider.logCompetencyView(this.competencyId, name).catch(() => { // Ignore errors. }); }).finally(() => { diff --git a/src/addon/competency/pages/plan/plan.html b/src/addon/competency/pages/plan/plan.html index f84bc276e..f8c5a91da 100644 --- a/src/addon/competency/pages/plan/plan.html +++ b/src/addon/competency/pages/plan/plan.html @@ -16,6 +16,9 @@ + + + {{ 'addon.competency.status' | translate }}: {{ plan.plan.statusname }} @@ -36,13 +39,13 @@ - {{ 'addon.competency.learningplancompetencies' | translate }} +

{{ 'addon.competency.learningplancompetencies' | translate }}

{{ 'addon.competency.nocompetencies' | translate }} - {{competency.competency.shortname}} {{competency.competency.idnumber}} +

{{competency.competency.shortname}} {{competency.competency.idnumber}}

{{ competency.usercompetency.gradename }}
diff --git a/src/addon/competency/pages/planlist/planlist.html b/src/addon/competency/pages/planlist/planlist.html index 9088d2cf6..485ce2a45 100644 --- a/src/addon/competency/pages/planlist/planlist.html +++ b/src/addon/competency/pages/planlist/planlist.html @@ -15,7 +15,7 @@

{{ plan.name }}

{{ 'addon.competency.duedate' | translate }}: {{ plan.duedate * 1000 | coreFormatDate :'strftimedatetimeshort' }}

- {{ plan.statusname }} + {{ plan.statusname }}
diff --git a/src/addon/competency/providers/competency-link-handler.ts b/src/addon/competency/providers/competency-link-handler.ts new file mode 100644 index 000000000..a85c8d1bb --- /dev/null +++ b/src/addon/competency/providers/competency-link-handler.ts @@ -0,0 +1,74 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { AddonCompetencyProvider } from './competency'; + +/** + * Handler to treat links to a competency in a plan or in a course. + */ +@Injectable() +export class AddonCompetencyCompetencyLinkHandler extends CoreContentLinksHandlerBase { + name = 'AddonCompetencyCompetencyLinkHandler'; + pattern = /\/admin\/tool\/lp\/(user_competency_in_course|user_competency_in_plan)\.php/; + + constructor(private linkHelper: CoreContentLinksHelperProvider, private competencyProvider: AddonCompetencyProvider) { + super(); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + courseId = courseId || params.courseid || params.cid; + + return [{ + action: (siteId, navCtrl?): void => { + this.linkHelper.goInSite(navCtrl, 'AddonCompetencyCompetencyPage', { + planId: params.planid, + competencyId: params.competencyid, + courseId: courseId, + userId: params.userid + }, siteId); + } + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + // Handler is disabled if all competency features are disabled. + return this.competencyProvider.allCompetenciesDisabled(siteId).then((disabled) => { + return !disabled; + }); + } +} diff --git a/src/addon/competency/providers/competency.ts b/src/addon/competency/providers/competency.ts index 28fae6304..f2ba8f2a6 100644 --- a/src/addon/competency/providers/competency.ts +++ b/src/addon/competency/providers/competency.ts @@ -15,6 +15,8 @@ import { Injectable } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; +import { CorePushNotificationsProvider } from '@core/pushnotifications/providers/pushnotifications'; +import { CoreSite } from '@classes/site'; /** * Service to handle caompetency learning plans. @@ -38,10 +40,25 @@ export class AddonCompetencyProvider { protected logger; - constructor(loggerProvider: CoreLoggerProvider, private sitesProvider: CoreSitesProvider) { + constructor(loggerProvider: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, + protected pushNotificationsProvider: CorePushNotificationsProvider) { this.logger = loggerProvider.getInstance('AddonCompetencyProvider'); } + /** + * Check if all competencies features are disabled. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: whether all competency features are disabled. + */ + allCompetenciesDisabled(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.isFeatureDisabled('CoreMainMenuDelegate_AddonCompetency') && + site.isFeatureDisabled('CoreCourseOptionsDelegate_AddonCompetency') && + site.isFeatureDisabled('CoreUserDelegate_AddonCompetency'); + }); + } + /** * Get cache key for user learning plans data WS calls. * @@ -140,7 +157,8 @@ export class AddonCompetencyProvider { userid: userId }, preSets = { - cacheKey: this.getLearningPlansCacheKey(userId) + cacheKey: this.getLearningPlansCacheKey(userId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('tool_lp_data_for_plans_page', params, preSets).then((response) => { @@ -169,7 +187,8 @@ export class AddonCompetencyProvider { planid: planId }, preSets = { - cacheKey: this.getLearningPlanCacheKey(planId) + cacheKey: this.getLearningPlanCacheKey(planId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('tool_lp_data_for_plan_page', params, preSets).then((response) => { @@ -200,7 +219,8 @@ export class AddonCompetencyProvider { competencyid: competencyId }, preSets = { - cacheKey: this.getCompetencyInPlanCacheKey(planId, competencyId) + cacheKey: this.getCompetencyInPlanCacheKey(planId, competencyId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; return site.read('tool_lp_data_for_user_competency_summary_in_plan', params, preSets).then((response) => { @@ -237,7 +257,8 @@ export class AddonCompetencyProvider { userid: userId }, preSets: any = { - cacheKey: this.getCompetencyInCourseCacheKey(courseId, competencyId, userId) + cacheKey: this.getCompetencyInCourseCacheKey(courseId, competencyId, userId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; if (ignoreCache) { @@ -275,7 +296,8 @@ export class AddonCompetencyProvider { userid: userId }, preSets: any = { - cacheKey: this.getCompetencySummaryCacheKey(competencyId, userId) + cacheKey: this.getCompetencySummaryCacheKey(competencyId, userId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; if (ignoreCache) { @@ -311,7 +333,8 @@ export class AddonCompetencyProvider { courseid: courseId }, preSets: any = { - cacheKey: this.getCourseCompetenciesCacheKey(courseId) + cacheKey: this.getCourseCompetenciesCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; if (ignoreCache) { @@ -457,13 +480,15 @@ export class AddonCompetencyProvider { * @param {number} planId ID of the plan. * @param {number} competencyId ID of the competency. * @param {number} planStatus Current plan Status to decide what action should be logged. + * @param {string} [name] Name of the competency. * @param {number} [userId] User ID. If not defined, current user. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logCompetencyInPlanView(planId: number, competencyId: number, planStatus: number, userId?: number, siteId?: string) - : Promise { + logCompetencyInPlanView(planId: number, competencyId: number, planStatus: number, name?: string, userId?: number, + siteId?: string): Promise { if (planId && competencyId) { + return this.sitesProvider.getSite(siteId).then((site) => { userId = userId || site.getUserId(); @@ -474,13 +499,17 @@ export class AddonCompetencyProvider { }, preSets = { typeExpected: 'boolean' - }; + }, + wsName = planStatus == AddonCompetencyProvider.STATUS_COMPLETE ? + 'core_competency_user_competency_plan_viewed' : 'core_competency_user_competency_viewed_in_plan'; - if (planStatus == AddonCompetencyProvider.STATUS_COMPLETE) { - return site.write('core_competency_user_competency_plan_viewed', params, preSets); - } else { - return site.write('core_competency_user_competency_viewed_in_plan', params, preSets); - } + this.pushNotificationsProvider.logViewEvent(competencyId, name, 'competency', wsName, { + planid: planId, + planstatus: planStatus, + userid: userId + }, siteId); + + return site.write(wsName, params, preSets); }); } @@ -492,11 +521,14 @@ export class AddonCompetencyProvider { * * @param {number} courseId ID of the course. * @param {number} competencyId ID of the competency. + * @param {string} [name] Name of the competency. * @param {number} [userId] User ID. If not defined, current user. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logCompetencyInCourseView(courseId: number, competencyId: number, userId?: number, siteId?: string): Promise { + logCompetencyInCourseView(courseId: number, competencyId: number, name?: string, userId?: number, siteId?: string) + : Promise { + if (courseId && competencyId) { return this.sitesProvider.getSite(siteId).then((site) => { userId = userId || site.getUserId(); @@ -509,8 +541,14 @@ export class AddonCompetencyProvider { const preSets = { typeExpected: 'boolean' }; + const wsName = 'core_competency_user_competency_viewed_in_course'; - return site.write('core_competency_user_competency_viewed_in_course', params, preSets); + this.pushNotificationsProvider.logViewEvent(competencyId, name, 'competency', wsName, { + courseid: courseId, + userid: userId + }, siteId); + + return site.write(wsName, params, preSets); }); } @@ -521,10 +559,11 @@ export class AddonCompetencyProvider { * Report the competency as being viewed. * * @param {number} competencyId ID of the competency. + * @param {string} [name] Name of the competency. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logCompetencyView(competencyId: number, siteId?: string): Promise { + logCompetencyView(competencyId: number, name?: string, siteId?: string): Promise { if (competencyId) { return this.sitesProvider.getSite(siteId).then((site) => { const params = { @@ -533,6 +572,9 @@ export class AddonCompetencyProvider { const preSets = { typeExpected: 'boolean' }; + const wsName = 'core_competency_competency_viewed'; + + this.pushNotificationsProvider.logViewEvent(competencyId, name, 'competency', wsName, {}, siteId); return site.write('core_competency_competency_viewed', params, preSets); }); diff --git a/src/addon/competency/providers/plan-link-handler.ts b/src/addon/competency/providers/plan-link-handler.ts new file mode 100644 index 000000000..1e20c5fe7 --- /dev/null +++ b/src/addon/competency/providers/plan-link-handler.ts @@ -0,0 +1,68 @@ +// (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 { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { AddonCompetencyProvider } from './competency'; + +/** + * Handler to treat links to a plan. + */ +@Injectable() +export class AddonCompetencyPlanLinkHandler extends CoreContentLinksHandlerBase { + name = 'AddonCompetencyPlanLinkHandler'; + pattern = /\/admin\/tool\/lp\/plan\.php.*([\?\&]id=\d+)/; + + constructor(private linkHelper: CoreContentLinksHelperProvider, private competencyProvider: AddonCompetencyProvider) { + super(); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + + return [{ + action: (siteId, navCtrl?): void => { + this.linkHelper.goInSite(navCtrl, 'AddonCompetencyPlanPage', { planId: params.id }, siteId); + } + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + // Handler is disabled if all competency features are disabled. + return this.competencyProvider.allCompetenciesDisabled(siteId).then((disabled) => { + return !disabled; + }); + } +} diff --git a/src/addon/competency/providers/plans-link-handler.ts b/src/addon/competency/providers/plans-link-handler.ts new file mode 100644 index 000000000..a99280f3a --- /dev/null +++ b/src/addon/competency/providers/plans-link-handler.ts @@ -0,0 +1,69 @@ +// (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 { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreLoginHelperProvider } from '@core/login/providers/helper'; +import { AddonCompetencyProvider } from './competency'; + +/** + * Handler to treat links to user plans. + */ +@Injectable() +export class AddonCompetencyPlansLinkHandler extends CoreContentLinksHandlerBase { + name = 'AddonCompetencyPlansLinkHandler'; + pattern = /\/admin\/tool\/lp\/plans\.php/; + + constructor(private loginHelper: CoreLoginHelperProvider, private competencyProvider: AddonCompetencyProvider) { + super(); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + + return [{ + action: (siteId, navCtrl?): void => { + // Always use redirect to make it the new history root (to avoid "loops" in history). + this.loginHelper.redirect('AddonCompetencyPlanListPage', { userId: params.userid }, siteId); + } + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + // Handler is disabled if all competency features are disabled. + return this.competencyProvider.allCompetenciesDisabled(siteId).then((disabled) => { + return !disabled; + }); + } +} diff --git a/src/addon/competency/providers/push-click-handler.ts b/src/addon/competency/providers/push-click-handler.ts new file mode 100644 index 000000000..eca567205 --- /dev/null +++ b/src/addon/competency/providers/push-click-handler.ts @@ -0,0 +1,100 @@ +// (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 { CoreUrlUtilsProvider } from '@providers/utils/url'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CorePushNotificationsClickHandler } from '@core/pushnotifications/providers/delegate'; +import { CoreLoginHelperProvider } from '@core/login/providers/helper'; +import { AddonCompetencyProvider } from './competency'; + +/** + * Handler for competencies push notifications clicks. + */ +@Injectable() +export class AddonCompetencyPushClickHandler implements CorePushNotificationsClickHandler { + name = 'AddonCompetencyPushClickHandler'; + priority = 200; + + constructor(private utils: CoreUtilsProvider, private urlUtils: CoreUrlUtilsProvider, + private competencyProvider: AddonCompetencyProvider, private loginHelper: CoreLoginHelperProvider) {} + + /** + * Check if a notification click is handled by this handler. + * + * @param {any} notification The notification to check. + * @return {boolean} Whether the notification click is handled by this handler + */ + handles(notification: any): boolean | Promise { + if (this.utils.isTrueOrOne(notification.notif) && notification.moodlecomponent == 'moodle' && + (notification.name == 'competencyplancomment' || notification.name == 'competencyusercompcomment')) { + // If all competency features are disabled, don't handle the click. + return this.competencyProvider.allCompetenciesDisabled(notification.site).then((disabled) => { + return !disabled; + }); + } + + return false; + } + + /** + * Handle the notification click. + * + * @param {any} notification The notification to check. + * @return {Promise} Promise resolved when done. + */ + handleClick(notification: any): Promise { + const contextUrlParams = this.urlUtils.extractUrlParams(notification.contexturl); + + if (notification.name == 'competencyplancomment') { + // Open the learning plan. + const planId = Number(contextUrlParams.id); + + return this.competencyProvider.invalidateLearningPlan(planId, notification.site).catch(() => { + // Ignore errors. + }).then(() => { + return this.loginHelper.redirect('AddonCompetencyPlanPage', { planId: planId }, notification.site); + }); + } else { + + if (notification.contexturl && notification.contexturl.indexOf('user_competency_in_plan.php') != -1) { + // Open the competency. + const courseId = Number(notification.course), + competencyId = Number(contextUrlParams.competencyid), + planId = Number(contextUrlParams.planid), + userId = Number(contextUrlParams.userid); + + return this.competencyProvider.invalidateCompetencyInPlan(planId, competencyId, notification.site).catch(() => { + // Ignore errors. + }).then(() => { + return this.loginHelper.redirect('AddonCompetencyCompetencyPage', { + planId: planId, + competencyId: competencyId, + courseId: courseId, + userId: userId + }, notification.site); + }); + } else { + // Open the list of plans. + const userId = Number(contextUrlParams.userid); + + return this.competencyProvider.invalidateLearningPlans(userId, notification.site).catch(() => { + // Ignore errors. + }).then(() => { + return this.loginHelper.redirect('AddonCompetencyPlanListPage', { userId: userId }, notification.site); + }); + } + } + } +} diff --git a/src/addon/competency/providers/user-competency-link-handler.ts b/src/addon/competency/providers/user-competency-link-handler.ts new file mode 100644 index 000000000..06cee4f90 --- /dev/null +++ b/src/addon/competency/providers/user-competency-link-handler.ts @@ -0,0 +1,68 @@ +// (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 { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { AddonCompetencyProvider } from './competency'; + +/** + * Handler to treat links to a usr competency. + */ +@Injectable() +export class AddonCompetencyUserCompetencyLinkHandler extends CoreContentLinksHandlerBase { + name = 'AddonCompetencyUserCompetencyLinkHandler'; + pattern = /\/admin\/tool\/lp\/user_competency\.php.*([\?\&]id=\d+)/; + + constructor(private linkHelper: CoreContentLinksHelperProvider, private competencyProvider: AddonCompetencyProvider) { + super(); + } + + /** + * Get the list of actions for a link (url). + * + * @param {string[]} siteIds List of sites the URL belongs to. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: any, courseId?: number): + CoreContentLinksAction[] | Promise { + + return [{ + action: (siteId, navCtrl?): void => { + this.linkHelper.goInSite(navCtrl, 'AddonCompetencyCompetencySummaryPage', { competencyId: params.id }, siteId); + } + }]; + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + // Handler is disabled if all competency features are disabled. + return this.competencyProvider.allCompetenciesDisabled(siteId).then((disabled) => { + return !disabled; + }); + } +} diff --git a/src/addon/coursecompletion/providers/coursecompletion.ts b/src/addon/coursecompletion/providers/coursecompletion.ts index fba2e064d..262bb8e3b 100644 --- a/src/addon/coursecompletion/providers/coursecompletion.ts +++ b/src/addon/coursecompletion/providers/coursecompletion.ts @@ -17,6 +17,7 @@ import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreCoursesProvider } from '@core/courses/providers/courses'; +import { CoreSite } from '@classes/site'; /** * Service to handle course completion. @@ -108,6 +109,7 @@ export class AddonCourseCompletionProvider { }; preSets.cacheKey = this.getCompletionCacheKey(courseId, userId); + preSets.updateFrequency = preSets.updateFrequency || CoreSite.FREQUENCY_SOMETIMES; return site.read('core_completion_get_course_completion_status', data, preSets).then((data) => { if (data.completionstatus) { diff --git a/src/addon/files/providers/files.ts b/src/addon/files/providers/files.ts index 8cad3e251..1db49f1da 100644 --- a/src/addon/files/providers/files.ts +++ b/src/addon/files/providers/files.ts @@ -77,7 +77,8 @@ export class AddonFilesProvider { return this.sitesProvider.getSite(siteId).then((site) => { const preSets = { - cacheKey: this.getFilesListCacheKey(params) + cacheKey: this.getFilesListCacheKey(params), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; return site.read('core_files_get_files', params, preSets); @@ -171,7 +172,8 @@ export class AddonFilesProvider { userid: userId }, preSets = { - cacheKey: this.getPrivateFilesInfoCacheKey(userId) + cacheKey: this.getPrivateFilesInfoCacheKey(userId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; return site.read('core_user_get_private_files_info', params, preSets); diff --git a/src/addon/messageoutput/airnotifier/pages/devices/devices.ts b/src/addon/messageoutput/airnotifier/pages/devices/devices.ts index 0bea819b4..ddb966afa 100644 --- a/src/addon/messageoutput/airnotifier/pages/devices/devices.ts +++ b/src/addon/messageoutput/airnotifier/pages/devices/devices.ts @@ -15,7 +15,7 @@ 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 { CorePushNotificationsProvider } from '@core/pushnotifications/providers/pushnotifications'; import { AddonMessageOutputAirnotifierProvider } from '../../providers/airnotifier'; /** @@ -34,7 +34,7 @@ export class AddonMessageOutputAirnotifierDevicesPage implements OnDestroy { protected updateTimeout: any; constructor(private domUtils: CoreDomUtilsProvider, private airnotifierProivder: AddonMessageOutputAirnotifierProvider, - private pushNotificationsProvider: AddonPushNotificationsProvider ) { + private pushNotificationsProvider: CorePushNotificationsProvider ) { } /** diff --git a/src/addon/messageoutput/airnotifier/providers/airnotifier.ts b/src/addon/messageoutput/airnotifier/providers/airnotifier.ts index f8ca5be71..129c1ebf0 100644 --- a/src/addon/messageoutput/airnotifier/providers/airnotifier.ts +++ b/src/addon/messageoutput/airnotifier/providers/airnotifier.ts @@ -16,6 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; import { CoreConfigConstants } from '../../../../configconstants'; +import { CoreSite } from '@classes/site'; /** * Service to handle Airnotifier message output. @@ -81,7 +82,8 @@ export class AddonMessageOutputAirnotifierProvider { appid: CoreConfigConstants.app_id }; const preSets = { - cacheKey: this.getUserDevicesCacheKey() + cacheKey: this.getUserDevicesCacheKey(), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('message_airnotifier_get_user_devices', data, preSets).then((data) => { diff --git a/src/addon/messages/components/discussions/discussions.ts b/src/addon/messages/components/discussions/discussions.ts index b072b719b..983e27a13 100644 --- a/src/addon/messages/components/discussions/discussions.ts +++ b/src/addon/messages/components/discussions/discussions.ts @@ -21,7 +21,7 @@ import { AddonMessagesProvider } from '../../providers/messages'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreAppProvider } from '@providers/app'; -import { AddonPushNotificationsDelegate } from '@addon/pushnotifications/providers/delegate'; +import { CorePushNotificationsDelegate } from '@core/pushnotifications/providers/delegate'; /** * Component that displays the list of discussions. @@ -54,7 +54,7 @@ export class AddonMessagesDiscussionsComponent implements OnDestroy { constructor(private eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider, translate: TranslateService, private messagesProvider: AddonMessagesProvider, private domUtils: CoreDomUtilsProvider, navParams: NavParams, private appProvider: CoreAppProvider, platform: Platform, private utils: CoreUtilsProvider, - pushNotificationsDelegate: AddonPushNotificationsDelegate) { + pushNotificationsDelegate: CorePushNotificationsDelegate) { this.search.loading = translate.instant('core.searching'); this.loadingMessages = translate.instant('core.loading'); diff --git a/src/addon/messages/lang/en.json b/src/addon/messages/lang/en.json index 1b542a0c7..071d2fd73 100644 --- a/src/addon/messages/lang/en.json +++ b/src/addon/messages/lang/en.json @@ -16,9 +16,12 @@ "contactname": "Contact name", "contactrequestsent": "Contact request sent", "contacts": "Contacts", + "conversationactions": "Conversation actions menu", "decline": "Decline", "deleteallconfirm": "Are you sure you would like to delete this entire conversation? This will not delete it for other conversation participants.", + "deleteallselfconfirm": "Are you sure you would like to delete this entire personal conversation?", "deleteconversation": "Delete conversation", + "deleteforeveryone": "Delete for me and everyone", "deletemessage": "Delete message", "deletemessageconfirmation": "Are you sure you want to delete this message? It will only be deleted from your messaging history and will still be viewable by the user who sent or received the message.", "errordeletemessage": "Error while deleting the message.", @@ -35,6 +38,8 @@ "messagenotsent": "The message was not sent. Please try again later.", "messagepreferences": "Message preferences", "messages": "Messages", + "muteconversation": "Mute", + "mutedconversation": "Muted conversation", "newmessage": "New message", "newmessages": "New messages", "nocontactrequests": "No contact requests", @@ -53,9 +58,8 @@ "requests": "Requests", "requirecontacttomessage": "You need to request {{$a}} to add you as a contact to be able to message them.", "searchcombined": "Search people and messages", - "searchnocontactsfound": "No contacts found", - "searchnomessagesfound": "No messages found", - "searchnononcontactsfound": "No non contacts found", + "selfconversation": "Personal space", + "selfconversationdefaultmessage": "Save draft messages, links, notes etc. to access later.", "sendcontactrequest": "Send contact request", "showdeletemessages": "Show delete messages", "type_blocked": "Blocked", @@ -66,6 +70,7 @@ "unabletomessage": "You are unable to message this user", "unblockuser": "Unblock user", "unblockuserconfirm": "Are you sure you want to unblock {{$a}}?", + "unmuteconversation": "Unmute", "useentertosend": "Use enter to send", "useentertosenddescdesktop": "If disabled, you can use Ctrl+Enter to send the message.", "useentertosenddescmac": "If disabled, you can use Cmd+Enter to send the message.", diff --git a/src/addon/messages/messages.module.ts b/src/addon/messages/messages.module.ts index 9ea151d83..8cc6a9b7b 100644 --- a/src/addon/messages/messages.module.ts +++ b/src/addon/messages/messages.module.ts @@ -29,13 +29,14 @@ import { AddonMessagesContactRequestLinkHandler } from './providers/contact-requ import { AddonMessagesDiscussionLinkHandler } from './providers/discussion-link-handler'; import { AddonMessagesIndexLinkHandler } from './providers/index-link-handler'; import { AddonMessagesSyncCronHandler } from './providers/sync-cron-handler'; +import { AddonMessagesPushClickHandler } from './providers/push-click-handler'; import { CoreAppProvider } from '@providers/app'; import { CoreSitesProvider } from '@providers/sites'; import { CoreLocalNotificationsProvider } from '@providers/local-notifications'; import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { CoreSettingsDelegate } from '@core/settings/providers/delegate'; import { AddonMessagesSettingsHandler } from './providers/settings-handler'; -import { AddonPushNotificationsDelegate } from '@addon/pushnotifications/providers/delegate'; +import { CorePushNotificationsDelegate } from '@core/pushnotifications/providers/delegate'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreUpdateManagerProvider } from '@providers/update-manager'; @@ -63,7 +64,8 @@ export const ADDON_MESSAGES_PROVIDERS: any[] = [ AddonMessagesDiscussionLinkHandler, AddonMessagesIndexLinkHandler, AddonMessagesSyncCronHandler, - AddonMessagesSettingsHandler + AddonMessagesSettingsHandler, + AddonMessagesPushClickHandler ] }) export class AddonMessagesModule { @@ -75,9 +77,9 @@ export class AddonMessagesModule { localNotifications: CoreLocalNotificationsProvider, messagesProvider: AddonMessagesProvider, sitesProvider: CoreSitesProvider, linkHelper: CoreContentLinksHelperProvider, updateManager: CoreUpdateManagerProvider, settingsHandler: AddonMessagesSettingsHandler, settingsDelegate: CoreSettingsDelegate, - pushNotificationsDelegate: AddonPushNotificationsDelegate, utils: CoreUtilsProvider, + pushNotificationsDelegate: CorePushNotificationsDelegate, utils: CoreUtilsProvider, addContactHandler: AddonMessagesAddContactUserHandler, blockContactHandler: AddonMessagesBlockContactUserHandler, - contactRequestLinkHandler: AddonMessagesContactRequestLinkHandler) { + contactRequestLinkHandler: AddonMessagesContactRequestLinkHandler, pushClickHandler: AddonMessagesPushClickHandler) { // Register handlers. mainMenuDelegate.registerHandler(mainmenuHandler); contentLinksDelegate.registerHandler(indexLinkHandler); @@ -89,6 +91,7 @@ export class AddonMessagesModule { cronDelegate.register(syncHandler); cronDelegate.register(mainmenuHandler); settingsDelegate.registerHandler(settingsHandler); + pushNotificationsDelegate.registerClickHandler(pushClickHandler); // Sync some discussions when device goes online. network.onConnect().subscribe(() => { @@ -118,8 +121,8 @@ export class AddonMessagesModule { // Check if we have enough information to open the conversation. if (notification.convid && enabled) { pageParams.conversationId = Number(notification.convid); - } else if (notification.userfromid) { - pageParams.discussionUserId = Number(notification.userfromid); + } else if (notification.userfromid || notification.useridfrom) { + pageParams.discussionUserId = Number(notification.userfromid || notification.useridfrom); } linkHelper.goInSite(undefined, pageName, pageParams, notification.site); @@ -134,18 +137,6 @@ export class AddonMessagesModule { localNotifications.registerClick(AddonMessagesProvider.PUSH_SIMULATION_COMPONENT, notificationClicked); } - // Register push notification clicks. - pushNotificationsDelegate.on('click').subscribe((notification) => { - if (utils.isFalseOrZero(notification.notif)) { - // Execute the callback in the Angular zone, so change detection doesn't stop working. - zone.run(() => { - notificationClicked(notification); - }); - - return true; - } - }); - // Allow migrating the table from the old app to the new schema. updateManager.registerSiteTableMigration({ name: 'mma_messages_offline_messages', diff --git a/src/addon/messages/pages/discussion/discussion.html b/src/addon/messages/pages/discussion/discussion.html index 7fb8da636..8363436b7 100644 --- a/src/addon/messages/pages/discussion/discussion.html +++ b/src/addon/messages/pages/discussion/discussion.html @@ -1,24 +1,26 @@ - + - + + - - - - - - - - - - + + + + + + + + + + + @@ -26,6 +28,12 @@ + + +

{{ 'addon.messages.selfconversation' | translate }}

+

{{ 'addon.messages.selfconversationdefaultmessage' | translate }}

+
+
@@ -39,7 +47,7 @@ -

+

{{ members[message.useridfrom].fullname }}
@@ -56,6 +64,7 @@ +
diff --git a/src/addon/messages/pages/discussion/discussion.scss b/src/addon/messages/pages/discussion/discussion.scss index b09449b24..ddffe0900 100644 --- a/src/addon/messages/pages/discussion/discussion.scss +++ b/src/addon/messages/pages/discussion/discussion.scss @@ -47,6 +47,9 @@ ion-app.app-root page-addon-messages-discussion { min-height: 0; position: relative; @include core-transition(width); + // This is needed to display bubble tails. + overflow: visible; + contain: none; core-format-text > p:only-child { display: inline; @@ -127,6 +130,15 @@ ion-app.app-root page-addon-messages-discussion { line-height: initial; } } + + .tail { + content: ''; + width: 0; + height: 0; + border: 0.5rem solid transparent; + position: absolute; + touch-action: none; + } } .addon-message.addon-message-mine + .addon-message-no-user.addon-message-mine, @@ -158,6 +170,26 @@ ion-app.app-root page-addon-messages-discussion { height: 16px; } } + + .tail { + @include position(null, 0, 0, null); + @include margin-horizontal(null, -0.5rem); + border-bottom-color: $item-message-mine-bg; + } + + &.activated .tail { + border-bottom-color: darken($item-message-mine-bg, 10%); + } + } + + .addon-message-not-mine .tail { + @include position(null, null, 0, 0); + @include margin-horizontal(-0.5rem, null); + border-bottom-color: $item-message-bg; + } + + .addon-message-not-mine.activated .tail { + border-bottom-color: darken($item-message-bg, 10%); } .toolbar-title { diff --git a/src/addon/messages/pages/discussion/discussion.ts b/src/addon/messages/pages/discussion/discussion.ts index 25cdcab13..26c1eb0fb 100644 --- a/src/addon/messages/pages/discussion/discussion.ts +++ b/src/addon/messages/pages/discussion/discussion.ts @@ -23,10 +23,12 @@ import { AddonMessagesSyncProvider } from '../../providers/sync'; import { CoreUserProvider } from '@core/user/providers/user'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreAppProvider } from '@providers/app'; import { coreSlideInOut } from '@classes/animations'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; +import { CoreInfiniteLoadingComponent } from '@components/infinite-loading/infinite-loading'; import { Md5 } from 'ts-md5/dist/md5'; import * as moment from 'moment'; @@ -41,6 +43,7 @@ import * as moment from 'moment'; }) export class AddonMessagesDiscussionPage implements OnDestroy { @ViewChild(Content) content: Content; + @ViewChild(CoreInfiniteLoadingComponent) infinite: CoreInfiniteLoadingComponent; siteId: string; protected fetching: boolean; @@ -78,24 +81,29 @@ export class AddonMessagesDiscussionPage implements OnDestroy { isGroup = false; members: any = {}; // Members that wrote a message, indexed by ID. favouriteIcon = 'fa-star'; + favouriteIconSlash = false; deleteIcon = 'trash'; blockIcon = 'close-circle'; - addRemoveIcon = 'add'; + addRemoveIcon = 'person'; otherMember: any; // Other member information (individual conversations only). footerType: 'message' | 'blocked' | 'requiresContact' | 'requestSent' | 'requestReceived' | 'unable'; requestContactSent = false; requestContactReceived = false; + isSelf = false; + muteEnabled = false; + muteIcon = 'volume-off'; constructor(private eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider, navParams: NavParams, private userProvider: CoreUserProvider, private navCtrl: NavController, private messagesSync: AddonMessagesSyncProvider, private domUtils: CoreDomUtilsProvider, private messagesProvider: AddonMessagesProvider, logger: CoreLoggerProvider, private utils: CoreUtilsProvider, private appProvider: CoreAppProvider, private translate: TranslateService, @Optional() private svComponent: CoreSplitViewComponent, private messagesOffline: AddonMessagesOfflineProvider, - private modalCtrl: ModalController) { + private modalCtrl: ModalController, private textUtils: CoreTextUtilsProvider) { this.siteId = sitesProvider.getCurrentSiteId(); this.currentUserId = sitesProvider.getCurrentSiteUserId(); this.groupMessagingEnabled = this.messagesProvider.isGroupMessagingEnabled(); + this.muteEnabled = this.messagesProvider.isMuteConversationEnabled(); this.logger = logger.getInstance('AddonMessagesDiscussionPage'); @@ -238,7 +246,6 @@ export class AddonMessagesDiscussionPage implements OnDestroy { this.title = member.fullname; } this.blockIcon = this.otherMember && this.otherMember.isblocked ? 'checkmark-circle' : 'close-circle'; - this.addRemoveIcon = this.otherMember && this.otherMember.iscontact ? 'remove' : 'add'; })); } else { this.otherMember = null; @@ -378,13 +385,22 @@ export class AddonMessagesDiscussionPage implements OnDestroy { this.messages.forEach((message, index): any => { message.showDate = this.showDate(message, this.messages[index - 1]); message.showUserData = this.showUserData(message, this.messages[index - 1]); + message.showTail = this.showTail(message, this.messages[index + 1]); }); + // Call resize to recalculate the dimensions. + this.content && this.content.resize(); + + // If we received a new message while using group messaging, force mark messages as read. + const last = this.messages[this.messages.length - 1], + forceMark = this.groupMessagingEnabled && last && last.useridfrom != this.currentUserId && this.lastMessage.text != '' + && (last.text !== this.lastMessage.text || last.timecreated !== this.lastMessage.timecreated); + // Notify that there can be a new message. this.notifyNewMessage(); // Mark retrieved messages as read if they are not. - this.markMessagesAsRead(); + this.markMessagesAsRead(forceMark); } /** @@ -402,7 +418,13 @@ export class AddonMessagesDiscussionPage implements OnDestroy { if (conversationId) { promise = Promise.resolve(conversationId); } else { - promise = this.messagesProvider.getConversationBetweenUsers(userId, undefined, true).then((conversation) => { + if (userId == this.currentUserId && this.messagesProvider.isSelfConversationEnabled()) { + promise = this.messagesProvider.getSelfConversation(); + } else { + promise = this.messagesProvider.getConversationBetweenUsers(userId, undefined, true); + } + + promise = promise.then((conversation) => { fallbackConversation = conversation; return conversation.id; @@ -430,10 +452,13 @@ export class AddonMessagesDiscussionPage implements OnDestroy { this.title = conversation.name; this.conversationImage = conversation.imageurl; this.isGroup = conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP; - this.favouriteIcon = conversation.isfavourite ? 'fa-star-o' : 'fa-star'; + this.favouriteIcon = 'fa-star'; + this.favouriteIconSlash = conversation.isfavourite; + this.muteIcon = conversation.ismuted ? 'volume-up' : 'volume-off'; if (!this.isGroup) { this.userId = conversation.userid; } + this.isSelf = conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_SELF; return true; } else { @@ -442,7 +467,9 @@ export class AddonMessagesDiscussionPage implements OnDestroy { }); }, (error) => { // Probably conversation does not exist or user is offline. Try to load offline messages. - return this.messagesOffline.getMessages(userId).then((messages) => { + this.isSelf = userId == this.currentUserId; + + return this.messagesOffline.getMessages(userId).then((messages): any => { if (messages && messages.length) { // We have offline messages, this probably means that the conversation didn't exist. Don't display error. messages.forEach((message) => { @@ -455,6 +482,8 @@ export class AddonMessagesDiscussionPage implements OnDestroy { // Display the error. return Promise.reject(error); } + + return false; }); }); } @@ -553,7 +582,7 @@ export class AddonMessagesDiscussionPage implements OnDestroy { /** * Mark messages as read. */ - protected markMessagesAsRead(): void { + protected markMessagesAsRead(forceMark: boolean): void { let readChanged = false; const promises = []; @@ -561,7 +590,9 @@ export class AddonMessagesDiscussionPage implements OnDestroy { let messageUnreadFound = false; // Mark all messages at a time if there is any unread message. - if (this.groupMessagingEnabled) { + if (forceMark) { + messageUnreadFound = true; + } else if (this.groupMessagingEnabled) { messageUnreadFound = this.conversation && this.conversation.unreadcount > 0 && this.conversationId > 0; } else { for (const x in this.messages) { @@ -625,6 +656,7 @@ export class AddonMessagesDiscussionPage implements OnDestroy { const last = this.messages[this.messages.length - 1]; let trigger = false; + if (!last) { this.lastMessage = {text: '', timecreated: 0}; trigger = true; @@ -777,7 +809,8 @@ export class AddonMessagesDiscussionPage implements OnDestroy { * @param {any} message Message to be copied. */ copyMessage(message: any): void { - this.utils.copyToClipboard(message.smallmessage || message.text || ''); + const text = this.textUtils.decodeHTMLEntities(message.smallmessage || message.text || ''); + this.utils.copyToClipboard(text); } /** @@ -787,11 +820,26 @@ export class AddonMessagesDiscussionPage implements OnDestroy { * @param {number} index Index where the message is to delete it from the view. */ deleteMessage(message: any, index: number): void { - const langKey = message.pending ? 'core.areyousure' : 'addon.messages.deletemessageconfirmation'; - this.domUtils.showConfirm(this.translate.instant(langKey)).then(() => { + const canDeleteAll = this.conversation && this.conversation.candeletemessagesforallusers, + langKey = message.pending || canDeleteAll || this.isSelf ? 'core.areyousure' : + 'addon.messages.deletemessageconfirmation', + options: any = {}; + + if (canDeleteAll && !message.pending) { + // Show delete for all checkbox. + options.inputs = [{ + type: 'checkbox', + name: 'deleteforall', + checked: false, + value: true, + label: this.translate.instant('addon.messages.deleteforeveryone') + }]; + } + + this.domUtils.showConfirm(this.translate.instant(langKey), undefined, undefined, undefined, options).then((data) => { const modal = this.domUtils.showModalLoading('core.deleting', true); - return this.messagesProvider.deleteMessage(message).then(() => { + return this.messagesProvider.deleteMessage(message, data && data[0]).then(() => { // Remove message from the list without having to wait for re-fetch. this.messages.splice(index, 1); this.removeMessage(message.hash); @@ -813,11 +861,28 @@ export class AddonMessagesDiscussionPage implements OnDestroy { * @return {Promise} Resolved when done. */ loadPrevious(infiniteComplete?: any): Promise { + let infiniteHeight = this.infinite ? this.infinite.getHeight() : 0; + const scrollHeight = this.domUtils.getScrollHeight(this.content); + // If there is an ongoing fetch, wait for it to finish. return this.waitForFetch().finally(() => { this.pagesLoaded++; - this.fetchMessages().catch((error) => { + this.fetchMessages().then(() => { + + // Try to keep the scroll position. + const scrollBottom = scrollHeight - this.domUtils.getScrollTop(this.content); + + if (this.canLoadMore && infiniteHeight && this.infinite) { + // The height of the infinite is different while spinner is shown. Add that difference. + infiniteHeight = infiniteHeight - this.infinite.getHeight(); + } else if (!this.canLoadMore) { + // Can't load more, take into account the full height of the infinite loading since it will disappear now. + infiniteHeight = infiniteHeight || (this.infinite ? this.infinite.getHeight() : 0); + } + + this.keepScroll(scrollHeight, scrollBottom, infiniteHeight); + }).catch((error) => { this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. this.pagesLoaded--; this.domUtils.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingmessages', true); @@ -827,6 +892,31 @@ export class AddonMessagesDiscussionPage implements OnDestroy { }); } + /** + * Keep scroll position after loading previous messages. + * We don't use resizeContent because the approach used is different and it isn't easy to calculate these positions. + */ + protected keepScroll(oldScrollHeight: number, oldScrollBottom: number, infiniteHeight: number, retries?: number): void { + retries = retries || 0; + + setTimeout(() => { + const newScrollHeight = this.domUtils.getScrollHeight(this.content); + + if (newScrollHeight == oldScrollHeight) { + // Height hasn't changed yet. Retry if max retries haven't been reached. + if (retries <= 10) { + this.keepScroll(oldScrollHeight, oldScrollBottom, infiniteHeight, retries + 1); + } + + return; + } + + const scrollTo = newScrollHeight - oldScrollBottom + infiniteHeight; + + this.domUtils.scrollTo(this.content, 0, scrollTo, 0); + }, 30); + } + /** * Content or scroll has been resized. For content, only call it if it's been added on top. */ @@ -983,6 +1073,17 @@ export class AddonMessagesDiscussionPage implements OnDestroy { (!prevMessage || prevMessage.useridfrom != message.useridfrom || message.showDate); } + /** + * Check if a css tail should be shown. + * + * @param {any} message Current message where to show the user info. + * @param {any} [nextMessage] Next message. + * @return {boolean} Whether user data should be shown. + */ + showTail(message: any, nextMessage?: any): boolean { + return !nextMessage || nextMessage.useridfrom != message.useridfrom || nextMessage.showDate; + } + /** * Toggles delete state. */ @@ -1043,7 +1144,35 @@ export class AddonMessagesDiscussionPage implements OnDestroy { }).catch((error) => { this.domUtils.showErrorModalDefault(error, 'Error changing favourite state.'); }).finally(() => { - this.favouriteIcon = this.conversation.isfavourite ? 'fa-star-o' : 'fa-star'; + this.favouriteIcon = 'fa-star'; + this.favouriteIconSlash = this.conversation.isfavourite; + done && done(); + }); + } + + /** + * Change the mute state of the current conversation. + * + * @param {Function} [done] Function to call when done. + */ + changeMute(done?: () => void): void { + this.muteIcon = 'spinner'; + + this.messagesProvider.muteConversation(this.conversation.id, !this.conversation.ismuted).then(() => { + this.conversation.ismuted = !this.conversation.ismuted; + + // Get the conversation data so it's cached. Don't block the user for this. + this.messagesProvider.getConversation(this.conversation.id, undefined, true); + + this.eventsProvider.trigger(AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT, { + conversationId: this.conversation.id, + action: 'mute', + value: this.conversation.ismuted + }, this.siteId); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error changing muted state.'); + }).finally(() => { + this.muteIcon = this.conversation.ismuted ? 'volume-up' : 'volume-off'; done && done(); }); } @@ -1123,7 +1252,9 @@ export class AddonMessagesDiscussionPage implements OnDestroy { * @param {Function} [done] Function to call when done. */ deleteConversation(done?: () => void): void { - this.domUtils.showConfirm(this.translate.instant('addon.messages.deleteallconfirm')).then(() => { + const confirmMessage = 'addon.messages.' + (this.isSelf ? 'deleteallselfconfirm' : 'deleteallconfirm'); + + this.domUtils.showConfirm(this.translate.instant(confirmMessage)).then(() => { this.deleteIcon = 'spinner'; return this.messagesProvider.deleteConversation(this.conversation.id).then(() => { @@ -1132,8 +1263,6 @@ export class AddonMessagesDiscussionPage implements OnDestroy { action: 'delete' }, this.siteId); - this.conversationId = undefined; - this.conversation = undefined; this.messages = []; }).finally(() => { this.deleteIcon = 'trash'; @@ -1202,7 +1331,7 @@ export class AddonMessagesDiscussionPage implements OnDestroy { }).catch((error) => { this.domUtils.showErrorModalDefault(error, 'core.error', true); }).finally(() => { - this.addRemoveIcon = this.otherMember.iscontact ? 'remove' : 'add'; + this.addRemoveIcon = 'person'; }); } @@ -1277,7 +1406,7 @@ export class AddonMessagesDiscussionPage implements OnDestroy { }).catch((error) => { this.domUtils.showErrorModalDefault(error, 'core.error', true); }).finally(() => { - this.addRemoveIcon = this.otherMember.iscontact ? 'remove' : 'add'; + this.addRemoveIcon = 'person'; }); } diff --git a/src/addon/messages/pages/group-conversations/group-conversations.html b/src/addon/messages/pages/group-conversations/group-conversations.html index 10d5c3052..c32cdec85 100644 --- a/src/addon/messages/pages/group-conversations/group-conversations.html +++ b/src/addon/messages/pages/group-conversations/group-conversations.html @@ -91,16 +91,17 @@ - + - +

+

{{ conversation.unreadcount }} @@ -109,7 +110,7 @@

{{ 'addon.messages.you' | translate }} - +

diff --git a/src/addon/messages/pages/group-conversations/group-conversations.ts b/src/addon/messages/pages/group-conversations/group-conversations.ts index 332bae82c..92b81f75e 100644 --- a/src/addon/messages/pages/group-conversations/group-conversations.ts +++ b/src/addon/messages/pages/group-conversations/group-conversations.ts @@ -21,7 +21,7 @@ import { AddonMessagesProvider } from '../../providers/messages'; import { AddonMessagesOfflineProvider } from '../../providers/messages-offline'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreUtilsProvider } from '@providers/utils/utils'; -import { AddonPushNotificationsDelegate } from '@addon/pushnotifications/providers/delegate'; +import { CorePushNotificationsDelegate } from '@core/pushnotifications/providers/delegate'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { CoreUserProvider } from '@core/user/providers/user'; @@ -63,7 +63,7 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { count: 0, unread: 0 }; - typeIndividual = AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL; + typeGroup = AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP; currentListEl: HTMLElement; protected loadingString: string; @@ -84,7 +84,7 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { constructor(eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider, translate: TranslateService, private messagesProvider: AddonMessagesProvider, private domUtils: CoreDomUtilsProvider, navParams: NavParams, private navCtrl: NavController, platform: Platform, private utils: CoreUtilsProvider, - pushNotificationsDelegate: AddonPushNotificationsDelegate, private messagesOffline: AddonMessagesOfflineProvider, + pushNotificationsDelegate: CorePushNotificationsDelegate, private messagesOffline: AddonMessagesOfflineProvider, private userProvider: CoreUserProvider) { this.loadingString = translate.instant('core.loading'); @@ -166,8 +166,23 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { }); // Update conversations if we receive an event to do so. - this.updateConversationListObserver = eventsProvider.on(AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT, () => { + this.updateConversationListObserver = eventsProvider.on(AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT, (data) => { + if (data && data.action == 'mute') { + // If the conversation is displayed, change its muted value. + const expandedOption = this.getExpandedOption(); + + if (expandedOption && expandedOption.conversations) { + const conversation = this.findConversation(data.conversationId, undefined, expandedOption); + if (conversation) { + conversation.ismuted = data.value; + } + } + + return; + } + this.refreshData(); + }, this.siteId); // If a message push notification is received, refresh the view. @@ -182,7 +197,7 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { // Update unread conversation counts. this.cronObserver = eventsProvider.on(AddonMessagesProvider.UNREAD_CONVERSATION_COUNTS_EVENT, (data) => { this.favourites.unread = data.favourites; - this.individual.unread = data.individual; + this.individual.unread = data.individual + data.self; // Self is only returned if it's not favourite. this.group.unread = data.group; }, this.siteId); @@ -251,7 +266,12 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { const promises = []; promises.push(this.fetchConversationCounts()); - promises.push(this.messagesProvider.getContactRequestsCount(this.siteId)); // View updated by the event observer. + + // View updated by the events observers. + promises.push(this.messagesProvider.getContactRequestsCount(this.siteId)); + if (refreshUnreadCounts) { + promises.push(this.messagesProvider.refreshUnreadConversationCounts(this.siteId)); + } return Promise.all(promises).then(() => { if (typeof this.favourites.expanded == 'undefined') { @@ -261,9 +281,9 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { // We don't know which option it belongs to, so we need to fetch the data for all of them. const promises = []; - promises.push(this.fetchDataForOption(this.favourites, false, refreshUnreadCounts)); - promises.push(this.fetchDataForOption(this.group, false, refreshUnreadCounts)); - promises.push(this.fetchDataForOption(this.individual, false, refreshUnreadCounts)); + promises.push(this.fetchDataForOption(this.favourites, false)); + promises.push(this.fetchDataForOption(this.group, false)); + promises.push(this.fetchDataForOption(this.individual, false)); return Promise.all(promises).then(() => { // All conversations have been loaded, find the one we need to load and expand its option. @@ -271,13 +291,13 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { if (conversation) { const option = this.getConversationOption(conversation); - return this.expandOption(option, refreshUnreadCounts); + return this.expandOption(option); } else { // Conversation not found, just open the default option. this.calculateExpandedStatus(); // Now load the data for the expanded option. - return this.fetchDataForExpandedOption(refreshUnreadCounts); + return this.fetchDataForExpandedOption(); } }); } @@ -287,7 +307,7 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { } // Now load the data for the expanded option. - return this.fetchDataForExpandedOption(refreshUnreadCounts); + return this.fetchDataForExpandedOption(); }).catch((error) => { this.domUtils.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingdiscussions', true); }).finally(() => { @@ -299,9 +319,9 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { * Calculate which option should be expanded initially. */ protected calculateExpandedStatus(): void { - this.favourites.expanded = this.favourites.count != 0; - this.group.expanded = this.favourites.count == 0 && this.group.count != 0; - this.individual.expanded = this.favourites.count == 0 && this.group.count == 0; + this.favourites.expanded = this.favourites.count != 0 && !this.group.unread && !this.individual.unread; + this.group.expanded = !this.favourites.expanded && this.group.count != 0 && !this.individual.unread; + this.individual.expanded = !this.favourites.expanded && !this.group.expanded; this.loadCurrentListElement(); } @@ -309,26 +329,16 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { /** * Fetch data for the expanded option. * - * @param {booleam} [refreshUnreadCounts=true] Whether to refresh unread counts. * @return {Promise} Promise resolved when done. */ - protected fetchDataForExpandedOption(refreshUnreadCounts: boolean = true): Promise { + protected fetchDataForExpandedOption(): Promise { const expandedOption = this.getExpandedOption(); if (expandedOption) { - return this.fetchDataForOption(expandedOption, false, refreshUnreadCounts); - } else { - // All options are collapsed, update the counts. - const promises = []; - - promises.push(this.fetchConversationCounts()); - if (refreshUnreadCounts) { - // View updated by event observer. - promises.push(this.messagesProvider.refreshUnreadConversationCounts(this.siteId)); - } - - return Promise.all(promises); + return this.fetchDataForOption(expandedOption, false); } + + return Promise.resolve(); } /** @@ -336,10 +346,10 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { * * @param {any} option The option to fetch data for. * @param {boolean} [loadingMore} Whether we are loading more data or just the first ones. - * @param {booleam} [refreshUnreadCounts=true] Whether to refresh unread counts. + * @param {booleam} [getCounts] Whether to get counts data. * @return {Promise} Promise resolved when done. */ - fetchDataForOption(option: any, loadingMore?: boolean, refreshUnreadCounts: boolean = true): Promise { + fetchDataForOption(option: any, loadingMore?: boolean, getCounts?: boolean): Promise { option.loadMoreError = false; const limitFrom = loadingMore ? option.conversations.length : 0, @@ -360,12 +370,11 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { promises.push(this.messagesOffline.getAllMessages().then((data) => { offlineMessages = data; })); + } + if (getCounts) { promises.push(this.fetchConversationCounts()); - if (refreshUnreadCounts) { - // View updated by the event observer. - promises.push(this.messagesProvider.refreshUnreadConversationCounts(this.siteId)); - } + promises.push(this.messagesProvider.refreshUnreadConversationCounts(this.siteId)); } return Promise.all(promises).then(() => { @@ -400,7 +409,7 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { return this.messagesProvider.getConversationCounts(this.siteId); }).then((counts) => { this.favourites.count = counts.favourites; - this.individual.count = counts.individual; + this.individual.count = counts.individual + counts.self; // Self is only returned if it's not favourite. this.group.count = counts.group; }); } @@ -635,7 +644,8 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { option.expanded = false; this.loadCurrentListElement(); } else { - this.expandOption(option).catch((error) => { + // Pass getCounts=true to update the counts everytime the user expands an option. + this.expandOption(option, true).catch((error) => { this.domUtils.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingdiscussions', true); }); } @@ -645,10 +655,10 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { * Expand a certain option. * * @param {any} option The option to expand. - * @param {booleam} [refreshUnreadCounts=true] Whether to refresh unread counts. + * @param {booleam} [getCounts] Whether to get counts data. * @return {Promise} Promise resolved when done. */ - protected expandOption(option: any, refreshUnreadCounts: boolean = true): Promise { + protected expandOption(option: any, getCounts?: boolean): Promise { // Collapse all and expand the right one. this.favourites.expanded = false; this.group.expanded = false; @@ -657,7 +667,7 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { option.expanded = true; option.loading = true; - return this.fetchDataForOption(option, false, refreshUnreadCounts).then(() => { + return this.fetchDataForOption(option, false, getCounts).then(() => { this.loadCurrentListElement(); }).catch((error) => { option.expanded = false; diff --git a/src/addon/messages/pages/search/search.html b/src/addon/messages/pages/search/search.html index 4a9ba7621..86e701fe4 100644 --- a/src/addon/messages/pages/search/search.html +++ b/src/addon/messages/pages/search/search.html @@ -18,42 +18,43 @@ + + - {{ item.titleString | translate }} - - {{ item.emptyString | translate }} - + + {{ item.titleString | translate }} - - - -

- - -

- - {{result.lastmessagedate | coreDateDayOrTime}} - -

- {{ 'addon.messages.you' | translate }} - -

-
+ + + +

+ + +

+ + {{result.lastmessagedate | coreDateDayOrTime}} + +

+ {{ 'addon.messages.you' | translate }} + +

+
- - -
- -
-
- -
+ + +
+ +
+
+ +
+
\ No newline at end of file diff --git a/src/addon/messages/pages/search/search.ts b/src/addon/messages/pages/search/search.ts index 6e51b1e20..14d5b6ab1 100644 --- a/src/addon/messages/pages/search/search.ts +++ b/src/addon/messages/pages/search/search.ts @@ -38,7 +38,6 @@ export class AddonMessagesSearchPage implements OnDestroy { contacts = { type: 'contacts', titleString: 'addon.messages.contacts', - emptyString: 'addon.messages.searchnocontactsfound', results: [], canLoadMore: false, loadingMore: false @@ -46,7 +45,6 @@ export class AddonMessagesSearchPage implements OnDestroy { nonContacts = { type: 'noncontacts', titleString: 'addon.messages.noncontacts', - emptyString: 'addon.messages.searchnononcontactsfound', results: [], canLoadMore: false, loadingMore: false @@ -54,7 +52,6 @@ export class AddonMessagesSearchPage implements OnDestroy { messages = { type: 'messages', titleString: 'addon.messages.messages', - emptyString: 'addon.messages.searchnomessagesfound', results: [], canLoadMore: false, loadingMore: false, @@ -178,17 +175,20 @@ export class AddonMessagesSearchPage implements OnDestroy { if (!loadMore || loadMore == 'contacts') { this.contacts.results.push(...newContacts); this.contacts.canLoadMore = canLoadMoreContacts; + this.setHighlight(newContacts, true); } if (!loadMore || loadMore == 'noncontacts') { this.nonContacts.results.push(...newNonContacts); this.nonContacts.canLoadMore = canLoadMoreNonContacts; + this.setHighlight(newNonContacts, true); } if (!loadMore || loadMore == 'messages') { this.messages.results.push(...newMessages); this.messages.canLoadMore = canLoadMoreMessages; this.messages.loadMoreError = false; + this.setHighlight(newMessages, false); } if (!loadMore) { @@ -241,6 +241,19 @@ export class AddonMessagesSearchPage implements OnDestroy { } } + /** + * Set the highlight values for each entry. + * + * @param {any[]} results Results to highlight. + * @param {boolean} isUser Whether the results are from a user search or from a message search. + */ + setHighlight(results: any[], isUser: boolean): void { + results.forEach((result) => { + result.highlightName = isUser ? this.query : undefined; + result.highlightMessage = !isUser ? this.query : undefined; + }); + } + /** * Component destroyed. */ diff --git a/src/addon/messages/providers/mainmenu-handler.ts b/src/addon/messages/providers/mainmenu-handler.ts index 170d86082..0875f19b6 100644 --- a/src/addon/messages/providers/mainmenu-handler.ts +++ b/src/addon/messages/providers/mainmenu-handler.ts @@ -22,8 +22,8 @@ import { CoreAppProvider } from '@providers/app'; import { CoreTextUtilsProvider } from '@providers/utils/text'; 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 { CorePushNotificationsProvider } from '@core/pushnotifications/providers/pushnotifications'; +import { CorePushNotificationsDelegate } from '@core/pushnotifications/providers/delegate'; import { CoreEmulatorHelperProvider } from '@core/emulator/providers/helper'; /** @@ -50,11 +50,11 @@ export class AddonMessagesMainMenuHandler implements CoreMainMenuHandler, CoreCr constructor(private messagesProvider: AddonMessagesProvider, private sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider, private appProvider: CoreAppProvider, private localNotificationsProvider: CoreLocalNotificationsProvider, private textUtils: CoreTextUtilsProvider, - private pushNotificationsProvider: AddonPushNotificationsProvider, utils: CoreUtilsProvider, - pushNotificationsDelegate: AddonPushNotificationsDelegate, private emulatorHelper: CoreEmulatorHelperProvider) { + private pushNotificationsProvider: CorePushNotificationsProvider, utils: CoreUtilsProvider, + pushNotificationsDelegate: CorePushNotificationsDelegate, private emulatorHelper: CoreEmulatorHelperProvider) { eventsProvider.on(AddonMessagesProvider.UNREAD_CONVERSATION_COUNTS_EVENT, (data) => { - this.unreadCount = data.favourites + data.individual + data.group; + this.unreadCount = data.favourites + data.individual + data.group + data.self; this.orMore = data.orMore; this.updateBadge(data.siteId); }); @@ -76,7 +76,8 @@ export class AddonMessagesMainMenuHandler implements CoreMainMenuHandler, CoreCr // If a message push notification is received, refresh the count. pushNotificationsDelegate.on('receive').subscribe((notification) => { // New message received. If it's from current site, refresh the data. - if (utils.isFalseOrZero(notification.notif) && this.sitesProvider.isCurrentSite(notification.site)) { + const isMessage = utils.isFalseOrZero(notification.notif) || notification.name == 'messagecontactrequests'; + if (isMessage && this.sitesProvider.isCurrentSite(notification.site)) { this.refreshBadge(notification.site); } }); @@ -165,9 +166,10 @@ export class AddonMessagesMainMenuHandler implements CoreMainMenuHandler, CoreCr * Receives the ID of the site affected, undefined for all sites. * * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). * @return {Promise} Promise resolved when done, rejected if failure. */ - execute(siteId?: string): Promise { + execute(siteId?: string, force?: boolean): Promise { if (this.sitesProvider.isCurrentSite(siteId)) { this.refreshBadge(); } @@ -228,8 +230,59 @@ export class AddonMessagesMainMenuHandler implements CoreMainMenuHandler, CoreCr * @return {Promise} Promise resolved with the notifications. */ protected fetchMessages(siteId?: string): Promise { - return this.messagesProvider.getUnreadReceivedMessages(true, false, true, siteId).then((response) => { - return response.messages; + return this.sitesProvider.getSite(siteId).then((site) => { + if (site.isVersionGreaterEqualThan('3.7')) { + + // Use get conversations WS to be able to get group conversations messages. + return this.messagesProvider.getConversations(undefined, undefined, 0, site.id, undefined, false, true) + .then((result) => { + + // Find the first unmuted conversation. + const conv = result.conversations.find((conversation) => { + return !conversation.ismuted; + }); + + if (conv.isread) { + // The conversation is read, no unread messages. + return []; + } + + const currentUserId = site.getUserId(), + message = conv.messages[0]; // Treat only the last message, is the one we're interested. + + if (!message || message.useridfrom == currentUserId) { + // No last message or not from current user. Return empty list. + return []; + } + + // Add some calculated data. + message.contexturl = ''; + message.contexturlname = ''; + message.convid = conv.id; + message.fullmessage = message.text; + message.fullmessageformat = 0; + message.fullmessagehtml = ''; + message.notification = 0; + message.read = 0; + message.smallmessage = message.smallmessage || message.text; + message.subject = conv.name; + message.timecreated = message.timecreated * 1000; + message.timeread = 0; + message.useridto = currentUserId; + message.usertofullname = site.getInfo().fullname; + + const userFrom = conv.members.find((member) => { + return member.id == message.useridfrom; + }); + message.userfromfullname = userFrom && userFrom.fullname; + + return [message]; + }); + } else { + return this.messagesProvider.getUnreadReceivedMessages(true, false, true, siteId).then((response) => { + return response.messages; + }); + } }); } @@ -241,7 +294,7 @@ export class AddonMessagesMainMenuHandler implements CoreMainMenuHandler, CoreCr */ protected getTitleAndText(message: any): Promise { const data = { - title: message.userfromfullname, + title: message.name || message.userfromfullname, }; return this.textUtils.formatText(message.text, true, true).catch(() => { diff --git a/src/addon/messages/providers/messages.ts b/src/addon/messages/providers/messages.ts index c81d27e65..b4181773a 100644 --- a/src/addon/messages/providers/messages.ts +++ b/src/addon/messages/providers/messages.ts @@ -47,6 +47,7 @@ export class AddonMessagesProvider { static MESSAGE_PRIVACY_SITE = 2; // Privacy setting for being messaged by anyone on the site. static MESSAGE_CONVERSATION_TYPE_INDIVIDUAL = 1; // An individual conversation. static MESSAGE_CONVERSATION_TYPE_GROUP = 2; // A group conversation. + static MESSAGE_CONVERSATION_TYPE_SELF = 3; // A self conversation. static LIMIT_CONTACTS = 50; static LIMIT_MESSAGES = 50; static LIMIT_INITIAL_USER_SEARCH = 3; @@ -243,12 +244,17 @@ export class AddonMessagesProvider { * Delete a message (online or offline). * * @param {any} message Message to delete. + * @param {boolean} [deleteForAll] Whether the message should be deleted for all users. * @return {Promise} Promise resolved when the message has been deleted. */ - deleteMessage(message: any): Promise { + deleteMessage(message: any, deleteForAll?: boolean): Promise { if (message.id) { // Message has ID, it means it has been sent to the server. - return this.deleteMessageOnline(message.id, message.read); + if (deleteForAll) { + return this.deleteMessageForAllOnline(message.id); + } else { + return this.deleteMessageOnline(message.id, message.read); + } } // It's an offline message. @@ -282,6 +288,24 @@ export class AddonMessagesProvider { }); } + /** + * Delete a message for all users. + * + * @param {number} id Message ID. + * @param {number} [userId] User we want to delete the message for. If not defined, use current user. + * @return {Promise} Promise resolved when the message has been deleted. + */ + deleteMessageForAllOnline(id: number, userId?: number): Promise { + const params: any = { + messageid: id, + userid: userId || this.sitesProvider.getCurrentSiteUserId() + }; + + return this.sitesProvider.getCurrentSite().write('core_message_delete_message_for_all_users', params).then(() => { + return this.invalidateDiscussionCache(userId); + }); + } + /** * Format a conversation. * @@ -297,14 +321,15 @@ export class AddonMessagesProvider { conversation.lastmessagedate = lastMessage ? lastMessage.timecreated : null; conversation.sentfromcurrentuser = lastMessage ? lastMessage.useridfrom == userId : null; - if (conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL) { - const otherUser = conversation.members.reduce((carry, member) => { - if (!carry && member.id != userId) { - carry = member; - } + if (conversation.type != AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP) { + const isIndividual = conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL, + otherUser = conversation.members.reduce((carry, member) => { + if (!carry && ((isIndividual && member.id != userId) || (!isIndividual && member.id == userId))) { + carry = member; + } - return carry; - }, null); + return carry; + }, null); conversation.name = conversation.name ? conversation.name : otherUser.fullname; conversation.imageurl = conversation.imageurl ? conversation.imageurl : otherUser.profileimageurl; @@ -478,6 +503,16 @@ export class AddonMessagesProvider { return this.ROOT_CACHE_KEY + 'memberInfo:' + userId + ':' + otherUserId; } + /** + * Get cache key for get self conversation. + * + * @param {number} userId User ID. + * @return {string} Cache key. + */ + protected getCacheKeyForSelfConversation(userId: number): string { + return this.ROOT_CACHE_KEY + 'selfconversation:' + userId; + } + /** * Get common cache key for get user conversations. * @@ -536,7 +571,8 @@ export class AddonMessagesProvider { userid: userId }, preSets = { - cacheKey: this.getCacheKeyForBlockedContacts(userId) + cacheKey: this.getCacheKeyForBlockedContacts(userId), + updateFrequency: CoreSite.FREQUENCY_OFTEN }; return site.read('core_message_get_blocked_users', params, preSets); @@ -555,7 +591,8 @@ export class AddonMessagesProvider { getContacts(siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { const preSets = { - cacheKey: this.getCacheKeyForContacts() + cacheKey: this.getCacheKeyForContacts(), + updateFrequency: CoreSite.FREQUENCY_OFTEN }; return site.read('core_message_get_contacts', undefined, preSets).then((contacts) => { @@ -597,7 +634,8 @@ export class AddonMessagesProvider { limitnum: limitNum <= 0 ? 0 : limitNum + 1 }; const preSets = { - cacheKey: this.getCacheKeyForUserContacts() + cacheKey: this.getCacheKeyForUserContacts(), + updateFrequency: CoreSite.FREQUENCY_OFTEN }; return site.read('core_message_get_user_contacts', params, preSets).then((contacts) => { @@ -638,7 +676,8 @@ export class AddonMessagesProvider { limitnum: limitNum <= 0 ? 0 : limitNum + 1 }; const preSets = { - cacheKey: this.getCacheKeyForContactRequests() + cacheKey: this.getCacheKeyForContactRequests(), + updateFrequency: CoreSite.FREQUENCY_OFTEN }; return site.read('core_message_get_contact_requests', data, preSets).then((requests) => { @@ -802,7 +841,8 @@ export class AddonMessagesProvider { } const preSets = { - cacheKey: this.getCacheKeyForConversationMembers(userId, conversationId) + cacheKey: this.getCacheKeyForConversationMembers(userId, conversationId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }, params: any = { userid: userId, @@ -882,14 +922,20 @@ export class AddonMessagesProvider { result.messages = result.messages.slice(0, limitTo); } + let lastReceived; + result.messages.forEach((message) => { // Convert time to milliseconds. message.timecreated = message.timecreated ? message.timecreated * 1000 : 0; + + if (!lastReceived && message.useridfrom != userId) { + lastReceived = message; + } }); - if (this.appProvider.isDesktop() && params.useridto == userId && limitFrom === 0) { + if (this.appProvider.isDesktop() && limitFrom === 0 && lastReceived) { // Store the last received message (we cannot know if it's unread or not). Don't block the user for this. - this.storeLastReceivedMessageIfNeeded(conversationId, result.messages[0], site.getId()); + this.storeLastReceivedMessageIfNeeded(conversationId, lastReceived, site.getId()); } if (excludePending) { @@ -923,11 +969,13 @@ export class AddonMessagesProvider { * @param {number} [limitFrom=0] The offset to start at. * @param {string} [siteId] Site ID. If not defined, use current site. * @param {number} [userId] User ID. If not defined, current user in the site. + * @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). * @return {Promise} Promise resolved with the conversations. * @since 3.6 */ - getConversations(type?: number, favourites?: boolean, limitFrom: number = 0, siteId?: string, userId?: number) - : Promise<{conversations: any[], canLoadMore: boolean}> { + getConversations(type?: number, favourites?: boolean, limitFrom: number = 0, siteId?: string, userId?: number, + forceCache?: boolean, ignoreCache?: boolean): Promise<{conversations: any[], canLoadMore: boolean}> { return this.sitesProvider.getSite(siteId).then((site) => { userId = userId || site.getUserId(); @@ -941,6 +989,13 @@ export class AddonMessagesProvider { limitnum: this.LIMIT_MESSAGES + 1, }; + if (forceCache) { + preSets['omitExpires'] = true; + } else if (ignoreCache) { + preSets['getFromCache'] = false; + preSets['emergencyCache'] = false; + } + if (typeof type != 'undefined' && type != null) { params.type = type; } @@ -948,11 +1003,32 @@ export class AddonMessagesProvider { params.favourites = favourites ? 1 : 0; } - return site.read('core_message_get_conversations', params, preSets).then((response) => { + if (site.isVersionGreaterEqualThan('3.7') && type != AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP) { + // Add self conversation to the list. + params.mergeself = 1; + } + + return site.read('core_message_get_conversations', params, preSets).catch((error) => { + if (params.mergeself) { + // Try again without the new param. Maybe the user is offline and he has a previous request cached. + delete params.mergeself; + + return site.read('core_message_get_conversations', params, preSets); + } + + return Promise.reject(error); + }).then((response) => { // Format the conversations, adding some calculated fields. const conversations = response.conversations.slice(0, this.LIMIT_MESSAGES).map((conversation) => { - return this.formatConversation(conversation, userId); - }); + return this.formatConversation(conversation, userId); + }), + conv = conversations[0], + lastMessage = conv && conv.messages[0]; + + if (this.appProvider.isDesktop() && limitFrom === 0 && lastMessage && !conv.sentfromcurrentuser) { + // Store the last received message (we cannot know if it's unread or not). Don't block the user for this. + this.storeLastReceivedMessageIfNeeded(conv.id, lastMessage, site.getId()); + } return { conversations: conversations, @@ -966,11 +1042,11 @@ export class AddonMessagesProvider { * Get conversation counts by type. * * @param {string} [siteId] Site ID. If not defined, use current site. - * @return {Promise} Promise resolved with favourite, individual and - * group conversation counts. + * @return {Promise} Promise resolved with favourite, + * individual, group and self conversation counts. * @since 3.6 */ - getConversationCounts(siteId?: string): Promise<{favourites: number, individual: number, group: number}> { + getConversationCounts(siteId?: string): Promise<{favourites: number, individual: number, group: number, self: number}> { return this.sitesProvider.getSite(siteId).then((site) => { const preSets = { @@ -981,7 +1057,8 @@ export class AddonMessagesProvider { const counts = { favourites: result.favourites, individual: result.types[AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL], - group: result.types[AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP] + group: result.types[AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP], + self: result.types[AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_SELF] || 0 }; return counts; @@ -1200,7 +1277,8 @@ export class AddonMessagesProvider { userId = userId || site.getUserId(); const preSets = { - cacheKey: this.getCacheKeyForMemberInfo(userId, otherUserId) + cacheKey: this.getCacheKeyForMemberInfo(userId, otherUserId), + updateFrequency: CoreSite.FREQUENCY_OFTEN }, params: any = { referenceuserid: userId, @@ -1240,7 +1318,8 @@ export class AddonMessagesProvider { return this.sitesProvider.getSite(siteId).then((site) => { const preSets = { - cacheKey: this.getMessagePreferencesCacheKey() + cacheKey: this.getMessagePreferencesCacheKey(), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; return site.read('core_message_get_user_message_preferences', {}, preSets).then((data) => { @@ -1337,6 +1416,40 @@ export class AddonMessagesProvider { }); } + /** + * Get a self conversation. + * + * @param {number} [messageOffset=0] Offset for messages list. + * @param {number} [messageLimit=1] Limit of messages. Defaults to 1 (last message). + * We recommend getConversationMessages to get them. + * @param {boolean} [newestFirst=true] Whether to order messages by newest first. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @param {string} [userId] User ID to get the self conversation for. If not defined, current user in the site. + * @return {Promise} Promise resolved with the response. + * @since 3.7 + */ + getSelfConversation(messageOffset: number = 0, messageLimit: number = 1, newestFirst: boolean = true, siteId?: string, + userId?: number): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + const preSets = { + cacheKey: this.getCacheKeyForSelfConversation(userId) + }, + params: any = { + userid: userId, + messageoffset: messageOffset, + messagelimit: messageLimit, + newestmessagesfirst: newestFirst ? 1 : 0 + }; + + return site.read('core_message_get_self_conversation', params, preSets).then((conversation) => { + return this.formatConversation(conversation, userId); + }); + }); + } + /** * Get unread conversation counts by type. * @@ -1344,10 +1457,10 @@ export class AddonMessagesProvider { * @return {Promise} Resolved with the unread favourite, individual and group conversation counts. */ getUnreadConversationCounts(siteId?: string): - Promise<{favourites: number, individual: number, group: number, orMore?: boolean}> { + Promise<{favourites: number, individual: number, group: number, self: number, orMore?: boolean}> { return this.sitesProvider.getSite(siteId).then((site) => { - let promise: Promise<{favourites: number, individual: number, group: number, orMore?: boolean}>; + let promise: Promise<{favourites: number, individual: number, group: number, self: number, orMore?: boolean}>; if (this.isGroupMessagingEnabled()) { // @since 3.6 @@ -1359,7 +1472,8 @@ export class AddonMessagesProvider { return { favourites: result.favourites, individual: result.types[AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL], - group: result.types[AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP] + group: result.types[AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP], + self: result.types[AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_SELF] || 0 }; }); @@ -1374,7 +1488,7 @@ export class AddonMessagesProvider { }; promise = site.read('core_message_get_unread_conversations_count', params, preSets).then((count) => { - return { favourites: 0, individual: count, group: 0 }; + return { favourites: 0, individual: count, group: 0, self: 0 }; }); } else { // Fallback call. @@ -1399,6 +1513,7 @@ export class AddonMessagesProvider { favourites: 0, individual: count, group: 0, + self: 0, orMore: count > this.LIMIT_MESSAGES }; }); @@ -1696,6 +1811,21 @@ export class AddonMessagesProvider { ]); } + /** + * Invalidate a self conversation. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {number} [userId] User ID. If not defined, current user in the site. + * @return {Promise} Resolved when done. + */ + invalidateSelfConversation(siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.invalidateWsCacheForKey(this.getCacheKeyForSelfConversation(userId)); + }); + } + /** * Invalidate unread conversation counts cache. * @@ -1838,6 +1968,34 @@ export class AddonMessagesProvider { }); } + /** + * Returns whether or not a site supports muting or unmuting a conversation. + * + * @param {CoreSite} [site] The site to check, undefined for current site. + * @return {boolean} If related WS is available on current site. + * @since 3.7 + */ + isMuteConversationEnabled(site?: CoreSite): boolean { + site = site || this.sitesProvider.getCurrentSite(); + + return site.wsAvailable('core_message_mute_conversations'); + } + + /** + * Returns whether or not a site supports muting or unmuting a conversation. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: whether related WS is available on a certain site. + * @since 3.7 + */ + isMuteConversationEnabledInSite(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.isMuteConversationEnabled(site); + }).catch(() => { + return false; + }); + } + /** * Returns whether or not the plugin is enabled in a certain site. * @@ -1860,19 +2018,50 @@ export class AddonMessagesProvider { return this.sitesProvider.wsAvailableInCurrentSite('core_message_data_for_messagearea_search_messages'); } + /** + * Returns whether or not self conversation is supported in a certain site. + * + * @param {CoreSite} [site] Site. If not defined, current site. + * @return {boolean} If related WS is available on the site. + * @since 3.7 + */ + isSelfConversationEnabled(site?: CoreSite): boolean { + site = site || this.sitesProvider.getCurrentSite(); + + return site.wsAvailable('core_message_get_self_conversation'); + } + + /** + * Returns whether or not self conversation is supported in a certain site. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: whether related WS is available on a certain site. + * @since 3.7 + */ + isSelfConversationEnabledInSite(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.isSelfConversationEnabled(site); + }).catch(() => { + return false; + }); + } + /** * Mark message as read. * - * @param {number} messageId ID of message to mark as read + * @param {number} messageId ID of message to mark as read + * @param {string} [siteId] Site ID. If not defined, current site. * @returns {Promise} Promise resolved with boolean marking success or not. */ - markMessageRead(messageId: number): Promise { - const params = { - messageid: messageId, - timeread: this.timeUtils.timestamp() - }; + markMessageRead(messageId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + messageid: messageId, + timeread: this.timeUtils.timestamp() + }; - return this.sitesProvider.getCurrentSite().write('core_message_mark_message_read', params); + return site.write('core_message_mark_message_read', params); + }); } /** @@ -1912,6 +2101,53 @@ export class AddonMessagesProvider { return this.sitesProvider.getCurrentSite().write('core_message_mark_all_messages_as_read', params, preSets); } + /** + * Mute or unmute a conversation. + * + * @param {number} conversationId Conversation ID. + * @param {boolean} set Whether to mute or unmute. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @param {number} [userId] User ID. If not defined, current user in the site. + * @return {Promise} Resolved when done. + */ + muteConversation(conversationId: number, set: boolean, siteId?: string, userId?: number): Promise { + return this.muteConversations([conversationId], set, siteId, userId); + } + + /** + * Mute or unmute some conversations. + * + * @param {number[]} conversations Conversation IDs. + * @param {boolean} set Whether to mute or unmute. + * @param {string} [siteId] Site ID. If not defined, use current site. + * @param {number} [userId] User ID. If not defined, current user in the site. + * @return {Promise} Resolved when done. + */ + muteConversations(conversations: number[], set: boolean, siteId?: string, userId?: number): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + const params = { + userid: userId, + conversationids: conversations + }, + wsName = set ? 'core_message_mute_conversations' : 'core_message_unmute_conversations'; + + return site.write(wsName, params).then(() => { + // Invalidate the conversations data. + const promises = []; + + conversations.forEach((conversationId) => { + promises.push(this.invalidateConversation(conversationId, site.getId(), userId)); + }); + + return Promise.all(promises).catch(() => { + // Ignore errors. + }); + }); + }); + } + /** * Refresh the number of contact requests sent to the current user. * diff --git a/src/addon/messages/providers/push-click-handler.ts b/src/addon/messages/providers/push-click-handler.ts new file mode 100644 index 000000000..f6fcf3c0e --- /dev/null +++ b/src/addon/messages/providers/push-click-handler.ts @@ -0,0 +1,77 @@ +// (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 { CoreUtilsProvider } from '@providers/utils/utils'; +import { CorePushNotificationsClickHandler } from '@core/pushnotifications/providers/delegate'; +import { CoreLoginHelperProvider } from '@core/login/providers/helper'; +import { AddonMessagesProvider } from './messages'; + +/** + * Handler for messaging push notifications clicks. + */ +@Injectable() +export class AddonMessagesPushClickHandler implements CorePushNotificationsClickHandler { + name = 'AddonMessagesPushClickHandler'; + priority = 200; + featureName = 'CoreMainMenuDelegate_AddonMessages'; + + constructor(private utils: CoreUtilsProvider, private messagesProvider: AddonMessagesProvider, + private loginHelper: CoreLoginHelperProvider) {} + + /** + * Check if a notification click is handled by this handler. + * + * @param {any} notification The notification to check. + * @return {boolean} Whether the notification click is handled by this handler + */ + handles(notification: any): boolean | Promise { + if (this.utils.isTrueOrOne(notification.notif) && notification.name != 'messagecontactrequests') { + return false; + } + + // Check that messaging is enabled. + return this.messagesProvider.isPluginEnabled(notification.site); + } + + /** + * Handle the notification click. + * + * @param {any} notification The notification to check. + * @return {Promise} Promise resolved when done. + */ + handleClick(notification: any): Promise { + return this.messagesProvider.invalidateDiscussionsCache(notification.site).catch(() => { + // Ignore errors. + }).then(() => { + // Check if group messaging is enabled, to determine which page should be loaded. + return this.messagesProvider.isGroupMessagingEnabledInSite(notification.site).then((enabled) => { + const pageParams: any = {}; + let pageName = 'AddonMessagesIndexPage'; + if (enabled) { + pageName = 'AddonMessagesGroupConversationsPage'; + } + + // Check if we have enough information to open the conversation. + if (notification.convid && enabled) { + pageParams.conversationId = Number(notification.convid); + } else if (notification.userfromid) { + pageParams.discussionUserId = Number(notification.userfromid); + } + + return this.loginHelper.redirect(pageName, pageParams, notification.site); + }); + }); + } +} diff --git a/src/addon/messages/providers/sync-cron-handler.ts b/src/addon/messages/providers/sync-cron-handler.ts index 79dc464a3..466a593c3 100644 --- a/src/addon/messages/providers/sync-cron-handler.ts +++ b/src/addon/messages/providers/sync-cron-handler.ts @@ -30,9 +30,10 @@ export class AddonMessagesSyncCronHandler implements CoreCronHandler { * Receives the ID of the site affected, undefined for all sites. * * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). * @return {Promise} Promise resolved when done, rejected if failure. */ - execute(siteId?: string): Promise { + execute(siteId?: string, force?: boolean): Promise { return this.messagesSync.syncAllDiscussions(siteId); } diff --git a/src/addon/messages/providers/user-send-message-handler.ts b/src/addon/messages/providers/user-send-message-handler.ts index e42a2ca67..a80b90ade 100644 --- a/src/addon/messages/providers/user-send-message-handler.ts +++ b/src/addon/messages/providers/user-send-message-handler.ts @@ -49,7 +49,14 @@ export class AddonMessagesSendMessageUserHandler implements CoreUserProfileHandl * @return {boolean|Promise} Promise resolved with true if enabled, resolved with false otherwise. */ isEnabledForUser(user: any, courseId: number, navOptions?: any, admOptions?: any): boolean | Promise { - return user.id != this.sitesProvider.getCurrentSiteUserId(); + const currentSite = this.sitesProvider.getCurrentSite(); + + if (!currentSite) { + return false; + } + + // From 3.7 you can send messages to yourself. + return user.id != currentSite.getUserId() || currentSite.isVersionGreaterEqualThan('3.7'); } /** diff --git a/src/addon/mod/assign/assign.module.ts b/src/addon/mod/assign/assign.module.ts index 39a87ad9d..74fe86c4e 100644 --- a/src/addon/mod/assign/assign.module.ts +++ b/src/addon/mod/assign/assign.module.ts @@ -17,6 +17,7 @@ import { CoreCronDelegate } from '@providers/cron'; import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; +import { CorePushNotificationsDelegate } from '@core/pushnotifications/providers/delegate'; import { AddonModAssignProvider } from './providers/assign'; import { AddonModAssignOfflineProvider } from './providers/assign-offline'; import { AddonModAssignSyncProvider } from './providers/assign-sync'; @@ -30,6 +31,7 @@ import { AddonModAssignPrefetchHandler } from './providers/prefetch-handler'; import { AddonModAssignSyncCronHandler } from './providers/sync-cron-handler'; import { AddonModAssignIndexLinkHandler } from './providers/index-link-handler'; import { AddonModAssignListLinkHandler } from './providers/list-link-handler'; +import { AddonModAssignPushClickHandler } from './providers/push-click-handler'; import { AddonModAssignSubmissionModule } from './submission/submission.module'; import { AddonModAssignFeedbackModule } from './feedback/feedback.module'; import { CoreUpdateManagerProvider } from '@providers/update-manager'; @@ -64,7 +66,8 @@ export const ADDON_MOD_ASSIGN_PROVIDERS: any[] = [ AddonModAssignPrefetchHandler, AddonModAssignSyncCronHandler, AddonModAssignIndexLinkHandler, - AddonModAssignListLinkHandler + AddonModAssignListLinkHandler, + AddonModAssignPushClickHandler ] }) export class AddonModAssignModule { @@ -72,13 +75,15 @@ export class AddonModAssignModule { prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModAssignPrefetchHandler, cronDelegate: CoreCronDelegate, syncHandler: AddonModAssignSyncCronHandler, updateManager: CoreUpdateManagerProvider, contentLinksDelegate: CoreContentLinksDelegate, linkHandler: AddonModAssignIndexLinkHandler, - listLinkHandler: AddonModAssignListLinkHandler) { + listLinkHandler: AddonModAssignListLinkHandler, pushNotificationsDelegate: CorePushNotificationsDelegate, + pushClickHandler: AddonModAssignPushClickHandler) { moduleDelegate.registerHandler(moduleHandler); prefetchDelegate.registerHandler(prefetchHandler); cronDelegate.register(syncHandler); contentLinksDelegate.registerHandler(linkHandler); contentLinksDelegate.registerHandler(listLinkHandler); + pushNotificationsDelegate.registerClickHandler(pushClickHandler); // Allow migrating the tables from the old app to the new schema. updateManager.registerSiteTablesMigration([ diff --git a/src/addon/mod/assign/components/index/addon-mod-assign-index.html b/src/addon/mod/assign/components/index/addon-mod-assign-index.html index edf43166f..aac1c7ea8 100644 --- a/src/addon/mod/assign/components/index/addon-mod-assign-index.html +++ b/src/addon/mod/assign/components/index/addon-mod-assign-index.html @@ -6,7 +6,7 @@ - + diff --git a/src/addon/mod/assign/components/index/index.ts b/src/addon/mod/assign/components/index/index.ts index 7cd42a015..013c6b378 100644 --- a/src/addon/mod/assign/components/index/index.ts +++ b/src/addon/mod/assign/components/index/index.ts @@ -80,7 +80,7 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo this.userId = this.sitesProvider.getCurrentSiteUserId(); this.loadContent(false, true).then(() => { - this.assignProvider.logView(this.assign.id).then(() => { + this.assignProvider.logView(this.assign.id, this.assign.name).then(() => { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); }).catch(() => { // Ignore errors. @@ -88,12 +88,12 @@ export class AddonModAssignIndexComponent extends CoreCourseModuleMainActivityCo if (this.canViewAllSubmissions) { // User can see all submissions, log grading view. - this.assignProvider.logGradingView(this.assign.id).catch(() => { + this.assignProvider.logGradingView(this.assign.id, this.assign.name).catch(() => { // Ignore errors. }); } else if (this.canViewOwnSubmission) { // User can only see their own submission, log view the user submission. - this.assignProvider.logSubmissionView(this.assign.id).catch(() => { + this.assignProvider.logSubmissionView(this.assign.id, this.assign.name).catch(() => { // Ignore errors. }); } diff --git a/src/addon/mod/assign/components/submission/addon-mod-assign-submission.html b/src/addon/mod/assign/components/submission/addon-mod-assign-submission.html index 84e54a560..0ff335202 100644 --- a/src/addon/mod/assign/components/submission/addon-mod-assign-submission.html +++ b/src/addon/mod/assign/components/submission/addon-mod-assign-submission.html @@ -243,7 +243,7 @@

{{ 'addon.mod_assign.noteam' | translate }}

{{ 'addon.mod_assign.noteam_desc' | translate }}

- +

{{ 'addon.mod_assign.multipleteams' | translate }}

{{ 'addon.mod_assign.multipleteams_desc' | translate }}

diff --git a/src/addon/mod/assign/pages/submission-list/submission-list.html b/src/addon/mod/assign/pages/submission-list/submission-list.html index cd96434da..7b6301304 100644 --- a/src/addon/mod/assign/pages/submission-list/submission-list.html +++ b/src/addon/mod/assign/pages/submission-list/submission-list.html @@ -30,8 +30,8 @@

{{ 'addon.mod_assign.hiddenuser' | translate }}{{submission.blindid}}

{{submission.groupname}} - {{ 'addon.mod_assign.noteam' | translate }} - {{ 'addon.mod_assign.multipleteams' | translate }} + {{ 'addon.mod_assign.noteam' | translate }} + {{ 'addon.mod_assign.multipleteams' | translate }} {{ 'addon.mod_assign.defaultteam' | translate }}

diff --git a/src/addon/mod/assign/pages/submission-list/submission-list.ts b/src/addon/mod/assign/pages/submission-list/submission-list.ts index 8ce653613..abf2cd778 100644 --- a/src/addon/mod/assign/pages/submission-list/submission-list.ts +++ b/src/addon/mod/assign/pages/submission-list/submission-list.ts @@ -55,7 +55,7 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { protected gradedObserver; // Observer to refresh data when a grade changes. protected submissionsData: any; - constructor(navParams: NavParams, sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider, + constructor(navParams: NavParams, protected sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider, protected domUtils: CoreDomUtilsProvider, protected translate: TranslateService, protected assignProvider: AddonModAssignProvider, protected assignOfflineProvider: AddonModAssignOfflineProvider, protected assignHelper: AddonModAssignHelperProvider, protected groupsProvider: CoreGroupsProvider) { @@ -142,32 +142,26 @@ export class AddonModAssignSubmissionListPage implements OnInit, OnDestroy { * @return {Promise} Resolved when done. */ setGroup(groupId: number): Promise { - let participants, - grades; - this.groupId = groupId; this.haveAllParticipants = true; - // Get the participants. - return this.assignHelper.getParticipants(this.assign, this.groupId).then((parts) => { - this.haveAllParticipants = true; - participants = parts; - }).catch(() => { + if (!this.sitesProvider.getCurrentSite().wsAvailable('mod_assign_list_participants')) { + // Submissions are not displayed in Moodle 3.1 without the local plugin, see MOBILE-2968. this.haveAllParticipants = false; - }).then(() => { - if (!this.assign.markingworkflow) { - // Get assignment grades only if workflow is not enabled to check grading date. - return this.assignProvider.getAssignmentGrades(this.assign.id).then((assignmentGrades) => { - grades = assignmentGrades; - }); - } - }).then(() => { - // We want to show the user data on each submission. - return this.assignProvider.getSubmissionsUserData(this.submissionsData.submissions, this.courseId, this.assign.id, - this.assign.blindmarking && !this.assign.revealidentities, participants); - }).then((submissions) => { + this.submissions = []; + return Promise.resolve(); + } + + // Fetch submissions and grades. + const promises = [ + this.assignHelper.getSubmissionsUserData(this.assign, this.submissionsData.submissions, this.groupId), + // Get assignment grades only if workflow is not enabled to check grading date. + !this.assign.markingworkflow ? this.assignProvider.getAssignmentGrades(this.assign.id) : Promise.resolve(null), + ]; + + return Promise.all(promises).then(([submissions, grades]) => { // Filter the submissions to get only the ones with the right status and add some extra data. const getNeedGrading = this.selectedStatus == AddonModAssignProvider.NEED_GRADING, searchStatus = getNeedGrading ? AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED : this.selectedStatus, diff --git a/src/addon/mod/assign/providers/assign-sync.ts b/src/addon/mod/assign/providers/assign-sync.ts index cff8c421a..049b44ba3 100644 --- a/src/addon/mod/assign/providers/assign-sync.ts +++ b/src/addon/mod/assign/providers/assign-sync.ts @@ -110,26 +110,28 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider { * Try to synchronize all the assignments in a certain site or in all sites. * * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} force Wether to force sync not depending on last execution. * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - syncAllAssignments(siteId?: string): Promise { - return this.syncOnSites('all assignments', this.syncAllAssignmentsFunc.bind(this), [], siteId); + syncAllAssignments(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all assignments', this.syncAllAssignmentsFunc.bind(this), [force], siteId); } /** * Sync all assignments on a site. * * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} [force] Wether to force sync not depending on last execution. * @param {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - protected syncAllAssignmentsFunc(siteId?: string): Promise { + protected syncAllAssignmentsFunc(siteId?: string, force?: boolean): Promise { // Get all assignments that have offline data. return this.assignOfflineProvider.getAllAssigns(siteId).then((assignIds) => { - const promises = []; - // Sync all assignments that haven't been synced for a while. - assignIds.forEach((assignId) => { - promises.push(this.syncAssignIfNeeded(assignId, siteId).then((data) => { + const promises = assignIds.map((assignId) => { + const promise = force ? this.syncAssign(assignId, siteId) : this.syncAssignIfNeeded(assignId, siteId); + + return promise.then((data) => { if (data && data.updated) { // Sync done. Send event. this.eventsProvider.trigger(AddonModAssignSyncProvider.AUTO_SYNCED, { @@ -137,7 +139,7 @@ export class AddonModAssignSyncProvider extends CoreSyncBaseProvider { warnings: data.warnings }, siteId); } - })); + }); }); return Promise.all(promises); diff --git a/src/addon/mod/assign/providers/assign.ts b/src/addon/mod/assign/providers/assign.ts index 93e8204f0..26c6ecf41 100644 --- a/src/addon/mod/assign/providers/assign.ts +++ b/src/addon/mod/assign/providers/assign.ts @@ -25,7 +25,7 @@ import { CoreGradesProvider } from '@core/grades/providers/grades'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; import { AddonModAssignSubmissionDelegate } from './submission-delegate'; import { AddonModAssignOfflineProvider } from './assign-offline'; -import { CoreSiteWSPreSets } from '@classes/site'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreInterceptor } from '@classes/interceptor'; /** @@ -146,7 +146,8 @@ export class AddonModAssignProvider { includenotenrolledcourses: 1 }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getAssignmentCacheKey(courseId) + cacheKey: this.getAssignmentCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (ignoreCache) { @@ -215,7 +216,8 @@ export class AddonModAssignProvider { assignmentids: [assignId] }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getAssignmentUserMappingsCacheKey(assignId) + cacheKey: this.getAssignmentUserMappingsCacheKey(assignId), + updateFrequency: CoreSite.FREQUENCY_OFTEN }; if (ignoreCache) { @@ -315,27 +317,6 @@ export class AddonModAssignProvider { return this.ROOT_CACHE_KEY + 'assigngrades:' + assignId; } - /** - * Find participant on a list. - * - * @param {any[]} participants List of participants. - * @param {number} id ID of the participant to get. - * @return {any} Participant, undefined if not found. - */ - protected getParticipantFromUserId(participants: any[], id: number): any { - if (participants) { - for (const i in participants) { - if (participants[i].id == id) { - // Remove the participant from the list and return it. - const participant = participants[i]; - delete participants[i]; - - return participant; - } - } - } - } - /** * Returns the color name for a given grading status name. * @@ -458,7 +439,8 @@ export class AddonModAssignProvider { assignmentids: [assignId] }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getSubmissionsCacheKey(assignId) + cacheKey: this.getSubmissionsCacheKey(assignId), + updateFrequency: CoreSite.FREQUENCY_OFTEN }; if (ignoreCache) { @@ -616,103 +598,6 @@ export class AddonModAssignProvider { } } - /** - * Get user data for submissions since they only have userid. - * - * @param {any[]} submissions Submissions to get the data for. - * @param {number} courseId ID of the course the submissions belong to. - * @param {number} assignId ID of the assignment the submissions belong to. - * @param {boolean} [blind] Whether the user data need to be blinded. - * @param {any[]} [participants] List of participants in the assignment. - * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). - * @param {string} [siteId] Site id (empty for current site). - * @return {Promise} Promise always resolved. Resolve param is the formatted submissions. - */ - getSubmissionsUserData(submissions: any[], courseId: number, assignId: number, blind?: boolean, participants?: any[], - ignoreCache?: boolean, siteId?: string): Promise { - - const promises = [], - subs = [], - hasParticipants = participants && participants.length > 0; - - if (!hasParticipants) { - return Promise.resolve([]); - } - - submissions.forEach((submission) => { - submission.submitid = submission.userid > 0 ? submission.userid : submission.blindid; - if (submission.submitid <= 0) { - return; - } - - const participant = this.getParticipantFromUserId(participants, submission.submitid); - if (!participant) { - // Avoid permission denied error. Participant not found on list. - return; - } - - if (!blind) { - submission.userfullname = participant.fullname; - submission.userprofileimageurl = participant.profileimageurl; - } - - submission.manyGroups = !!participant.groups && participant.groups.length > 1; - if (participant.groupname) { - submission.groupid = participant.groupid; - submission.groupname = participant.groupname; - } - - let promise; - if (submission.userid > 0 && blind) { - // Blind but not blinded! (Moodle < 3.1.1, 3.2). - delete submission.userid; - - promise = this.getAssignmentUserMappings(assignId, submission.submitid, ignoreCache, siteId).then((blindId) => { - submission.blindid = blindId; - }); - } - - promise = promise || Promise.resolve(); - - promises.push(promise.then(() => { - // Add to the list. - if (submission.userfullname || submission.blindid) { - subs.push(submission); - } - })); - }); - - return Promise.all(promises).then(() => { - if (hasParticipants) { - // Create a submission for each participant left in the list (the participants already treated were removed). - participants.forEach((participant) => { - const submission: any = { - submitid: participant.id - }; - - if (!blind) { - submission.userid = participant.id; - submission.userfullname = participant.fullname; - submission.userprofileimageurl = participant.profileimageurl; - } else { - submission.blindid = participant.id; - } - - if (participant.groupname) { - submission.groupid = participant.groupid; - submission.groupname = participant.groupname; - } - submission.status = participant.submitted ? AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED : - AddonModAssignProvider.SUBMISSION_STATUS_NEW; - - subs.push(submission); - }); - } - - return subs; - }); - } - /** * Given a list of plugins, returns the plugin names that aren't supported for editing. * @@ -760,7 +645,8 @@ export class AddonModAssignProvider { filter: '' }, preSets: CoreSiteWSPreSets = { - cacheKey: this.listParticipantsCacheKey(assignId, groupId) + cacheKey: this.listParticipantsCacheKey(assignId, groupId), + updateFrequency: CoreSite.FREQUENCY_OFTEN }; if (ignoreCache) { @@ -1029,16 +915,19 @@ export class AddonModAssignProvider { * Report an assignment submission as being viewed. * * @param {number} assignId Assignment ID. + * @param {string} [name] Name of the assign. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logSubmissionView(assignId: number, siteId?: string): Promise { + logSubmissionView(assignId: number, name?: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { const params = { assignid: assignId }; - return site.write('mod_assign_view_submission_status', params); + return this.logHelper.logSingle('mod_assign_view_submission_status', params, AddonModAssignProvider.COMPONENT, + assignId, name, 'assign', {}, siteId); }); } @@ -1046,30 +935,34 @@ export class AddonModAssignProvider { * Report an assignment grading table is being viewed. * * @param {number} assignId Assignment ID. + * @param {string} [name] Name of the assign. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logGradingView(assignId: number, siteId?: string): Promise { + logGradingView(assignId: number, name?: string, siteId?: string): Promise { const params = { assignid: assignId }; - return this.logHelper.log('mod_assign_view_grading_table', params, AddonModAssignProvider.COMPONENT, assignId, siteId); + return this.logHelper.logSingle('mod_assign_view_grading_table', params, AddonModAssignProvider.COMPONENT, assignId, + name, 'assign', {}, siteId); } /** * Report an assign as being viewed. * * @param {number} assignId Assignment ID. + * @param {string} [name] Name of the assign. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logView(assignId: number, siteId?: string): Promise { + logView(assignId: number, name?: string, siteId?: string): Promise { const params = { assignid: assignId }; - return this.logHelper.log('mod_assign_view_assign', params, AddonModAssignProvider.COMPONENT, assignId, siteId); + return this.logHelper.logSingle('mod_assign_view_assign', params, AddonModAssignProvider.COMPONENT, assignId, name, + 'assign', {}, siteId); } /** diff --git a/src/addon/mod/assign/providers/helper.ts b/src/addon/mod/assign/providers/helper.ts index 6b64d5c1b..f0c939766 100644 --- a/src/addon/mod/assign/providers/helper.ts +++ b/src/addon/mod/assign/providers/helper.ts @@ -153,7 +153,7 @@ export class AddonModAssignHelperProvider { * @param {number} [groupId] Group Id. * @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, current site. - * @return {Promise} Promise resolved with the list of participants and summary of submissions. */ getParticipants(assign: any, groupId?: number, ignoreCache?: boolean, siteId?: string): Promise { groupId = groupId || 0; @@ -288,6 +288,104 @@ export class AddonModAssignHelperProvider { }); } + /** + * Get user data for submissions since they only have userid. + * + * @param {any} assign Assignment object. + * @param {any[]} submissions Submissions to get the data for. + * @param {number} [groupId] Group Id. + * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site id (empty for current site). + * @return {Promise} Promise always resolved. Resolve param is the formatted submissions. + */ + getSubmissionsUserData(assign: any, submissions: any[], groupId?: number, ignoreCache?: boolean, siteId?: string): + Promise { + return this.getParticipants(assign, groupId).then((participants) => { + const blind = assign.blindmarking && !assign.revealidentities; + const promises = []; + const result = []; + + participants = this.utils.arrayToObject(participants, 'id'); + + submissions.forEach((submission) => { + submission.submitid = submission.userid > 0 ? submission.userid : submission.blindid; + if (submission.submitid <= 0) { + return; + } + + const participant = participants[submission.submitid]; + if (participant) { + delete participants[submission.submitid]; + } else { + // Avoid permission denied error. Participant not found on list. + return; + } + + if (!blind) { + submission.userfullname = participant.fullname; + submission.userprofileimageurl = participant.profileimageurl; + } + + submission.manyGroups = !!participant.groups && participant.groups.length > 1; + submission.noGroups = !!participant.groups && participant.groups.length == 0; + if (participant.groupname) { + submission.groupid = participant.groupid; + submission.groupname = participant.groupname; + } + + let promise; + if (submission.userid > 0 && blind) { + // Blind but not blinded! (Moodle < 3.1.1, 3.2). + delete submission.userid; + + promise = this.assignProvider.getAssignmentUserMappings(assign.id, submission.submitid, ignoreCache, siteId). + then((blindId) => { + submission.blindid = blindId; + }); + } + + promise = promise || Promise.resolve(); + + promises.push(promise.then(() => { + // Add to the list. + if (submission.userfullname || submission.blindid) { + result.push(submission); + } + })); + }); + + return Promise.all(promises).then(() => { + // Create a submission for each participant left in the list (the participants already treated were removed). + this.utils.objectToArray(participants).forEach((participant) => { + const submission: any = { + submitid: participant.id + }; + + if (!blind) { + submission.userid = participant.id; + submission.userfullname = participant.fullname; + submission.userprofileimageurl = participant.profileimageurl; + } else { + submission.blindid = participant.id; + } + + submission.manyGroups = !!participant.groups && participant.groups.length > 1; + submission.noGroups = !!participant.groups && participant.groups.length == 0; + if (participant.groupname) { + submission.groupid = participant.groupid; + submission.groupname = participant.groupname; + } + submission.status = participant.submitted ? AddonModAssignProvider.SUBMISSION_STATUS_SUBMITTED : + AddonModAssignProvider.SUBMISSION_STATUS_NEW; + + result.push(submission); + }); + + return result; + }); + }); + } + /** * Check if the feedback data has changed for a certain submission and assign. * diff --git a/src/addon/mod/assign/providers/prefetch-handler.ts b/src/addon/mod/assign/providers/prefetch-handler.ts index cae9b6fec..a5f1e6fc9 100644 --- a/src/addon/mod/assign/providers/prefetch-handler.ts +++ b/src/addon/mod/assign/providers/prefetch-handler.ts @@ -28,6 +28,7 @@ import { CoreGradesHelperProvider } from '@core/grades/providers/helper'; import { CoreUserProvider } from '@core/user/providers/user'; import { AddonModAssignProvider } from './assign'; import { AddonModAssignHelperProvider } from './helper'; +import { AddonModAssignSyncProvider } from './assign-sync'; import { AddonModAssignFeedbackDelegate } from './feedback-delegate'; import { AddonModAssignSubmissionDelegate } from './submission-delegate'; @@ -47,7 +48,8 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan protected textUtils: CoreTextUtilsProvider, protected feedbackDelegate: AddonModAssignFeedbackDelegate, protected submissionDelegate: AddonModAssignSubmissionDelegate, protected courseHelper: CoreCourseHelperProvider, protected groupsProvider: CoreGroupsProvider, protected gradesHelper: CoreGradesHelperProvider, - protected userProvider: CoreUserProvider, protected assignHelper: AddonModAssignHelperProvider) { + protected userProvider: CoreUserProvider, protected assignHelper: AddonModAssignHelperProvider, + protected syncProvider: AddonModAssignSyncProvider) { super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils); } @@ -103,8 +105,8 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan if (data.canviewsubmissions) { // Teacher, get all submissions. - return this.assignProvider.getSubmissionsUserData(data.submissions, courseId, assign.id, blindMarking, - undefined, false, siteId).then((submissions) => { + return this.assignHelper.getSubmissionsUserData(assign, data.submissions, 0, false, siteId) + .then((submissions) => { const promises = []; @@ -289,11 +291,10 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan protected prefetchSubmissions(assign: any, courseId: number, moduleId: number, userId: number, siteId: string): Promise { // Get submissions. return this.assignProvider.getSubmissions(assign.id, true, siteId).then((data) => { - const promises = [], - blindMarking = assign.blindmarking && !assign.revealidentities; + const promises = []; if (data.canviewsubmissions) { - // Teacher. Do not send participants to getSubmissionsUserData to retrieve user profiles. + // Teacher, prefetch all submissions. promises.push(this.groupsProvider.getActivityGroupInfo(assign.cmid, false, undefined, siteId).then((groupInfo) => { const groupProms = []; if (!groupInfo.groups || groupInfo.groups.length == 0) { @@ -301,8 +302,8 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan } groupInfo.groups.forEach((group) => { - groupProms.push(this.assignProvider.getSubmissionsUserData(data.submissions, courseId, assign.id, - blindMarking, undefined, true, siteId).then((submissions) => { + groupProms.push(this.assignHelper.getSubmissionsUserData(assign, data.submissions, group.id, true, siteId) + .then((submissions) => { const subPromises = []; @@ -334,37 +335,41 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan } return Promise.all(subPromises); - })); + }).then(() => { + // Participiants already fetched, we don't need to ignore cache now. + return this.assignHelper.getParticipants(assign, group.id, false, siteId).then((participants) => { + const promises = []; - // Get list participants. - groupProms.push(this.assignHelper.getParticipants(assign, group.id, true, siteId).then((participants) => { - participants.forEach((participant) => { - if (participant.profileimageurl) { - this.filepoolProvider.addToQueueByUrl(siteId, participant.profileimageurl); - } + participants.forEach((participant) => { + if (participant.profileimageurl) { + promises.push(this.filepoolProvider.addToQueueByUrl(siteId, participant.profileimageurl)); + } + }); + + return Promise.all(promises); + }).catch(() => { + // Fail silently (Moodle < 3.2). }); - }).catch(() => { - // Fail silently (Moodle < 3.2). })); }); return Promise.all(groupProms); })); - } else { - // Student. - promises.push( - this.assignProvider.getSubmissionStatusWithRetry(assign, userId, undefined, false, true, true, siteId) - .then((subm) => { - return this.prefetchSubmission(assign, courseId, moduleId, subm, userId, siteId); - }).catch((error) => { - // Ignore if the user can't view their own submission. - if (error.errorcode != 'nopermission') { - return Promise.reject(error); - } - }) - ); } + // Prefetch own submission, we need to do this for teachers too so the response with error is cached. + promises.push( + this.assignProvider.getSubmissionStatusWithRetry(assign, userId, undefined, false, true, true, siteId) + .then((subm) => { + return this.prefetchSubmission(assign, courseId, moduleId, subm, userId, siteId); + }).catch((error) => { + // Ignore if the user can't view their own submission. + if (error.errorcode != 'nopermission') { + return Promise.reject(error); + } + }) + ); + promises.push(this.groupsProvider.activityHasGroups(assign.cmid, siteId, true)); promises.push(this.groupsProvider.getActivityAllowedGroups(assign.cmid, undefined, siteId, true)); @@ -422,6 +427,11 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan } } + // Prefetch grade items. + if (userId) { + promises.push(this.gradesHelper.getGradeModuleItems(courseId, moduleId, userId, undefined, siteId, true)); + } + // Prefetch feedback. if (submission.feedback) { // Get profile and image of the grader. @@ -429,10 +439,6 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan userIds.push(submission.feedback.grade.grader); } - if (userId) { - promises.push(this.gradesHelper.getGradeModuleItems(courseId, moduleId, userId, undefined, siteId, true)); - } - // Prefetch feedback plugins data. if (submission.feedback.plugins) { submission.feedback.plugins.forEach((plugin) => { @@ -454,4 +460,16 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan return Promise.all(promises); } + + /** + * Sync a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + sync(module: any, courseId: number, siteId?: any): Promise { + return this.syncProvider.syncAssign(module.instance, siteId); + } } diff --git a/src/addon/mod/assign/providers/push-click-handler.ts b/src/addon/mod/assign/providers/push-click-handler.ts new file mode 100644 index 000000000..abe6fa608 --- /dev/null +++ b/src/addon/mod/assign/providers/push-click-handler.ts @@ -0,0 +1,62 @@ +// (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 { CoreUrlUtilsProvider } from '@providers/utils/url'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CorePushNotificationsClickHandler } from '@core/pushnotifications/providers/delegate'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { AddonModAssignProvider } from './assign'; + +/** + * Handler for assign push notifications clicks. + */ +@Injectable() +export class AddonModAssignPushClickHandler implements CorePushNotificationsClickHandler { + name = 'AddonModAssignPushClickHandler'; + priority = 200; + featureName = 'CoreCourseModuleDelegate_AddonModAssign'; + + constructor(private utils: CoreUtilsProvider, private assignProvider: AddonModAssignProvider, + private urlUtils: CoreUrlUtilsProvider, private courseHelper: CoreCourseHelperProvider) {} + + /** + * Check if a notification click is handled by this handler. + * + * @param {any} notification The notification to check. + * @return {boolean} Whether the notification click is handled by this handler + */ + handles(notification: any): boolean | Promise { + return this.utils.isTrueOrOne(notification.notif) && notification.moodlecomponent == 'mod_assign' && + notification.name == 'assign_notification'; + } + + /** + * Handle the notification click. + * + * @param {any} notification The notification to check. + * @return {Promise} Promise resolved when done. + */ + handleClick(notification: any): Promise { + const contextUrlParams = this.urlUtils.extractUrlParams(notification.contexturl), + courseId = Number(notification.courseid), + moduleId = Number(contextUrlParams.id); + + return this.assignProvider.invalidateContent(moduleId, courseId, notification.site).catch(() => { + // Ignore errors. + }).then(() => { + return this.courseHelper.navigateToModule(moduleId, notification.site, courseId); + }); + } +} diff --git a/src/addon/mod/assign/providers/sync-cron-handler.ts b/src/addon/mod/assign/providers/sync-cron-handler.ts index fe68ccddd..fbfb6600c 100644 --- a/src/addon/mod/assign/providers/sync-cron-handler.ts +++ b/src/addon/mod/assign/providers/sync-cron-handler.ts @@ -30,10 +30,11 @@ export class AddonModAssignSyncCronHandler implements CoreCronHandler { * Receives the ID of the site affected, undefined for all sites. * * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). * @return {Promise} Promise resolved when done, rejected if failure. */ - execute(siteId?: string): Promise { - return this.assignSync.syncAllAssignments(siteId); + execute(siteId?: string, force?: boolean): Promise { + return this.assignSync.syncAllAssignments(siteId, force); } /** diff --git a/src/addon/mod/assign/submission/comments/component/addon-mod-assign-submission-comments.html b/src/addon/mod/assign/submission/comments/component/addon-mod-assign-submission-comments.html index 07a5ae68e..be2ba466f 100644 --- a/src/addon/mod/assign/submission/comments/component/addon-mod-assign-submission-comments.html +++ b/src/addon/mod/assign/submission/comments/component/addon-mod-assign-submission-comments.html @@ -1,4 +1,4 @@ - +

{{plugin.name}}

diff --git a/src/addon/mod/assign/submission/comments/component/comments.ts b/src/addon/mod/assign/submission/comments/component/comments.ts index 31f62de86..98c8be9e0 100644 --- a/src/addon/mod/assign/submission/comments/component/comments.ts +++ b/src/addon/mod/assign/submission/comments/component/comments.ts @@ -27,8 +27,12 @@ import { AddonModAssignSubmissionPluginComponent } from '../../../classes/submis export class AddonModAssignSubmissionCommentsComponent extends AddonModAssignSubmissionPluginComponent { @ViewChild(CoreCommentsCommentsComponent) commentsComponent: CoreCommentsCommentsComponent; + commentsEnabled: boolean; + constructor(protected commentsProvider: CoreCommentsProvider) { super(); + + this.commentsEnabled = !commentsProvider.areCommentsDisabledInSite(); } /** diff --git a/src/addon/mod/book/components/index/addon-mod-book-index.html b/src/addon/mod/book/components/index/addon-mod-book-index.html index b5ce9efc2..6f4e0be3a 100644 --- a/src/addon/mod/book/components/index/addon-mod-book-index.html +++ b/src/addon/mod/book/components/index/addon-mod-book-index.html @@ -8,7 +8,7 @@ - + diff --git a/src/addon/mod/book/components/index/index.ts b/src/addon/mod/book/components/index/index.ts index 75ad3beb1..1b154d373 100644 --- a/src/addon/mod/book/components/index/index.ts +++ b/src/addon/mod/book/components/index/index.ts @@ -184,9 +184,9 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp this.nextChapter = this.bookProvider.getNextChapter(this.chapters, chapterId); // Chapter loaded, log view. We don't return the promise because we don't want to block the user for this. - this.bookProvider.logView(this.module.instance, chapterId).then(() => { + this.bookProvider.logView(this.module.instance, chapterId, this.module.name).then(() => { // Module is completed when last chapter is viewed, so we only check completion if the last is reached. - if (!this.nextChapter) { + if (this.nextChapter == '0') { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); } }).catch(() => { diff --git a/src/addon/mod/book/providers/book.ts b/src/addon/mod/book/providers/book.ts index e322284c6..1655ed164 100644 --- a/src/addon/mod/book/providers/book.ts +++ b/src/addon/mod/book/providers/book.ts @@ -23,6 +23,7 @@ import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; +import { CoreSite } from '@classes/site'; /** * A book chapter inside the toc list. @@ -97,7 +98,8 @@ export class AddonModBookProvider { courseids: [courseId] }, preSets = { - cacheKey: this.getBookDataCacheKey(courseId) + cacheKey: this.getBookDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('mod_book_get_books_by_courses', params, preSets).then((response) => { @@ -380,15 +382,17 @@ export class AddonModBookProvider { * * @param {number} id Module ID. * @param {string} chapterId Chapter ID. + * @param {string} [name] Name of the book. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logView(id: number, chapterId: string, siteId?: string): Promise { + logView(id: number, chapterId: string, name?: string, siteId?: string): Promise { const params = { bookid: id, chapterid: chapterId }; - return this.logHelper.log('mod_book_view_book', params, AddonModBookProvider.COMPONENT, id, siteId); + return this.logHelper.logSingle('mod_book_view_book', params, AddonModBookProvider.COMPONENT, id, name, 'book', + {chapterid: chapterId}, siteId); } } diff --git a/src/addon/mod/chat/components/index/addon-mod-chat-index.html b/src/addon/mod/chat/components/index/addon-mod-chat-index.html index f76b8f8f8..3aaa3b0ce 100644 --- a/src/addon/mod/chat/components/index/addon-mod-chat-index.html +++ b/src/addon/mod/chat/components/index/addon-mod-chat-index.html @@ -5,7 +5,7 @@ - + diff --git a/src/addon/mod/chat/components/index/index.ts b/src/addon/mod/chat/components/index/index.ts index a04cf11d0..585c6f4e8 100644 --- a/src/addon/mod/chat/components/index/index.ts +++ b/src/addon/mod/chat/components/index/index.ts @@ -47,7 +47,7 @@ export class AddonModChatIndexComponent extends CoreCourseModuleMainActivityComp super.ngOnInit(); this.loadContent().then(() => { - this.chatProvider.logView(this.chat.id).then(() => { + this.chatProvider.logView(this.chat.id, this.chat.name).then(() => { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); }).catch(() => { // Ignore errors. diff --git a/src/addon/mod/chat/providers/chat.ts b/src/addon/mod/chat/providers/chat.ts index f12107dc4..4e6b6df9b 100644 --- a/src/addon/mod/chat/providers/chat.ts +++ b/src/addon/mod/chat/providers/chat.ts @@ -18,7 +18,7 @@ import { CoreSitesProvider } from '@providers/sites'; import { CoreUserProvider } from '@core/user/providers/user'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; import { CoreUtilsProvider } from '@providers/utils/utils'; -import { CoreSiteWSPreSets } from '@classes/site'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; /** * Service that provides some features for chats. @@ -47,7 +47,8 @@ export class AddonModChatProvider { courseids: [courseId] }; const preSets: CoreSiteWSPreSets = { - cacheKey: this.getChatsCacheKey(courseId) + cacheKey: this.getChatsCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('mod_chat_get_chats_by_courses', params, preSets).then((response) => { @@ -87,15 +88,16 @@ export class AddonModChatProvider { * Report a chat as being viewed. * * @param {number} id Chat instance ID. + * @param {string} [name] Name of the chat. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logView(id: number, siteId?: string): Promise { + logView(id: number, name?: string, siteId?: string): Promise { const params = { chatid: id }; - return this.logHelper.log('mod_chat_view_chat', params, AddonModChatProvider.COMPONENT, id, siteId); + return this.logHelper.logSingle('mod_chat_view_chat', params, AddonModChatProvider.COMPONENT, id, name, 'chat', {}, siteId); } /** @@ -213,6 +215,7 @@ export class AddonModChatProvider { }; const preSets: CoreSiteWSPreSets = { cacheKey: this.getSessionsCacheKey(chatId, groupId, showAll), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; if (ignoreCache) { preSets.getFromCache = false; @@ -251,7 +254,8 @@ export class AddonModChatProvider { groupid: groupId }; const preSets: CoreSiteWSPreSets = { - cacheKey: this.getSessionMessagesCacheKey(chatId, sessionStart, groupId) + cacheKey: this.getSessionMessagesCacheKey(chatId, sessionStart, groupId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (ignoreCache) { preSets.getFromCache = false; diff --git a/src/addon/mod/choice/components/index/addon-mod-choice-index.html b/src/addon/mod/choice/components/index/addon-mod-choice-index.html index 59a85ab7b..a9e81b5f6 100644 --- a/src/addon/mod/choice/components/index/addon-mod-choice-index.html +++ b/src/addon/mod/choice/components/index/addon-mod-choice-index.html @@ -6,7 +6,7 @@ - + diff --git a/src/addon/mod/choice/components/index/index.ts b/src/addon/mod/choice/components/index/index.ts index fb2243153..b543e4013 100644 --- a/src/addon/mod/choice/components/index/index.ts +++ b/src/addon/mod/choice/components/index/index.ts @@ -67,7 +67,7 @@ export class AddonModChoiceIndexComponent extends CoreCourseModuleMainActivityCo if (!this.choice) { return; } - this.choiceProvider.logView(this.choice.id).then(() => { + this.choiceProvider.logView(this.choice.id, this.choice.name).then(() => { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); }).catch((error) => { // Ignore errors. diff --git a/src/addon/mod/choice/providers/choice.ts b/src/addon/mod/choice/providers/choice.ts index b94e92b9c..770afa6c0 100644 --- a/src/addon/mod/choice/providers/choice.ts +++ b/src/addon/mod/choice/providers/choice.ts @@ -19,7 +19,7 @@ import { CoreAppProvider } from '@providers/app'; import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; import { AddonModChoiceOfflineProvider } from './offline'; -import { CoreSiteWSPreSets } from '@classes/site'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; /** * Service that provides some features for choices. @@ -187,7 +187,8 @@ export class AddonModChoiceProvider { }; const preSets: CoreSiteWSPreSets = { cacheKey: this.getChoiceDataCacheKey(courseId), - omitExpires: forceCache + omitExpires: forceCache, + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (forceCache) { @@ -252,7 +253,8 @@ export class AddonModChoiceProvider { choiceid: choiceId }; const preSets: CoreSiteWSPreSets = { - cacheKey: this.getChoiceOptionsCacheKey(choiceId) + cacheKey: this.getChoiceOptionsCacheKey(choiceId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (ignoreCache) { @@ -371,15 +373,17 @@ export class AddonModChoiceProvider { * Report the choice as being viewed. * * @param {string} id Choice ID. + * @param {string} [name] Name of the choice. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logView(id: number, siteId?: string): Promise { + logView(id: number, name?: string, siteId?: string): Promise { const params = { choiceid: id }; - return this.logHelper.log('mod_choice_view_choice', params, AddonModChoiceProvider.COMPONENT, id, siteId); + return this.logHelper.logSingle('mod_choice_view_choice', params, AddonModChoiceProvider.COMPONENT, id, name, 'choice', + {}, siteId); } /** diff --git a/src/addon/mod/choice/providers/prefetch-handler.ts b/src/addon/mod/choice/providers/prefetch-handler.ts index 0b2f74eba..eb8a72d4b 100644 --- a/src/addon/mod/choice/providers/prefetch-handler.ts +++ b/src/addon/mod/choice/providers/prefetch-handler.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, Injector } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from '@providers/app'; import { CoreFilepoolProvider } from '@providers/filepool'; @@ -22,6 +22,7 @@ import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseActivityPrefetchHandlerBase } from '@core/course/classes/activity-prefetch-handler'; import { CoreUserProvider } from '@core/user/providers/user'; +import { AddonModChoiceSyncProvider } from './sync'; import { AddonModChoiceProvider } from './choice'; /** @@ -34,10 +35,12 @@ export class AddonModChoicePrefetchHandler extends CoreCourseActivityPrefetchHan component = AddonModChoiceProvider.COMPONENT; updatesNames = /^configuration$|^.*files$|^answers$/; + protected syncProvider: AddonModChoiceSyncProvider; // It will be injected later to prevent circular dependencies. + constructor(translate: TranslateService, appProvider: CoreAppProvider, utils: CoreUtilsProvider, courseProvider: CoreCourseProvider, filepoolProvider: CoreFilepoolProvider, sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, protected choiceProvider: AddonModChoiceProvider, - protected userProvider: CoreUserProvider) { + protected userProvider: CoreUserProvider, protected injector: Injector) { super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils); } @@ -133,4 +136,20 @@ export class AddonModChoicePrefetchHandler extends CoreCourseActivityPrefetchHan invalidateModule(module: any, courseId: number): Promise { return this.choiceProvider.invalidateChoiceData(courseId); } + + /** + * Sync a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + sync(module: any, courseId: number, siteId?: any): Promise { + if (!this.syncProvider) { + this.syncProvider = this.injector.get(AddonModChoiceSyncProvider); + } + + return this.syncProvider.syncChoice(module.instance, undefined, siteId); + } } diff --git a/src/addon/mod/choice/providers/sync-cron-handler.ts b/src/addon/mod/choice/providers/sync-cron-handler.ts index af6cb8e81..92d88b52b 100644 --- a/src/addon/mod/choice/providers/sync-cron-handler.ts +++ b/src/addon/mod/choice/providers/sync-cron-handler.ts @@ -30,10 +30,11 @@ export class AddonModChoiceSyncCronHandler implements CoreCronHandler { * Receives the ID of the site affected, undefined for all sites. * * @param {string} [siteId] ID of the site affected, undefined for all sites. - * @return {Promise} Promise resolved when done, rejected if failure. + * @param {boolean} [force] Wether the execution is forced (manual sync). + * @return {Promise} Promise resolved when done, rejected if failure. */ - execute(siteId?: string): Promise { - return this.choiceSync.syncAllChoices(siteId); + execute(siteId?: string, force?: boolean): Promise { + return this.choiceSync.syncAllChoices(siteId, force); } /** diff --git a/src/addon/mod/choice/providers/sync.ts b/src/addon/mod/choice/providers/sync.ts index 9a14b71d4..965a1b06d 100644 --- a/src/addon/mod/choice/providers/sync.ts +++ b/src/addon/mod/choice/providers/sync.ts @@ -68,25 +68,28 @@ export class AddonModChoiceSyncProvider extends CoreCourseActivitySyncBaseProvid * Try to synchronize all the choices in a certain site or in all sites. * * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} force Wether to force sync not depending on last execution. * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - syncAllChoices(siteId?: string): Promise { - return this.syncOnSites('choices', this.syncAllChoicesFunc.bind(this), undefined, siteId); + syncAllChoices(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('choices', this.syncAllChoicesFunc.bind(this), [force], siteId); } /** * Sync all pending choices on a site. * - * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} force Wether to force sync not depending on last execution. * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - protected syncAllChoicesFunc(siteId?: string): Promise { + protected syncAllChoicesFunc(siteId?: string, force?: boolean): Promise { return this.choiceOffline.getResponses(siteId).then((responses) => { - const promises = []; - // Sync all responses. - responses.forEach((response) => { - promises.push(this.syncChoiceIfNeeded(response.choiceid, response.userid, siteId).then((result) => { + const promises = responses.map((response) => { + const promise = force ? this.syncChoice(response.choiceid, response.userid, siteId) : + this.syncChoiceIfNeeded(response.choiceid, response.userid, siteId); + + return promise.then((result) => { if (result && result.updated) { // Sync successful, send event. this.eventsProvider.trigger(AddonModChoiceSyncProvider.AUTO_SYNCED, { @@ -95,8 +98,10 @@ export class AddonModChoiceSyncProvider extends CoreCourseActivitySyncBaseProvid warnings: result.warnings }, siteId); } - })); + }); }); + + return Promise.all(promises); }); } @@ -122,98 +127,101 @@ export class AddonModChoiceSyncProvider extends CoreCourseActivitySyncBaseProvid * Synchronize a choice. * * @param {number} choiceId Choice ID to be synced. - * @param {number} userId User the answers belong to. + * @param {number} [userId] User the answers belong to. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved if sync is successful, rejected otherwise. */ - syncChoice(choiceId: number, userId: number, siteId?: string): Promise { - siteId = siteId || this.sitesProvider.getCurrentSiteId(); + syncChoice(choiceId: number, userId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + siteId = site.getId(); - const syncId = this.getSyncId(choiceId, userId); - if (this.isSyncing(syncId, siteId)) { - // There's already a sync ongoing for this discussion, return the promise. - return this.getOngoingSync(syncId, siteId); - } - - this.logger.debug(`Try to sync choice '${choiceId}' for user '${userId}'`); - - let courseId; - const result = { - warnings: [], - updated: false - }; - - // Sync offline logs. - const syncPromise = this.logHelper.syncIfNeeded(AddonModChoiceProvider.COMPONENT, choiceId, siteId).catch(() => { - // Ignore errors. - }).then(() => { - return this.choiceOffline.getResponse(choiceId, siteId, userId).catch(() => { - // No offline data found, return empty object. - return {}; - }); - }).then((data) => { - if (!data.choiceid) { - // Nothing to sync. - return; + const syncId = this.getSyncId(choiceId, userId); + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing for this discussion, return the promise. + return this.getOngoingSync(syncId, siteId); } - if (!this.appProvider.isOnline()) { - // Cannot sync in offline. - return Promise.reject(null); - } + this.logger.debug(`Try to sync choice '${choiceId}' for user '${userId}'`); - courseId = data.courseid; + let courseId; + const result = { + warnings: [], + updated: false + }; - // Send the responses. - let promise; - - if (data.deleting) { - // A user has deleted some responses. - promise = this.choiceProvider.deleteResponsesOnline(choiceId, data.responses, siteId); - } else { - // A user has added some responses. - promise = this.choiceProvider.submitResponseOnline(choiceId, data.responses, siteId); - } - - return promise.then(() => { - result.updated = true; - - return this.choiceOffline.deleteResponse(choiceId, siteId, userId); - }).catch((error) => { - if (this.utils.isWebServiceError(error)) { - // The WebService has thrown an error, this means that responses cannot be submitted. Delete them. - result.updated = true; - - return this.choiceOffline.deleteResponse(choiceId, siteId, userId).then(() => { - // Responses deleted, add a warning. - result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { - component: this.componentTranslate, - name: data.name, - error: this.textUtils.getErrorMessageFromError(error) - })); - }); + // Sync offline logs. + const syncPromise = this.logHelper.syncIfNeeded(AddonModChoiceProvider.COMPONENT, choiceId, siteId).catch(() => { + // Ignore errors. + }).then(() => { + return this.choiceOffline.getResponse(choiceId, siteId, userId).catch(() => { + // No offline data found, return empty object. + return {}; + }); + }).then((data) => { + if (!data.choiceid) { + // Nothing to sync. + return; } - // Couldn't connect to server, reject. - return Promise.reject(error); - }); - }).then(() => { - if (courseId) { - // Data has been sent to server, prefetch choice if needed. - return this.courseProvider.getModuleBasicInfoByInstance(choiceId, 'choice', siteId).then((module) => { - return this.prefetchAfterUpdate(module, courseId, undefined, siteId); - }).catch(() => { - // Ignore errors. - }); - } - }).then(() => { - // Sync finished, set sync time. - return this.setSyncTime(syncId, siteId); - }).then(() => { - // All done, return the warnings. - return result; - }); + if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } - return this.addOngoingSync(syncId, syncPromise, siteId); + courseId = data.courseid; + + // Send the responses. + let promise; + + if (data.deleting) { + // A user has deleted some responses. + promise = this.choiceProvider.deleteResponsesOnline(choiceId, data.responses, siteId); + } else { + // A user has added some responses. + promise = this.choiceProvider.submitResponseOnline(choiceId, data.responses, siteId); + } + + return promise.then(() => { + result.updated = true; + + return this.choiceOffline.deleteResponse(choiceId, siteId, userId); + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. Delete them. + result.updated = true; + + return this.choiceOffline.deleteResponse(choiceId, siteId, userId).then(() => { + // Responses deleted, add a warning. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: data.name, + error: this.textUtils.getErrorMessageFromError(error) + })); + }); + } + + // Couldn't connect to server, reject. + return Promise.reject(error); + }); + }).then(() => { + if (courseId) { + // Data has been sent to server, prefetch choice if needed. + return this.courseProvider.getModuleBasicInfoByInstance(choiceId, 'choice', siteId).then((module) => { + return this.prefetchAfterUpdate(module, courseId, undefined, siteId); + }).catch(() => { + // Ignore errors. + }); + } + }).then(() => { + // Sync finished, set sync time. + return this.setSyncTime(syncId, siteId); + }).then(() => { + // All done, return the warnings. + return result; + }); + + return this.addOngoingSync(syncId, syncPromise, siteId); + }); } } diff --git a/src/addon/mod/data/components/action/action.ts b/src/addon/mod/data/components/action/action.ts index 32be60ec3..9e96c3d1a 100644 --- a/src/addon/mod/data/components/action/action.ts +++ b/src/addon/mod/data/components/action/action.ts @@ -12,10 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. import { Component, Input, OnInit, Injector } from '@angular/core'; +import { NavController } from 'ionic-angular'; import { CoreEventsProvider } from '@providers/events'; import { AddonModDataProvider } from '../../providers/data'; +import { AddonModDataHelperProvider } from '../../providers/helper'; import { AddonModDataOfflineProvider } from '../../providers/offline'; import { CoreSitesProvider } from '@providers/sites'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { CoreUserProvider } from '@core/user/providers/user'; /** @@ -30,6 +33,8 @@ export class AddonModDataActionComponent implements OnInit { @Input() action: string; // The field to render. @Input() entry?: any; // The value of the field. @Input() database: any; // Database object. + @Input() module: any; // Module object. + @Input() group: number; // Module object. @Input() offset?: number; // Offset of the entry. siteId: string; @@ -39,11 +44,72 @@ export class AddonModDataActionComponent implements OnInit { constructor(protected injector: Injector, protected dataProvider: AddonModDataProvider, protected dataOffline: AddonModDataOfflineProvider, protected eventsProvider: CoreEventsProvider, - sitesProvider: CoreSitesProvider, protected userProvider: CoreUserProvider) { + sitesProvider: CoreSitesProvider, protected userProvider: CoreUserProvider, private navCtrl: NavController, + protected linkHelper: CoreContentLinksHelperProvider, private dataHelper: AddonModDataHelperProvider) { this.rootUrl = sitesProvider.getCurrentSite().getURL(); this.siteId = sitesProvider.getCurrentSiteId(); } + /** + * Component being initialized. + */ + ngOnInit(): void { + if (this.action == 'userpicture') { + this.userProvider.getProfile(this.entry.userid, this.database.courseid).then((profile) => { + this.userPicture = profile.profileimageurl; + }); + } + } + + /** + * Approve the entry. + */ + approveEntry(): void { + this.dataHelper.approveOrDisapproveEntry(this.database.id, this.entry.id, true, this.database.courseid); + } + + /** + * Show confirmation modal for deleting the entry. + */ + deleteEntry(): void { + this.dataHelper.showDeleteEntryModal(this.database.id, this.entry.id, this.database.courseid); + } + + /** + * Disapprove the entry. + */ + disapproveEntry(): void { + this.dataHelper.approveOrDisapproveEntry(this.database.id, this.entry.id, false, this.database.courseid); + } + + /** + * Go to the edit page of the entry. + */ + editEntry(): void { + const pageParams = { + courseId: this.database.course, + module: this.module, + entryId: this.entry.id + }; + + this.linkHelper.goInSite(this.navCtrl, 'AddonModDataEditPage', pageParams); + } + + /** + * Go to the view page of the entry. + */ + viewEntry(): void { + const pageParams: any = { + courseId: this.database.course, + module: this.module, + entryId: this.entry.id, + group: this.group, + offset: this.offset + }; + + this.linkHelper.goInSite(this.navCtrl, 'AddonModDataEntryPage', pageParams); + } + /** * Undo delete action. * @@ -60,37 +126,4 @@ export class AddonModDataActionComponent implements OnInit { this.eventsProvider.trigger(AddonModDataProvider.ENTRY_CHANGED, {dataId: dataId, entryId: entryId}, this.siteId); }); } - - /** - * Component being initialized. - */ - ngOnInit(): void { - switch (this.action) { - case 'more': - this.url = this.rootUrl + '/mod/data/view.php?d= ' + this.entry.dataid + '&rid=' + this.entry.id; - if (typeof this.offset == 'number') { - this.url += '&mode=single&page=' + this.offset; - } - break; - case 'edit': - this.url = this.rootUrl + '/mod/data/edit.php?d= ' + this.entry.dataid + '&rid=' + this.entry.id; - break; - case 'delete': - this.url = this.rootUrl + '/mod/data/view.php?d= ' + this.entry.dataid + '&delete=' + this.entry.id; - break; - case 'approve': - this.url = this.rootUrl + '/mod/data/view.php?d= ' + this.entry.dataid + '&approve=' + this.entry.id; - break; - case 'disapprove': - this.url = this.rootUrl + '/mod/data/view.php?d= ' + this.entry.dataid + '&disapprove=' + this.entry.id; - break; - case 'userpicture': - this.userProvider.getProfile(this.entry.userid, this.database.courseid).then((profile) => { - this.userPicture = profile.profileimageurl; - }); - break; - default: - break; - } - } } diff --git a/src/addon/mod/data/components/action/addon-mod-data-action.html b/src/addon/mod/data/components/action/addon-mod-data-action.html index 2faa09162..41a44e5fa 100644 --- a/src/addon/mod/data/components/action/addon-mod-data-action.html +++ b/src/addon/mod/data/components/action/addon-mod-data-action.html @@ -1,12 +1,12 @@ - + - + - + @@ -14,11 +14,11 @@ - + - + diff --git a/src/addon/mod/data/components/index/addon-mod-data-index.html b/src/addon/mod/data/components/index/addon-mod-data-index.html index d6c306669..dd2beb9a0 100644 --- a/src/addon/mod/data/components/index/addon-mod-data-index.html +++ b/src/addon/mod/data/components/index/addon-mod-data-index.html @@ -11,7 +11,7 @@ - + diff --git a/src/addon/mod/data/components/index/index.ts b/src/addon/mod/data/components/index/index.ts index ddd5975a9..37a806f43 100644 --- a/src/addon/mod/data/components/index/index.ts +++ b/src/addon/mod/data/components/index/index.ts @@ -20,13 +20,12 @@ import { CoreGroupsProvider, CoreGroupInfo } from '@providers/groups'; import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component'; import { CoreCommentsProvider } from '@core/comments/providers/comments'; import { CoreRatingProvider } from '@core/rating/providers/rating'; -import { CoreRatingOfflineProvider } from '@core/rating/providers/offline'; import { CoreRatingSyncProvider } from '@core/rating/providers/sync'; import { AddonModDataProvider } from '../../providers/data'; import { AddonModDataHelperProvider } from '../../providers/helper'; -import { AddonModDataOfflineProvider } from '../../providers/offline'; import { AddonModDataSyncProvider } from '../../providers/sync'; import { AddonModDataComponentsModule } from '../components.module'; +import { AddonModDataPrefetchHandler } from '../../providers/prefetch-handler'; /** * Component that displays a data index page. @@ -64,8 +63,6 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp advanced: [] }; hasNextPage = false; - offlineActions: any; - offlineEntries: any; entriesRendered = ''; extraImports = [AddonModDataComponentsModule]; jsData; @@ -80,12 +77,19 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp protected ratingOfflineObserver: any; protected ratingSyncObserver: any; - constructor(injector: Injector, private dataProvider: AddonModDataProvider, private dataHelper: AddonModDataHelperProvider, - private dataOffline: AddonModDataOfflineProvider, @Optional() content: Content, - private dataSync: AddonModDataSyncProvider, private timeUtils: CoreTimeUtilsProvider, - private groupsProvider: CoreGroupsProvider, private commentsProvider: CoreCommentsProvider, - private modalCtrl: ModalController, private utils: CoreUtilsProvider, protected navCtrl: NavController, - private ratingOffline: CoreRatingOfflineProvider) { + constructor( + injector: Injector, + @Optional() content: Content, + private dataProvider: AddonModDataProvider, + private dataHelper: AddonModDataHelperProvider, + private prefetchHandler: AddonModDataPrefetchHandler, + private timeUtils: CoreTimeUtilsProvider, + private groupsProvider: CoreGroupsProvider, + private commentsProvider: CoreCommentsProvider, + private modalCtrl: ModalController, + private utils: CoreUtilsProvider, + protected navCtrl: NavController) { + super(injector, content); // Refresh entries on change. @@ -125,7 +129,7 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp return; } - this.dataProvider.logView(this.data.id).then(() => { + this.dataProvider.logView(this.data.id, this.data.name).then(() => { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); }).catch(() => { // Ignore errors. @@ -232,8 +236,6 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp this.selectedGroup = groupInfo.groups[0].id; } } - - return this.fetchOfflineEntries(); }); }).then(() => { return this.dataProvider.getFields(this.data.id).then((fields) => { @@ -269,21 +271,19 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp // Update values for current group. this.access.canaddentry = accessData.canaddentry; - if (this.search.searching) { - const text = this.search.searchingAdvanced ? undefined : this.search.text, - advanced = this.search.searchingAdvanced ? this.search.advanced : undefined; + const search = this.search.searching && !this.search.searchingAdvanced ? this.search.text : undefined; + const advSearch = this.search.searching && this.search.searchingAdvanced ? this.search.advanced : undefined; - return this.dataProvider.searchEntries(this.data.id, this.selectedGroup, text, advanced, this.search.sortBy, - this.search.sortDirection, this.search.page); - } else { - return this.dataProvider.getEntries(this.data.id, this.selectedGroup, this.search.sortBy, this.search.sortDirection, - this.search.page); - } + return this.dataHelper.fetchEntries(this.data, this.fieldsArray, this.selectedGroup, search, advSearch, + this.search.sortBy, this.search.sortDirection, this.search.page); }).then((entries) => { - const numEntries = (entries && entries.entries && entries.entries.length) || 0; - this.isEmpty = !numEntries && !Object.keys(this.offlineActions).length && !Object.keys(this.offlineEntries).length; + const numEntries = entries.entries.length; + const numOfflineEntries = entries.offlineEntries.length; + this.isEmpty = !numEntries && !entries.offlineEntries.length; this.hasNextPage = numEntries >= AddonModDataProvider.PER_PAGE && ((this.search.page + 1) * AddonModDataProvider.PER_PAGE) < entries.totalcount; + this.hasOffline = entries.hasOfflineActions; + this.hasOfflineRatings = entries.hasOfflineRatings; this.entriesRendered = ''; if (typeof entries.maxcount != 'undefined') { @@ -297,79 +297,40 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp } if (!this.isEmpty) { - const siteInfo = this.sitesProvider.getCurrentSite().getInfo(), - promises = []; + this.entries = entries.offlineEntries.concat(entries.entries); - this.utils.objectToArray(this.offlineEntries).forEach((offlineActions) => { - const offlineEntry = offlineActions.find((offlineEntry) => offlineEntry.action == 'add'); + let entriesHTML = this.data.listtemplateheader || ''; - if (offlineEntry) { - const entry = { - id: offlineEntry.entryid, - canmanageentry: true, - approved: !this.data.approval || this.data.manageapproved, - dataid: offlineEntry.dataid, - groupid: offlineEntry.groupid, - timecreated: -offlineEntry.entryid, - timemodified: -offlineEntry.entryid, - userid: siteInfo.userid, - fullname: siteInfo.fullname, - contents: {} - }; + // Get first entry from the whole list. + if (!this.search.searching || !this.firstEntry) { + this.firstEntry = this.entries[0].id; + } - if (offlineActions.length > 0) { - promises.push(this.dataHelper.applyOfflineActions(entry, offlineActions, this.fieldsArray)); - } else { - promises.push(Promise.resolve(entry)); - } - } + const template = this.data.listtemplate || this.dataHelper.getDefaultTemplate('list', this.fieldsArray); + + const entriesById = {}; + this.entries.forEach((entry, index) => { + entriesById[entry.id] = entry; + + const actions = this.dataHelper.getActions(this.data, this.access, entry); + const offset = this.search.searching ? undefined : + this.search.page * AddonModDataProvider.PER_PAGE + index - numOfflineEntries; + + entriesHTML += this.dataHelper.displayShowFields(template, this.fieldsArray, entry, offset, 'list', actions); }); + entriesHTML += this.data.listtemplatefooter || ''; - entries.entries.forEach((entry) => { - // Index contents by fieldid. - entry.contents = this.utils.arrayToObject(entry.contents, 'fieldid'); + this.entriesRendered = entriesHTML; - if (typeof this.offlineActions[entry.id] != 'undefined') { - promises.push(this.dataHelper.applyOfflineActions(entry, this.offlineActions[entry.id], this.fieldsArray)); - } else { - promises.push(Promise.resolve(entry)); - } - }); - - return Promise.all(promises).then((entries) => { - this.entries = entries; - - let entriesHTML = this.data.listtemplateheader || ''; - - // Get first entry from the whole list. - if (entries && entries[0] && (!this.search.searching || !this.firstEntry)) { - this.firstEntry = entries[0].id; - } - - const template = this.data.listtemplate || this.dataHelper.getDefaultTemplate('list', this.fieldsArray); - - const entriesById = {}; - entries.forEach((entry, index) => { - entriesById[entry.id] = entry; - - const actions = this.dataHelper.getActions(this.data, this.access, entry); - const offset = this.search.page * AddonModDataProvider.PER_PAGE + index; - - entriesHTML += this.dataHelper.displayShowFields(template, this.fieldsArray, entry, offset, 'list', - actions); - }); - entriesHTML += this.data.listtemplatefooter || ''; - - this.entriesRendered = entriesHTML; - - // Pass the input data to the component. - this.jsData = { - fields: this.fields, - entries: entriesById, - data: this.data, - gotoEntry: this.gotoEntry.bind(this) - }; - }); + // Pass the input data to the component. + this.jsData = { + fields: this.fields, + entries: entriesById, + data: this.data, + module: this.module, + group: this.selectedGroup, + gotoEntry: this.gotoEntry.bind(this) + }; } else if (!this.search.searching) { // Empty and no searching. this.canSearch = false; @@ -434,6 +395,7 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp */ setGroup(groupId: number): Promise { this.selectedGroup = groupId; + this.search.page = 0; return this.fetchEntriesData().catch((message) => { this.domUtils.showErrorModalDefault(message, 'core.course.errorgetmodule', true); @@ -478,59 +440,13 @@ export class AddonModDataIndexComponent extends CoreCourseModuleMainActivityComp this.navCtrl.push('AddonModDataEntryPage', params); } - /** - * Fetch offline entries. - * - * @return {Promise} Resolved then done. - */ - protected fetchOfflineEntries(): Promise { - // Check if there are entries stored in offline. - return this.dataOffline.getDatabaseEntries(this.data.id).then((offlineEntries) => { - this.hasOffline = !!offlineEntries.length; - - this.offlineActions = {}; - this.offlineEntries = {}; - - // Only show offline entries on first page. - if (this.search.page == 0 && this.hasOffline) { - offlineEntries.forEach((entry) => { - if (entry.entryid > 0) { - if (typeof this.offlineActions[entry.entryid] == 'undefined') { - this.offlineActions[entry.entryid] = []; - } - this.offlineActions[entry.entryid].push(entry); - } else { - if (typeof this.offlineActions[entry.entryid] == 'undefined') { - this.offlineEntries[entry.entryid] = []; - } - this.offlineEntries[entry.entryid].push(entry); - } - }); - } - }).then(() => { - return this.ratingOffline.hasRatings('mod_data', 'entry', 'module', this.data.coursemodule).then((hasRatings) => { - this.hasOfflineRatings = hasRatings; - }); - }); - } - /** * Performs the sync of the activity. * * @return {Promise} Promise resolved when done. */ protected sync(): Promise { - const promises = [ - this.dataSync.syncDatabase(this.data.id), - this.dataSync.syncRatings(this.data.coursemodule) - ]; - - return Promise.all(promises).then((results) => { - return results.reduce((a, b) => ({ - updated: a.updated || b.updated, - warnings: (a.warnings || []).concat(b.warnings || []), - }), {updated: false}); - }); + return this.prefetchHandler.sync(this.module, this.courseId); } /** diff --git a/src/addon/mod/data/fields/textarea/providers/handler.ts b/src/addon/mod/data/fields/textarea/providers/handler.ts index 1c1a0bc0d..28ad00b9a 100644 --- a/src/addon/mod/data/fields/textarea/providers/handler.ts +++ b/src/addon/mod/data/fields/textarea/providers/handler.ts @@ -103,11 +103,9 @@ export class AddonModDataFieldTextareaHandler extends AddonModDataFieldTextHandl return this.translate.instant('addon.mod_data.errormustsupplyvalue'); } - const found = inputData.some((input) => { - return !input.subfield && this.textUtils.htmlIsBlank(input.value); - }); + const value = inputData.find((value) => value.subfield == ''); - if (!found) { + if (!value || this.textUtils.htmlIsBlank(value.value)) { return this.translate.instant('addon.mod_data.errormustsupplyvalue'); } } diff --git a/src/addon/mod/data/lang/en.json b/src/addon/mod/data/lang/en.json index 036cc21e4..f358c48a8 100644 --- a/src/addon/mod/data/lang/en.json +++ b/src/addon/mod/data/lang/en.json @@ -34,7 +34,6 @@ "recorddisapproved": "Entry unapproved", "resetsettings": "Reset filters", "search": "Search", - "single": "View single", "selectedrequired": "All selected required", "single": "View single", "timeadded": "Time added", diff --git a/src/addon/mod/data/pages/edit/edit.html b/src/addon/mod/data/pages/edit/edit.html index 2e7994fc3..71673625f 100644 --- a/src/addon/mod/data/pages/edit/edit.html +++ b/src/addon/mod/data/pages/edit/edit.html @@ -2,7 +2,7 @@ - diff --git a/src/addon/mod/data/pages/edit/edit.ts b/src/addon/mod/data/pages/edit/edit.ts index 4ca834934..35e74cd06 100644 --- a/src/addon/mod/data/pages/edit/edit.ts +++ b/src/addon/mod/data/pages/edit/edit.ts @@ -45,7 +45,6 @@ export class AddonModDataEditPage { protected data: any; protected entryId: number; protected entry: any; - protected offlineActions = []; protected fields = {}; protected fieldsArray = []; protected siteId: string; @@ -95,7 +94,7 @@ export class AddonModDataEditPage { * @return {boolean | Promise} Resolved if we can leave it, rejected if not. */ ionViewCanLeave(): boolean | Promise { - if (this.forceLeave) { + if (this.forceLeave || !this.entry) { return true; } @@ -145,31 +144,14 @@ export class AddonModDataEditPage { }); } }).then(() => { - return this.dataOffline.getEntryActions(this.data.id, this.entryId); - }).then((actions) => { - this.offlineActions = actions; - return this.dataProvider.getFields(this.data.id); }).then((fieldsData) => { this.fieldsArray = fieldsData; this.fields = this.utils.arrayToObject(fieldsData, 'id'); - return this.dataHelper.getEntry(this.data, this.entryId, this.offlineActions); + return this.dataHelper.fetchEntry(this.data, fieldsData, this.entryId); }).then((entry) => { - if (entry) { - entry = entry.entry; - - // Index contents by fieldid. - entry.contents = this.utils.arrayToObject(entry.contents, 'fieldid'); - } else { - entry = { - contents: {} - }; - } - - return this.dataHelper.applyOfflineActions(entry, this.offlineActions, this.fieldsArray); - }).then((entryData) => { - this.entry = entryData; + this.entry = entry.entry; this.editFormRender = this.displayEditFields(); }).catch((message) => { diff --git a/src/addon/mod/data/pages/entry/entry.html b/src/addon/mod/data/pages/entry/entry.html index 1767dc9c0..4bc24c4a6 100644 --- a/src/addon/mod/data/pages/entry/entry.html +++ b/src/addon/mod/data/pages/entry/entry.html @@ -4,12 +4,12 @@ - + - + -
+
{{ 'core.hasdatatosync' | translate: {$a: moduleName} }}
@@ -25,14 +25,14 @@
- +
- + - - + + diff --git a/src/addon/mod/data/pages/entry/entry.ts b/src/addon/mod/data/pages/entry/entry.ts index 6f6cf7649..51e95ce9c 100644 --- a/src/addon/mod/data/pages/entry/entry.ts +++ b/src/addon/mod/data/pages/entry/entry.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, ViewChild, OnDestroy } from '@angular/core'; +import { ChangeDetectorRef, Component, ViewChild, OnDestroy } from '@angular/core'; import { Content, IonicPage, NavParams, NavController } from 'ionic-angular'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; @@ -23,10 +23,10 @@ import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreRatingInfo } from '@core/rating/providers/rating'; import { AddonModDataProvider } from '../../providers/data'; import { AddonModDataHelperProvider } from '../../providers/helper'; -import { AddonModDataOfflineProvider } from '../../providers/offline'; import { AddonModDataSyncProvider } from '../../providers/sync'; import { AddonModDataFieldsDelegate } from '../../providers/fields-delegate'; import { AddonModDataComponentsModule } from '../../components/components.module'; +import { CoreCommentsProvider } from '@core/comments/providers/comments'; /** * Page that displays the view entry page. @@ -46,33 +46,37 @@ export class AddonModDataEntryPage implements OnDestroy { protected syncObserver: any; // It will observe the sync auto event. protected entryChangedObserver: any; // It will observe the changed entry event. protected fields = {}; + protected fieldsArray = []; title = ''; moduleName = 'data'; component = AddonModDataProvider.COMPONENT; entryLoaded = false; + renderingEntry = false; + loadingComments = false; + loadingRating = false; selectedGroup = 0; entry: any; - offlineActions = []; - hasOffline = false; previousOffset: number; nextOffset: number; access: any; data: any; groupInfo: any; showComments: any; - entryRendered = ''; + entryHtml = ''; siteId: string; extraImports = [AddonModDataComponentsModule]; jsData; ratingInfo: CoreRatingInfo; + isPullingToRefresh = false; // Whether the last fetching of data was started by a pull-to-refresh action + commentsEnabled: boolean; constructor(params: NavParams, protected utils: CoreUtilsProvider, protected groupsProvider: CoreGroupsProvider, protected domUtils: CoreDomUtilsProvider, protected fieldsDelegate: AddonModDataFieldsDelegate, protected courseProvider: CoreCourseProvider, protected dataProvider: AddonModDataProvider, - protected dataOffline: AddonModDataOfflineProvider, protected dataHelper: AddonModDataHelperProvider, - sitesProvider: CoreSitesProvider, protected navCtrl: NavController, - protected eventsProvider: CoreEventsProvider) { + protected dataHelper: AddonModDataHelperProvider, + sitesProvider: CoreSitesProvider, protected navCtrl: NavController, protected eventsProvider: CoreEventsProvider, + private cdr: ChangeDetectorRef, protected commentsProvider: CoreCommentsProvider) { this.module = params.get('module') || {}; this.entryId = params.get('entryId') || null; this.courseId = params.get('courseId'); @@ -89,6 +93,7 @@ export class AddonModDataEntryPage implements OnDestroy { * View loaded. */ ionViewDidLoad(): void { + this.commentsEnabled = !this.commentsProvider.areCommentsDisabledInSite(); this.fetchEntryData(); // Refresh data if this discussion is synchronized automatically. @@ -122,18 +127,24 @@ export class AddonModDataEntryPage implements OnDestroy { /** * Fetch the entry data. * - * @param {boolean} refresh If refresh the current data or not. - * @return {Promise} Resolved when done. + * @param {boolean} [refresh] Whether to refresh the current data or not. + * @param {boolean} [isPtr] Whether is a pull to refresh action. + * @return {Promise} Resolved when done. */ - protected fetchEntryData(refresh?: boolean): Promise { - let fieldsArray; + protected fetchEntryData(refresh?: boolean, isPtr?: boolean): Promise { + this.isPullingToRefresh = isPtr; return this.dataProvider.getDatabase(this.courseId, this.module.id).then((data) => { this.title = data.name || this.title; this.data = data; - return this.setEntryIdFromOffset(data.id, this.offset, this.selectedGroup).then(() => { - return this.dataProvider.getDatabaseAccessInformation(data.id); + return this.dataProvider.getFields(this.data.id).then((fieldsData) => { + this.fields = this.utils.arrayToObject(fieldsData, 'id'); + this.fieldsArray = fieldsData; + }); + }).then(() => { + return this.setEntryFromOffset().then(() => { + return this.dataProvider.getDatabaseAccessInformation(this.data.id); }); }).then((accessData) => { this.access = accessData; @@ -148,35 +159,13 @@ export class AddonModDataEntryPage implements OnDestroy { this.selectedGroup = groupInfo.groups[0].id; } } - - return this.dataOffline.getEntryActions(this.data.id, this.entryId); }); - }).then((actions) => { - this.offlineActions = actions; - this.hasOffline = !!actions.length; - - return this.dataProvider.getFields(this.data.id).then((fieldsData) => { - this.fields = this.utils.arrayToObject(fieldsData, 'id'); - - return this.dataHelper.getEntry(this.data, this.entryId, this.offlineActions); - }); - }).then((entry) => { - this.ratingInfo = entry.ratinginfo; - entry = entry.entry; - - // Index contents by fieldid. - entry.contents = this.utils.arrayToObject(entry.contents, 'fieldid'); - - fieldsArray = this.utils.objectToArray(this.fields); - - return this.dataHelper.applyOfflineActions(entry, this.offlineActions, fieldsArray); - }).then((entryData) => { - this.entry = entryData; - + }).then(() => { const actions = this.dataHelper.getActions(this.data, this.access, this.entry); - const templte = this.data.singletemplate || this.dataHelper.getDefaultTemplate('single', fieldsArray); - this.entryRendered = this.dataHelper.displayShowFields(templte, fieldsArray, this.entry, this.offset, 'show', actions); + const template = this.data.singletemplate || this.dataHelper.getDefaultTemplate('single', this.fieldsArray); + this.entryHtml = this.dataHelper.displayShowFields(template, this.fieldsArray, this.entry, this.offset, 'show', + actions); this.showComments = actions.comments; const entries = {}; @@ -186,12 +175,14 @@ export class AddonModDataEntryPage implements OnDestroy { this.jsData = { fields: this.fields, entries: entries, - data: this.data + data: this.data, + module: this.module, + group: this.selectedGroup }; }).catch((message) => { if (!refresh) { // Some call failed, retry without using cache since it might be a new activity. - return this.refreshAllData(); + return this.refreshAllData(isPtr); } this.domUtils.showErrorModalDefault(message, 'core.course.errorgetmodule', true); @@ -219,9 +210,10 @@ export class AddonModDataEntryPage implements OnDestroy { /** * Refresh all the data. * + * @param {boolean} [isPtr] Whether is a pull to refresh action. * @return {Promise} Promise resolved when done. */ - protected refreshAllData(): Promise { + protected refreshAllData(isPtr?: boolean): Promise { const promises = []; promises.push(this.dataProvider.invalidateDatabaseData(this.courseId)); @@ -232,7 +224,7 @@ export class AddonModDataEntryPage implements OnDestroy { } return Promise.all(promises).finally(() => { - return this.fetchEntryData(true); + return this.fetchEntryData(true, isPtr); }); } @@ -244,7 +236,7 @@ export class AddonModDataEntryPage implements OnDestroy { */ refreshDatabase(refresher?: any): Promise { if (this.entryLoaded) { - return this.refreshAllData().finally(() => { + return this.refreshAllData(true).finally(() => { refresher && refresher.complete(); }); } @@ -258,7 +250,7 @@ export class AddonModDataEntryPage implements OnDestroy { */ setGroup(groupId: number): Promise { this.selectedGroup = groupId; - this.offset = 0; + this.offset = null; this.entry = null; this.entryId = null; this.entryLoaded = false; @@ -267,49 +259,100 @@ export class AddonModDataEntryPage implements OnDestroy { } /** - * Convenience function to translate offset to entry identifier and set next/previous entries. + * Convenience function to fetch the entry and set next/previous entries. * - * @param {number} dataId Data Id. - * @param {number} [offset] Offset of the entry. - * @param {number} [groupId] Group Id to get the entry. * @return {Promise} Resolved when done. */ - protected setEntryIdFromOffset(dataId: number, offset?: number, groupId?: number): Promise { - if (typeof offset != 'number') { + protected setEntryFromOffset(): Promise { + const emptyOffset = typeof this.offset != 'number'; + + if (emptyOffset && typeof this.entryId == 'number') { // Entry id passed as navigation parameter instead of the offset. // We don't display next/previous buttons in this case. this.nextOffset = null; this.previousOffset = null; - return Promise.resolve(); + return this.dataHelper.fetchEntry(this.data, this.fieldsArray, this.entryId).then((entry) => { + this.entry = entry.entry; + this.ratingInfo = entry.ratinginfo; + }); } const perPage = AddonModDataProvider.PER_PAGE; - const page = Math.floor(offset / perPage); - const pageOffset = offset % perPage; + const page = !emptyOffset && this.offset >= 0 ? Math.floor(this.offset / perPage) : 0; - return this.dataProvider.getEntries(dataId, groupId, undefined, undefined, page, perPage).then((entries) => { - if (!entries || !entries.entries || !entries.entries.length || pageOffset >= entries.entries.length) { - return Promise.reject(null); + return this.dataHelper.fetchEntries(this.data, this.fieldsArray, this.selectedGroup, undefined, undefined, '0', 'DESC', + page, perPage).then((entries) => { + + const pageEntries = entries.offlineEntries.concat(entries.entries); + let pageIndex; // Index of the entry when concatenating offline and online page entries. + if (emptyOffset) { + // No offset passed, display the first entry. + pageIndex = 0; + } else if (this.offset > 0) { + // Online entry. + pageIndex = this.offset % perPage + entries.offlineEntries.length; + } else { + // Offline entry. + pageIndex = this.offset + entries.offlineEntries.length; } - this.entryId = entries.entries[pageOffset].id; - this.previousOffset = offset > 0 ? offset - 1 : null; - if (pageOffset + 1 < entries.entries.length) { + this.entry = pageEntries[pageIndex]; + this.entryId = this.entry.id; + + this.previousOffset = page > 0 || pageIndex > 0 ? this.offset - 1 : null; + + let promise; + + if (pageIndex + 1 < pageEntries.length) { // Not the last entry on the page; - this.nextOffset = offset + 1; - } else if (entries.entries.length < perPage) { + this.nextOffset = this.offset + 1; + } else if (pageEntries.length < perPage) { // Last entry of the last page. this.nextOffset = null; } else { // Last entry of the page, check if there are more pages. - return this.dataProvider.getEntries(dataId, groupId, undefined, undefined, page + 1, perPage).then((entries) => { - this.nextOffset = entries && entries.entries && entries.entries.length > 0 ? offset + 1 : null; + promise = this.dataProvider.getEntries(this.data.id, this.selectedGroup, '0', 'DESC', page + 1, perPage) + .then((entries) => { + this.nextOffset = entries && entries.entries && entries.entries.length > 0 ? this.offset + 1 : null; }); } + + return Promise.resolve(promise).then(() => { + if (this.entryId > 0) { + // Online entry, we need to fetch the the rating info. + return this.dataProvider.getEntry(this.data.id, this.entryId).then((entry) => { + this.ratingInfo = entry.ratinginfo; + }); + } + }); }); } + /** + * Function called when entry is being rendered. + */ + setRenderingEntry(rendering: boolean): void { + this.renderingEntry = rendering; + this.cdr.detectChanges(); + } + + /** + * Function called when comments component is loading data. + */ + setLoadingComments(loading: boolean): void { + this.loadingComments = loading; + this.cdr.detectChanges(); + } + + /** + * Function called when rate component is loading data. + */ + setLoadingRating(loading: boolean): void { + this.loadingRating = loading; + this.cdr.detectChanges(); + } + /** * Function called when rating is updated online. */ diff --git a/src/addon/mod/data/providers/approve-link-handler.ts b/src/addon/mod/data/providers/approve-link-handler.ts index 5e7a88a7d..5084b8fb3 100644 --- a/src/addon/mod/data/providers/approve-link-handler.ts +++ b/src/addon/mod/data/providers/approve-link-handler.ts @@ -16,9 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; import { AddonModDataProvider } from './data'; -import { CoreCourseProvider } from '@core/course/providers/course'; -import { CoreDomUtilsProvider } from '@providers/utils/dom'; -import { CoreEventsProvider } from '@providers/events'; +import { AddonModDataHelperProvider } from './helper'; /** * Content links handler for database approve/disapprove entry. @@ -30,29 +28,10 @@ export class AddonModDataApproveLinkHandler extends CoreContentLinksHandlerBase featureName = 'CoreCourseModuleDelegate_AddonModData'; pattern = /\/mod\/data\/view\.php.*([\?\&](d|approve|disapprove)=\d+)/; - constructor(private dataProvider: AddonModDataProvider, private courseProvider: CoreCourseProvider, - private domUtils: CoreDomUtilsProvider, private eventsProvider: CoreEventsProvider) { + constructor(private dataProvider: AddonModDataProvider, private dataHelper: AddonModDataHelperProvider) { super(); } - /** - * Convenience function to help get courseId. - * - * @param {number} dataId Database Id. - * @param {string} siteId Site Id, if not set, current site will be used. - * @param {number} courseId Course Id if already set. - * @return {Promise} Resolved with course Id when done. - */ - protected getActivityCourseIdIfNotSet(dataId: number, siteId: string, courseId: number): Promise { - if (courseId) { - return Promise.resolve(courseId); - } - - return this.courseProvider.getModuleBasicInfoByInstance(dataId, 'data', siteId).then((module) => { - return module.course; - }); - } - /** * Get the list of actions for a link (url). * @@ -66,34 +45,11 @@ export class AddonModDataApproveLinkHandler extends CoreContentLinksHandlerBase CoreContentLinksAction[] | Promise { return [{ action: (siteId, navCtrl?): void => { - const modal = this.domUtils.showModalLoading(), - dataId = parseInt(params.d, 10), + const dataId = parseInt(params.d, 10), entryId = parseInt(params.approve, 10) || parseInt(params.disapprove, 10), approve = parseInt(params.approve, 10) ? true : false; - this.getActivityCourseIdIfNotSet(dataId, siteId, courseId).then((cId) => { - courseId = cId; - - // Approve/disapprove entry. - return this.dataProvider.approveEntry(dataId, entryId, approve, courseId, siteId).catch((message) => { - this.domUtils.showErrorModalDefault(message, 'addon.mod_data.errorapproving', true); - - return Promise.reject(null); - }); - }).then(() => { - const promises = []; - promises.push(this.dataProvider.invalidateEntryData(dataId, entryId, siteId)); - promises.push(this.dataProvider.invalidateEntriesData(dataId, siteId)); - - return Promise.all(promises); - }).then(() => { - this.eventsProvider.trigger(AddonModDataProvider.ENTRY_CHANGED, {dataId: dataId, entryId: entryId}, siteId); - - this.domUtils.showToast(approve ? 'addon.mod_data.recordapproved' : 'addon.mod_data.recorddisapproved', true, - 3000); - }).finally(() => { - modal.dismiss(); - }); + this.dataHelper.approveOrDisapproveEntry(dataId, entryId, approve, courseId, siteId); } }]; } diff --git a/src/addon/mod/data/providers/data.ts b/src/addon/mod/data/providers/data.ts index a50cf5156..63e53e364 100644 --- a/src/addon/mod/data/providers/data.ts +++ b/src/addon/mod/data/providers/data.ts @@ -21,6 +21,68 @@ import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; import { AddonModDataOfflineProvider } from './offline'; import { AddonModDataFieldsDelegate } from './fields-delegate'; +import { CoreRatingInfo } from '@core/rating/providers/rating'; +import { CoreSite } from '@classes/site'; + +/** + * Database entry (online or offline). + */ +export interface AddonModDataEntry { + id: number; // Negative for offline entries. + userid: number; + groupid: number; + dataid: number; + timecreated: number; + timemodified: number; + approved: boolean; + canmanageentry: boolean; + fullname: string; + contents: AddonModDataEntryFields; + deleted?: boolean; // Entry is deleted offline. + hasOffline?: boolean; // Entry has offline actions. +} + +/** + * Entry field content. + */ +export interface AddonModDataEntryField { + fieldid: number; + content: string; + content1: string; + content2: string; + content3: string; + content4: string; + files: any[]; +} + +/** + * Entry contents indexed by field id. + */ +export interface AddonModDataEntryFields { + [fieldid: number]: AddonModDataEntryField; +} + +/** + * List of entries returned by web service and helper functions. + */ +export interface AddonModDataEntries { + entries: AddonModDataEntry[]; // Online entries. + totalcount: number; // Total count of online entries or found entries. + maxcount?: number; // Total count of online entries. Only returned when searching. + offlineEntries?: AddonModDataEntry[]; // Offline entries. + hasOfflineActions?: boolean; // Whether the database has offline data. + hasOfflineRatings?: boolean; // Whether the database has offline ratings. +} + +/** + * Subfield form data. + */ +export interface AddonModDataSubfieldData { + fieldid: number; + subfield?: string; + value?: string; // Value encoded in JSON. + files?: any[]; +} /** * Service that provides some features for databases. @@ -49,13 +111,13 @@ export class AddonModDataProvider { * @param {number} courseId Course ID. * @param {any} contents The fields data to be created. * @param {number} [groupId] Group id, 0 means that the function will determine the user group. - * @param {any} fields The fields that define the contents. + * @param {any[]} fields The fields that define the contents. * @param {string} [siteId] Site ID. If not defined, current site. * @param {boolean} [forceOffline] Force editing entry in offline. * @return {Promise} Promise resolved when the action is done. */ - addEntry(dataId: number, entryId: number, courseId: number, contents: any, groupId: number = 0, fields: any, siteId?: string, - forceOffline: boolean = false): Promise { + addEntry(dataId: number, entryId: number, courseId: number, contents: AddonModDataSubfieldData[], groupId: number = 0, + fields: any, siteId?: string, forceOffline: boolean = false): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); // Convenience function to store a data to be synchronized later. @@ -76,6 +138,8 @@ export class AddonModDataProvider { fieldnotifications: notifications }); } + + return storeOffline(); } return this.addEntryOnline(dataId, contents, groupId, siteId).catch((error) => { @@ -93,12 +157,12 @@ export class AddonModDataProvider { * Adds a new entry to a database. It does not cache calls. It will fail if offline or cannot connect. * * @param {number} dataId Database ID. - * @param {any} data The fields data to be created. + * @param {any[]} data The fields data to be created. * @param {number} [groupId] Group id, 0 means that the function will determine the user group. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the action is done. */ - addEntryOnline(dataId: number, data: any, groupId?: number, siteId?: string): Promise { + addEntryOnline(dataId: number, data: AddonModDataSubfieldData[], groupId?: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { const params = { databaseid: dataId, @@ -184,7 +248,7 @@ export class AddonModDataProvider { * @param {any} contents The contents data of the fields. * @return {any} Array of notifications if any or false. */ - protected checkFields(fields: any, contents: any): any { + protected checkFields(fields: any, contents: AddonModDataSubfieldData[]): any[] | false { const notifications = [], contentsIndexed = {}; @@ -289,13 +353,13 @@ export class AddonModDataProvider { * @param {number} dataId Database ID. * @param {number} entryId Entry ID. * @param {number} courseId Course ID. - * @param {any} contents The contents data to be updated. + * @param {any[]} contents The contents data to be updated. * @param {any} fields The fields that define the contents. * @param {string} [siteId] Site ID. If not defined, current site. * @param {boolean} forceOffline Force editing entry in offline. * @return {Promise} Promise resolved when the action is done. */ - editEntry(dataId: number, entryId: number, courseId: number, contents: any, fields: any, siteId?: string, + editEntry(dataId: number, entryId: number, courseId: number, contents: AddonModDataSubfieldData[], fields: any, siteId?: string, forceOffline: boolean = false): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); @@ -370,11 +434,11 @@ export class AddonModDataProvider { * Updates an existing entry. It does not cache calls. It will fail if offline or cannot connect. * * @param {number} entryId Entry ID. - * @param {any} data The fields data to be updated. + * @param {any[]} data The fields data to be updated. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the action is done. */ - editEntryOnline(entryId: number, data: number, siteId?: string): Promise { + editEntryOnline(entryId: number, data: AddonModDataSubfieldData[], siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { const params = { entryid: entryId, @@ -397,11 +461,11 @@ export class AddonModDataProvider { * @param {boolean} [forceCache] True to always get the value from cache, false otherwise. Default false. * @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, current site. - * @return {Promise} Promise resolved when done. + * @return {Promise} Promise resolved when done. */ fetchAllEntries(dataId: number, groupId: number = 0, sort: string = '0', order: string = 'DESC', perPage: number = AddonModDataProvider.PER_PAGE, forceCache: boolean = false, ignoreCache: boolean = false, - siteId?: string): Promise { + siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); return this.fetchEntriesRecursive(dataId, groupId, sort, order, perPage, forceCache, ignoreCache, [], 0, siteId); @@ -420,10 +484,10 @@ export class AddonModDataProvider { * @param {any} entries Entries already fetch (just to concatenate them). * @param {number} page Page of records to return. * @param {string} siteId Site ID. - * @return {Promise} Promise resolved when done. + * @return {Promise} Promise resolved when done. */ protected fetchEntriesRecursive(dataId: number, groupId: number, sort: string, order: string, perPage: number, - forceCache: boolean, ignoreCache: boolean, entries: any, page: number, siteId: string): Promise { + forceCache: boolean, ignoreCache: boolean, entries: any, page: number, siteId: string): Promise { return this.getEntries(dataId, groupId, sort, order, page, perPage, forceCache, ignoreCache, siteId) .then((result) => { entries = entries.concat(result.entries); @@ -475,7 +539,8 @@ export class AddonModDataProvider { courseids: [courseId] }, preSets = { - cacheKey: this.getDatabaseDataCacheKey(courseId) + cacheKey: this.getDatabaseDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (forceCache) { preSets['omitExpires'] = true; @@ -595,11 +660,11 @@ export class AddonModDataProvider { * @param {boolean} [forceCache=false] True to always get the value from cache, false otherwise. Default false. * @param {boolean} [ignoreCache=false] True if it should ignore cached data (it'll always fail in offline or server down). * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} Promise resolved when the database is retrieved. + * @return {Promise} Promise resolved when the database is retrieved. */ getEntries(dataId: number, groupId: number = 0, sort: string = '0', order: string = 'DESC', page: number = 0, perPage: number = AddonModDataProvider.PER_PAGE, forceCache: boolean = false, ignoreCache: boolean = false, - siteId?: string): Promise { + siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { // Always use sort and order params to improve cache usage (entries are identified by params). const params = { @@ -612,7 +677,8 @@ export class AddonModDataProvider { order: order }, preSets = { - cacheKey: this.getEntriesCacheKey(dataId, groupId) + cacheKey: this.getEntriesCacheKey(dataId, groupId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; if (forceCache) { @@ -622,7 +688,13 @@ export class AddonModDataProvider { preSets['emergencyCache'] = false; } - return site.read('mod_data_get_entries', params, preSets); + return site.read('mod_data_get_entries', params, preSets).then((response) => { + response.entries.forEach((entry) => { + entry.contents = this.utils.arrayToObject(entry.contents, 'fieldid'); + }); + + return response; + }); }); } @@ -654,16 +726,18 @@ export class AddonModDataProvider { * @param {number} entryId Entry ID. * @param {boolean} [ignoreCache=false] True if it should ignore cached data (it'll always fail in offline or server down). * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} Promise resolved when the database entry is retrieved. + * @return {Promise<{entry: AddonModDataEntry, ratinginfo: CoreRatingInfo}>} Promise resolved when the entry is retrieved. */ - getEntry(dataId: number, entryId: number, ignoreCache: boolean = false, siteId?: string): Promise { + getEntry(dataId: number, entryId: number, ignoreCache: boolean = false, siteId?: string): + Promise<{entry: AddonModDataEntry, ratinginfo: CoreRatingInfo}> { return this.sitesProvider.getSite(siteId).then((site) => { const params = { entryid: entryId, returncontents: 1 }, preSets = { - cacheKey: this.getEntryCacheKey(dataId, entryId) + cacheKey: this.getEntryCacheKey(dataId, entryId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; if (ignoreCache) { @@ -671,7 +745,11 @@ export class AddonModDataProvider { preSets['emergencyCache'] = false; } - return site.read('mod_data_get_entry', params, preSets); + return site.read('mod_data_get_entry', params, preSets).then((response) => { + response.entry.contents = this.utils.arrayToObject(response.entry.contents, 'fieldid'); + + return response; + }); }); } @@ -701,7 +779,8 @@ export class AddonModDataProvider { databaseid: dataId }, preSets = { - cacheKey: this.getFieldsCacheKey(dataId) + cacheKey: this.getFieldsCacheKey(dataId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (forceCache) { @@ -854,15 +933,17 @@ export class AddonModDataProvider { * Report the database as being viewed. * * @param {number} id Module ID. + * @param {string} [name] Name of the data. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logView(id: number, siteId?: string): Promise { + logView(id: number, name?: string, siteId?: string): Promise { const params = { databaseid: id }; - return this.logHelper.log('mod_data_view_database', params, AddonModDataProvider.COMPONENT, id, siteId); + return this.logHelper.logSingle('mod_data_view_database', params, AddonModDataProvider.COMPONENT, id, name, 'data', {}, + siteId); } /** @@ -871,16 +952,16 @@ export class AddonModDataProvider { * @param {number} dataId The data instance id. * @param {number} [groupId=0] Group id, 0 means that the function will determine the user group. * @param {string} [search] Search text. It will be used if advSearch is not defined. - * @param {any} [advSearch] Advanced search data. + * @param {any[]} [advSearch] Advanced search data. * @param {string} [sort] Sort by this field. * @param {string} [order] The direction of the sorting. * @param {number} [page=0] Page of records to return. * @param {number} [perPage=PER_PAGE] Records per page to return. Default on AddonModDataProvider.PER_PAGE. * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} Promise resolved when the action is done. + * @return {Promise} Promise resolved when the action is done. */ searchEntries(dataId: number, groupId: number = 0, search?: string, advSearch?: any, sort?: string, order?: string, - page: number = 0, perPage: number = AddonModDataProvider.PER_PAGE, siteId?: string): Promise { + page: number = 0, perPage: number = AddonModDataProvider.PER_PAGE, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { const params = { databaseid: dataId, @@ -911,7 +992,13 @@ export class AddonModDataProvider { params['advsearch'] = advSearch; } - return site.read('mod_data_search_entries', params, preSets); + return site.read('mod_data_search_entries', params, preSets).then((response) => { + response.entries.forEach((entry) => { + entry.contents = this.utils.arrayToObject(entry.contents, 'fieldid'); + }); + + return response; + }); }); } } diff --git a/src/addon/mod/data/providers/delete-link-handler.ts b/src/addon/mod/data/providers/delete-link-handler.ts index 5ba8f1b31..8da37ba6b 100644 --- a/src/addon/mod/data/providers/delete-link-handler.ts +++ b/src/addon/mod/data/providers/delete-link-handler.ts @@ -13,13 +13,10 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { TranslateService } from '@ngx-translate/core'; import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; import { AddonModDataProvider } from './data'; -import { CoreCourseProvider } from '@core/course/providers/course'; -import { CoreDomUtilsProvider } from '@providers/utils/dom'; -import { CoreEventsProvider } from '@providers/events'; +import { AddonModDataHelperProvider } from './helper'; /** * Content links handler for database delete entry. @@ -31,30 +28,10 @@ export class AddonModDataDeleteLinkHandler extends CoreContentLinksHandlerBase { featureName = 'CoreCourseModuleDelegate_AddonModData'; pattern = /\/mod\/data\/view\.php.*([\?\&](d|delete)=\d+)/; - constructor(private dataProvider: AddonModDataProvider, private courseProvider: CoreCourseProvider, - private domUtils: CoreDomUtilsProvider, private eventsProvider: CoreEventsProvider, - private translate: TranslateService) { + constructor(private dataProvider: AddonModDataProvider, private dataHelper: AddonModDataHelperProvider) { super(); } - /** - * Convenience function to help get courseId. - * - * @param {number} dataId Database Id. - * @param {string} siteId Site Id, if not set, current site will be used. - * @param {number} courseId Course Id if already set. - * @return {Promise} Resolved with course Id when done. - */ - protected getActivityCourseIdIfNotSet(dataId: number, siteId: string, courseId: number): Promise { - if (courseId) { - return Promise.resolve(courseId); - } - - return this.courseProvider.getModuleBasicInfoByInstance(dataId, 'data', siteId).then((module) => { - return module.course; - }); - } - /** * Get the list of actions for a link (url). * @@ -68,38 +45,10 @@ export class AddonModDataDeleteLinkHandler extends CoreContentLinksHandlerBase { CoreContentLinksAction[] | Promise { return [{ action: (siteId, navCtrl?): void => { + const dataId = parseInt(params.d, 10); + const entryId = parseInt(params.delete, 10); - this.domUtils.showConfirm(this.translate.instant('addon.mod_data.confirmdeleterecord')).then(() => { - const modal = this.domUtils.showModalLoading(), - dataId = parseInt(params.d, 10), - entryId = parseInt(params.delete, 10); - - return this.getActivityCourseIdIfNotSet(dataId, siteId, courseId).then((cId) => { - courseId = cId; - - // Delete entry. - return this.dataProvider.deleteEntry(dataId, entryId, courseId, siteId).catch((message) => { - this.domUtils.showErrorModalDefault(message, 'addon.mod_data.errordeleting', true); - - return Promise.reject(null); - }); - }).then(() => { - const promises = []; - promises.push(this.dataProvider.invalidateEntryData(dataId, entryId, siteId)); - promises.push(this.dataProvider.invalidateEntriesData(dataId, siteId)); - - return Promise.all(promises); - }).then(() => { - this.eventsProvider.trigger(AddonModDataProvider.ENTRY_CHANGED, {dataId: dataId, entryId: entryId, - deleted: true}, siteId); - - this.domUtils.showToast('addon.mod_data.recorddeleted', true, 3000); - }).finally(() => { - modal.dismiss(); - }); - }).catch(() => { - // Nothing to do. - }); + this.dataHelper.showDeleteEntryModal(dataId, entryId, courseId); } }]; } diff --git a/src/addon/mod/data/providers/fields-delegate.ts b/src/addon/mod/data/providers/fields-delegate.ts index 43f2895c5..544d9f349 100644 --- a/src/addon/mod/data/providers/fields-delegate.ts +++ b/src/addon/mod/data/providers/fields-delegate.ts @@ -212,11 +212,13 @@ export class AddonModDataFieldsDelegate extends CoreDelegate { * @return {any} Data overriden */ overrideData(field: any, originalContent: any, offlineContent: any, offlineFiles?: any): any { + originalContent = originalContent || {}; + if (!offlineContent) { return originalContent; } - return this.executeFunctionOnEnabled(field.type, 'overrideData', [originalContent || {}, offlineContent, offlineFiles]); + return this.executeFunctionOnEnabled(field.type, 'overrideData', [originalContent, offlineContent, offlineFiles]); } } diff --git a/src/addon/mod/data/providers/helper.ts b/src/addon/mod/data/providers/helper.ts index c713f3674..b5aaadcec 100644 --- a/src/addon/mod/data/providers/helper.ts +++ b/src/addon/mod/data/providers/helper.ts @@ -14,12 +14,18 @@ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; +import { CoreEventsProvider } from '@providers/events'; import { CoreSitesProvider } from '@providers/sites'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader'; import { AddonModDataFieldsDelegate } from './fields-delegate'; -import { AddonModDataOfflineProvider } from './offline'; -import { AddonModDataProvider } from './data'; +import { AddonModDataOfflineProvider, AddonModDataOfflineAction } from './offline'; +import { AddonModDataProvider, AddonModDataEntry, AddonModDataEntryFields, AddonModDataEntries } from './data'; +import { CoreRatingInfo } from '@core/rating/providers/rating'; +import { CoreRatingOfflineProvider } from '@core/rating/providers/offline'; /** * Service that provides helper functions for datas. @@ -30,20 +36,26 @@ export class AddonModDataHelperProvider { constructor(private sitesProvider: CoreSitesProvider, protected dataProvider: AddonModDataProvider, private translate: TranslateService, private fieldsDelegate: AddonModDataFieldsDelegate, private dataOffline: AddonModDataOfflineProvider, private fileUploaderProvider: CoreFileUploaderProvider, - private textUtils: CoreTextUtilsProvider) { } + private textUtils: CoreTextUtilsProvider, private eventsProvider: CoreEventsProvider, private utils: CoreUtilsProvider, + private domUtils: CoreDomUtilsProvider, private courseProvider: CoreCourseProvider, + private ratingOffline: CoreRatingOfflineProvider) {} /** * Returns the record with the offline actions applied. * - * @param {any} record Entry to modify. - * @param {any} offlineActions Offline data with the actions done. - * @param {any} fields Entry defined fields indexed by fieldid. - * @return {any} Modified entry. + * @param {AddonModDataEntry} record Entry to modify. + * @param {AddonModDataOfflineAction[]} offlineActions Offline data with the actions done. + * @param {any[]} fields Entry defined fields indexed by fieldid. + * @return {Promise} Promise resolved when done. */ - applyOfflineActions(record: any, offlineActions: any[], fields: any[]): any { + applyOfflineActions(record: AddonModDataEntry, offlineActions: AddonModDataOfflineAction[], fields: any[]): + Promise { const promises = []; offlineActions.forEach((action) => { + record.timemodified = action.timemodified; + record.hasOffline = true; + switch (action.action) { case 'approve': record.approved = true; @@ -56,6 +68,8 @@ export class AddonModDataHelperProvider { break; case 'add': case 'edit': + record.groupid = action.groupid; + const offlineContents = {}; action.fields.forEach((offlineContent) => { @@ -77,10 +91,12 @@ export class AddonModDataHelperProvider { promises.push(this.getStoredFiles(record.dataid, record.id, field.id).then((offlineFiles) => { record.contents[field.id] = this.fieldsDelegate.overrideData(field, record.contents[field.id], offlineContents[field.id], offlineFiles); + record.contents[field.id].fieldid = field.id; })); } else { record.contents[field.id] = this.fieldsDelegate.overrideData(field, record.contents[field.id], offlineContents[field.id]); + record.contents[field.id].fieldid = field.id; } }); break; @@ -94,18 +110,59 @@ export class AddonModDataHelperProvider { }); } + /** + * Approve or disapprove a database entry. + * + * @param {number} dataId Database ID. + * @param {number} entryId Entry ID. + * @param {boolaen} approve True to approve, false to disapprove. + * @param {number} [courseId] Course ID. It not defined, it will be fetched. + * @param {string} [siteId] Site ID. If not defined, current site. + */ + approveOrDisapproveEntry(dataId: number, entryId: number, approve: boolean, courseId?: number, siteId?: string): void { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const modal = this.domUtils.showModalLoading('core.sending', true); + + this.getActivityCourseIdIfNotSet(dataId, courseId, siteId).then((courseId) => { + // Approve/disapprove entry. + return this.dataProvider.approveEntry(dataId, entryId, approve, courseId, siteId).catch((message) => { + this.domUtils.showErrorModalDefault(message, 'addon.mod_data.errorapproving', true); + + return Promise.reject(null); + }); + }).then(() => { + const promises = []; + promises.push(this.dataProvider.invalidateEntryData(dataId, entryId, siteId)); + promises.push(this.dataProvider.invalidateEntriesData(dataId, siteId)); + + return Promise.all(promises).catch(() => { + // Ignore errors. + }); + }).then(() => { + this.eventsProvider.trigger(AddonModDataProvider.ENTRY_CHANGED, {dataId: dataId, entryId: entryId}, siteId); + + this.domUtils.showToast(approve ? 'addon.mod_data.recordapproved' : 'addon.mod_data.recorddisapproved', true, 3000); + }).catch(() => { + // Ignore error, it was already displayed. + }).finally(() => { + modal.dismiss(); + }); + } + /** * Displays fields for being shown. * - * @param {string} template Template HMTL. - * @param {any[]} fields Fields that defines every content in the entry. - * @param {any} entry Entry. - * @param {number} offset Entry offset. - * @param {string} mode Mode list or show. - * @param {any} actions Actions that can be performed to the record. - * @return {string} Generated HTML. + * @param {string} template Template HMTL. + * @param {any[]} fields Fields that defines every content in the entry. + * @param {any} entry Entry. + * @param {number} offset Entry offset. + * @param {string} mode Mode list or show. + * @param {AddonModDataOfflineAction[]} actions Actions that can be performed to the record. + * @return {string} Generated HTML. */ - displayShowFields(template: string, fields: any[], entry: any, offset: number, mode: string, actions: any): string { + displayShowFields(template: string, fields: any[], entry: any, offset: number, mode: string, + actions: AddonModDataOfflineAction[]): string { if (!template) { return ''; } @@ -135,8 +192,8 @@ export class AddonModDataHelperProvider { } else if (action == 'approvalstatus') { render = this.translate.instant('addon.mod_data.' + (entry.approved ? 'approved' : 'notapproved')); } else { - render = ''; + render = ''; } template = template.replace(replace, render); } else { @@ -147,6 +204,153 @@ export class AddonModDataHelperProvider { return template; } + /** + * Get online and offline entries, or search entries. + * + * @param {any} data Database object. + * @param {any[]} fields The fields that define the contents. + * @param {number} [groupId=0] Group ID. + * @param {string} [search] Search text. It will be used if advSearch is not defined. + * @param {any[]} [advSearch] Advanced search data. + * @param {string} [sort=0] Sort the records by this field id, reserved ids are: + * 0: timeadded + * -1: firstname + * -2: lastname + * -3: approved + * -4: timemodified. + * Empty for using the default database setting. + * @param {string} [order=DESC] The direction of the sorting: 'ASC' or 'DESC'. + * Empty for using the default database setting. + * @param {number} [page=0] Page of records to return. + * @param {number} [perPage=PER_PAGE] Records per page to return. Default on PER_PAGE. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the database is retrieved. + */ + fetchEntries(data: any, fields: any[], groupId: number = 0, search?: string, advSearch?: any[], sort: string = '0', + order: string = 'DESC', page: number = 0, perPage: number = AddonModDataProvider.PER_PAGE, siteId?: string): + Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const offlineActions = {}; + const result: AddonModDataEntries = { + entries: [], + totalcount: 0, + offlineEntries: [] + }; + + const offlinePromise = this.dataOffline.getDatabaseEntries(data.id, site.id).then((actions) => { + result.hasOfflineActions = !!actions.length; + + actions.forEach((action) => { + if (typeof offlineActions[action.entryid] == 'undefined') { + offlineActions[action.entryid] = []; + } + offlineActions[action.entryid].push(action); + + // We only display new entries in the first page when not searching. + if (action.action == 'add' && page == 0 && !search && !advSearch && + (!action.groupid || !groupId || action.groupid == groupId)) { + result.offlineEntries.push({ + id: action.entryid, + canmanageentry: true, + approved: !data.approval || data.manageapproved, + dataid: data.id, + groupid: action.groupid, + timecreated: -action.entryid, + timemodified: -action.entryid, + userid: site.getUserId(), + fullname: site.getInfo().fullname, + contents: {} + }); + } + }); + + // Sort offline entries by creation time. + result.offlineEntries.sort((entry1, entry2) => entry2.timecreated - entry1.timecreated); + }); + + const ratingsPromise = this.ratingOffline.hasRatings('mod_data', 'entry', 'module', data.coursemodule) + .then((hasRatings) => { + result.hasOfflineRatings = hasRatings; + }); + + let fetchPromise: Promise; + if (search || advSearch) { + fetchPromise = this.dataProvider.searchEntries(data.id, groupId, search, advSearch, sort, order, page, perPage, + site.id).then((fetchResult) => { + result.entries = fetchResult.entries; + result.totalcount = fetchResult.totalcount; + result.maxcount = fetchResult.maxcount; + }); + } else { + fetchPromise = this.dataProvider.getEntries(data.id, groupId, sort, order, page, perPage, false, false, site.id) + .then((fetchResult) => { + result.entries = fetchResult.entries; + result.totalcount = fetchResult.totalcount; + }); + } + + return Promise.all([offlinePromise, ratingsPromise, fetchPromise]).then(() => { + // Apply offline actions to online and offline entries. + const promises = []; + result.entries.forEach((entry) => { + promises.push(this.applyOfflineActions(entry, offlineActions[entry.id] || [], fields)); + }); + result.offlineEntries.forEach((entry) => { + promises.push(this.applyOfflineActions(entry, offlineActions[entry.id] || [], fields)); + }); + + return Promise.all(promises); + }).then(() => { + return result; + }); + }); + } + + /** + * Fetch an online or offline entry. + * + * @param {any} data Database. + * @param {any[]} fields List of database fields. + * @param {number} entryId Entry ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise<{entry: AddonModDataEntry, ratinginfo?: CoreRatingInfo}>} Promise resolved with the entry. + */ + fetchEntry(data: any, fields: any[], entryId: number, siteId?: string): + Promise<{entry: AddonModDataEntry, ratinginfo?: CoreRatingInfo}> { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.dataOffline.getEntryActions(data.id, entryId, site.id).then((offlineActions) => { + let promise: Promise<{entry: AddonModDataEntry, ratinginfo?: CoreRatingInfo}>; + + if (entryId > 0) { + // Online entry. + promise = this.dataProvider.getEntry(data.id, entryId, false, site.id); + } else { + // Offline entry or new entry. + promise = Promise.resolve({ + entry: { + id: entryId, + userid: site.getUserId(), + groupid: 0, + dataid: data.id, + timecreated: -entryId, + timemodified: -entryId, + approved: !data.approval || data.manageapproved, + canmanageentry: true, + fullname: site.getInfo().fullname, + contents: [], + } + }); + } + + return promise.then((response) => { + return this.applyOfflineActions(response.entry, offlineActions, fields).then(() => { + return response; + }); + }); + }); + }); + } + /** * Returns an object with all the actions that the user can do over the record. * @@ -179,6 +383,24 @@ export class AddonModDataHelperProvider { }; } + /** + * Convenience function to get the course id of the database. + * + * @param {number} dataId Database id. + * @param {number} [courseId] Course id, if known. + * @param {string} [siteId] Site id, if not set, current site will be used. + * @return {Promise} Resolved with course Id when done. + */ + protected getActivityCourseIdIfNotSet(dataId: number, courseId?: number, siteId?: string): Promise { + if (courseId) { + return Promise.resolve(courseId); + } + + return this.courseProvider.getModuleBasicInfoByInstance(dataId, 'data', siteId).then((module) => { + return module.course; + }); + } + /** * Returns the default template of a certain type. * @@ -256,17 +478,17 @@ export class AddonModDataHelperProvider { * Retrieve the entered data in the edit form. * We don't use ng-model because it doesn't detect changes done by JavaScript. * - * @param {any} inputData Array with the entered form values. - * @param {Array} fields Fields that defines every content in the entry. - * @param {number} [dataId] Database Id. If set, files will be uploaded and itemId set. - * @param {number} entryId Entry Id. - * @param {any} entryContents Original entry contents indexed by field id. - * @param {boolean} offline True to prepare the data for an offline uploading, false otherwise. - * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} That contains object with the answers. + * @param {any} inputData Array with the entered form values. + * @param {Array} fields Fields that defines every content in the entry. + * @param {number} [dataId] Database Id. If set, files will be uploaded and itemId set. + * @param {number} entryId Entry Id. + * @param {AddonModDataEntryFields} entryContents Original entry contents. + * @param {boolean} offline True to prepare the data for an offline uploading, false otherwise. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} That contains object with the answers. */ - getEditDataFromForm(inputData: any, fields: any, dataId: number, entryId: number, entryContents: any, offline: boolean = false, - siteId?: string): Promise { + getEditDataFromForm(inputData: any, fields: any, dataId: number, entryId: number, entryContents: AddonModDataEntryFields, + offline: boolean = false, siteId?: string): Promise { if (!inputData) { return Promise.resolve({}); } @@ -322,13 +544,13 @@ export class AddonModDataHelperProvider { /** * Retrieve the temp files to be updated. * - * @param {any} inputData Array with the entered form values. - * @param {Array} fields Fields that defines every content in the entry. - * @param {number} [dataId] Database Id. If set, fils will be uploaded and itemId set. - * @param {any} entryContents Original entry contents indexed by field id. - * @return {Promise} That contains object with the files. + * @param {any} inputData Array with the entered form values. + * @param {any[]} fields Fields that defines every content in the entry. + * @param {number} [dataId] Database Id. If set, fils will be uploaded and itemId set. + * @param {AddonModDataEntryFields} entryContents Original entry contents indexed by field id. + * @return {Promise} That contains object with the files. */ - getEditTmpFiles(inputData: any, fields: any, dataId: number, entryContents: any): Promise { + getEditTmpFiles(inputData: any, fields: any[], dataId: number, entryContents: AddonModDataEntryFields): Promise { if (!inputData) { return Promise.resolve([]); } @@ -343,45 +565,6 @@ export class AddonModDataHelperProvider { }); } - /** - * Get an online or offline entry. - * - * @param {any} data Database. - * @param {number} entryId Entry ID. - * @param {any} [offlineActions] Offline data with the actions done. Required for offline entries. - * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} Promise resolved with the entry. - */ - getEntry(data: any, entryId: number, offlineActions?: any, siteId?: string): Promise { - if (entryId > 0) { - // It's an online entry, get it from WS. - return this.dataProvider.getEntry(data.id, entryId, false, siteId); - } - - // It's an offline entry, search it in the offline actions. - return this.sitesProvider.getSite(siteId).then((site) => { - const offlineEntry = offlineActions.find((offlineAction) => offlineAction.action == 'add'); - - if (offlineEntry) { - const siteInfo = site.getInfo(); - - return {entry: { - id: offlineEntry.entryid, - canmanageentry: true, - approved: !data.approval || data.manageapproved, - dataid: offlineEntry.dataid, - groupid: offlineEntry.groupid, - timecreated: -offlineEntry.entryid, - timemodified: -offlineEntry.entryid, - userid: siteInfo.userid, - fullname: siteInfo.fullname, - contents: {} - } - }; - } - }); - } - /** * Get a list of stored attachment files for a new entry. See $mmaModDataHelper#storeFiles. * @@ -403,13 +586,13 @@ export class AddonModDataHelperProvider { /** * Check if data has been changed by the user. * - * @param {any} inputData Array with the entered form values. - * @param {any} fields Fields that defines every content in the entry. - * @param {number} [dataId] Database Id. If set, fils will be uploaded and itemId set. - * @param {any} entryContents Original entry contents indexed by field id. - * @return {Promise} True if changed, false if not. + * @param {any} inputData Object with the entered form values. + * @param {any[]} fields Fields that defines every content in the entry. + * @param {number} [dataId] Database Id. If set, fils will be uploaded and itemId set. + * @param {AddonModDataEntryFields} entryContents Original entry contents indexed by field id. + * @return {Promise} True if changed, false if not. */ - hasEditDataChanged(inputData: any, fields: any, dataId: number, entryContents: any): Promise { + hasEditDataChanged(inputData: any, fields: any[], dataId: number, entryContents: AddonModDataEntryFields): Promise { const promises = fields.map((field) => { return this.fieldsDelegate.hasFieldDataChanged(field, inputData, entryContents[field.id]); }); @@ -424,6 +607,45 @@ export class AddonModDataHelperProvider { }); } + /** + * Displays a confirmation modal for deleting an entry. + * + * @param {number} dataId Database ID. + * @param {number} entryId Entry ID. + * @param {number} [courseId] Course ID. It not defined, it will be fetched. + * @param {string} [siteId] Site ID. If not defined, current site. + */ + showDeleteEntryModal(dataId: number, entryId: number, courseId?: number, siteId?: string): void { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + this.domUtils.showConfirm(this.translate.instant('addon.mod_data.confirmdeleterecord')).then(() => { + const modal = this.domUtils.showModalLoading(); + + return this.getActivityCourseIdIfNotSet(dataId, courseId, siteId).then((courseId) => { + return this.dataProvider.deleteEntry(dataId, entryId, courseId, siteId); + }).catch((message) => { + this.domUtils.showErrorModalDefault(message, 'addon.mod_data.errordeleting', true); + + return Promise.reject(null); + }).then(() => { + return this.utils.allPromises([ + this.dataProvider.invalidateEntryData(dataId, entryId, siteId), + this.dataProvider.invalidateEntriesData(dataId, siteId) + ]).catch(() => { + // Ignore errors. + }); + }).then(() => { + this.eventsProvider.trigger(AddonModDataProvider.ENTRY_CHANGED, {dataId, entryId, deleted: true}, siteId); + + this.domUtils.showToast('addon.mod_data.recorddeleted', true, 3000); + }).finally(() => { + modal.dismiss(); + }); + }).catch(() => { + // Ignore error, it was already displayed. + }); + } + /** * Given a list of files (either online files or local files), store the local files in a local folder * to be submitted later. diff --git a/src/addon/mod/data/providers/offline.ts b/src/addon/mod/data/providers/offline.ts index 0c773f978..2d6d6ca4a 100644 --- a/src/addon/mod/data/providers/offline.ts +++ b/src/addon/mod/data/providers/offline.ts @@ -16,9 +16,24 @@ import { Injectable } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreFileProvider } from '@providers/file'; import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader'; import { SQLiteDB } from '@classes/sqlitedb'; +import { AddonModDataSubfieldData } from './data'; + +/** + * Entry action stored offline. + */ +export interface AddonModDataOfflineAction { + dataid: number; + courseid: number; + groupid: number; + action: string; + entryid: number; // Negative for offline entries. + fields: AddonModDataSubfieldData[]; + timemodified: number; +} /** * Service to handle Offline data. @@ -87,7 +102,8 @@ export class AddonModDataOfflineProvider { }; constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private textUtils: CoreTextUtilsProvider, - private fileProvider: CoreFileProvider, private fileUploaderProvider: CoreFileUploaderProvider) { + private fileProvider: CoreFileProvider, private fileUploaderProvider: CoreFileUploaderProvider, + private utils: CoreUtilsProvider) { this.logger = logger.getInstance('AddonModDataOfflineProvider'); this.sitesProvider.registerSiteSchema(this.siteSchema); } @@ -175,10 +191,10 @@ export class AddonModDataOfflineProvider { /** * Get all the stored entry data from all the databases. * - * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} Promise resolved with entries. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with entries. */ - getAllEntries(siteId?: string): Promise { + getAllEntries(siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { return site.getDb().getAllRecords(AddonModDataOfflineProvider.DATA_ENTRY_TABLE); }).then((entries) => { @@ -187,15 +203,15 @@ export class AddonModDataOfflineProvider { } /** - * Get all the stored entry data from a certain database. + * Get all the stored entry actions from a certain database, sorted by modification time. * - * @param {number} dataId Database ID. - * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} Promise resolved with entries. + * @param {number} dataId Database ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with entries. */ - getDatabaseEntries(dataId: number, siteId?: string): Promise { + getDatabaseEntries(dataId: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { - return site.getDb().getRecords(AddonModDataOfflineProvider.DATA_ENTRY_TABLE, {dataid: dataId}); + return site.getDb().getRecords(AddonModDataOfflineProvider.DATA_ENTRY_TABLE, {dataid: dataId}, 'timemodified'); }).then((entries) => { return entries.map(this.parseRecord.bind(this)); }); @@ -208,9 +224,9 @@ export class AddonModDataOfflineProvider { * @param {number} entryId Database entry Id. * @param {string} action Action to be done * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} Promise resolved with entry. + * @return {Promise} Promise resolved with entry. */ - getEntry(dataId: number, entryId: number, action: string, siteId?: string): Promise { + getEntry(dataId: number, entryId: number, action: string, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { return site.getDb().getRecord(AddonModDataOfflineProvider.DATA_ENTRY_TABLE, {dataid: dataId, entryid: entryId, action: action}); @@ -225,9 +241,9 @@ export class AddonModDataOfflineProvider { * @param {number} dataId Database ID. * @param {number} entryId Database entry Id. * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} Promise resolved with entry actions. + * @return {Promise} Promise resolved with entry actions. */ - getEntryActions(dataId: number, entryId: number, siteId?: string): Promise { + getEntryActions(dataId: number, entryId: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { return site.getDb().getRecords(AddonModDataOfflineProvider.DATA_ENTRY_TABLE, {dataid: dataId, entryid: entryId}); }).then((entries) => { @@ -243,11 +259,10 @@ export class AddonModDataOfflineProvider { * @return {Promise} Promise resolved with boolean: true if has offline answers, false otherwise. */ hasOfflineData(dataId: number, siteId?: string): Promise { - return this.getDatabaseEntries(dataId, siteId).then((entries) => { - return !!entries.length; - }).catch(() => { - // No offline data found, return false. - return false; + return this.sitesProvider.getSite(siteId).then((site) => { + return this.utils.promiseWorks( + site.getDb().recordExists(AddonModDataOfflineProvider.DATA_ENTRY_TABLE, {dataid: dataId}) + ); }); } @@ -286,10 +301,10 @@ export class AddonModDataOfflineProvider { /** * Parse "fields" of an offline record. * - * @param {any} record Record object - * @return {any} Record object with columns parsed. + * @param {any} record Record object + * @return {AddonModDataOfflineAction} Record object with columns parsed. */ - protected parseRecord(record: any): any { + protected parseRecord(record: any): AddonModDataOfflineAction { record.fields = this.textUtils.parseJSON(record.fields); return record; @@ -308,8 +323,8 @@ export class AddonModDataOfflineProvider { * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved if stored, rejected if failure. */ - saveEntry(dataId: number, entryId: number, action: string, courseId: number, groupId?: number, fields?: any[], - timemodified?: number, siteId?: string): Promise { + saveEntry(dataId: number, entryId: number, action: string, courseId: number, groupId?: number, + fields?: AddonModDataSubfieldData[], timemodified?: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { timemodified = timemodified || new Date().getTime(); diff --git a/src/addon/mod/data/providers/prefetch-handler.ts b/src/addon/mod/data/providers/prefetch-handler.ts index 85c54d10e..eeb51206f 100644 --- a/src/addon/mod/data/providers/prefetch-handler.ts +++ b/src/addon/mod/data/providers/prefetch-handler.ts @@ -24,8 +24,8 @@ import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreCommentsProvider } from '@core/comments/providers/comments'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseActivityPrefetchHandlerBase } from '@core/course/classes/activity-prefetch-handler'; -import { CoreRatingProvider } from '@core/rating/providers/rating'; -import { AddonModDataProvider } from './data'; +import { AddonModDataProvider, AddonModDataEntry } from './data'; +import { AddonModDataSyncProvider } from './sync'; import { AddonModDataHelperProvider } from './helper'; /** @@ -43,7 +43,7 @@ export class AddonModDataPrefetchHandler extends CoreCourseActivityPrefetchHandl domUtils: CoreDomUtilsProvider, protected dataProvider: AddonModDataProvider, protected timeUtils: CoreTimeUtilsProvider, protected dataHelper: AddonModDataHelperProvider, protected groupsProvider: CoreGroupsProvider, protected commentsProvider: CoreCommentsProvider, - private ratingProvider: CoreRatingProvider) { + protected syncProvider: AddonModDataSyncProvider) { super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils); } @@ -56,10 +56,10 @@ export class AddonModDataPrefetchHandler extends CoreCourseActivityPrefetchHandl * @param {boolean} [forceCache] True to always get the value from cache, false otherwise. Default false. * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). * @param {string} [siteId] Site ID. - * @return {Promise} All unique entries. + * @return {Promise} All unique entries. */ protected getAllUniqueEntries(dataId: number, groups: any[], forceCache: boolean = false, ignoreCache: boolean = false, - siteId?: string): Promise { + siteId?: string): Promise { const promises = groups.map((group) => { return this.dataProvider.fetchAllEntries(dataId, group.id, undefined, undefined, undefined, forceCache, ignoreCache, siteId); @@ -138,14 +138,14 @@ export class AddonModDataPrefetchHandler extends CoreCourseActivityPrefetchHandl /** * Returns the file contained in the entries. * - * @param {any[]} entries List of entries to get files from. - * @return {any[]} List of files. + * @param {AddonModDataEntry[]} entries List of entries to get files from. + * @return {any[]} List of files. */ - protected getEntriesFiles(entries: any[]): any[] { + protected getEntriesFiles(entries: AddonModDataEntry[]): any[] { let files = []; entries.forEach((entry) => { - entry.contents.forEach((content) => { + this.utils.objectToArray(entry.contents).forEach((content) => { files = files.concat(content.files); }); }); @@ -284,10 +284,7 @@ export class AddonModDataPrefetchHandler extends CoreCourseActivityPrefetchHandl }); info.entries.forEach((entry) => { - promises.push(this.dataProvider.getEntry(database.id, entry.id, true, siteId).then((entry) => { - return this.ratingProvider.prefetchRatings('module', module.id, database.scale, courseId, entry.ratinginfo, - siteId); - })); + promises.push(this.dataProvider.getEntry(database.id, entry.id, true, siteId)); if (database.comments) { promises.push(this.commentsProvider.getComments('module', database.coursemodule, 'mod_data', entry.id, @@ -301,4 +298,26 @@ export class AddonModDataPrefetchHandler extends CoreCourseActivityPrefetchHandl return Promise.all(promises); }); } + + /** + * Sync a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + sync(module: any, courseId: number, siteId?: any): Promise { + const promises = [ + this.syncProvider.syncDatabase(module.instance, siteId), + this.syncProvider.syncRatings(module.id, true, siteId) + ]; + + return Promise.all(promises).then((results) => { + return results.reduce((a, b) => ({ + updated: a.updated || b.updated, + warnings: (a.warnings || []).concat(b.warnings || []), + }), {updated: false}); + }); + } } diff --git a/src/addon/mod/data/providers/sync-cron-handler.ts b/src/addon/mod/data/providers/sync-cron-handler.ts index 31a3db30a..c67e7a102 100644 --- a/src/addon/mod/data/providers/sync-cron-handler.ts +++ b/src/addon/mod/data/providers/sync-cron-handler.ts @@ -30,10 +30,11 @@ export class AddonModDataSyncCronHandler implements CoreCronHandler { * Receives the ID of the site affected, undefined for all sites. * * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). * @return {Promise} Promise resolved when done, rejected if failure. */ - execute(siteId?: string): Promise { - return this.dataSync.syncAllDatabases(siteId); + execute(siteId?: string, force?: boolean): Promise { + return this.dataSync.syncAllDatabases(siteId, force); } /** diff --git a/src/addon/mod/data/providers/sync.ts b/src/addon/mod/data/providers/sync.ts index aeb16b190..0553511c4 100644 --- a/src/addon/mod/data/providers/sync.ts +++ b/src/addon/mod/data/providers/sync.ts @@ -20,7 +20,7 @@ import { CoreAppProvider } from '@providers/app'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; -import { AddonModDataOfflineProvider } from './offline'; +import { AddonModDataOfflineProvider, AddonModDataOfflineAction } from './offline'; import { AddonModDataProvider } from './data'; import { AddonModDataHelperProvider } from './helper'; import { CoreEventsProvider } from '@providers/events'; @@ -67,18 +67,21 @@ export class AddonModDataSyncProvider extends CoreSyncBaseProvider { * Try to synchronize all the databases in a certain site or in all sites. * * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} force Wether to force sync not depending on last execution. * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - syncAllDatabases(siteId?: string): Promise { - return this.syncOnSites('all databases', this.syncAllDatabasesFunc.bind(this), undefined, siteId); + syncAllDatabases(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all databases', this.syncAllDatabasesFunc.bind(this), [force], siteId); } /** * Sync all pending databases on a site. - * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} force Wether to force sync not depending on last execution. * @param {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - protected syncAllDatabasesFunc(siteId?: string): Promise { + protected syncAllDatabasesFunc(siteId?: string, force?: boolean): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); const promises = []; @@ -93,8 +96,10 @@ export class AddonModDataSyncProvider extends CoreSyncBaseProvider { return; } - promises[action.dataid] = this.syncDatabaseIfNeeded(action.dataid, siteId) - .then((result) => { + promises[action.dataid] = force ? this.syncDatabase(action.dataid, siteId) : + this.syncDatabaseIfNeeded(action.dataid, siteId); + + promises[action.dataid].then((result) => { if (result && result.updated) { // Sync done. Send event. this.eventsProvider.trigger(AddonModDataSyncProvider.AUTO_SYNCED, { @@ -109,7 +114,7 @@ export class AddonModDataSyncProvider extends CoreSyncBaseProvider { return Promise.all(this.utils.objectToArray(promises)); })); - promises.push(this.syncRatings(undefined, siteId)); + promises.push(this.syncRatings(undefined, force, siteId)); return Promise.all(promises); } @@ -169,7 +174,7 @@ export class AddonModDataSyncProvider extends CoreSyncBaseProvider { // No offline data found, return empty object. return []; }); - }).then((offlineActions) => { + }).then((offlineActions: AddonModDataOfflineAction[]) => { if (!offlineActions.length) { // Nothing to sync. return; @@ -221,35 +226,41 @@ export class AddonModDataSyncProvider extends CoreSyncBaseProvider { /** * Synchronize an entry. * - * @param {any} data Database. - * @param {any} entryActions Entry actions. - * @param {any} result Object with the result of the sync. - * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise} Promise resolved if success, rejected otherwise. + * @param {any} data Database. + * @param {AddonModDataOfflineAction[]} entryActions Entry actions. + * @param {any} result Object with the result of the sync. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved if success, rejected otherwise. */ - protected syncEntry(data: any, entryActions: any[], result: any, siteId?: string): Promise { + protected syncEntry(data: any, entryActions: AddonModDataOfflineAction[], result: any, siteId?: string): Promise { let discardError, timePromise, - entryId = 0, + entryId = entryActions[0].entryid, offlineId, deleted = false; - const promises = []; - - // Sort entries by timemodified. - entryActions = entryActions.sort((a: any, b: any) => a.timemodified - b.timemodified); - - entryId = entryActions[0].entryid; + const editAction = entryActions.find((action) => action.action == 'add' || action.action == 'edit'); + const approveAction = entryActions.find((action) => action.action == 'approve' || action.action == 'disapprove'); + const deleteAction = entryActions.find((action) => action.action == 'delete'); if (entryId > 0) { - timePromise = this.dataProvider.getEntry(data.id, entryId, false, siteId).then((entry) => { + timePromise = this.dataProvider.getEntry(data.id, entryId, true, siteId).then((entry) => { return entry.entry.timemodified; - }).catch(() => { - return -1; + }).catch((error) => { + if (error && this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means the entry has been deleted. + return Promise.resolve(-1); + } + + return Promise.reject(error); }); - } else { + } else if (editAction) { + // New entry. offlineId = entryId; timePromise = Promise.resolve(0); + } else { + // New entry but the add action is missing, discard. + timePromise = Promise.resolve(-1); } return timePromise.then((timemodified) => { @@ -261,58 +272,11 @@ export class AddonModDataSyncProvider extends CoreSyncBaseProvider { return this.dataOffline.deleteAllEntryActions(data.id, entryId, siteId); } - entryActions.forEach((action) => { - let actionPromise; - const proms = []; - - entryId = action.entryid > 0 ? action.entryid : entryId; - - if (action.fields) { - action.fields.forEach((field) => { - // Upload Files if asked. - const value = this.textUtils.parseJSON(field.value); - if (value.online || value.offline) { - let files = value.online || []; - const fileProm = value.offline ? this.dataHelper.getStoredFiles(action.dataid, entryId, field.fieldid) : - Promise.resolve([]); - - proms.push(fileProm.then((offlineFiles) => { - files = files.concat(offlineFiles); - - return this.dataHelper.uploadOrStoreFiles(action.dataid, 0, entryId, field.fieldid, files, false, - siteId).then((filesResult) => { - field.value = JSON.stringify(filesResult); - }); - })); - } - }); - } - - actionPromise = Promise.all(proms).then(() => { - // Perform the action. - switch (action.action) { - case 'add': - return this.dataProvider.addEntryOnline(action.dataid, action.fields, data.groupid, siteId) - .then((result) => { - entryId = result.newentryid; - }); - case 'edit': - return this.dataProvider.editEntryOnline(entryId, action.fields, siteId); - case 'approve': - return this.dataProvider.approveEntryOnline(entryId, true, siteId); - case 'disapprove': - return this.dataProvider.approveEntryOnline(entryId, false, siteId); - case 'delete': - return this.dataProvider.deleteEntryOnline(entryId, siteId).then(() => { - deleted = true; - }); - default: - break; - } - }); - - promises.push(actionPromise.catch((error) => { - if (error && error.wserror) { + if (deleteAction) { + return this.dataProvider.deleteEntryOnline(entryId, siteId).then(() => { + deleted = true; + }).catch((error) => { + if (error && this.utils.isWebServiceError(error)) { // The WebService has thrown an error, this means it cannot be performed. Discard. discardError = this.textUtils.getErrorMessageFromError(error); } else { @@ -323,11 +287,79 @@ export class AddonModDataSyncProvider extends CoreSyncBaseProvider { // Delete the offline data. result.updated = true; - return this.dataOffline.deleteEntry(action.dataid, action.entryid, action.action, siteId); - })); - }); + return this.dataOffline.deleteAllEntryActions(deleteAction.dataid, deleteAction.entryid, siteId); + }); + } + + let editPromise; + + if (editAction) { + editPromise = Promise.all(editAction.fields.map((field) => { + // Upload Files if asked. + const value = this.textUtils.parseJSON(field.value); + if (value.online || value.offline) { + let files = value.online || []; + const fileProm = value.offline ? + this.dataHelper.getStoredFiles(editAction.dataid, entryId, field.fieldid) : + Promise.resolve([]); + + return fileProm.then((offlineFiles) => { + files = files.concat(offlineFiles); + + return this.dataHelper.uploadOrStoreFiles(editAction.dataid, 0, entryId, field.fieldid, files, + false, siteId).then((filesResult) => { + field.value = JSON.stringify(filesResult); + }); + }); + } + })).then(() => { + if (editAction.action == 'add') { + return this.dataProvider.addEntryOnline(editAction.dataid, editAction.fields, editAction.groupid, siteId) + .then((result) => { + entryId = result.newentryid; + }); + } else { + return this.dataProvider.editEntryOnline(entryId, editAction.fields, siteId); + } + }).catch((error) => { + if (error && this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means it cannot be performed. Discard. + discardError = this.textUtils.getErrorMessageFromError(error); + } else { + // Couldn't connect to server, reject. + return Promise.reject(error); + } + }).then(() => { + // Delete the offline data. + result.updated = true; + + return this.dataOffline.deleteEntry(editAction.dataid, editAction.entryid, editAction.action, siteId); + }); + } else { + editPromise = Promise.resolve(); + } + + if (approveAction) { + editPromise = editPromise.then(() => { + return this.dataProvider.approveEntryOnline(entryId, approveAction.action == 'approve', siteId); + }).catch((error) => { + if (error && this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means it cannot be performed. Discard. + discardError = this.textUtils.getErrorMessageFromError(error); + } else { + // Couldn't connect to server, reject. + return Promise.reject(error); + } + }).then(() => { + // Delete the offline data. + result.updated = true; + + return this.dataOffline.deleteEntry(approveAction.dataid, approveAction.entryid, approveAction.action, siteId); + }); + } + + return editPromise; - return Promise.all(promises); }).then(() => { if (discardError) { // Submission was discarded, add a warning. @@ -357,13 +389,14 @@ export class AddonModDataSyncProvider extends CoreSyncBaseProvider { * Synchronize offline ratings. * * @param {number} [cmId] Course module to be synced. If not defined, sync all databases. + * @param {boolean} [force] Wether to force sync not depending on last execution. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved if sync is successful, rejected otherwise. */ - syncRatings(cmId?: number, siteId?: string): Promise { + syncRatings(cmId?: number, force?: boolean, siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); - return this.ratingSync.syncRatings('mod_data', 'entry', 'module', cmId, 0, siteId).then((results) => { + return this.ratingSync.syncRatings('mod_data', 'entry', 'module', cmId, 0, force, siteId).then((results) => { let updated = false; const warnings = []; const promises = []; diff --git a/src/addon/mod/feedback/components/index/addon-mod-feedback-index.html b/src/addon/mod/feedback/components/index/addon-mod-feedback-index.html index 9fef582e3..bc403c9e0 100644 --- a/src/addon/mod/feedback/components/index/addon-mod-feedback-index.html +++ b/src/addon/mod/feedback/components/index/addon-mod-feedback-index.html @@ -6,7 +6,7 @@ - + diff --git a/src/addon/mod/feedback/components/index/index.ts b/src/addon/mod/feedback/components/index/index.ts index 55ffc4d21..953a8c8bd 100644 --- a/src/addon/mod/feedback/components/index/index.ts +++ b/src/addon/mod/feedback/components/index/index.ts @@ -113,7 +113,7 @@ export class AddonModFeedbackIndexComponent extends CoreCourseModuleMainActivity super.ngOnInit(); this.loadContent(false, true).then(() => { - this.feedbackProvider.logView(this.feedback.id).catch(() => { + this.feedbackProvider.logView(this.feedback.id, this.feedback.name).catch(() => { // Ignore errors. }); }).finally(() => { diff --git a/src/addon/mod/feedback/feedback.module.ts b/src/addon/mod/feedback/feedback.module.ts index ded66de7e..7811fc5cd 100644 --- a/src/addon/mod/feedback/feedback.module.ts +++ b/src/addon/mod/feedback/feedback.module.ts @@ -17,6 +17,7 @@ import { CoreCronDelegate } from '@providers/cron'; import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; +import { CorePushNotificationsDelegate } from '@core/pushnotifications/providers/delegate'; import { AddonModFeedbackComponentsModule } from './components/components.module'; import { AddonModFeedbackModuleHandler } from './providers/module-handler'; import { AddonModFeedbackProvider } from './providers/feedback'; @@ -29,6 +30,7 @@ import { AddonModFeedbackPrintLinkHandler } from './providers/print-link-handler import { AddonModFeedbackListLinkHandler } from './providers/list-link-handler'; import { AddonModFeedbackHelperProvider } from './providers/helper'; import { AddonModFeedbackPrefetchHandler } from './providers/prefetch-handler'; +import { AddonModFeedbackPushClickHandler } from './providers/push-click-handler'; import { AddonModFeedbackSyncProvider } from './providers/sync'; import { AddonModFeedbackSyncCronHandler } from './providers/sync-cron-handler'; import { AddonModFeedbackOfflineProvider } from './providers/offline'; @@ -62,7 +64,8 @@ export const ADDON_MOD_FEEDBACK_PROVIDERS: any[] = [ AddonModFeedbackCompleteLinkHandler, AddonModFeedbackPrintLinkHandler, AddonModFeedbackListLinkHandler, - AddonModFeedbackSyncCronHandler + AddonModFeedbackSyncCronHandler, + AddonModFeedbackPushClickHandler ] }) export class AddonModFeedbackModule { @@ -74,7 +77,8 @@ export class AddonModFeedbackModule { showEntriesLinkHandler: AddonModFeedbackShowEntriesLinkHandler, showNonRespondentsLinkHandler: AddonModFeedbackShowNonRespondentsLinkHandler, completeLinkHandler: AddonModFeedbackCompleteLinkHandler, - printLinkHandler: AddonModFeedbackPrintLinkHandler, listLinkHandler: AddonModFeedbackListLinkHandler) { + printLinkHandler: AddonModFeedbackPrintLinkHandler, listLinkHandler: AddonModFeedbackListLinkHandler, + pushNotificationsDelegate: CorePushNotificationsDelegate, pushClickHandler: AddonModFeedbackPushClickHandler) { moduleDelegate.registerHandler(moduleHandler); prefetchDelegate.registerHandler(prefetchHandler); @@ -86,6 +90,7 @@ export class AddonModFeedbackModule { contentLinksDelegate.registerHandler(printLinkHandler); contentLinksDelegate.registerHandler(listLinkHandler); cronDelegate.register(syncHandler); + pushNotificationsDelegate.registerClickHandler(pushClickHandler); // Allow migrating the tables from the old app to the new schema. updateManager.registerSiteTableMigration({ diff --git a/src/addon/mod/feedback/lang/en.json b/src/addon/mod/feedback/lang/en.json index 01eec2c06..68e0a2b12 100644 --- a/src/addon/mod/feedback/lang/en.json +++ b/src/addon/mod/feedback/lang/en.json @@ -12,6 +12,8 @@ "feedback_is_not_open": "The feedback is not open", "feedback_submitted_offline": "This feedback has been saved to be submitted later.", "mapcourses": "Map feedback to courses", + "maximal": "Maximum", + "minimal": "Minimum", "mode": "Mode", "modulenameplural": "Feedback", "next_page": "Next page", diff --git a/src/addon/mod/feedback/pages/form/form.html b/src/addon/mod/feedback/pages/form/form.html index 29405ccb4..2725d3525 100644 --- a/src/addon/mod/feedback/pages/form/form.html +++ b/src/addon/mod/feedback/pages/form/form.html @@ -18,6 +18,7 @@ {{item.itemnumber}}. + {{item.postfix}}
diff --git a/src/addon/mod/feedback/pages/form/form.scss b/src/addon/mod/feedback/pages/form/form.scss index f4674d933..95b1e2c54 100644 --- a/src/addon/mod/feedback/pages/form/form.scss +++ b/src/addon/mod/feedback/pages/form/form.scss @@ -12,4 +12,7 @@ ion-app.app-root page-addon-mod-feedback-form { .item-wp .addon-mod_feedback-form-content { @include margin($item-wp-padding-media-top, ($item-wp-padding-end / 2), $item-wp-padding-media-bottom, 0); } + .addon-mod_feedback-postfix { + font-size: 1.4rem; + } } \ No newline at end of file diff --git a/src/addon/mod/feedback/pages/form/form.ts b/src/addon/mod/feedback/pages/form/form.ts index 0d8bbf9fa..65a21bec8 100644 --- a/src/addon/mod/feedback/pages/form/form.ts +++ b/src/addon/mod/feedback/pages/form/form.ts @@ -24,6 +24,7 @@ import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreAppProvider } from '@providers/app'; import { CoreEventsProvider } from '@providers/events'; import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; import { CoreLoginHelperProvider } from '@core/login/providers/helper'; import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { CoreSitesProvider } from '@providers/sites'; @@ -69,7 +70,7 @@ export class AddonModFeedbackFormPage implements OnDestroy { protected eventsProvider: CoreEventsProvider, protected feedbackSync: AddonModFeedbackSyncProvider, network: Network, protected translate: TranslateService, protected loginHelper: CoreLoginHelperProvider, protected linkHelper: CoreContentLinksHelperProvider, sitesProvider: CoreSitesProvider, - @Optional() private content: Content, zone: NgZone) { + @Optional() private content: Content, zone: NgZone, protected courseHelper: CoreCourseHelperProvider) { this.module = navParams.get('module'); this.courseId = navParams.get('courseId'); @@ -94,7 +95,7 @@ export class AddonModFeedbackFormPage implements OnDestroy { */ ionViewDidLoad(): void { this.fetchData().then(() => { - this.feedbackProvider.logView(this.feedback.id, true).then(() => { + this.feedbackProvider.logView(this.feedback.id, this.feedback.name, true).then(() => { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); }).catch(() => { // Ignore errors. @@ -325,10 +326,7 @@ export class AddonModFeedbackFormPage implements OnDestroy { modal.dismiss(); }); } else { - // Use redirect to make the course the new history root (to avoid "loops" in history). - this.loginHelper.redirect('CoreCourseSectionPage', { - course: { id: this.courseId } - }, this.currentSite.getId()); + this.courseHelper.getAndOpenCourse(undefined, this.courseId, {}, this.currentSite.getId()); } } diff --git a/src/addon/mod/feedback/providers/feedback.ts b/src/addon/mod/feedback/providers/feedback.ts index 14976deb3..a9aed0880 100644 --- a/src/addon/mod/feedback/providers/feedback.ts +++ b/src/addon/mod/feedback/providers/feedback.ts @@ -20,7 +20,7 @@ import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreAppProvider } from '@providers/app'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; import { AddonModFeedbackOfflineProvider } from './offline'; -import { CoreSiteWSPreSets } from '@classes/site'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; /** * Service that provides some features for feedbacks. @@ -583,7 +583,8 @@ export class AddonModFeedbackProvider { courseids: [courseId] }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getFeedbackCacheKey(courseId) + cacheKey: this.getFeedbackCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (forceCache) { @@ -650,7 +651,8 @@ export class AddonModFeedbackProvider { feedbackid: feedbackId }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getItemsDataCacheKey(feedbackId) + cacheKey: this.getItemsDataCacheKey(feedbackId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; if (ignoreCache) { @@ -1124,17 +1126,19 @@ export class AddonModFeedbackProvider { * Report the feedback as being viewed. * * @param {number} id Module ID. + * @param {string} [name] Name of the feedback. * @param {boolean} [formViewed=false] True if form was viewed. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logView(id: number, formViewed: boolean = false, siteId?: string): Promise { + logView(id: number, name?: string, formViewed: boolean = false, siteId?: string): Promise { const params = { feedbackid: id, moduleviewed: formViewed ? 1 : 0 }; - return this.logHelper.log('mod_feedback_view_feedback', params, AddonModFeedbackProvider.COMPONENT, id, siteId); + return this.logHelper.logSingle('mod_feedback_view_feedback', params, AddonModFeedbackProvider.COMPONENT, id, name, + 'feedback', {moduleviewed: params.moduleviewed}, siteId); } /** diff --git a/src/addon/mod/feedback/providers/helper.ts b/src/addon/mod/feedback/providers/helper.ts index 538dde0c2..e66a24287 100644 --- a/src/addon/mod/feedback/providers/helper.ts +++ b/src/addon/mod/feedback/providers/helper.ts @@ -16,8 +16,13 @@ import { Injectable } from '@angular/core'; import { NavController, ViewController } from 'ionic-angular'; import { AddonModFeedbackProvider } from './feedback'; import { CoreUserProvider } from '@core/user/providers/user'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; import { TranslateService } from '@ngx-translate/core'; /** @@ -32,7 +37,9 @@ export class AddonModFeedbackHelperProvider { constructor(protected feedbackProvider: AddonModFeedbackProvider, protected userProvider: CoreUserProvider, protected textUtils: CoreTextUtilsProvider, protected translate: TranslateService, - protected timeUtils: CoreTimeUtilsProvider) { + protected timeUtils: CoreTimeUtilsProvider, protected domUtils: CoreDomUtilsProvider, + protected courseProvider: CoreCourseProvider, protected linkHelper: CoreContentLinksHelperProvider, + protected sitesProvider: CoreSitesProvider, protected utils: CoreUtilsProvider) { } /** @@ -193,6 +200,48 @@ export class AddonModFeedbackHelperProvider { }); } + /** + * Handle a show entries link. + * + * @param {NavController} navCtrl Nav controller to use to navigate. Can be undefined/null. + * @param {any} params URL params. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + handleShowEntriesLink(navCtrl: NavController, params: any, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const modal = this.domUtils.showModalLoading(), + moduleId = params.id; + + return this.courseProvider.getModuleBasicInfo(moduleId, siteId).then((module) => { + let stateParams; + + if (typeof params.showcompleted == 'undefined') { + // Param showcompleted not defined. Show entry list. + stateParams = { + module: module, + courseId: module.course + }; + + return this.linkHelper.goInSite(navCtrl, 'AddonModFeedbackRespondentsPage', stateParams, siteId); + } + + return this.feedbackProvider.getAttempt(module.instance, params.showcompleted, true, siteId).then((attempt) => { + stateParams = { + moduleId: module.id, + attempt: attempt, + feedbackId: module.instance, + courseId: module.course + }; + + return this.linkHelper.goInSite(navCtrl, 'AddonModFeedbackAttemptPage', stateParams, siteId); + }); + }).finally(() => { + modal.dismiss(); + }); + } + /** * Add Image profile url field on attempts * @@ -298,9 +347,13 @@ export class AddonModFeedbackHelperProvider { item.template = 'numeric'; const range = item.presentation.split(AddonModFeedbackProvider.LINE_SEP) || []; - item.rangefrom = range.length > 0 ? parseInt(range[0], 10) || '' : ''; - item.rangeto = range.length > 1 ? parseInt(range[1], 10) || '' : ''; + range[0] = range.length > 0 ? parseInt(range[0], 10) : undefined; + range[1] = range.length > 1 ? parseInt(range[1], 10) : undefined; + + item.rangefrom = typeof range[0] == 'number' && !isNaN(range[0]) ? range[0] : ''; + item.rangeto = typeof range[1] == 'number' && !isNaN(range[1]) ? range[1] : ''; item.value = typeof item.rawValue != 'undefined' ? parseFloat(item.rawValue) : ''; + item.postfix = this.getNumericBoundariesForDisplay(item.rangefrom, item.rangeto); return item; } @@ -445,4 +498,27 @@ export class AddonModFeedbackHelperProvider { return item; } + /** + * Returns human-readable boundaries (min - max). + * Based on Moodle's get_boundaries_for_display. + * + * @param {number} rangeFrom Range from. + * @param {number} rangeTo Range to. + * @return {string} Human-readable boundaries. + */ + protected getNumericBoundariesForDisplay(rangeFrom: number, rangeTo: number): string { + const rangeFromSet = typeof rangeFrom == 'number', + rangeToSet = typeof rangeTo == 'number'; + + if (!rangeFromSet && rangeToSet) { + return ' (' + this.translate.instant('addon.mod_feedback.maximal') + ': ' + this.utils.formatFloat(rangeTo) + ')'; + } else if (rangeFromSet && !rangeToSet) { + return ' (' + this.translate.instant('addon.mod_feedback.minimal') + ': ' + this.utils.formatFloat(rangeFrom) + ')'; + } else if (!rangeFromSet && !rangeToSet) { + return ''; + } + + return ' (' + this.utils.formatFloat(rangeFrom) + ' - ' + this.utils.formatFloat(rangeTo) + ')'; + } + } diff --git a/src/addon/mod/feedback/providers/prefetch-handler.ts b/src/addon/mod/feedback/providers/prefetch-handler.ts index ab9528fc7..097e31331 100644 --- a/src/addon/mod/feedback/providers/prefetch-handler.ts +++ b/src/addon/mod/feedback/providers/prefetch-handler.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, Injector } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from '@providers/app'; import { CoreFilepoolProvider } from '@providers/filepool'; @@ -25,7 +25,7 @@ import { AddonModFeedbackProvider } from './feedback'; import { AddonModFeedbackHelperProvider } from './helper'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreGroupsProvider } from '@providers/groups'; -import { CoreUserProvider } from '@core/user/providers/user'; +import { AddonModFeedbackSyncProvider } from './sync'; /** * Handler to prefetch feedbacks. @@ -37,11 +37,14 @@ export class AddonModFeedbackPrefetchHandler extends CoreCourseActivityPrefetchH component = AddonModFeedbackProvider.COMPONENT; updatesNames = /^configuration$|^.*files$|^attemptsfinished|^attemptsunfinished$/; + protected syncProvider: AddonModFeedbackSyncProvider; // It will be injected later to prevent circular dependencies. + constructor(translate: TranslateService, appProvider: CoreAppProvider, utils: CoreUtilsProvider, courseProvider: CoreCourseProvider, filepoolProvider: CoreFilepoolProvider, sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, protected feedbackProvider: AddonModFeedbackProvider, - protected userProvider: CoreUserProvider, protected feedbackHelper: AddonModFeedbackHelperProvider, - protected timeUtils: CoreTimeUtilsProvider, protected groupsProvider: CoreGroupsProvider) { + protected feedbackHelper: AddonModFeedbackHelperProvider, + protected timeUtils: CoreTimeUtilsProvider, protected groupsProvider: CoreGroupsProvider, + protected injector: Injector) { super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils); } @@ -183,35 +186,21 @@ export class AddonModFeedbackPrefetchHandler extends CoreCourseActivityPrefetchH p2.push(this.feedbackProvider.getAnalysis(feedback.id, undefined, true, siteId)); p2.push(this.groupsProvider.getActivityGroupInfo(feedback.coursemodule, true, undefined, siteId, true) .then((groupInfo) => { - const p3 = [], - userIds = []; + const p3 = []; if (!groupInfo.groups || groupInfo.groups.length == 0) { groupInfo.groups = [{id: 0}]; } groupInfo.groups.forEach((group) => { p3.push(this.feedbackProvider.getAnalysis(feedback.id, group.id, true, siteId)); - p3.push(this.feedbackProvider.getAllResponsesAnalysis(feedback.id, group.id, true, siteId) - .then((responses) => { - responses.attempts.forEach((attempt) => { - userIds.push(attempt.userid); - }); - })); + p3.push(this.feedbackProvider.getAllResponsesAnalysis(feedback.id, group.id, true, siteId)); if (!accessData.isanonymous) { - p3.push(this.feedbackProvider.getAllNonRespondents(feedback.id, group.id, true, siteId) - .then((responses) => { - responses.users.forEach((user) => { - userIds.push(user.userid); - }); - })); + p3.push(this.feedbackProvider.getAllNonRespondents(feedback.id, group.id, true, siteId)); } }); - return Promise.all(p3).then(() => { - // Prefetch user profiles. - return this.userProvider.prefetchProfiles(userIds, courseId, siteId); - }); + return Promise.all(p3); })); } @@ -239,4 +228,20 @@ export class AddonModFeedbackPrefetchHandler extends CoreCourseActivityPrefetchH }); }); } + + /** + * Sync a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + sync(module: any, courseId: number, siteId?: any): Promise { + if (!this.syncProvider) { + this.syncProvider = this.injector.get(AddonModFeedbackSyncProvider); + } + + return this.syncProvider.syncFeedback(module.instance, siteId); + } } diff --git a/src/addon/mod/feedback/providers/push-click-handler.ts b/src/addon/mod/feedback/providers/push-click-handler.ts new file mode 100644 index 000000000..675e7d0dd --- /dev/null +++ b/src/addon/mod/feedback/providers/push-click-handler.ts @@ -0,0 +1,69 @@ +// (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 { CoreUrlUtilsProvider } from '@providers/utils/url'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CorePushNotificationsClickHandler } from '@core/pushnotifications/providers/delegate'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { AddonModFeedbackProvider } from './feedback'; +import { AddonModFeedbackHelperProvider } from './helper'; + +/** + * Handler for feedback push notifications clicks. + */ +@Injectable() +export class AddonModFeedbackPushClickHandler implements CorePushNotificationsClickHandler { + name = 'AddonModFeedbackPushClickHandler'; + priority = 200; + featureName = 'CoreCourseModuleDelegate_AddonModFeedback'; + + constructor(private utils: CoreUtilsProvider, private feedbackHelper: AddonModFeedbackHelperProvider, + private urlUtils: CoreUrlUtilsProvider, private courseHelper: CoreCourseHelperProvider, + private feedbackProvider: AddonModFeedbackProvider) {} + + /** + * Check if a notification click is handled by this handler. + * + * @param {any} notification The notification to check. + * @return {boolean} Whether the notification click is handled by this handler + */ + handles(notification: any): boolean | Promise { + if (this.utils.isTrueOrOne(notification.notif) && notification.moodlecomponent == 'mod_feedback' && + (notification.name == 'submission' || notification.name == 'message')) { + + return this.feedbackProvider.isPluginEnabled(notification.site); + } + + return false; + } + + /** + * Handle the notification click. + * + * @param {any} notification The notification to check. + * @return {Promise} Promise resolved when done. + */ + handleClick(notification: any): Promise { + const contextUrlParams = this.urlUtils.extractUrlParams(notification.contexturl), + courseId = Number(notification.courseid), + moduleId = Number(contextUrlParams.id); + + if (notification.name == 'submission') { + return this.feedbackHelper.handleShowEntriesLink(undefined, contextUrlParams, notification.site); + } else { + return this.courseHelper.navigateToModule(moduleId, notification.site, courseId); + } + } +} diff --git a/src/addon/mod/feedback/providers/show-entries-link-handler.ts b/src/addon/mod/feedback/providers/show-entries-link-handler.ts index d10c156da..498703386 100644 --- a/src/addon/mod/feedback/providers/show-entries-link-handler.ts +++ b/src/addon/mod/feedback/providers/show-entries-link-handler.ts @@ -15,10 +15,8 @@ import { Injectable } from '@angular/core'; import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; -import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { AddonModFeedbackProvider } from './feedback'; -import { CoreCourseProvider } from '@core/course/providers/course'; -import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { AddonModFeedbackHelperProvider } from './helper'; /** * Content links handler for feedback show entries questions. @@ -30,8 +28,7 @@ export class AddonModFeedbackShowEntriesLinkHandler extends CoreContentLinksHand featureName = 'CoreCourseModuleDelegate_AddonModFeedback'; pattern = /\/mod\/feedback\/show_entries\.php.*([\?\&](id|showcompleted)=\d+)/; - constructor(private linkHelper: CoreContentLinksHelperProvider, private feedbackProvider: AddonModFeedbackProvider, - private courseProvider: CoreCourseProvider, private domUtils: CoreDomUtilsProvider) { + constructor(private feedbackProvider: AddonModFeedbackProvider, private feedbackHelper: AddonModFeedbackHelperProvider) { super(); } @@ -48,37 +45,7 @@ export class AddonModFeedbackShowEntriesLinkHandler extends CoreContentLinksHand CoreContentLinksAction[] | Promise { return [{ action: (siteId, navCtrl?): void => { - const modal = this.domUtils.showModalLoading(), - moduleId = params.id; - - this.courseProvider.getModuleBasicInfo(moduleId, siteId).then((module) => { - let stateParams; - - if (typeof params.showcompleted == 'undefined') { - // Param showcompleted not defined. Show entry list. - stateParams = { - moduleId: module.id, - module: module, - courseId: module.course - }; - - return this.linkHelper.goInSite(navCtrl, 'AddonModFeedbackRespondentsPage', stateParams, siteId); - } - - return this.feedbackProvider.getAttempt(module.instance, params.showcompleted, true, siteId).then((attempt) => { - stateParams = { - moduleId: module.id, - attempt: attempt, - attemptId: attempt.id, - feedbackId: module.instance, - courseId: module.course - }; - - return this.linkHelper.goInSite(navCtrl, 'AddonModFeedbackAttemptPage', stateParams, siteId); - }); - }).finally(() => { - modal.dismiss(); - }); + this.feedbackHelper.handleShowEntriesLink(navCtrl, params, siteId); } }]; } diff --git a/src/addon/mod/feedback/providers/sync-cron-handler.ts b/src/addon/mod/feedback/providers/sync-cron-handler.ts index 6ace28013..94d43e905 100644 --- a/src/addon/mod/feedback/providers/sync-cron-handler.ts +++ b/src/addon/mod/feedback/providers/sync-cron-handler.ts @@ -30,10 +30,11 @@ export class AddonModFeedbackSyncCronHandler implements CoreCronHandler { * Receives the ID of the site affected, undefined for all sites. * * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). * @return {Promise} Promise resolved when done, rejected if failure. */ - execute(siteId?: string): Promise { - return this.feedbackSync.syncAllFeedbacks(siteId); + execute(siteId?: string, force?: boolean): Promise { + return this.feedbackSync.syncAllFeedbacks(siteId, force); } /** diff --git a/src/addon/mod/feedback/providers/sync.ts b/src/addon/mod/feedback/providers/sync.ts index 6c6e008a4..c643f237b 100644 --- a/src/addon/mod/feedback/providers/sync.ts +++ b/src/addon/mod/feedback/providers/sync.ts @@ -72,19 +72,21 @@ export class AddonModFeedbackSyncProvider extends CoreCourseActivitySyncBaseProv * Try to synchronize all the feedbacks in a certain site or in all sites. * * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} force Wether to force sync not depending on last execution. * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - syncAllFeedbacks(siteId?: string): Promise { - return this.syncOnSites('all feedbacks', this.syncAllFeedbacksFunc.bind(this), undefined, siteId); + syncAllFeedbacks(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all feedbacks', this.syncAllFeedbacksFunc.bind(this), [force], siteId); } /** * Sync all pending feedbacks on a site. * - * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} force Wether to force sync not depending on last execution. * @param {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - protected syncAllFeedbacksFunc(siteId?: string): Promise { + protected syncAllFeedbacksFunc(siteId?: string, force?: boolean): Promise { // Sync all new responses. return this.feedbackOffline.getAllFeedbackResponses(siteId).then((responses) => { const promises = {}; @@ -97,7 +99,10 @@ export class AddonModFeedbackSyncProvider extends CoreCourseActivitySyncBaseProv continue; } - promises[response.feedbackid] = this.syncFeedbackIfNeeded(response.feedbackid, siteId).then((result) => { + promises[response.feedbackid] = force ? this.syncFeedback(response.feedbackid, siteId) : + this.syncFeedbackIfNeeded(response.feedbackid, siteId); + + promises[response.feedbackid].then((result) => { if (result && result.updated) { // Sync successful, send event. this.eventsProvider.trigger(AddonModFeedbackSyncProvider.AUTO_SYNCED, { diff --git a/src/addon/mod/folder/components/index/addon-mod-folder-index.html b/src/addon/mod/folder/components/index/addon-mod-folder-index.html index 62cda2e8e..c59ab5ac7 100644 --- a/src/addon/mod/folder/components/index/addon-mod-folder-index.html +++ b/src/addon/mod/folder/components/index/addon-mod-folder-index.html @@ -5,7 +5,7 @@ - + diff --git a/src/addon/mod/folder/components/index/index.ts b/src/addon/mod/folder/components/index/index.ts index 6b735e53d..d8c296b00 100644 --- a/src/addon/mod/folder/components/index/index.ts +++ b/src/addon/mod/folder/components/index/index.ts @@ -55,7 +55,7 @@ export class AddonModFolderIndexComponent extends CoreCourseModuleMainResourceCo this.refreshIcon = 'refresh'; } else { this.loadContent().then(() => { - this.folderProvider.logView(this.module.instance).then(() => { + this.folderProvider.logView(this.module.instance, this.module.name).then(() => { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); }).catch(() => { // Ignore errors. diff --git a/src/addon/mod/folder/providers/folder.ts b/src/addon/mod/folder/providers/folder.ts index f26369af8..ce3336634 100644 --- a/src/addon/mod/folder/providers/folder.ts +++ b/src/addon/mod/folder/providers/folder.ts @@ -18,6 +18,7 @@ import { CoreSitesProvider } from '@providers/sites'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; +import { CoreSite } from '@classes/site'; /** * Service that provides some features for folder. @@ -61,7 +62,8 @@ export class AddonModFolderProvider { courseids: [courseId] }, preSets = { - cacheKey: this.getFolderCacheKey(courseId) + cacheKey: this.getFolderCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('mod_folder_get_folders_by_courses', params, preSets).then((response) => { @@ -133,14 +135,16 @@ export class AddonModFolderProvider { * Report a folder as being viewed. * * @param {number} id Module ID. + * @param {string} [name] Name of the folder. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logView(id: number, siteId?: string): Promise { + logView(id: number, name?: string, siteId?: string): Promise { const params = { folderid: id }; - return this.logHelper.log('mod_folder_view_folder', params, AddonModFolderProvider.COMPONENT, id, siteId); + return this.logHelper.logSingle('mod_folder_view_folder', params, AddonModFolderProvider.COMPONENT, id, name, 'folder', + {}, siteId); } } diff --git a/src/addon/mod/forum/components/index/addon-mod-forum-index.html b/src/addon/mod/forum/components/index/addon-mod-forum-index.html index 47b6037c1..8a7dae440 100644 --- a/src/addon/mod/forum/components/index/addon-mod-forum-index.html +++ b/src/addon/mod/forum/components/index/addon-mod-forum-index.html @@ -6,8 +6,9 @@ - + + @@ -25,7 +26,22 @@ {{ 'core.hasdatatosync' | translate:{$a: moduleName} }} - + + + {{ availabilityMessage }} + + + + + +
+ +
+ + @@ -45,7 +61,11 @@ -

+

+ + + +

{{discussion.created | coreDateDayOrTime}} @@ -77,18 +97,10 @@ - -

- -
- - - + diff --git a/src/addon/mod/forum/components/index/index.scss b/src/addon/mod/forum/components/index/index.scss index 5234ac16e..733686a59 100644 --- a/src/addon/mod/forum/components/index/index.scss +++ b/src/addon/mod/forum/components/index/index.scss @@ -2,4 +2,15 @@ ion-app.app-root addon-mod-forum-index { .addon-forum-discussion-selected { border-top: 5px solid $core-splitview-selected; } + + .addon-forum-star { + color: $core-star-color; + } + + button.core-button-select .core-section-selector-text { + overflow: hidden; + text-overflow: ellipsis; + line-height: 2em; + white-space: nowrap; + } } diff --git a/src/addon/mod/forum/components/index/index.ts b/src/addon/mod/forum/components/index/index.ts index 036697410..b3a4209b6 100644 --- a/src/addon/mod/forum/components/index/index.ts +++ b/src/addon/mod/forum/components/index/index.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Component, Optional, Injector, ViewChild } from '@angular/core'; -import { Content, NavController } from 'ionic-angular'; +import { Content, ModalController, NavController } from 'ionic-angular'; import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; @@ -48,7 +48,14 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom discussions = []; offlineDiscussions = []; selectedDiscussion = 0; // Disucssion ID or negative timecreated if it's an offline discussion. + canAddDiscussion = false; addDiscussionText = this.translate.instant('addon.mod_forum.addanewdiscussion'); + availabilityMessage: string; + + sortingAvailable: boolean; + sortOrders = []; + selectedSortOrder = null; + sortOrderSelectorExpanded = false; protected syncEventName = AddonModForumSyncProvider.AUTO_SYNCED; protected page = 0; @@ -58,6 +65,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom protected replyObserver: any; protected newDiscObserver: any; protected viewDiscObserver: any; + protected changeDiscObserver: any; hasOfflineRatings: boolean; protected ratingOfflineObserver: any; @@ -66,6 +74,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom constructor(injector: Injector, @Optional() protected content: Content, protected navCtrl: NavController, + protected modalCtrl: ModalController, protected groupsProvider: CoreGroupsProvider, protected userProvider: CoreUserProvider, protected forumProvider: AddonModForumProvider, @@ -76,6 +85,9 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom protected prefetchHandler: AddonModForumPrefetchHandler, protected ratingOffline: CoreRatingOfflineProvider) { super(injector); + + this.sortingAvailable = this.forumProvider.isDiscussionListSortingAvailable(); + this.sortOrders = this.forumProvider.getAvailableSortOrders(); } /** @@ -94,6 +106,8 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom this.eventReceived.bind(this, true)); this.replyObserver = this.eventsProvider.on(AddonModForumProvider.REPLY_DISCUSSION_EVENT, this.eventReceived.bind(this, false)); + this.changeDiscObserver = this.eventsProvider.on(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, + this.eventReceived.bind(this, false)); // Select the current opened discussion. this.viewDiscObserver = this.eventsProvider.on(AddonModForumProvider.VIEW_DISCUSSION_EVENT, (data) => { @@ -138,7 +152,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom } } - this.forumProvider.logView(this.forum.id).then(() => { + this.forumProvider.logView(this.forum.id, this.forum.name).then(() => { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); }).catch((error) => { // Ignore errors. @@ -157,7 +171,9 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom protected fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { this.loadMoreError = false; - return this.forumProvider.getForum(this.courseId, this.module.id).then((forum) => { + const promises = []; + + promises.push(this.forumProvider.getForum(this.courseId, this.module.id).then((forum) => { this.forum = forum; this.description = forum.intro || this.description; @@ -165,6 +181,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom if (typeof forum.istracked != 'undefined') { this.trackPosts = forum.istracked; } + this.availabilityMessage = this.forumHelper.getAvailabilityMessage(forum); this.dataRetrieved.emit(forum); @@ -194,11 +211,23 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom }); } }).then(() => { - // Check if the activity uses groups. - return this.groupsProvider.getActivityGroupMode(this.forum.cmid).then((mode) => { - this.usesGroups = (mode === CoreGroupsProvider.SEPARATEGROUPS || mode === CoreGroupsProvider.VISIBLEGROUPS); - }); - }).then(() => { + return Promise.all([ + // Check if the activity uses groups. + this.groupsProvider.getActivityGroupMode(this.forum.cmid).then((mode) => { + this.usesGroups = (mode === CoreGroupsProvider.SEPARATEGROUPS || mode === CoreGroupsProvider.VISIBLEGROUPS); + }), + this.forumProvider.getAccessInformation(this.forum.id).then((accessInfo) => { + // Disallow adding discussions if cut-off date is reached and the user has not the capability to override it. + // Just in case the forum was fetched from WS when the cut-off date was not reached but it is now. + const cutoffDateReached = this.forumHelper.isCutoffDateReached(this.forum) && !accessInfo.cancanoverridecutoff; + this.canAddDiscussion = this.forum.cancreatediscussions && !cutoffDateReached; + }), + ]); + })); + + promises.push(this.fetchSortOrderPreference()); + + return Promise.all(promises).then(() => { return Promise.all([ this.fetchOfflineDiscussion(), this.fetchDiscussions(refresh), @@ -215,6 +244,9 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom this.domUtils.showErrorModalDefault(message, 'addon.mod_forum.errorgetforum', true); this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. + }).then(() => { + // All data obtained, now fill the context menu. + this.fillContextMenu(refresh); }); } @@ -277,7 +309,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom this.page = 0; } - return this.forumProvider.getDiscussions(this.forum.id, this.page).then((response) => { + return this.forumProvider.getDiscussions(this.forum.id, this.selectedSortOrder.value, this.page).then((response) => { let promise; if (this.usesGroups) { promise = this.forumProvider.formatDiscussionsGroups(this.forum.cmid, response.discussions); @@ -352,6 +384,27 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom }); } + /** + * Convenience function to fetch the sort order preference. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchSortOrderPreference(): Promise { + let promise; + if (this.sortingAvailable) { + promise = this.userProvider.getUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER).then((value) => { + return value ? parseInt(value, 10) : null; + }); + } else { + // Use default. + promise = Promise.resolve(null); + } + + return promise.then((value) => { + this.selectedSortOrder = this.sortOrders.find((sortOrder) => sortOrder.value === value) || this.sortOrders[0]; + }); + } + /** * Perform the invalidate content function. * @@ -365,6 +418,11 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom if (this.forum) { promises.push(this.forumProvider.invalidateDiscussionsList(this.forum.id)); promises.push(this.groupsProvider.invalidateActivityGroupMode(this.forum.cmid)); + promises.push(this.forumProvider.invalidateAccessInformation(this.forum.id)); + } + + if (this.sortingAvailable) { + promises.push(this.userProvider.invalidateUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER)); } return Promise.all(promises); @@ -376,18 +434,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom * @return {Promise} Promise resolved when done. */ protected sync(): Promise { - const promises = []; - - promises.push(this.forumSync.syncForumDiscussions(this.forum.id)); - promises.push(this.forumSync.syncForumReplies(this.forum.id)); - promises.push(this.forumSync.syncRatings(this.forum.cmid)); - - return Promise.all(promises).then((results) => { - return results.reduce((a, b) => ({ - updated: a.updated || b.updated, - warnings: (a.warnings || []).concat(b.warnings || []), - }), {updated: false}); - }); + return this.prefetchHandler.sync(this.module, this.courseId); } /** @@ -428,9 +475,11 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom // If it's a new discussion in tablet mode, try to open it. if (isNewDiscussion && this.splitviewCtrl.isOn()) { - if (data.discussionId) { + if (data.discussionIds) { // Discussion sent to server, search it in the list of discussions. - const discussion = this.discussions.find((disc) => { return disc.discussion == data.discussionId; }); + const discussion = this.discussions.find((disc) => { + return data.discussionIds.indexOf(disc.discussion) >= 0; + }); if (discussion) { this.openDiscussion(discussion); } @@ -457,9 +506,8 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom courseId: this.courseId, cmId: this.module.id, forumId: this.forum.id, - discussionId: discussion.discussion, + discussion: discussion, trackPosts: this.trackPosts, - locked: discussion.locked }; this.splitviewCtrl.push('AddonModForumDiscussionPage', params); } @@ -481,6 +529,37 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom this.selectedDiscussion = 0; } + /** + * Display the sort order selector modal. + * + * @param {MouseEvent} event Event. + */ + showSortOrderSelector(event: MouseEvent): void { + if (!this.sortingAvailable) { + return; + } + + const params = { sortOrders: this.sortOrders, selected: this.selectedSortOrder.value }; + const modal = this.modalCtrl.create('AddonModForumSortOrderSelectorPage', params); + modal.onDidDismiss((sortOrder) => { + this.sortOrderSelectorExpanded = false; + + if (sortOrder && sortOrder.value != this.selectedSortOrder.value) { + this.selectedSortOrder = sortOrder; + this.page = 0; + this.userProvider.setUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER, sortOrder.value.toFixed(0)) + .then(() => { + this.showLoadingAndFetch(); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error updating preference.'); + }); + } + }); + + modal.present({ev: event}); + this.sortOrderSelectorExpanded = true; + } + /** * Component being destroyed. */ @@ -491,6 +570,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom this.newDiscObserver && this.newDiscObserver.off(); this.replyObserver && this.replyObserver.off(); this.viewDiscObserver && this.viewDiscObserver.off(); + this.changeDiscObserver && this.changeDiscObserver.off(); this.ratingOfflineObserver && this.ratingOfflineObserver.off(); this.ratingSyncObserver && this.ratingSyncObserver.off(); } diff --git a/src/addon/mod/forum/components/post/addon-mod-forum-post.html b/src/addon/mod/forum/components/post/addon-mod-forum-post.html index 6592dabb2..cb8ffc1b3 100644 --- a/src/addon/mod/forum/components/post/addon-mod-forum-post.html +++ b/src/addon/mod/forum/components/post/addon-mod-forum-post.html @@ -1,7 +1,11 @@ -

+

+ + + +

{{ 'core.notsent' | translate }} @@ -13,6 +17,9 @@ +

+ {{ 'addon.mod_forum.postisprivatereply' | translate }} +
@@ -25,7 +32,7 @@ - + @@ -42,9 +49,20 @@ {{ 'addon.mod_forum.message' | translate }} - + - + + {{ 'addon.mod_forum.privatereply' | translate }} + + + + + + {{ 'addon.mod_forum.advanced' | translate }} + + + + diff --git a/src/addon/mod/forum/components/post/post.scss b/src/addon/mod/forum/components/post/post.scss new file mode 100644 index 000000000..43358381e --- /dev/null +++ b/src/addon/mod/forum/components/post/post.scss @@ -0,0 +1,5 @@ +ion-app.app-root addon-mod-forum-post { + .addon-forum-star { + color: $core-star-color; + } +} diff --git a/src/addon/mod/forum/components/post/post.ts b/src/addon/mod/forum/components/post/post.ts index d245ddff0..581c7eefe 100644 --- a/src/addon/mod/forum/components/post/post.ts +++ b/src/addon/mod/forum/components/post/post.ts @@ -44,6 +44,7 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy { @Input() originalData: any; // Object with the original post data. Usually shared between posts. @Input() trackPosts: boolean; // True if post is being tracked. @Input() forum: any; // The forum the post belongs to. Required for attachments and offline posts. + @Input() accessInfo: any; // Forum access information. @Input() defaultSubject: string; // Default subject to set to new posts. @Input() ratingInfo?: CoreRatingInfo; // Rating info item. @Output() onPostChange: EventEmitter; // Event emitted when a reply is posted or modified. @@ -51,6 +52,7 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy { messageControl = new FormControl(); uniqueId: string; + advanced = false; // Display all form fields. protected syncId: string; @@ -95,9 +97,11 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy { * @param {boolean} [isEditing] True it's an offline reply beeing edited, false otherwise. * @param {string} [subject] Subject of the reply. * @param {string} [message] Message of the reply. + * @param {boolean} [isPrivate] True if it's private reply. * @param {any[]} [files] Reply attachments. */ - protected setReplyData(replyingTo?: number, isEditing?: boolean, subject?: string, message?: string, files?: any[]): void { + protected setReplyData(replyingTo?: number, isEditing?: boolean, subject?: string, message?: string, files?: any[], + isPrivate?: boolean): void { // Delete the local files from the tmp folder if any. this.uploaderProvider.clearTmpFiles(this.replyData.files); @@ -106,6 +110,7 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy { this.replyData.subject = subject || this.defaultSubject || ''; this.replyData.message = message || null; this.replyData.files = files || []; + this.replyData.isprivatereply = !!isPrivate; // Update rich text editor. this.messageControl.setValue(this.replyData.message); @@ -114,6 +119,10 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy { this.originalData.subject = this.replyData.subject; this.originalData.message = this.replyData.message; this.originalData.files = this.replyData.files.slice(); + this.originalData.isprivatereply = this.replyData.isprivatereply; + + // Show advanced fields if any of them has not the default value. + this.advanced = this.replyData.files.length > 0; } /** @@ -163,7 +172,8 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy { this.syncId = this.forumSync.getDiscussionSyncId(this.discussionId); this.syncProvider.blockOperation(AddonModForumProvider.COMPONENT, this.syncId); - this.setReplyData(this.post.parent, true, this.post.subject, this.post.message, this.post.attachments); + this.setReplyData(this.post.parent, true, this.post.subject, this.post.message, this.post.attachments, + this.post.isprivatereply); }).catch(() => { // Cancelled. }); @@ -206,6 +216,11 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy { // Add some HTML to the message if needed. message = this.textUtils.formatHtmlLines(message); + // Set private option if checked. + if (this.replyData.isprivatereply) { + options.private = true; + } + // Upload attachments first if any. if (files.length) { promise = this.forumHelper.uploadOrStoreReplyFiles(this.forum.id, replyingTo, files, false).catch((error) => { @@ -314,6 +329,13 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy { this.forumProvider.invalidateDiscussionPosts(this.discussionId); } + /** + * Show or hide advanced form fields. + */ + toggleAdvanced(): void { + this.advanced = !this.advanced; + } + /** * Component being destroyed. */ diff --git a/src/addon/mod/forum/forum.module.ts b/src/addon/mod/forum/forum.module.ts index b925af0b4..215c343e6 100644 --- a/src/addon/mod/forum/forum.module.ts +++ b/src/addon/mod/forum/forum.module.ts @@ -17,6 +17,7 @@ import { CoreCronDelegate } from '@providers/cron'; import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; +import { CorePushNotificationsDelegate } from '@core/pushnotifications/providers/delegate'; import { AddonModForumProvider } from './providers/forum'; import { AddonModForumOfflineProvider } from './providers/offline'; import { AddonModForumHelperProvider } from './providers/helper'; @@ -27,6 +28,7 @@ import { AddonModForumSyncCronHandler } from './providers/sync-cron-handler'; import { AddonModForumIndexLinkHandler } from './providers/index-link-handler'; import { AddonModForumDiscussionLinkHandler } from './providers/discussion-link-handler'; import { AddonModForumListLinkHandler } from './providers/list-link-handler'; +import { AddonModForumPushClickHandler } from './providers/push-click-handler'; import { AddonModForumComponentsModule } from './components/components.module'; import { CoreUpdateManagerProvider } from '@providers/update-manager'; @@ -54,7 +56,8 @@ export const ADDON_MOD_FORUM_PROVIDERS: any[] = [ AddonModForumSyncCronHandler, AddonModForumIndexLinkHandler, AddonModForumListLinkHandler, - AddonModForumDiscussionLinkHandler + AddonModForumDiscussionLinkHandler, + AddonModForumPushClickHandler ] }) export class AddonModForumModule { @@ -62,7 +65,8 @@ export class AddonModForumModule { prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModForumPrefetchHandler, cronDelegate: CoreCronDelegate, syncHandler: AddonModForumSyncCronHandler, linksDelegate: CoreContentLinksDelegate, indexHandler: AddonModForumIndexLinkHandler, discussionHandler: AddonModForumDiscussionLinkHandler, - updateManager: CoreUpdateManagerProvider, listLinkHandler: AddonModForumListLinkHandler) { + updateManager: CoreUpdateManagerProvider, listLinkHandler: AddonModForumListLinkHandler, + pushNotificationsDelegate: CorePushNotificationsDelegate, pushClickHandler: AddonModForumPushClickHandler) { moduleDelegate.registerHandler(moduleHandler); prefetchDelegate.registerHandler(prefetchHandler); @@ -70,6 +74,7 @@ export class AddonModForumModule { linksDelegate.registerHandler(indexHandler); linksDelegate.registerHandler(discussionHandler); linksDelegate.registerHandler(listLinkHandler); + pushNotificationsDelegate.registerClickHandler(pushClickHandler); // Allow migrating the tables from the old app to the new schema. updateManager.registerSiteTablesMigration([ diff --git a/src/addon/mod/forum/lang/en.json b/src/addon/mod/forum/lang/en.json index 214e828d0..de429fcad 100644 --- a/src/addon/mod/forum/lang/en.json +++ b/src/addon/mod/forum/lang/en.json @@ -2,11 +2,20 @@ "addanewdiscussion": "Add a new discussion topic", "addanewquestion": "Add a new question", "addanewtopic": "Add a new topic", + "addtofavourites": "Star this discussion", + "advanced": "Advanced", "cannotadddiscussion": "Adding discussions to this forum requires group membership.", "cannotadddiscussionall": "You do not have permission to add a new discussion topic for all participants.", "cannotcreatediscussion": "Could not create new discussion", "couldnotadd": "Could not add your post due to an unknown error", + "cutoffdatereached": "The cut-off date for posting to this forum is reached so you can no longer post to it.", "discussion": "Discussion", + "discussionlistsortbycreatedasc": "Sort by creation date in ascending order", + "discussionlistsortbycreateddesc": "Sort by creation date in descending order", + "discussionlistsortbylastpostasc": "Sort by last post creation date in ascending order", + "discussionlistsortbylastpostdesc": "Sort by last post creation date in descending order", + "discussionlistsortbyrepliesasc": "Sort by number of replies in ascending order", + "discussionlistsortbyrepliesdesc": "Sort by number of replies in descending order", "discussionlocked": "This discussion has been locked so you can no longer reply to it.", "discussionpinned": "Pinned", "discussionsubscription": "Discussion subscription", @@ -15,8 +24,12 @@ "erroremptysubject": "Post subject cannot be empty.", "errorgetforum": "Error getting forum data.", "errorgetgroups": "Error getting group settings.", + "errorposttoallgroups": "Could not create new discussion in all groups.", + "favouriteupdated": "Your star option has been updated.", "forumnodiscussionsyet": "There are no discussions yet in this forum.", "group": "Group", + "lockdiscussion": "Lock this discussion", + "lockupdated": "The lock option has been updated.", "message": "Message", "modeflatnewestfirst": "Display replies flat, with newest first", "modeflatoldestfirst": "Display replies flat, with oldest first", @@ -24,12 +37,23 @@ "modulenameplural": "Forums", "numdiscussions": "{{numdiscussions}} discussions", "numreplies": "{{numreplies}} replies", + "pindiscussion": "Pin this discussion", + "pinupdated": "The pin option has been updated.", + "postisprivatereply": "This post was made privately and is not visible to all users.", "posttoforum": "Post to forum", + "posttomygroups": "Post a copy to all groups", + "privatereply": "Reply privately", "re": "Re:", "refreshdiscussions": "Refresh discussions", "refreshposts": "Refresh posts", + "removefromfavourites": "Unstar this discussion", "reply": "Reply", + "replyplaceholder": "Write your reply...", "subject": "Subject", + "thisforumhasduedate": "The due date for posting to this forum is {{$a}}.", + "thisforumisdue": "The due date for posting to this forum was {{$a}}.", + "unlockdiscussion": "Unlock this discussion", + "unpindiscussion": "Unpin this discussion", "unread": "Unread", "unreadpostsnumber": "{{$a}} unread posts" } diff --git a/src/addon/mod/forum/pages/discussion/discussion.html b/src/addon/mod/forum/pages/discussion/discussion.html index 01be9ede2..9111ecdd5 100644 --- a/src/addon/mod/forum/pages/discussion/discussion.html +++ b/src/addon/mod/forum/pages/discussion/discussion.html @@ -13,6 +13,12 @@ + + + + + + @@ -26,18 +32,23 @@ {{ 'core.hasdatatosync' | translate:{$a: discussionStr} }} - - {{ 'addon.mod_forum.discussionlocked' | translate }} + + + {{ availabilityMessage }} + + + + {{ 'addon.mod_forum.discussionlocked' | translate }} - + - + @@ -49,7 +60,7 @@ - +
diff --git a/src/addon/mod/forum/pages/discussion/discussion.ts b/src/addon/mod/forum/pages/discussion/discussion.ts index 7cf56cdf0..f8514dd5f 100644 --- a/src/addon/mod/forum/pages/discussion/discussion.ts +++ b/src/addon/mod/forum/pages/discussion/discussion.ts @@ -47,13 +47,13 @@ export class AddonModForumDiscussionPage implements OnDestroy { courseId: number; discussionId: number; forum: any; + accessInfo: any; discussion: any; posts: any[]; discussionLoaded = false; defaultSubject: string; isOnline: boolean; isSplitViewOn: boolean; - locked: boolean; postHasOffline: boolean; sort: SortType = 'flat-oldest'; trackPosts: boolean; @@ -63,17 +63,21 @@ export class AddonModForumDiscussionPage implements OnDestroy { subject: '', message: null, // Null means empty or just white space. files: [], + isprivatereply: false, }; originalData = { subject: null, // Null means original data is not set. message: null, // Null means empty or just white space. files: [], + isprivatereply: false, }; refreshIcon = 'spinner'; syncIcon = 'spinner'; discussionStr = ''; component = AddonModForumProvider.COMPONENT; cmId: number; + canPin = false; + availabilityMessage: string; protected forumId: number; protected postId: number; @@ -105,9 +109,9 @@ export class AddonModForumDiscussionPage implements OnDestroy { this.courseId = navParams.get('courseId'); this.cmId = navParams.get('cmId'); this.forumId = navParams.get('forumId'); - this.discussionId = navParams.get('discussionId'); + this.discussion = navParams.get('discussion'); + this.discussionId = this.discussion ? this.discussion.discussion : navParams.get('discussionId'); this.trackPosts = navParams.get('trackPosts'); - this.locked = navParams.get('locked'); this.postId = navParams.get('postId'); this.isOnline = this.appProvider.isOnline(); @@ -219,7 +223,7 @@ export class AddonModForumDiscussionPage implements OnDestroy { } /** - * Convenience function to get forum discussions. + * Convenience function to get the posts. * * @param {boolean} [sync] Whether to try to synchronize the discussion. * @param {boolean} [showErrors] Whether to show errors in a modal. @@ -240,11 +244,12 @@ export class AddonModForumDiscussionPage implements OnDestroy { let onlinePosts = []; const offlineReplies = []; let hasUnreadPosts = false; + let ratingInfo; return syncPromise.then(() => { return this.forumProvider.getDiscussionPosts(this.discussionId).then((response) => { onlinePosts = response.posts; - this.ratingInfo = response.ratinginfo; + ratingInfo = response.ratinginfo; }).then(() => { // Check if there are responses stored in offline. return this.forumOffline.getDiscussionReplies(this.discussionId).then((replies) => { @@ -282,34 +287,21 @@ export class AddonModForumDiscussionPage implements OnDestroy { }); }); }).then(() => { - const posts = offlineReplies.concat(onlinePosts); - this.discussion = this.forumProvider.extractStartingPost(posts); - - if (!this.discussion) { - return Promise.reject('Invalid forum discussion'); - } + let posts = offlineReplies.concat(onlinePosts); // If sort type is nested, normal sorting is disabled and nested posts will be displayed. if (this.sort == 'nested') { // Sort first by creation date to make format tree work. this.forumProvider.sortDiscussionPosts(posts, 'ASC'); - this.posts = this.utils.formatTree(posts, 'parent', 'id', this.discussion.id); + posts = this.utils.formatTree(posts, 'parent', 'id', this.discussion.id); } else { // Set default reply subject. const direction = this.sort == 'flat-newest' ? 'DESC' : 'ASC'; this.forumProvider.sortDiscussionPosts(posts, direction); - this.posts = posts; } - this.defaultSubject = this.translate.instant('addon.mod_forum.re') + ' ' + this.discussion.subject; - this.replyData.subject = this.defaultSubject; // Now try to get the forum. return this.fetchForum().then((forum) => { - if (this.discussion.userfullname && this.discussion.parent == 0 && forum.type == 'single') { - // Hide author for first post and type single. - this.discussion.userfullname = null; - } - // "forum.istracked" is more reliable than "trackPosts". if (typeof forum.istracked != 'undefined') { this.trackPosts = forum.istracked; @@ -318,10 +310,69 @@ export class AddonModForumDiscussionPage implements OnDestroy { this.forumId = forum.id; this.cmId = forum.cmid; this.forum = forum; + this.availabilityMessage = this.forumHelper.getAvailabilityMessage(forum); + + const promises = []; + + promises.push(this.forumProvider.getAccessInformation(this.forum.id).then((accessInfo) => { + this.accessInfo = accessInfo; + + // Disallow replying if cut-off date is reached and the user has not the capability to override it. + // Just in case the posts were fetched from WS when the cut-off date was not reached but it is now. + if (this.forumHelper.isCutoffDateReached(forum) && !accessInfo.cancanoverridecutoff) { + posts.forEach((post) => { + post.canreply = false; + }); + } + })); + + // Fetch the discussion if not passed as parameter. + if (!this.discussion) { + promises.push(this.forumHelper.getDiscussionById(forum.id, this.discussionId).then((discussion) => { + this.discussion = discussion; + }).catch(() => { + // Ignore errors. + })); + } + + return Promise.all(promises); }).catch(() => { // Ignore errors. this.forum = {}; + this.accessInfo = {}; + }).then(() => { + this.defaultSubject = this.translate.instant('addon.mod_forum.re') + ' ' + + (this.discussion ? this.discussion.subject : ''); + this.replyData.subject = this.defaultSubject; + + const startingPost = this.forumProvider.extractStartingPost(posts); + if (startingPost) { + // Update discussion data from first post. + this.discussion = Object.assign(this.discussion || {}, startingPost); + } else if (!this.discussion) { + // The discussion object was not passed as parameter and there is no starting post. + return Promise.reject('Invalid forum discussion.'); + } + + if (this.discussion.userfullname && this.discussion.parent == 0 && this.forum.type == 'single') { + // Hide author for first post and type single. + this.discussion.userfullname = null; + } + + this.posts = posts; + this.ratingInfo = ratingInfo; }); + }).then(() => { + if (this.forumProvider.isSetPinStateAvailableForSite()) { + // Use the canAddDiscussion WS to check if the user can pin discussions. + return this.forumProvider.canAddDiscussionToAll(this.forumId).then((response) => { + this.canPin = !!response.canpindiscussions; + }).catch(() => { + this.canPin = false; + }); + } else { + this.canPin = false; + } }).then(() => { return this.ratingOffline.hasRatings('mod_forum', 'post', 'module', this.cmId, this.discussionId).then((hasRatings) => { this.hasOfflineRatings = hasRatings; @@ -335,7 +386,7 @@ export class AddonModForumDiscussionPage implements OnDestroy { if (forceMarkAsRead || (hasUnreadPosts && this.trackPosts)) { // // Add log in Moodle and mark unread posts as readed. - this.forumProvider.logDiscussionView(this.discussionId, this.forumId || -1).catch(() => { + this.forumProvider.logDiscussionView(this.discussionId, this.forumId || -1, this.forum.name).catch(() => { // Ignore errors. }).finally(() => { // Trigger mark read posts. @@ -420,7 +471,14 @@ export class AddonModForumDiscussionPage implements OnDestroy { this.refreshIcon = 'spinner'; this.syncIcon = 'spinner'; - return this.forumProvider.invalidateDiscussionPosts(this.discussionId).catch(() => { + const promises = [ + this.forumProvider.invalidateForumData(this.courseId), + this.forumProvider.invalidateDiscussionPosts(this.discussionId), + this.forumProvider.invalidateAccessInformation(this.forumId), + this.forumProvider.invalidateCanAddDiscussion(this.forumId) + ]; + + return this.utils.allPromises(promises).catch(() => { // Ignore errors. }).then(() => { return this.fetchPosts(sync, showErrors); @@ -441,6 +499,87 @@ export class AddonModForumDiscussionPage implements OnDestroy { return this.fetchPosts(); } + /** + * Lock or unlock the discussion. + * + * @param {boolean} locked True to lock the discussion, false to unlock. + */ + setLockState(locked: boolean): void { + const modal = this.domUtils.showModalLoading('core.sending', true); + + this.forumProvider.setLockState(this.forumId, this.discussionId, locked).then((response) => { + this.discussion.locked = response.locked; + + const data = { + forumId: this.forumId, + discussionId: this.discussionId, + cmId: this.cmId, + locked: this.discussion.locked + }; + this.eventsProvider.trigger(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data, this.sitesProvider.getCurrentSiteId()); + + this.domUtils.showToast('addon.mod_forum.lockupdated', true); + }).catch((error) => { + this.domUtils.showErrorModal(error); + }).finally(() => { + modal.dismiss(); + }); + } + + /** + * Pin or unpin the discussion. + * + * @param {boolean} pinned True to pin the discussion, false to unpin it. + */ + setPinState(pinned: boolean): void { + const modal = this.domUtils.showModalLoading('core.sending', true); + + this.forumProvider.setPinState(this.discussionId, pinned).then(() => { + this.discussion.pinned = pinned; + + const data = { + forumId: this.forumId, + discussionId: this.discussionId, + cmId: this.cmId, + pinned: this.discussion.pinned + }; + this.eventsProvider.trigger(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data, this.sitesProvider.getCurrentSiteId()); + + this.domUtils.showToast('addon.mod_forum.pinupdated', true); + }).catch((error) => { + this.domUtils.showErrorModal(error); + }).finally(() => { + modal.dismiss(); + }); + } + + /** + * Star or unstar the discussion. + * + * @param {boolean} starred True to star the discussion, false to unstar it. + */ + toggleFavouriteState(starred: boolean): void { + const modal = this.domUtils.showModalLoading('core.sending', true); + + this.forumProvider.toggleFavouriteState(this.discussionId, starred).then(() => { + this.discussion.starred = starred; + + const data = { + forumId: this.forumId, + discussionId: this.discussionId, + cmId: this.cmId, + starred: this.discussion.starred + }; + this.eventsProvider.trigger(AddonModForumProvider.CHANGE_DISCUSSION_EVENT, data, this.sitesProvider.getCurrentSiteId()); + + this.domUtils.showToast('addon.mod_forum.favouriteupdated', true); + }).catch((error) => { + this.domUtils.showErrorModal(error); + }).finally(() => { + modal.dismiss(); + }); + } + /** * New post added. */ diff --git a/src/addon/mod/forum/pages/new-discussion/new-discussion.html b/src/addon/mod/forum/pages/new-discussion/new-discussion.html index 1003dd05a..95bb6e5ce 100644 --- a/src/addon/mod/forum/pages/new-discussion/new-discussion.html +++ b/src/addon/mod/forum/pages/new-discussion/new-discussion.html @@ -21,21 +21,32 @@ {{ 'addon.mod_forum.message' | translate }} - - {{ 'addon.mod_forum.group' | translate }} - - {{ group.name }} - - - - {{ 'addon.mod_forum.discussionsubscription' | translate }} - - - - {{ 'addon.mod_forum.discussionpinned' | translate }} - - - + + + + {{ 'addon.mod_forum.advanced' | translate }} + + + + {{ 'addon.mod_forum.posttomygroups' | translate }} + + + + {{ 'addon.mod_forum.group' | translate }} + + {{ group.name }} + + + + {{ 'addon.mod_forum.discussionsubscription' | translate }} + + + + {{ 'addon.mod_forum.discussionpinned' | translate }} + + + + diff --git a/src/addon/mod/forum/pages/new-discussion/new-discussion.ts b/src/addon/mod/forum/pages/new-discussion/new-discussion.ts index bb5c7f95a..be2fb7383 100644 --- a/src/addon/mod/forum/pages/new-discussion/new-discussion.ts +++ b/src/addon/mod/forum/pages/new-discussion/new-discussion.ts @@ -53,14 +53,18 @@ export class AddonModForumNewDiscussionPage implements OnDestroy { forum: any; showForm = false; groups = []; + groupIds = []; newDiscussion = { subject: '', message: null, // Null means empty or just white space. + postToAllGroups: false, groupId: 0, subscribe: true, pin: false, files: [] }; + advanced = false; // Display all form fields. + accessInfo: any = {}; protected courseId: number; protected cmId: number; @@ -145,9 +149,13 @@ export class AddonModForumNewDiscussionPage implements OnDestroy { return promise.then((forumGroups) => { if (forumGroups.length > 0) { this.groups = forumGroups; + this.groupIds = forumGroups.map((group) => group.id).filter((id) => id > 0); // Do not override group id. this.newDiscussion.groupId = this.newDiscussion.groupId || forumGroups[0].id; this.showGroups = true; + if (this.groupIds.length <= 1) { + this.newDiscussion.postToAllGroups = false; + } } else { const message = mode === CoreGroupsProvider.SEPARATEGROUPS ? 'addon.mod_forum.cannotadddiscussionall' : 'addon.mod_forum.cannotadddiscussion'; @@ -158,6 +166,7 @@ export class AddonModForumNewDiscussionPage implements OnDestroy { })); } else { this.showGroups = false; + this.newDiscussion.postToAllGroups = false; // Use the canAddDiscussion WS to check if the user can add attachments and pin discussions. promises.push(this.forumProvider.canAddDiscussionToAll(this.forumId).then((response) => { @@ -173,10 +182,18 @@ export class AddonModForumNewDiscussionPage implements OnDestroy { this.forum = forum; })); + // Get access information. + promises.push(this.forumProvider.getAccessInformation(this.forumId).then((accessInfo) => { + this.accessInfo = accessInfo; + })); + + return Promise.all(promises); + }).then(() => { // If editing a discussion, get offline data. if (this.timeCreated && !refresh) { this.syncId = this.forumSync.getForumSyncId(this.forumId); - promises.push(this.forumSync.waitForSync(this.syncId).then(() => { + + return this.forumSync.waitForSync(this.syncId).then(() => { // Do not block if the scope is already destroyed. if (!this.isDestroyed) { this.syncProvider.blockOperation(AddonModForumProvider.COMPONENT, this.syncId); @@ -185,7 +202,13 @@ export class AddonModForumNewDiscussionPage implements OnDestroy { return this.forumOffline.getNewDiscussion(this.forumId, this.timeCreated).then((discussion) => { this.hasOffline = true; discussion.options = discussion.options || {}; - this.newDiscussion.groupId = discussion.groupid ? discussion.groupid : this.newDiscussion.groupId; + if (discussion.groupid == AddonModForumProvider.ALL_GROUPS) { + this.newDiscussion.groupId = this.groups[0].id; + this.newDiscussion.postToAllGroups = true; + } else { + this.newDiscussion.groupId = discussion.groupid; + this.newDiscussion.postToAllGroups = false; + } this.newDiscussion.subject = discussion.subject; this.newDiscussion.message = discussion.message; this.newDiscussion.subscribe = discussion.options.discussionsubscribe; @@ -193,16 +216,24 @@ export class AddonModForumNewDiscussionPage implements OnDestroy { this.messageControl.setValue(discussion.message); // Treat offline attachments if any. + let promise; if (discussion.options.attachmentsid && discussion.options.attachmentsid.offline) { - return this.forumHelper.getNewDiscussionStoredFiles(this.forumId, this.timeCreated).then((files) => { + promise = this.forumHelper.getNewDiscussionStoredFiles(this.forumId, this.timeCreated).then((files) => { this.newDiscussion.files = files; }); } - }); - })); - } - return Promise.all(promises); + return Promise.resolve(promise).then(() => { + // Show advanced fields by default if any of them has not the default value. + if (!this.newDiscussion.subscribe || this.newDiscussion.pin || this.newDiscussion.files.length || + this.groups.length > 0 && this.newDiscussion.groupId != this.groups[0].id || + this.newDiscussion.postToAllGroups) { + this.advanced = true; + } + }); + }); + }); + } }).then(() => { if (!this.originalData) { // Initialize original data. @@ -223,9 +254,9 @@ export class AddonModForumNewDiscussionPage implements OnDestroy { * Validate which of the groups returned by getActivityAllowedGroups in visible groups should be shown to post to. * * @param {any[]} forumGroups Forum groups. - * @return {Promise} Promise resolved when done. + * @return {Promise} Promise resolved with the list of groups. */ - protected validateVisibleGroups(forumGroups: any[]): Promise { + protected validateVisibleGroups(forumGroups: any[]): Promise { // We first check if the user can post to all the groups. return this.forumProvider.canAddDiscussionToAll(this.forumId).catch(() => { // The call failed, let's assume he can't. @@ -322,7 +353,7 @@ export class AddonModForumNewDiscussionPage implements OnDestroy { if (canAdd) { groups.unshift({ courseid: this.courseId, - id: -1, + id: AddonModForumProvider.ALL_PARTICIPANTS, name: this.translate.instant('core.allparticipants') }); } @@ -353,14 +384,14 @@ export class AddonModForumNewDiscussionPage implements OnDestroy { /** * Convenience function to update or return to discussions depending on device. * - * @param {number} [discussionId] Id of the new discussion. + * @param {number} [discussionIds] Ids of the new discussions. * @param {number} [discTimecreated] The time created of the discussion (if offline). */ - protected returnToDiscussions(discussionId?: number, discTimecreated?: number): void { + protected returnToDiscussions(discussionIds?: number[], discTimecreated?: number): void { const data: any = { forumId: this.forumId, cmId: this.cmId, - discussionId: discussionId, + discussionIds: discussionIds, discTimecreated: discTimecreated }; this.eventsProvider.trigger(AddonModForumProvider.NEW_DISCUSSION_EVENT, data, this.sitesProvider.getCurrentSiteId()); @@ -374,6 +405,7 @@ export class AddonModForumNewDiscussionPage implements OnDestroy { this.newDiscussion.subject = ''; this.newDiscussion.message = null; this.newDiscussion.files = []; + this.newDiscussion.postToAllGroups = false; this.messageEditor.clearText(); this.originalData = this.utils.clone(this.newDiscussion); @@ -405,13 +437,11 @@ export class AddonModForumNewDiscussionPage implements OnDestroy { const subject = this.newDiscussion.subject; let message = this.newDiscussion.message; const pin = this.newDiscussion.pin; - const groupId = this.newDiscussion.groupId; const attachments = this.newDiscussion.files; const discTimecreated = this.timeCreated || Date.now(); const options: any = { discussionsubscribe: !!this.newDiscussion.subscribe }; - let saveOffline = false; if (!subject) { this.domUtils.showErrorModal('addon.mod_forum.erroremptysubject', true); @@ -425,51 +455,29 @@ export class AddonModForumNewDiscussionPage implements OnDestroy { } const modal = this.domUtils.showModalLoading('core.sending', true); - let promise; // Add some HTML to the message if needed. message = this.textUtils.formatHtmlLines(message); - // Upload attachments first if any. - if (attachments.length) { - promise = this.forumHelper.uploadOrStoreNewDiscussionFiles(this.forumId, discTimecreated, attachments, false) - .catch(() => { - // Cannot upload them in online, save them in offline. - saveOffline = true; - - return this.forumHelper.uploadOrStoreNewDiscussionFiles(this.forumId, discTimecreated, attachments, true); - }); - } else { - promise = Promise.resolve(); + if (pin) { + options.discussionpinned = true; } - promise.then((attach) => { - if (attach) { - options.attachmentsid = attach; - } - if (pin) { - options.discussionpinned = true; - } + const groupIds = this.newDiscussion.postToAllGroups ? this.groupIds : [this.newDiscussion.groupId]; - if (saveOffline) { - // Save discussion in offline. - return this.forumOffline.addNewDiscussion(this.forumId, forumName, this.courseId, subject, - message, options, groupId, discTimecreated).then(() => { - // Don't return anything. - }); - } else { - // Try to send it to server. - // Don't allow offline if there are attachments since they were uploaded fine. - return this.forumProvider.addNewDiscussion(this.forumId, forumName, this.courseId, subject, message, options, - groupId, undefined, discTimecreated, !attachments.length); - } - }).then((discussionId) => { - if (discussionId) { + this.forumHelper.addNewDiscussion(this.forumId, forumName, this.courseId, subject, message, attachments, options, groupIds, + discTimecreated).then((discussionIds) => { + if (discussionIds) { // Data sent to server, delete stored files (if any). this.forumHelper.deleteNewDiscussionStoredFiles(this.forumId, discTimecreated); } - this.returnToDiscussions(discussionId, discTimecreated); + if (discussionIds && discussionIds.length < groupIds.length) { + // Some discussions could not be created. + this.domUtils.showErrorModalDefault(null, 'addon.mod_forum.errorposttoallgroups', true); + } + + this.returnToDiscussions(discussionIds, discTimecreated); }).catch((message) => { this.domUtils.showErrorModalDefault(message, 'addon.mod_forum.cannotcreatediscussion', true); }).finally(() => { @@ -497,6 +505,13 @@ export class AddonModForumNewDiscussionPage implements OnDestroy { }); } + /** + * Show or hide advanced form fields. + */ + toggleAdvanced(): void { + this.advanced = !this.advanced; + } + /** * Check if we can leave the page or not. * diff --git a/src/addon/mod/forum/pages/sort-order-selector/sort-order-selector.html b/src/addon/mod/forum/pages/sort-order-selector/sort-order-selector.html new file mode 100644 index 000000000..a3411deea --- /dev/null +++ b/src/addon/mod/forum/pages/sort-order-selector/sort-order-selector.html @@ -0,0 +1,19 @@ + + + {{ 'core.sort' | translate }} + + + + + + + + + +

+
+
+
+
diff --git a/src/addon/mod/forum/pages/sort-order-selector/sort-order-selector.module.ts b/src/addon/mod/forum/pages/sort-order-selector/sort-order-selector.module.ts new file mode 100644 index 000000000..9a10ae395 --- /dev/null +++ b/src/addon/mod/forum/pages/sort-order-selector/sort-order-selector.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 { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonModForumSortOrderSelectorPage } from './sort-order-selector'; + +@NgModule({ + declarations: [ + AddonModForumSortOrderSelectorPage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + IonicPageModule.forChild(AddonModForumSortOrderSelectorPage), + TranslateModule.forChild() + ], +}) +export class AddonModForumSortOrderSelectorPagePageModule {} diff --git a/src/addon/mod/forum/pages/sort-order-selector/sort-order-selector.ts b/src/addon/mod/forum/pages/sort-order-selector/sort-order-selector.ts new file mode 100644 index 000000000..0d83646a6 --- /dev/null +++ b/src/addon/mod/forum/pages/sort-order-selector/sort-order-selector.ts @@ -0,0 +1,51 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component } from '@angular/core'; +import { IonicPage, NavParams, ViewController } from 'ionic-angular'; + +/** + * Page that displays the sort selector. + */ +@IonicPage({ segment: 'addon-mod-forum-sort-order-selector' }) +@Component({ + selector: 'page-addon-mod-forum-sort-order-selector', + templateUrl: 'sort-order-selector.html', +}) +export class AddonModForumSortOrderSelectorPage { + + sortOrders = []; + selected: number; + + constructor(navParams: NavParams, private viewCtrl: ViewController) { + this.sortOrders = navParams.get('sortOrders'); + this.selected = navParams.get('selected'); + } + + /** + * Close the modal. + */ + closeModal(): void { + this.viewCtrl.dismiss(); + } + + /** + * Select a sort order. + * + * @param {any} sortOrder Selected sort order. + */ + selectSortOrder(sortOrder: any): void { + this.viewCtrl.dismiss(sortOrder); + } +} diff --git a/src/addon/mod/forum/providers/discussion-link-handler.ts b/src/addon/mod/forum/providers/discussion-link-handler.ts index f4962baf0..d1c5399be 100644 --- a/src/addon/mod/forum/providers/discussion-link-handler.ts +++ b/src/addon/mod/forum/providers/discussion-link-handler.ts @@ -38,16 +38,26 @@ export class AddonModForumDiscussionLinkHandler extends CoreContentLinksHandlerB * @param {string} url The URL to treat. * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @param {any} [data] Extra data to handle the URL. * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. */ - getActions(siteIds: string[], url: string, params: any, courseId?: number): + getActions(siteIds: string[], url: string, params: any, courseId?: number, data?: any): CoreContentLinksAction[] | Promise { + data = data || {}; + return [{ action: (siteId, navCtrl?): void => { - const pageParams = { + const pageParams: any = { courseId: courseId || parseInt(params.courseid, 10) || parseInt(params.cid, 10), discussionId: parseInt(params.d, 10), + cmId: data.cmid && parseInt(data.cmid, 10), + forumId: data.instance && parseInt(data.instance, 10) }; + + if (data.postid || params.urlHash) { + pageParams.postId = parseInt(data.postid || params.urlHash.replace('p', '')); + } + this.linkHelper.goInSite(navCtrl, 'AddonModForumDiscussionPage', pageParams, siteId); } }]; diff --git a/src/addon/mod/forum/providers/forum.ts b/src/addon/mod/forum/providers/forum.ts index 83f1db720..3659e11ef 100644 --- a/src/addon/mod/forum/providers/forum.ts +++ b/src/addon/mod/forum/providers/forum.ts @@ -14,6 +14,7 @@ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreAppProvider } from '@providers/app'; import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreGroupsProvider } from '@providers/groups'; @@ -34,8 +35,20 @@ export class AddonModForumProvider { static NEW_DISCUSSION_EVENT = 'addon_mod_forum_new_discussion'; static REPLY_DISCUSSION_EVENT = 'addon_mod_forum_reply_discussion'; static VIEW_DISCUSSION_EVENT = 'addon_mod_forum_view_discussion'; + static CHANGE_DISCUSSION_EVENT = 'addon_mod_forum_lock_discussion'; static MARK_READ_EVENT = 'addon_mod_forum_mark_read'; + static PREFERENCE_SORTORDER = 'forum_discussionlistsortorder'; + static SORTORDER_LASTPOST_DESC = 1; + static SORTORDER_LASTPOST_ASC = 2; + static SORTORDER_CREATED_DESC = 3; + static SORTORDER_CREATED_ASC = 4; + static SORTORDER_REPLIES_DESC = 5; + static SORTORDER_REPLIES_ASC = 6; + + static ALL_PARTICIPANTS = -1; + static ALL_GROUPS = -2; + protected ROOT_CACHE_KEY = 'mmaModForum:'; constructor(private appProvider: CoreAppProvider, @@ -56,7 +69,7 @@ export class AddonModForumProvider { * @return {string} Cache key. */ protected getCanAddDiscussionCacheKey(forumId: number, groupId: number): string { - return this.getCommonCanAddDiscussionCacheKey(forumId) + ':' + groupId; + return this.getCommonCanAddDiscussionCacheKey(forumId) + groupId; } /** @@ -66,7 +79,7 @@ export class AddonModForumProvider { * @return {string} Cache key. */ protected getCommonCanAddDiscussionCacheKey(forumId: number): string { - return this.ROOT_CACHE_KEY + 'canadddiscussion:' + forumId; + return this.ROOT_CACHE_KEY + 'canadddiscussion:' + forumId + ':'; } /** @@ -79,6 +92,16 @@ export class AddonModForumProvider { return this.ROOT_CACHE_KEY + 'forum:' + courseId; } + /** + * Get cache key for forum access information WS calls. + * + * @param {number} forumId Forum ID. + * @return {string} Cache key. + */ + protected getAccessInformationCacheKey(forumId: number): string { + return this.ROOT_CACHE_KEY + 'accessInformation:' + forumId; + } + /** * Get cache key for forum discussion posts WS calls. * @@ -93,66 +116,17 @@ export class AddonModForumProvider { * Get cache key for forum discussions list WS calls. * * @param {number} forumId Forum ID. - * @return {string} Cache key. + * @param {number} sortOrder Sort order. + * @return {string} Cache key. */ - protected getDiscussionsListCacheKey(forumId: number): string { - return this.ROOT_CACHE_KEY + 'discussions:' + forumId; - } + protected getDiscussionsListCacheKey(forumId: number, sortOrder: number): string { + let key = this.ROOT_CACHE_KEY + 'discussions:' + forumId; - /** - * Add a new discussion. - * - * @param {number} forumId Forum ID. - * @param {string} name Forum name. - * @param {number} courseId Course ID the forum belongs to. - * @param {string} subject New discussion's subject. - * @param {string} message New discussion's message. - * @param {any} [options] Options (subscribe, pin, ...). - * @param {string} [groupId] Group this discussion belongs to. - * @param {string} [siteId] Site ID. If not defined, current site. - * @param {number} [timeCreated] The time the discussion was created. Only used when editing discussion. - * @param {boolean} allowOffline True if it can be stored in offline, false otherwise. - * @return {Promise} Promise resolved with discussion ID if sent online, resolved with false if stored offline. - */ - addNewDiscussion(forumId: number, name: string, courseId: number, subject: string, message: string, options?: any, - groupId?: number, siteId?: string, timeCreated?: number, allowOffline?: boolean): Promise { - siteId = siteId || this.sitesProvider.getCurrentSiteId(); - - // Convenience function to store a message to be synchronized later. - const storeOffline = (): Promise => { - return this.forumOffline.addNewDiscussion(forumId, name, courseId, subject, message, options, - groupId, timeCreated, siteId).then(() => { - return false; - }); - }; - - // If we are editing an offline discussion, discard previous first. - let discardPromise; - if (timeCreated) { - discardPromise = this.forumOffline.deleteNewDiscussion(forumId, timeCreated, siteId); - } else { - discardPromise = Promise.resolve(); + if (sortOrder != AddonModForumProvider.SORTORDER_LASTPOST_DESC) { + key += ':' + sortOrder; } - return discardPromise.then(() => { - if (!this.appProvider.isOnline() && allowOffline) { - // App is offline, store the action. - return storeOffline(); - } - - return this.addNewDiscussionOnline(forumId, subject, message, options, groupId, siteId).then((id) => { - // Success, return the discussion ID. - return id; - }).catch((error) => { - if (!allowOffline || this.utils.isWebServiceError(error)) { - // The WebService has thrown an error or offline not supported, reject. - return Promise.reject(error); - } - - // Couldn't connect to server, store in offline. - return storeOffline(); - }); - }); + return key; } /** @@ -241,7 +215,7 @@ export class AddonModForumProvider { * - cancreateattachment (boolean) */ canAddDiscussionToAll(forumId: number): Promise { - return this.canAddDiscussion(forumId, -1); + return this.canAddDiscussion(forumId, AddonModForumProvider.ALL_PARTICIPANTS); } /** @@ -282,6 +256,7 @@ export class AddonModForumProvider { return this.groupsProvider.getActivityAllowedGroups(cmId).then((forumGroups) => { const strAllParts = this.translate.instant('core.allparticipants'); + const strAllGroups = this.translate.instant('core.allgroups'); // Turn groups into an object where each group is identified by id. const groups = {}; @@ -291,8 +266,11 @@ export class AddonModForumProvider { // Format discussions. discussions.forEach((disc) => { - if (disc.groupid === -1) { + if (disc.groupid == AddonModForumProvider.ALL_PARTICIPANTS) { disc.groupname = strAllParts; + } else if (disc.groupid == AddonModForumProvider.ALL_GROUPS) { + // Offline discussions only. + disc.groupname = strAllGroups; } else { const group = groups[disc.groupid]; if (group) { @@ -320,7 +298,8 @@ export class AddonModForumProvider { courseids: [courseId] }; const preSets = { - cacheKey: this.getForumDataCacheKey(courseId) + cacheKey: this.getForumDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('mod_forum_get_forums_by_courses', params, preSets); @@ -365,6 +344,34 @@ export class AddonModForumProvider { }); } + /** + * Get access information for a given forum. + * + * @param {number} forumId Forum ID. + * @param {boolean} [forceCache] True to always get the value from cache. false otherwise. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Object with access information. + * @since 3.7 + */ + getAccessInformation(forumId: number, forceCache?: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + if (!site.wsAvailable('mod_forum_get_forum_access_information')) { + // Access information not available for 3.6 or older sites. + return Promise.resolve({}); + } + + const params = { + forumid: forumId + }; + const preSets = { + cacheKey: this.getAccessInformationCacheKey(forumId), + omitExpires: forceCache + }; + + return site.read('mod_forum_get_forum_access_information', params, preSets); + }); + } + /** * Get forum discussion posts. * @@ -412,10 +419,64 @@ export class AddonModForumProvider { }); } + /** + * Return whether discussion lists can be sorted. + * + * @param {CoreSite} [site] Site. If not defined, current site. + * @return {boolean} True if discussion lists can be sorted. + */ + isDiscussionListSortingAvailable(site?: CoreSite): boolean { + site = site || this.sitesProvider.getCurrentSite(); + + return site.isVersionGreaterEqualThan('3.7'); + } + + /** + * Return the list of available sort orders. + * + * @return {{label: string, value: number}[]} List of sort orders. + */ + getAvailableSortOrders(): {label: string, value: number}[] { + const sortOrders = [ + { + label: 'addon.mod_forum.discussionlistsortbylastpostdesc', + value: AddonModForumProvider.SORTORDER_LASTPOST_DESC + }, + ]; + + if (this.isDiscussionListSortingAvailable()) { + sortOrders.push( + { + label: 'addon.mod_forum.discussionlistsortbylastpostasc', + value: AddonModForumProvider.SORTORDER_LASTPOST_ASC + }, + { + label: 'addon.mod_forum.discussionlistsortbycreateddesc', + value: AddonModForumProvider.SORTORDER_CREATED_DESC + }, + { + label: 'addon.mod_forum.discussionlistsortbycreatedasc', + value: AddonModForumProvider.SORTORDER_CREATED_ASC + }, + { + label: 'addon.mod_forum.discussionlistsortbyrepliesdesc', + value: AddonModForumProvider.SORTORDER_REPLIES_DESC + }, + { + label: 'addon.mod_forum.discussionlistsortbyrepliesasc', + value: AddonModForumProvider.SORTORDER_REPLIES_ASC + } + ); + } + + return sortOrders; + } + /** * Get forum discussions. * * @param {number} forumId Forum ID. + * @param {number} [sortOrder] Sort order. * @param {number} [page=0] Page. * @param {boolean} [forceCache] True to always get the value from cache. false otherwise. * @param {string} [siteId] Site ID. If not defined, current site. @@ -423,23 +484,59 @@ export class AddonModForumProvider { * - discussions: List of discussions. * - canLoadMore: True if there may be more discussions to load. */ - getDiscussions(forumId: number, page: number = 0, forceCache?: boolean, siteId?: string): Promise { + getDiscussions(forumId: number, sortOrder?: number, page: number = 0, forceCache?: boolean, siteId?: string): Promise { + sortOrder = sortOrder || AddonModForumProvider.SORTORDER_LASTPOST_DESC; + return this.sitesProvider.getSite(siteId).then((site) => { - const params = { + let method = 'mod_forum_get_forum_discussions_paginated'; + const params: any = { forumid: forumId, - sortby: 'timemodified', - sortdirection: 'DESC', page: page, perpage: AddonModForumProvider.DISCUSSIONS_PER_PAGE }; - const preSets: any = { - cacheKey: this.getDiscussionsListCacheKey(forumId) + + if (site.wsAvailable('mod_forum_get_forum_discussions')) { + // Since Moodle 3.7. + method = 'mod_forum_get_forum_discussions'; + params.sortorder = sortOrder; + } else { + if (sortOrder == AddonModForumProvider.SORTORDER_LASTPOST_DESC) { + params.sortby = 'timemodified'; + params.sortdirection = 'DESC'; + } else { + // Sorting not supported with the old WS method. + return Promise.reject(null); + } + } + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getDiscussionsListCacheKey(forumId, sortOrder) }; if (forceCache) { preSets.omitExpires = true; } - return site.read('mod_forum_get_forum_discussions_paginated', params, preSets).then((response) => { + return site.read(method, params, preSets).catch((error) => { + // Try to get the data from cache stored with the old WS method. + if (!this.appProvider.isOnline() && method == 'mod_forum_get_forum_discussion' && + sortOrder == AddonModForumProvider.SORTORDER_LASTPOST_DESC) { + + const params = { + forumid: forumId, + page: page, + perpage: AddonModForumProvider.DISCUSSIONS_PER_PAGE, + sortby: 'timemodified', + sortdirection: 'DESC' + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getDiscussionsListCacheKey(forumId, sortOrder), + omitExpires: true + }; + + return site.read('mod_forum_get_forum_discussions_paginated', params, preSets); + } + + return Promise.reject(error); + }).then((response) => { if (response) { this.storeUserData(response.discussions); @@ -459,7 +556,8 @@ export class AddonModForumProvider { * If a page fails, the discussions until that page will be returned along with a flag indicating an error occurred. * * @param {number} forumId Forum ID. - * @param {boolean} forceCache True to always get the value from cache, false otherwise. + * @param {number} [sortOrder] Sort order. + * @param {boolean} [forceCache] True to always get the value from cache, false otherwise. * @param {number} [numPages] Number of pages to get. If not defined, all pages. * @param {number} [startPage] Page to start. If not defined, first page. * @param {string} [siteId] Site ID. If not defined, current site. @@ -467,8 +565,8 @@ export class AddonModForumProvider { * - discussions: List of discussions. * - error: True if an error occurred, false otherwise. */ - getDiscussionsInPages(forumId: number, forceCache?: boolean, numPages?: number, startPage?: number, siteId?: string) - : Promise { + getDiscussionsInPages(forumId: number, sortOrder?: number, forceCache?: boolean, numPages?: number, startPage?: number, + siteId?: string): Promise { if (typeof numPages == 'undefined') { numPages = -1; } @@ -485,7 +583,7 @@ export class AddonModForumProvider { const getPage = (page: number): Promise => { // Get page discussions. - return this.getDiscussions(forumId, page, forceCache, siteId).then((response) => { + return this.getDiscussions(forumId, sortOrder, page, forceCache, siteId).then((response) => { result.discussions = result.discussions.concat(response.discussions); numPages--; @@ -529,21 +627,45 @@ export class AddonModForumProvider { invalidateContent(moduleId: number, courseId: number): Promise { // Get the forum first, we need the forum ID. return this.getForum(courseId, moduleId).then((forum) => { - // We need to get the list of discussions to be able to invalidate their posts. - return this.getDiscussionsInPages(forum.id, true).then((response) => { - // Now invalidate the WS calls. - const promises = []; + const promises = []; - promises.push(this.invalidateForumData(courseId)); - promises.push(this.invalidateDiscussionsList(forum.id)); - promises.push(this.invalidateCanAddDiscussion(forum.id)); + promises.push(this.invalidateForumData(courseId)); + promises.push(this.invalidateDiscussionsList(forum.id)); + promises.push(this.invalidateCanAddDiscussion(forum.id)); + promises.push(this.invalidateAccessInformation(forum.id)); - response.discussions.forEach((discussion) => { - promises.push(this.invalidateDiscussionPosts(discussion.discussion)); - }); + this.getAvailableSortOrders().forEach((sortOrder) => { + // We need to get the list of discussions to be able to invalidate their posts. + promises.push(this.getDiscussionsInPages(forum.id, sortOrder.value, true).then((response) => { + // Now invalidate the WS calls. + const promises = []; - return this.utils.allPromises(promises); + response.discussions.forEach((discussion) => { + promises.push(this.invalidateDiscussionPosts(discussion.discussion)); + }); + + return this.utils.allPromises(promises); + })); }); + + if (this.isDiscussionListSortingAvailable()) { + promises.push(this.userProvider.invalidateUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER)); + } + + return this.utils.allPromises(promises); + }); + } + + /** + * Invalidates access information. + * + * @param {number} forumId Forum ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateAccessInformation(forumId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getAccessInformationCacheKey(forumId)); }); } @@ -569,7 +691,9 @@ export class AddonModForumProvider { */ invalidateDiscussionsList(forumId: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { - return site.invalidateWsCacheForKey(this.getDiscussionsListCacheKey(forumId)); + return this.utils.allPromises(this.getAvailableSortOrders().map((sortOrder) => { + return site.invalidateWsCacheForKey(this.getDiscussionsListCacheKey(forumId, sortOrder.value)); + })); }); } @@ -599,15 +723,17 @@ export class AddonModForumProvider { * Report a forum as being viewed. * * @param {number} id Module ID. + * @param {string} [name] Name of the forum. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logView(id: number, siteId?: string): Promise { + logView(id: number, name?: string, siteId?: string): Promise { const params = { forumid: id }; - return this.logHelper.log('mod_forum_view_forum', params, AddonModForumProvider.COMPONENT, id, siteId); + return this.logHelper.logSingle('mod_forum_view_forum', params, AddonModForumProvider.COMPONENT, id, name, 'forum', {}, + siteId); } /** @@ -615,15 +741,17 @@ export class AddonModForumProvider { * * @param {number} id Discussion ID. * @param {number} forumId Forum ID. + * @param {string} [name] Name of the forum. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logDiscussionView(id: number, forumId: number, siteId?: string): Promise { + logDiscussionView(id: number, forumId: number, name?: string, siteId?: string): Promise { const params = { discussionid: id }; - return this.logHelper.log('mod_forum_view_forum_discussion', params, AddonModForumProvider.COMPONENT, forumId, siteId); + return this.logHelper.logSingle('mod_forum_view_forum_discussion', params, AddonModForumProvider.COMPONENT, forumId, name, + 'forum', params, siteId); } /** @@ -709,6 +837,81 @@ export class AddonModForumProvider { }); } + /** + * Lock or unlock a discussion. + * + * @param {number} forumId Forum id. + * @param {number} discussionId DIscussion id. + * @param {boolean} locked True to lock, false to unlock. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resvoled when done. + * @since 3.7 + */ + setLockState(forumId: number, discussionId: number, locked: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + forumid: forumId, + discussionid: discussionId, + targetstate: locked ? 0 : 1 + }; + + return site.write('mod_forum_set_lock_state', params); + }); + } + + /** + * Returns whether the set pin state WS is available. + * + * @param {CoreSite} [site] Site. If not defined, current site. + * @return {boolean} Whether it's available. + * @since 3.7 + */ + isSetPinStateAvailableForSite(site?: CoreSite): boolean { + site = site || this.sitesProvider.getCurrentSite(); + + return this.sitesProvider.wsAvailableInCurrentSite('mod_forum_set_pin_state'); + } + + /** + * Pin or unpin a discussion. + * + * @param {number} discussionId Discussion id. + * @param {boolean} locked True to pin, false to unpin. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resvoled when done. + * @since 3.7 + */ + setPinState(discussionId: number, pinned: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + discussionid: discussionId, + targetstate: pinned ? 1 : 0 + }; + + return site.write('mod_forum_set_pin_state', params); + }); + } + + /** + * Star or unstar a discussion. + * + * @param {number} discussionId Discussion id. + * @param {boolean} starred True to star, false to unstar. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resvoled when done. + * @since 3.7 + */ + toggleFavouriteState(discussionId: number, starred: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + discussionid: discussionId, + targetstate: starred ? 1 : 0 + }; + + return site.write('mod_forum_toggle_favourite_state', params); + }); + } + /** * Store the users data from a discussions/posts list. * diff --git a/src/addon/mod/forum/providers/helper.ts b/src/addon/mod/forum/providers/helper.ts index e039cdf7b..fb46fab35 100644 --- a/src/addon/mod/forum/providers/helper.ts +++ b/src/addon/mod/forum/providers/helper.ts @@ -13,8 +13,13 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '@providers/app'; import { CoreFileProvider } from '@providers/file'; import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreUserProvider } from '@core/user/providers/user'; import { AddonModForumProvider } from './forum'; import { AddonModForumOfflineProvider } from './offline'; @@ -24,11 +29,129 @@ import { AddonModForumOfflineProvider } from './offline'; */ @Injectable() export class AddonModForumHelperProvider { - constructor(private fileProvider: CoreFileProvider, + constructor(private translate: TranslateService, + private fileProvider: CoreFileProvider, + private sitesProvider: CoreSitesProvider, private uploaderProvider: CoreFileUploaderProvider, + private timeUtils: CoreTimeUtilsProvider, private userProvider: CoreUserProvider, + private appProvider: CoreAppProvider, + private utils: CoreUtilsProvider, + private forumProvider: AddonModForumProvider, private forumOffline: AddonModForumOfflineProvider) {} + /** + * Add a new discussion. + * + * @param {number} forumId Forum ID. + * @param {string} name Forum name. + * @param {number} courseId Course ID the forum belongs to. + * @param {string} subject New discussion's subject. + * @param {string} message New discussion's message. + * @param {any[]} [attachments] New discussion's attachments. + * @param {any} [options] Options (subscribe, pin, ...). + * @param {number[]} [groupIds] Groups this discussion belongs to. + * @param {number} [timeCreated] The time the discussion was created. Only used when editing discussion. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with ids of the created discussions or null if stored offline + */ + addNewDiscussion(forumId: number, name: string, courseId: number, subject: string, message: string, attachments?: any[], + options?: any, groupIds?: number[], timeCreated?: number, siteId?: string): Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + groupIds = groupIds && groupIds.length > 0 ? groupIds : [0]; + + let saveOffline = false; + const attachmentsIds = []; + let offlineAttachments: any; + + // Convenience function to store a message to be synchronized later. + const storeOffline = (): Promise => { + // Multiple groups, the discussion is being posted to all groups. + const groupId = groupIds.length > 1 ? AddonModForumProvider.ALL_GROUPS : groupIds[0]; + + if (offlineAttachments) { + options.attachmentsid = offlineAttachments; + } + + return this.forumOffline.addNewDiscussion(forumId, name, courseId, subject, message, options, + groupId, timeCreated, siteId).then(() => { + return null; + }); + }; + + // First try to upload attachments, once per group. + let promise; + if (attachments && attachments.length > 0) { + const promises = groupIds.map(() => { + return this.uploadOrStoreNewDiscussionFiles(forumId, timeCreated, attachments, false).then((attach) => { + attachmentsIds.push(attach); + }); + }); + + promise = Promise.all(promises).catch(() => { + // Cannot upload them in online, save them in offline. + saveOffline = true; + + return this.uploadOrStoreNewDiscussionFiles(forumId, timeCreated, attachments, true).then((attach) => { + offlineAttachments = attach; + }); + }); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + // If we are editing an offline discussion, discard previous first. + let discardPromise; + if (timeCreated) { + discardPromise = this.forumOffline.deleteNewDiscussion(forumId, timeCreated, siteId); + } else { + discardPromise = Promise.resolve(); + } + + return discardPromise.then(() => { + if (saveOffline || !this.appProvider.isOnline()) { + return storeOffline(); + } + + const errors = []; + const discussionIds = []; + + const promises = groupIds.map((groupId, index) => { + const grouOptions = this.utils.clone(options); + if (attachmentsIds[index]) { + grouOptions.attachmentsid = attachmentsIds[index]; + } + + return this.forumProvider.addNewDiscussionOnline(forumId, subject, message, grouOptions, groupId, siteId) + .then((discussionId) => { + discussionIds.push(discussionId); + }).catch((error) => { + errors.push(error); + }); + }); + + return Promise.all(promises).then(() => { + if (errors.length == groupIds.length) { + // All requests have failed. + for (let i = 0; i < errors.length; i++) { + if (this.utils.isWebServiceError(errors[i]) || attachments.length > 0) { + // The WebService has thrown an error or offline not supported, reject. + return Promise.reject(errors[i]); + } + } + + // Couldn't connect to server, store offline. + return storeOffline(); + } + + return discussionIds; + }); + }); + }); + } + /** * Convert offline reply to online format in order to be compatible with them. * @@ -54,7 +177,8 @@ export class AddonModForumHelperProvider { postread: false, subject: offlineReply.subject, totalscore: 0, - userid: offlineReply.userid + userid: offlineReply.userid, + isprivatereply: offlineReply.options && offlineReply.options.private }, promises = []; @@ -118,6 +242,60 @@ export class AddonModForumHelperProvider { }); } + /** + * Returns the availability message of the given forum. + * + * @param {any} forum Forum instance. + * @return {string} Message or null if the forum has no cut-off or due date. + */ + getAvailabilityMessage(forum: any): string { + if (this.isCutoffDateReached(forum)) { + return this.translate.instant('addon.mod_forum.cutoffdatereached'); + } else if (this.isDueDateReached(forum)) { + const dueDate = this.timeUtils.userDate(forum.duedate * 1000); + + return this.translate.instant('addon.mod_forum.thisforumisdue', {$a: dueDate}); + } else if (forum.duedate > 0) { + const dueDate = this.timeUtils.userDate(forum.duedate * 1000); + + return this.translate.instant('addon.mod_forum.thisforumhasduedate', {$a: dueDate}); + } else { + return null; + } + } + + /** + * Get a forum discussion by id. + * + * This function is inefficient because it needs to fetch all discussion pages in the worst case. + * + * @param {number} forumId Forum ID. + * @param {number} discussionId Discussion ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the discussion data. + */ + getDiscussionById(forumId: number, discussionId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const findDiscussion = (page: number): Promise => { + return this.forumProvider.getDiscussions(forumId, undefined, page, false, siteId).then((response) => { + if (response.discussions && response.discussions.length > 0) { + const discussion = response.discussions.find((discussion) => discussion.id == discussionId); + if (discussion) { + return discussion; + } + if (response.canLoadMore) { + return findDiscussion(page + 1); + } + } + + return Promise.reject(null); + }); + }; + + return findDiscussion(0); + } + /** * Get a list of stored attachment files for a new discussion. See AddonModForumHelper#storeNewDiscussionFiles. * @@ -164,9 +342,37 @@ export class AddonModForumHelperProvider { return true; } + if (post.isprivatereply != original.isprivatereply) { + return true; + } + return this.uploaderProvider.areFileListDifferent(post.files, original.files); } + /** + * Is the cutoff date for the forum reached? + * + * @param {any} forum Forum instance. + * @return {boolean} + */ + isCutoffDateReached(forum: any): boolean { + const now = Date.now() / 1000; + + return forum.cutoffdate > 0 && forum.cutoffdate < now; + } + + /** + * Is the due date for the forum reached? + * + * @param {any} forum Forum instance. + * @return {boolean} + */ + isDueDateReached(forum: any): boolean { + const now = Date.now() / 1000; + + return forum.duedate > 0 && forum.duedate < now; + } + /** * Given a list of files (either online files or local files), store the local files in a local folder * to be submitted later. diff --git a/src/addon/mod/forum/providers/offline.ts b/src/addon/mod/forum/providers/offline.ts index a5223f786..af6a08b11 100644 --- a/src/addon/mod/forum/providers/offline.ts +++ b/src/addon/mod/forum/providers/offline.ts @@ -16,6 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreFileProvider } from '@providers/file'; import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { AddonModForumProvider } from './forum'; /** * Service to handle offline forum. @@ -248,7 +249,7 @@ export class AddonModForumOfflineProvider { subject: subject, message: message, options: JSON.stringify(options || {}), - groupid: groupId || -1, + groupid: groupId || AddonModForumProvider.ALL_PARTICIPANTS, userid: userId || site.getUserId(), timecreated: timeCreated || new Date().getTime() }; diff --git a/src/addon/mod/forum/providers/prefetch-handler.ts b/src/addon/mod/forum/providers/prefetch-handler.ts index 03e60a6ba..6241dce19 100644 --- a/src/addon/mod/forum/providers/prefetch-handler.ts +++ b/src/addon/mod/forum/providers/prefetch-handler.ts @@ -20,11 +20,11 @@ import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreUserProvider } from '@core/user/providers/user'; import { CoreCourseActivityPrefetchHandlerBase } from '@core/course/classes/activity-prefetch-handler'; import { CoreGroupsProvider } from '@providers/groups'; -import { CoreUserProvider } from '@core/user/providers/user'; import { AddonModForumProvider } from './forum'; -import { CoreRatingProvider } from '@core/rating/providers/rating'; +import { AddonModForumSyncProvider } from './sync'; /** * Handler to prefetch forums. @@ -43,10 +43,10 @@ export class AddonModForumPrefetchHandler extends CoreCourseActivityPrefetchHand filepoolProvider: CoreFilepoolProvider, sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, - private groupsProvider: CoreGroupsProvider, private userProvider: CoreUserProvider, + private groupsProvider: CoreGroupsProvider, private forumProvider: AddonModForumProvider, - private ratingProvider: CoreRatingProvider) { + private syncProvider: AddonModForumSyncProvider) { super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils); } @@ -105,27 +105,40 @@ export class AddonModForumPrefetchHandler extends CoreCourseActivityPrefetchHand * @return {Promise} Promise resolved with array of posts. */ protected getPostsForPrefetch(forum: any): Promise { - // Get discussions in first 2 pages. - return this.forumProvider.getDiscussionsInPages(forum.id, false, 2).then((response) => { - if (response.error) { - return Promise.reject(null); - } + const promises = this.forumProvider.getAvailableSortOrders().map((sortOrder) => { + // Get discussions in first 2 pages. + return this.forumProvider.getDiscussionsInPages(forum.id, sortOrder.value, false, 2).then((response) => { + if (response.error) { + return Promise.reject(null); + } - const promises = []; - let posts = []; + const promises = []; - response.discussions.forEach((discussion) => { - promises.push(this.forumProvider.getDiscussionPosts(discussion.discussion).then((response) => { - posts = posts.concat(response.posts); + response.discussions.forEach((discussion) => { + promises.push(this.forumProvider.getDiscussionPosts(discussion.discussion)); + }); - return this.ratingProvider.prefetchRatings('module', forum.cmid, forum.scale, forum.course, - response.ratinginfo); - })); + return Promise.all(promises); + }); + }); + + return Promise.all(promises).then((results) => { + // Each order has returned its own list of posts. Merge all the lists, preventing duplicates. + const posts = [], + postIds = {}; // To make the array unique. + + results.forEach((orderResults) => { + orderResults.forEach((orderResult) => { + orderResult.posts.forEach((post) => { + if (!postIds[post.id]) { + postIds[post.id] = true; + posts.push(post); + } + }); + }); }); - return Promise.all(promises).then(() => { - return posts; - }); + return posts; }); } @@ -183,25 +196,43 @@ export class AddonModForumPrefetchHandler extends CoreCourseActivityPrefetchHand protected prefetchForum(module: any, courseId: number, single: boolean, siteId: string): Promise { // Get the forum data. return this.forumProvider.getForum(courseId, module.id).then((forum) => { + const promises = []; + // Prefetch the posts. - return this.getPostsForPrefetch(forum).then((posts) => { + promises.push(this.getPostsForPrefetch(forum).then((posts) => { const promises = []; - // Prefetch user profiles. - const userIds = posts.map((post) => post.userid).filter((userId) => !!userId); - promises.push(this.userProvider.prefetchProfiles(userIds).catch(() => { - // Ignore failures. - })); + // Gather user profile images. + const avatars = {}; // List of user avatars, preventing duplicates. - // Prefetch intro files, attachments and embedded files. - const files = this.getIntroFilesFromInstance(module, forum).concat(this.getPostsFiles(posts)); + posts.forEach((post) => { + if (post.userpictureurl) { + avatars[post.userpictureurl] = true; + } + }); + + // Prefetch intro files, attachments, embedded files and user avatars. + const avatarFiles = Object.keys(avatars).map((url) => { + return { fileurl: url }; + }); + const files = this.getIntroFilesFromInstance(module, forum).concat(this.getPostsFiles(posts)).concat(avatarFiles); promises.push(this.filepoolProvider.addFilesToQueue(siteId, files, this.component, module.id)); // Prefetch groups data. promises.push(this.prefetchGroupsInfo(forum, courseId, forum.cancreatediscussions)); return Promise.all(promises); - }); + })); + + // Prefetch access information. + promises.push(this.forumProvider.getAccessInformation(forum.id)); + + // Prefetch sort order preference. + if (this.forumProvider.isDiscussionListSortingAvailable()) { + promises.push(this.userProvider.getUserPreference(AddonModForumProvider.PREFERENCE_SORTORDER)); + } + + return Promise.all(promises); }); } @@ -264,4 +295,27 @@ export class AddonModForumPrefetchHandler extends CoreCourseActivityPrefetchHand } }); } + + /** + * Sync a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + sync(module: any, courseId: number, siteId?: any): Promise { + const promises = []; + + promises.push(this.syncProvider.syncForumDiscussions(module.instance, undefined, siteId)); + promises.push(this.syncProvider.syncForumReplies(module.instance, undefined, siteId)); + promises.push(this.syncProvider.syncRatings(module.id, undefined, true, siteId)); + + return Promise.all(promises).then((results) => { + return results.reduce((a, b) => ({ + updated: a.updated || b.updated, + warnings: (a.warnings || []).concat(b.warnings || []), + }), {updated: false}); + }); + } } diff --git a/src/addon/mod/forum/providers/push-click-handler.ts b/src/addon/mod/forum/providers/push-click-handler.ts new file mode 100644 index 000000000..01cff72f8 --- /dev/null +++ b/src/addon/mod/forum/providers/push-click-handler.ts @@ -0,0 +1,71 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreUrlUtilsProvider } from '@providers/utils/url'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CorePushNotificationsClickHandler } from '@core/pushnotifications/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { AddonModForumProvider } from './forum'; + +/** + * Handler for forum push notifications clicks. + */ +@Injectable() +export class AddonModForumPushClickHandler implements CorePushNotificationsClickHandler { + name = 'AddonModForumPushClickHandler'; + priority = 200; + featureName = 'CoreCourseModuleDelegate_AddonModForum'; + + constructor(private utils: CoreUtilsProvider, private forumProvider: AddonModForumProvider, + private urlUtils: CoreUrlUtilsProvider, private linkHelper: CoreContentLinksHelperProvider) {} + + /** + * Check if a notification click is handled by this handler. + * + * @param {any} notification The notification to check. + * @return {boolean} Whether the notification click is handled by this handler + */ + handles(notification: any): boolean | Promise { + return this.utils.isTrueOrOne(notification.notif) && notification.moodlecomponent == 'mod_forum' && + notification.name == 'posts'; + } + + /** + * Handle the notification click. + * + * @param {any} notification The notification to check. + * @return {Promise} Promise resolved when done. + */ + handleClick(notification: any): Promise { + const contextUrlParams = this.urlUtils.extractUrlParams(notification.contexturl), + data = notification.customdata || {}, + pageParams: any = { + courseId: Number(notification.courseid), + discussionId: Number(contextUrlParams.d || data.discussionid), + cmId: Number(data.cmid), + forumId: Number(data.instance) + }; + + if (data.postid || contextUrlParams.urlHash) { + pageParams.postId = Number(data.postid || contextUrlParams.urlHash.replace('p', '')); + } + + return this.forumProvider.invalidateDiscussionPosts(pageParams.discussionId, notification.site).catch(() => { + // Ignore errors. + }).then(() => { + return this.linkHelper.goInSite(undefined, 'AddonModForumDiscussionPage', pageParams, notification.site); + }); + } +} diff --git a/src/addon/mod/forum/providers/sync-cron-handler.ts b/src/addon/mod/forum/providers/sync-cron-handler.ts index 860368b25..53a2406e7 100644 --- a/src/addon/mod/forum/providers/sync-cron-handler.ts +++ b/src/addon/mod/forum/providers/sync-cron-handler.ts @@ -30,10 +30,11 @@ export class AddonModForumSyncCronHandler implements CoreCronHandler { * Receives the ID of the site affected, undefined for all sites. * * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). * @return {Promise} Promise resolved when done, rejected if failure. */ - execute(siteId?: string): Promise { - return this.forumSync.syncAllForums(siteId); + execute(siteId?: string, force?: boolean): Promise { + return this.forumSync.syncAllForums(siteId, force); } /** diff --git a/src/addon/mod/forum/providers/sync.ts b/src/addon/mod/forum/providers/sync.ts index 8d63ccfc5..5ad34cbb6 100644 --- a/src/addon/mod/forum/providers/sync.ts +++ b/src/addon/mod/forum/providers/sync.ts @@ -21,6 +21,7 @@ import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploa import { CoreAppProvider } from '@providers/app'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreEventsProvider } from '@providers/events'; +import { CoreGroupsProvider } from '@providers/groups'; import { CoreSitesProvider } from '@providers/sites'; import { CoreSyncProvider } from '@providers/sync'; import { CoreTextUtilsProvider } from '@providers/utils/text'; @@ -46,6 +47,7 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider { appProvider: CoreAppProvider, courseProvider: CoreCourseProvider, private eventsProvider: CoreEventsProvider, + private groupsProvider: CoreGroupsProvider, loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, syncProvider: CoreSyncProvider, @@ -69,19 +71,21 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider { * Try to synchronize all the forums in a certain site or in all sites. * * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} [force] Wether to force sync not depending on last execution. * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - syncAllForums(siteId?: string): Promise { - return this.syncOnSites('all forums', this.syncAllForumsFunc.bind(this), [], siteId); + syncAllForums(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all forums', this.syncAllForumsFunc.bind(this), [force], siteId); } /** * Sync all forums on a site. * - * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {string} siteId Site ID to sync. + * @param {boolean} [force] Wether to force sync not depending on last execution. * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - protected syncAllForumsFunc(siteId?: string): Promise { + protected syncAllForumsFunc(siteId: string, force?: boolean): Promise { const sitePromises = []; // Sync all new discussions. @@ -94,8 +98,10 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider { return; } - promises[discussion.forumid] = this.syncForumDiscussionsIfNeeded(discussion.forumid, discussion.userid, siteId) - .then((result) => { + promises[discussion.forumid] = force ? this.syncForumDiscussions(discussion.forumid, discussion.userid, siteId) : + this.syncForumDiscussionsIfNeeded(discussion.forumid, discussion.userid, siteId); + + promises[discussion.forumid].then((result) => { if (result && result.updated) { // Sync successful, send event. this.eventsProvider.trigger(AddonModForumSyncProvider.AUTO_SYNCED, { @@ -120,8 +126,10 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider { return; } - promises[reply.discussionid] = this.syncDiscussionRepliesIfNeeded(reply.discussionid, reply.userid, siteId) - .then((result) => { + promises[reply.discussionid] = force ? this.syncDiscussionReplies(reply.discussionid, reply.userid, siteId) : + this.syncDiscussionRepliesIfNeeded(reply.discussionid, reply.userid, siteId); + + promises[reply.discussionid].then((result) => { if (result && result.updated) { // Sync successful, send event. this.eventsProvider.trigger(AddonModForumSyncProvider.AUTO_SYNCED, { @@ -137,7 +145,7 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider { return Promise.all(this.utils.objectToArray(promises)); })); - sitePromises.push(this.syncRatings(undefined, undefined, siteId)); + sitePromises.push(this.syncRatings(undefined, undefined, force, siteId)); return Promise.all(sitePromises); } @@ -216,38 +224,57 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider { const promises = []; discussions.forEach((data) => { - data.options = data.options || {}; + let groupsPromise; + if (data.groupid == AddonModForumProvider.ALL_GROUPS) { + // Fetch all group ids. + groupsPromise = this.forumProvider.getForumById(data.courseid, data.forumid, siteId).then((forum) => { + return this.groupsProvider.getActivityAllowedGroups(forum.cmid).then((groups) => { + return groups.map((group) => group.id); + }); + }); + } else { + groupsPromise = Promise.resolve([data.groupid]); + } - // First of all upload the attachments (if any). - const promise = this.uploadAttachments(forumId, data, true, siteId, userId).then((itemId) => { - // Now try to add the discussion. - data.options.attachmentsid = itemId; + promises.push(groupsPromise.then((groupIds) => { + const errors = []; - return this.forumProvider.addNewDiscussionOnline(forumId, data.subject, data.message, - data.options, data.groupid, siteId); - }); + return Promise.all(groupIds.map((groupId) => { + // First of all upload the attachments (if any). + return this.uploadAttachments(forumId, data, true, siteId, userId).then((itemId) => { + // Now try to add the discussion. + const options = this.utils.clone(data.options || {}); + options.attachmentsid = itemId; - promises.push(promise.then(() => { - result.updated = true; + return this.forumProvider.addNewDiscussionOnline(forumId, data.subject, data.message, options, + groupId, siteId); + }).catch((error) => { + errors.push(error); + }); + })).then(() => { + if (errors.length == groupIds.length) { + // All requests have failed, reject if errors were not returned by WS. + for (let i = 0; i < errors.length; i++) { + if (!this.utils.isWebServiceError(errors[i])) { + return Promise.reject(errors[i]); + } + } + } - return this.deleteNewDiscussion(forumId, data.timecreated, siteId, userId); - }).catch((error) => { - if (this.utils.isWebServiceError(error)) { - // The WebService has thrown an error, this means that responses cannot be submitted. Delete them. + // All requests succeeded, some failed or all failed with a WS error. result.updated = true; return this.deleteNewDiscussion(forumId, data.timecreated, siteId, userId).then(() => { - // Responses deleted, add a warning. - result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { - component: this.componentTranslate, - name: data.name, - error: this.textUtils.getErrorMessageFromError(error) - })); + if (errors.length == groupIds.length) { + // All requests failed with WS error. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: data.name, + error: this.textUtils.getErrorMessageFromError(errors[0]) + })); + } }); - } else { - // Couldn't connect to server, reject. - return Promise.reject(error); - } + }); })); }); @@ -282,13 +309,14 @@ export class AddonModForumSyncProvider extends CoreSyncBaseProvider { * * @param {number} [cmId] Course module to be synced. If not defined, sync all forums. * @param {number} [discussionId] Discussion id to be synced. If not defined, sync all discussions. + * @param {boolean} [force] Wether to force sync not depending on last execution. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved if sync is successful, rejected otherwise. */ - syncRatings(cmId?: number, discussionId?: number, siteId?: string): Promise { + syncRatings(cmId?: number, discussionId?: number, force?: boolean, siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); - return this.ratingSync.syncRatings('mod_forum', 'post', 'module', cmId, discussionId, siteId).then((results) => { + return this.ratingSync.syncRatings('mod_forum', 'post', 'module', cmId, discussionId, force, siteId).then((results) => { let updated = false; const warnings = []; const promises = []; diff --git a/src/addon/mod/glossary/components/index/addon-mod-glossary-index.html b/src/addon/mod/glossary/components/index/addon-mod-glossary-index.html index b5617f259..4695adab9 100644 --- a/src/addon/mod/glossary/components/index/addon-mod-glossary-index.html +++ b/src/addon/mod/glossary/components/index/addon-mod-glossary-index.html @@ -10,7 +10,7 @@ - + diff --git a/src/addon/mod/glossary/components/index/index.ts b/src/addon/mod/glossary/components/index/index.ts index e05699f71..558eac531 100644 --- a/src/addon/mod/glossary/components/index/index.ts +++ b/src/addon/mod/glossary/components/index/index.ts @@ -23,6 +23,7 @@ import { AddonModGlossaryProvider } from '../../providers/glossary'; import { AddonModGlossaryOfflineProvider } from '../../providers/offline'; import { AddonModGlossarySyncProvider } from '../../providers/sync'; import { AddonModGlossaryModePickerPopoverComponent } from '../mode-picker/mode-picker'; +import { AddonModGlossaryPrefetchHandler } from '../../providers/prefetch-handler'; type FetchMode = 'author_all' | 'cat_all' | 'newest_first' | 'recently_updated' | 'search' | 'letter_all'; @@ -68,7 +69,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity private popoverCtrl: PopoverController, private glossaryProvider: AddonModGlossaryProvider, private glossaryOffline: AddonModGlossaryOfflineProvider, - private glossarySync: AddonModGlossarySyncProvider, + private prefetchHandler: AddonModGlossaryPrefetchHandler, private ratingOffline: CoreRatingOfflineProvider) { super(injector); } @@ -108,7 +109,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity } } - this.glossaryProvider.logView(this.glossary.id, this.viewMode).then(() => { + this.glossaryProvider.logView(this.glossary.id, this.viewMode, this.glossary.name).then(() => { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); }).catch((error) => { // Ignore errors. @@ -219,17 +220,7 @@ export class AddonModGlossaryIndexComponent extends CoreCourseModuleMainActivity * @return {Promise} Promise resolved when done. */ protected sync(): Promise { - const promises = [ - this.glossarySync.syncGlossaryEntries(this.glossary.id), - this.glossarySync.syncRatings(this.glossary.coursemodule) - ]; - - return Promise.all(promises).then((results) => { - return results.reduce((a, b) => ({ - updated: a.updated || b.updated, - warnings: (a.warnings || []).concat(b.warnings || []), - }), {updated: false}); - }); + return this.prefetchHandler.sync(this.module, this.courseId); } /** diff --git a/src/addon/mod/glossary/pages/edit/edit.ts b/src/addon/mod/glossary/pages/edit/edit.ts index 8520727b6..837b0e760 100644 --- a/src/addon/mod/glossary/pages/edit/edit.ts +++ b/src/addon/mod/glossary/pages/edit/edit.ts @@ -88,6 +88,7 @@ export class AddonModGlossaryEditPage implements OnInit { if (entry) { this.entry.concept = entry.concept || ''; this.entry.definition = entry.definition || ''; + this.entry.timecreated = entry.timecreated || 0; this.originalData = { concept: this.entry.concept, diff --git a/src/addon/mod/glossary/pages/entry/entry.ts b/src/addon/mod/glossary/pages/entry/entry.ts index 03da1c30d..7bbf4f0bf 100644 --- a/src/addon/mod/glossary/pages/entry/entry.ts +++ b/src/addon/mod/glossary/pages/entry/entry.ts @@ -51,7 +51,7 @@ export class AddonModGlossaryEntryPage { */ ionViewDidLoad(): void { this.fetchEntry().then(() => { - this.glossaryProvider.logEntryView(this.entry.id, this.componentId).catch(() => { + this.glossaryProvider.logEntryView(this.entry.id, this.componentId, this.glossary.name).catch(() => { // Ignore errors. }); }).finally(() => { diff --git a/src/addon/mod/glossary/providers/glossary.ts b/src/addon/mod/glossary/providers/glossary.ts index a4014f0d0..bd6862d68 100644 --- a/src/addon/mod/glossary/providers/glossary.ts +++ b/src/addon/mod/glossary/providers/glossary.ts @@ -71,7 +71,8 @@ export class AddonModGlossaryProvider { courseids: [courseId] }; const preSets = { - cacheKey: this.getCourseGlossariesCacheKey(courseId) + cacheKey: this.getCourseGlossariesCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('mod_glossary_get_glossaries_by_courses', params, preSets).then((result) => { @@ -134,7 +135,8 @@ export class AddonModGlossaryProvider { }; const preSets = { cacheKey: this.getEntriesByAuthorCacheKey(glossaryId, letter, field, sort), - omitExpires: forceCache + omitExpires: forceCache, + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; return site.read('mod_glossary_get_entries_by_author', params, preSets); @@ -182,7 +184,8 @@ export class AddonModGlossaryProvider { }; const preSets = { cacheKey: this.getEntriesByCategoryCacheKey(glossaryId, categoryId), - omitExpires: forceCache + omitExpires: forceCache, + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; return site.read('mod_glossary_get_entries_by_category', params, preSets); @@ -254,7 +257,8 @@ export class AddonModGlossaryProvider { }; const preSets = { cacheKey: this.getEntriesByDateCacheKey(glossaryId, order, sort), - omitExpires: forceCache + omitExpires: forceCache, + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; return site.read('mod_glossary_get_entries_by_date', params, preSets); @@ -311,7 +315,8 @@ export class AddonModGlossaryProvider { }; const preSets = { cacheKey: this.getEntriesByLetterCacheKey(glossaryId, letter), - omitExpires: forceCache + omitExpires: forceCache, + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; return site.read('mod_glossary_get_entries_by_letter', params, preSets); @@ -378,6 +383,7 @@ export class AddonModGlossaryProvider { const preSets = { cacheKey: this.getEntriesBySearchCacheKey(glossaryId, query, fullSearch, order, sort), omitExpires: forceCache, + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; return site.read('mod_glossary_get_entries_by_search', params, preSets); @@ -444,7 +450,8 @@ export class AddonModGlossaryProvider { limit: limit }; const preSets = { - cacheKey: this.getCategoriesCacheKey(glossaryId) + cacheKey: this.getCategoriesCacheKey(glossaryId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; return site.read('mod_glossary_get_categories', params, preSets).then((response) => { @@ -496,7 +503,8 @@ export class AddonModGlossaryProvider { id: entryId }; const preSets = { - cacheKey: this.getEntryCacheKey(entryId) + cacheKey: this.getEntryCacheKey(entryId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('mod_glossary_get_entry_by_id', params, preSets).then((response) => { @@ -867,16 +875,18 @@ export class AddonModGlossaryProvider { * * @param {number} glossaryId Glossary ID. * @param {string} mode The mode in which the glossary was viewed. + * @param {string} [name] Name of the glossary. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logView(glossaryId: number, mode: string, siteId?: string): Promise { + logView(glossaryId: number, mode: string, name?: string, siteId?: string): Promise { const params = { id: glossaryId, mode: mode }; - return this.logHelper.log('mod_glossary_view_glossary', params, AddonModGlossaryProvider.COMPONENT, glossaryId, siteId); + return this.logHelper.logSingle('mod_glossary_view_glossary', params, AddonModGlossaryProvider.COMPONENT, glossaryId, name, + 'glossary', {mode: mode}, siteId); } /** @@ -884,14 +894,16 @@ export class AddonModGlossaryProvider { * * @param {number} entryId Entry ID. * @param {number} glossaryId Glossary ID. + * @param {string} [name] Name of the glossary. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logEntryView(entryId: number, glossaryId: number, siteId?: string): Promise { + logEntryView(entryId: number, glossaryId: number, name?: string, siteId?: string): Promise { const params = { id: entryId }; - return this.logHelper.log('mod_glossary_view_entry', params, AddonModGlossaryProvider.COMPONENT, glossaryId, siteId); + return this.logHelper.logSingle('mod_glossary_view_entry', params, AddonModGlossaryProvider.COMPONENT, glossaryId, name, + 'glossary', {entryid: entryId}, siteId); } } diff --git a/src/addon/mod/glossary/providers/prefetch-handler.ts b/src/addon/mod/glossary/providers/prefetch-handler.ts index 024fece96..ba5be62f3 100644 --- a/src/addon/mod/glossary/providers/prefetch-handler.ts +++ b/src/addon/mod/glossary/providers/prefetch-handler.ts @@ -21,9 +21,8 @@ import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseActivityPrefetchHandlerBase } from '@core/course/classes/activity-prefetch-handler'; -import { CoreUserProvider } from '@core/user/providers/user'; import { AddonModGlossaryProvider } from './glossary'; -import { CoreRatingProvider } from '@core/rating/providers/rating'; +import { AddonModGlossarySyncProvider } from './sync'; /** * Handler to prefetch forums. @@ -42,9 +41,8 @@ export class AddonModGlossaryPrefetchHandler extends CoreCourseActivityPrefetchH filepoolProvider: CoreFilepoolProvider, sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, - private userProvider: CoreUserProvider, - private ratingProvider: CoreRatingProvider, - private glossaryProvider: AddonModGlossaryProvider) { + private glossaryProvider: AddonModGlossaryProvider, + private syncProvider: AddonModGlossarySyncProvider) { super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils); } @@ -161,24 +159,22 @@ export class AddonModGlossaryPrefetchHandler extends CoreCourseActivityPrefetchH promises.push(this.glossaryProvider.fetchAllEntries(this.glossaryProvider.getEntriesByLetter, [glossary.id, 'ALL'], false, siteId).then((entries) => { const promises = []; - const userIds = []; + const avatars = {}; // List of user avatars, preventing duplicates. - // Fetch user avatars. entries.forEach((entry) => { // Fetch individual entries. - promises.push(this.glossaryProvider.getEntry(entry.id, siteId).then((entry) => { - // Fetch individual ratings. - return this.ratingProvider.prefetchRatings('module', module.id, glossary.scale, courseId, entry.ratinginfo, - siteId); - })); + promises.push(this.glossaryProvider.getEntry(entry.id, siteId)); - userIds.push(entry.userid); + if (entry.userpictureurl) { + avatars[entry.userpictureurl] = true; + } }); - // Prefetch user profiles. - promises.push(this.userProvider.prefetchProfiles(userIds, courseId, siteId)); - - const files = this.getFilesFromGlossaryAndEntries(module, glossary, entries); + // Prefetch intro files, entries files and user avatars. + const avatarFiles = Object.keys(avatars).map((url) => { + return { fileurl: url }; + }); + const files = this.getFilesFromGlossaryAndEntries(module, glossary, entries).concat(avatarFiles); promises.push(this.filepoolProvider.addFilesToQueue(siteId, files, this.component, module.id)); return Promise.all(promises); @@ -187,4 +183,26 @@ export class AddonModGlossaryPrefetchHandler extends CoreCourseActivityPrefetchH return Promise.all(promises); }); } + + /** + * Sync a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + sync(module: any, courseId: number, siteId?: any): Promise { + const promises = [ + this.syncProvider.syncGlossaryEntries(module.instance, undefined, siteId), + this.syncProvider.syncRatings(module.id, undefined, siteId) + ]; + + return Promise.all(promises).then((results) => { + return results.reduce((a, b) => ({ + updated: a.updated || b.updated, + warnings: (a.warnings || []).concat(b.warnings || []), + }), {updated: false}); + }); + } } diff --git a/src/addon/mod/glossary/providers/sync-cron-handler.ts b/src/addon/mod/glossary/providers/sync-cron-handler.ts index 77ea930d8..d952ce356 100644 --- a/src/addon/mod/glossary/providers/sync-cron-handler.ts +++ b/src/addon/mod/glossary/providers/sync-cron-handler.ts @@ -30,10 +30,11 @@ export class AddonModGlossarySyncCronHandler implements CoreCronHandler { * Receives the ID of the site affected, undefined for all sites. * * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). * @return {Promise} Promise resolved when done, rejected if failure. */ - execute(siteId?: string): Promise { - return this.glossarySync.syncAllGlossaries(siteId); + execute(siteId?: string, force?: boolean): Promise { + return this.glossarySync.syncAllGlossaries(siteId, force); } /** diff --git a/src/addon/mod/glossary/providers/sync.ts b/src/addon/mod/glossary/providers/sync.ts index 3a0dbe0e2..746563f67 100644 --- a/src/addon/mod/glossary/providers/sync.ts +++ b/src/addon/mod/glossary/providers/sync.ts @@ -68,19 +68,21 @@ export class AddonModGlossarySyncProvider extends CoreSyncBaseProvider { * Try to synchronize all the glossaries in a certain site or in all sites. * * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} [force] Wether to force sync not depending on last execution. * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - syncAllGlossaries(siteId?: string): Promise { - return this.syncOnSites('all glossaries', this.syncAllGlossariesFunc.bind(this), [], siteId); + syncAllGlossaries(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all glossaries', this.syncAllGlossariesFunc.bind(this), [force], siteId); } /** * Sync all glossaries on a site. * - * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {string} siteId Site ID to sync. + * @param {boolean} [force] Wether to force sync not depending on last execution. * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - protected syncAllGlossariesFunc(siteId?: string): Promise { + protected syncAllGlossariesFunc(siteId: string, force?: boolean): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); const promises = []; @@ -97,8 +99,10 @@ export class AddonModGlossarySyncProvider extends CoreSyncBaseProvider { continue; } - promises[entry.glossaryid] = this.syncGlossaryEntriesIfNeeded(entry.glossaryid, entry.userid, siteId) - .then((result) => { + promises[entry.glossaryid] = force ? this.syncGlossaryEntries(entry.glossaryid, entry.userid, siteId) : + this.syncGlossaryEntriesIfNeeded(entry.glossaryid, entry.userid, siteId); + + promises[entry.glossaryid].then((result) => { if (result && result.updated) { // Sync successful, send event. this.eventsProvider.trigger(AddonModGlossarySyncProvider.AUTO_SYNCED, { @@ -114,7 +118,7 @@ export class AddonModGlossarySyncProvider extends CoreSyncBaseProvider { return Promise.all(this.utils.objectToArray(promises)); })); - promises.push(this.syncRatings(undefined, siteId)); + promises.push(this.syncRatings(undefined, force, siteId)); return Promise.all(promises); } @@ -255,13 +259,14 @@ export class AddonModGlossarySyncProvider extends CoreSyncBaseProvider { * Synchronize offline ratings. * * @param {number} [cmId] Course module to be synced. If not defined, sync all glossaries. + * @param {boolean} [force] Wether to force sync not depending on last execution. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved if sync is successful, rejected otherwise. */ - syncRatings(cmId?: number, siteId?: string): Promise { + syncRatings(cmId?: number, force?: boolean, siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); - return this.ratingSync.syncRatings('mod_glossary', 'entry', 'module', cmId, 0, siteId).then((results) => { + return this.ratingSync.syncRatings('mod_glossary', 'entry', 'module', cmId, 0, force, siteId).then((results) => { let updated = false; const warnings = []; const promises = []; diff --git a/src/addon/mod/imscp/components/index/addon-mod-imscp-index.html b/src/addon/mod/imscp/components/index/addon-mod-imscp-index.html index eab430fd2..a07aa6be4 100644 --- a/src/addon/mod/imscp/components/index/addon-mod-imscp-index.html +++ b/src/addon/mod/imscp/components/index/addon-mod-imscp-index.html @@ -8,7 +8,7 @@ - + diff --git a/src/addon/mod/imscp/components/index/index.ts b/src/addon/mod/imscp/components/index/index.ts index 7ca40d13c..11e53d2b2 100644 --- a/src/addon/mod/imscp/components/index/index.ts +++ b/src/addon/mod/imscp/components/index/index.ts @@ -52,7 +52,7 @@ export class AddonModImscpIndexComponent extends CoreCourseModuleMainResourceCom super.ngOnInit(); this.loadContent().then(() => { - this.imscpProvider.logView(this.module.instance).then(() => { + this.imscpProvider.logView(this.module.instance, this.module.name).then(() => { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); }).catch(() => { // Ignore errors. diff --git a/src/addon/mod/imscp/providers/imscp.ts b/src/addon/mod/imscp/providers/imscp.ts index 5cc7b9258..86166f3c2 100644 --- a/src/addon/mod/imscp/providers/imscp.ts +++ b/src/addon/mod/imscp/providers/imscp.ts @@ -20,6 +20,7 @@ import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; +import { CoreSite } from '@classes/site'; /** * Service that provides some features for IMSCP. @@ -162,7 +163,8 @@ export class AddonModImscpProvider { courseids: [courseId] }; const preSets = { - cacheKey: this.getImscpDataCacheKey(courseId) + cacheKey: this.getImscpDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('mod_imscp_get_imscps_by_courses', params, preSets).then((response) => { @@ -309,14 +311,16 @@ export class AddonModImscpProvider { * Report a IMSCP as being viewed. * * @param {string} id Module ID. + * @param {string} [name] Name of the imscp. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logView(id: number, siteId?: string): Promise { + logView(id: number, name?: string, siteId?: string): Promise { const params = { imscpid: id }; - return this.logHelper.log('mod_imscp_view_imscp', params, AddonModImscpProvider.COMPONENT, id, siteId); + return this.logHelper.logSingle('mod_imscp_view_imscp', params, AddonModImscpProvider.COMPONENT, id, name, 'imscp', {}, + siteId); } } diff --git a/src/addon/mod/label/providers/label.ts b/src/addon/mod/label/providers/label.ts index b40b11ac9..2b4e79534 100644 --- a/src/addon/mod/label/providers/label.ts +++ b/src/addon/mod/label/providers/label.ts @@ -59,7 +59,8 @@ export class AddonModLabelProvider { courseids: [courseId] }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getLabelDataCacheKey(courseId) + cacheKey: this.getLabelDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (forceCache) { diff --git a/src/addon/mod/lesson/components/index/addon-mod-lesson-index.html b/src/addon/mod/lesson/components/index/addon-mod-lesson-index.html index 5e1c4d20b..55cc163c8 100644 --- a/src/addon/mod/lesson/components/index/addon-mod-lesson-index.html +++ b/src/addon/mod/lesson/components/index/addon-mod-lesson-index.html @@ -6,7 +6,7 @@ - + diff --git a/src/addon/mod/lesson/components/index/index.ts b/src/addon/mod/lesson/components/index/index.ts index 3750b56dc..8a0acdba1 100644 --- a/src/addon/mod/lesson/components/index/index.ts +++ b/src/addon/mod/lesson/components/index/index.ts @@ -345,7 +345,7 @@ export class AddonModLessonIndexComponent extends CoreCourseModuleMainActivityCo * Log viewing the lesson. */ protected logView(): void { - this.lessonProvider.logViewLesson(this.lesson.id, this.password).then(() => { + this.lessonProvider.logViewLesson(this.lesson.id, this.password, this.lesson.name).then(() => { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); }).catch((error) => { // Ignore errors. diff --git a/src/addon/mod/lesson/lesson.module.ts b/src/addon/mod/lesson/lesson.module.ts index caad60d06..0c1e194ec 100644 --- a/src/addon/mod/lesson/lesson.module.ts +++ b/src/addon/mod/lesson/lesson.module.ts @@ -17,6 +17,7 @@ import { CoreCronDelegate } from '@providers/cron'; import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; +import { CorePushNotificationsDelegate } from '@core/pushnotifications/providers/delegate'; import { AddonModLessonComponentsModule } from './components/components.module'; import { AddonModLessonProvider } from './providers/lesson'; import { AddonModLessonOfflineProvider } from './providers/lesson-offline'; @@ -29,6 +30,7 @@ import { AddonModLessonIndexLinkHandler } from './providers/index-link-handler'; import { AddonModLessonGradeLinkHandler } from './providers/grade-link-handler'; import { AddonModLessonReportLinkHandler } from './providers/report-link-handler'; import { AddonModLessonListLinkHandler } from './providers/list-link-handler'; +import { AddonModLessonPushClickHandler } from './providers/push-click-handler'; import { CoreUpdateManagerProvider } from '@providers/update-manager'; // List of providers (without handlers). @@ -56,7 +58,8 @@ export const ADDON_MOD_LESSON_PROVIDERS: any[] = [ AddonModLessonIndexLinkHandler, AddonModLessonGradeLinkHandler, AddonModLessonReportLinkHandler, - AddonModLessonListLinkHandler + AddonModLessonListLinkHandler, + AddonModLessonPushClickHandler ] }) export class AddonModLessonModule { @@ -65,7 +68,8 @@ export class AddonModLessonModule { cronDelegate: CoreCronDelegate, syncHandler: AddonModLessonSyncCronHandler, linksDelegate: CoreContentLinksDelegate, indexHandler: AddonModLessonIndexLinkHandler, gradeHandler: AddonModLessonGradeLinkHandler, reportHandler: AddonModLessonReportLinkHandler, updateManager: CoreUpdateManagerProvider, - listLinkHandler: AddonModLessonListLinkHandler) { + listLinkHandler: AddonModLessonListLinkHandler, pushNotificationsDelegate: CorePushNotificationsDelegate, + pushClickHandler: AddonModLessonPushClickHandler) { moduleDelegate.registerHandler(moduleHandler); prefetchDelegate.registerHandler(prefetchHandler); @@ -74,6 +78,7 @@ export class AddonModLessonModule { linksDelegate.registerHandler(gradeHandler); linksDelegate.registerHandler(reportHandler); linksDelegate.registerHandler(listLinkHandler); + pushNotificationsDelegate.registerClickHandler(pushClickHandler); // Allow migrating the tables from the old app to the new schema. updateManager.registerSiteTablesMigration([ diff --git a/src/addon/mod/lesson/pages/player/player.html b/src/addon/mod/lesson/pages/player/player.html index 0c2e2ac0b..6c83c1f4b 100644 --- a/src/addon/mod/lesson/pages/player/player.html +++ b/src/addon/mod/lesson/pages/player/player.html @@ -108,7 +108,7 @@
- + @@ -175,7 +175,7 @@ - + @@ -185,7 +185,7 @@ - + @@ -212,8 +212,8 @@
- {{ 'addon.mod_lesson.finish' | translate }} - {{ button.label | translate }} + {{ 'addon.mod_lesson.finish' | translate }} + {{ button.label | translate }}
diff --git a/src/addon/mod/lesson/pages/player/player.ts b/src/addon/mod/lesson/pages/player/player.ts index 10e5e3ab0..ab32eb9d6 100644 --- a/src/addon/mod/lesson/pages/player/player.ts +++ b/src/addon/mod/lesson/pages/player/player.ts @@ -570,10 +570,15 @@ export class AddonModLessonPlayerPage implements OnInit, OnDestroy { // Button to continue. if (this.lesson.review && !result.correctanswer && !result.noanswer && !result.isessayquestion && !result.maxattemptsreached) { - this.processData.buttons.push({ - label: 'addon.mod_lesson.reviewquestioncontinue', - pageId: result.newpageid - }); + /* If both the "Yes, I'd like to try again" and "No, I just want to go on to the next question" point to the + same page then don't show the "No, I just want to go on to the next question" button. It's confusing. */ + if (this.pageData.page.id != result.newpageid) { + // Button to continue the lesson (the page to go is configured by the teacher). + this.processData.buttons.push({ + label: 'addon.mod_lesson.reviewquestioncontinue', + pageId: result.newpageid + }); + } } else { this.processData.buttons.push({ label: 'addon.mod_lesson.continue', diff --git a/src/addon/mod/lesson/pages/user-retake/user-retake.html b/src/addon/mod/lesson/pages/user-retake/user-retake.html index 3b35e37e4..60c19802b 100644 --- a/src/addon/mod/lesson/pages/user-retake/user-retake.html +++ b/src/addon/mod/lesson/pages/user-retake/user-retake.html @@ -78,7 +78,7 @@ - +

diff --git a/src/addon/mod/lesson/providers/lesson-sync.ts b/src/addon/mod/lesson/providers/lesson-sync.ts index 907e8f4d3..1a1abdaf6 100644 --- a/src/addon/mod/lesson/providers/lesson-sync.ts +++ b/src/addon/mod/lesson/providers/lesson-sync.ts @@ -186,26 +186,31 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid * Try to synchronize all the lessons in a certain site or in all sites. * * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} [force] Wether to force sync not depending on last execution. * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - syncAllLessons(siteId?: string): Promise { - return this.syncOnSites('all lessons', this.syncAllLessonsFunc.bind(this), [], siteId); + syncAllLessons(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all lessons', this.syncAllLessonsFunc.bind(this), [force], siteId); } /** * Sync all lessons on a site. * - * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {string} siteId Site ID to sync. + * @param {boolean} [force] Wether to force sync not depending on last execution. * @param {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - protected syncAllLessonsFunc(siteId?: string): Promise { + protected syncAllLessonsFunc(siteId: string, force?: boolean): Promise { // Get all the lessons that have something to be synchronized. return this.lessonOfflineProvider.getAllLessonsWithData(siteId).then((lessons) => { // Sync all lessons that haven't been synced for a while. const promises = []; - lessons.forEach((lesson) => { - promises.push(this.syncLessonIfNeeded(lesson.id, false, siteId).then((result) => { + lessons.map((lesson) => { + const promise = force ? this.syncLesson(lesson.id, false, false, siteId) : + this.syncLessonIfNeeded(lesson.id, false, siteId); + + return promise.then((result) => { if (result && result.updated) { // Sync successful, send event. this.eventsProvider.trigger(AddonModLessonSyncProvider.AUTO_SYNCED, { @@ -213,7 +218,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid warnings: result.warnings }, siteId); } - })); + }); }); return Promise.all(promises); diff --git a/src/addon/mod/lesson/providers/lesson.ts b/src/addon/mod/lesson/providers/lesson.ts index a364d1693..72feffd7f 100644 --- a/src/addon/mod/lesson/providers/lesson.ts +++ b/src/addon/mod/lesson/providers/lesson.ts @@ -22,7 +22,7 @@ import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreGradesProvider } from '@core/grades/providers/grades'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; -import { CoreSiteWSPreSets } from '@classes/site'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { AddonModLessonOfflineProvider } from './lesson-offline'; /** @@ -156,6 +156,8 @@ export class AddonModLessonProvider { */ static MULTIANSWER_DELIMITER = '@^#|'; + static LESSON_OTHER_ANSWERS = '@#wronganswer#@'; + // Variables for database. static PASSWORD_TABLE = 'addon_mod_lesson_password'; protected siteSchema: CoreSiteSchema = { @@ -506,7 +508,12 @@ export class AddonModLessonProvider { return; } - if (typeof data['answer[text]'] != 'undefined') { + // The name was changed to "answer_editor" in 3.7. Before it was just "answer". Support both cases. + if (typeof data['answer_editor[text]'] != 'undefined') { + studentAnswer = data['answer_editor[text]']; + } else if (typeof data.answer_editor == 'object') { + studentAnswer = data.answer_editor.text; + } else if (typeof data['answer[text]'] != 'undefined') { studentAnswer = data['answer[text]']; } else if (typeof data.answer == 'object') { studentAnswer = data.answer.text; @@ -793,6 +800,8 @@ export class AddonModLessonProvider { break; } } + + this.checkOtherAnswers(lesson, pageData, result); } /** @@ -907,6 +916,8 @@ export class AddonModLessonProvider { } } + this.checkOtherAnswers(lesson, pageData, result); + result.userresponse = studentAnswer; result.studentanswer = this.textUtils.s(studentAnswer); // Clean student answer as it goes to output. } @@ -945,6 +956,33 @@ export class AddonModLessonProvider { } } + /** + * Check the "other answers" value. + * + * @param {any} lesson Lesson. + * @param {any} pageData Result of getPageData for the page to process. + * @param {AddonModLessonCheckAnswerResult} result Object where to store the result. + */ + protected checkOtherAnswers(lesson: any, pageData: any, result: AddonModLessonCheckAnswerResult): void { + // We could check here to see if we have a wrong answer jump to use. + if (result.answerid == 0) { + // Use the all other answers jump details if it is set up. + const lastAnswer = pageData.answers[pageData.answers.length - 1] || {}; + + // Double check that this is the OTHER_ANSWERS answer. + if (typeof lastAnswer.answer == 'string' && + lastAnswer.answer.indexOf(AddonModLessonProvider.LESSON_OTHER_ANSWERS) != -1) { + result.newpageid = lastAnswer.jumpto; + result.response = lastAnswer.response; + + if (lesson.custom) { + result.correctanswer = lastAnswer.score > 0; + } + result.answerid = lastAnswer.id; + } + } + } + /** * Create a list of pages indexed by page ID based on a list of pages. * @@ -1403,7 +1441,8 @@ export class AddonModLessonProvider { courseids: [courseId] }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getLessonDataCacheKey(courseId) + cacheKey: this.getLessonDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (forceCache) { @@ -1728,7 +1767,8 @@ export class AddonModLessonProvider { lessonid: lessonId, }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getPagesCacheKey(lessonId) + cacheKey: this.getPagesCacheKey(lessonId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; if (typeof password == 'string') { @@ -2085,7 +2125,8 @@ export class AddonModLessonProvider { groupid: groupId }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getRetakesOverviewCacheKey(lessonId, groupId) + cacheKey: this.getRetakesOverviewCacheKey(lessonId, groupId), + updateFrequency: CoreSite.FREQUENCY_OFTEN }; if (forceCache) { @@ -2315,7 +2356,8 @@ export class AddonModLessonProvider { lessonattempt: retake }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getUserRetakeCacheKey(lessonId, userId, retake) + cacheKey: this.getUserRetakeCacheKey(lessonId, userId, retake), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; if (forceCache) { @@ -2984,10 +3026,11 @@ export class AddonModLessonProvider { * * @param {string} id Module ID. * @param {string} [password] Lesson password (if any). + * @param {string} [name] Name of the assign. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logViewLesson(id: number, password?: string, siteId?: string): Promise { + logViewLesson(id: number, password?: string, name?: string, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { const params: any = { lessonid: id @@ -2997,7 +3040,8 @@ export class AddonModLessonProvider { params.password = password; } - return this.logHelper.log('mod_lesson_view_lesson', params, AddonModLessonProvider.COMPONENT, id, siteId); + return this.logHelper.logSingle('mod_lesson_view_lesson', params, AddonModLessonProvider.COMPONENT, id, name, + 'lesson', {}, siteId); }); } diff --git a/src/addon/mod/lesson/providers/prefetch-handler.ts b/src/addon/mod/lesson/providers/prefetch-handler.ts index 3660a90cd..a594a7493 100644 --- a/src/addon/mod/lesson/providers/prefetch-handler.ts +++ b/src/addon/mod/lesson/providers/prefetch-handler.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, Injector } from '@angular/core'; import { ModalController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from '@providers/app'; @@ -24,6 +24,7 @@ import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreGroupsProvider } from '@providers/groups'; import { CoreCourseActivityPrefetchHandlerBase } from '@core/course/classes/activity-prefetch-handler'; import { AddonModLessonProvider } from './lesson'; +import { AddonModLessonSyncProvider } from './lesson-sync'; /** * Handler to prefetch lessons. @@ -36,10 +37,12 @@ export class AddonModLessonPrefetchHandler extends CoreCourseActivityPrefetchHan // Don't check timers to decrease positives. If a user performs some action it will be reflected in other items. updatesNames = /^configuration$|^.*files$|^grades$|^gradeitems$|^pages$|^answers$|^questionattempts$|^pagesviewed$/; + protected syncProvider: AddonModLessonSyncProvider; // It will be injected later to prevent circular dependencies. + constructor(translate: TranslateService, appProvider: CoreAppProvider, utils: CoreUtilsProvider, courseProvider: CoreCourseProvider, filepoolProvider: CoreFilepoolProvider, sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, protected modalCtrl: ModalController, protected groupsProvider: CoreGroupsProvider, - protected lessonProvider: AddonModLessonProvider) { + protected lessonProvider: AddonModLessonProvider, protected injector: Injector) { super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils); } @@ -211,12 +214,12 @@ export class AddonModLessonPrefetchHandler extends CoreCourseActivityPrefetchHan const siteId = this.sitesProvider.getCurrentSiteId(); return this.lessonProvider.getLesson(courseId, module.id, false, false, siteId).then((lesson) => { - if (!this.lessonProvider.isLessonOffline(lesson)) { - return false; - } - // Check if there is any prevent access reason. return this.lessonProvider.getAccessInformation(lesson.id, false, false, siteId).then((info) => { + if (!info.canviewreports && !this.lessonProvider.isLessonOffline(lesson)) { + return false; + } + // It's downloadable if there are no prevent access reasons or there is just 1 and it's password. return !info.preventaccessreasons || !info.preventaccessreasons.length || (info.preventaccessreasons.length == 1 && this.lessonProvider.isPasswordProtected(info)); @@ -270,7 +273,7 @@ export class AddonModLessonPrefetchHandler extends CoreCourseActivityPrefetchHan lesson = data.lesson || lesson; accessInfo = data.accessInfo; - if (!this.lessonProvider.leftDuringTimed(accessInfo)) { + if (this.lessonProvider.isLessonOffline(lesson) && !this.lessonProvider.leftDuringTimed(accessInfo)) { // The user didn't left during a timed session. Call launch retake to make sure there is a started retake. return this.lessonProvider.launchRetake(lesson.id, password, undefined, false, siteId).then(() => { const promises = []; @@ -298,65 +301,68 @@ export class AddonModLessonPrefetchHandler extends CoreCourseActivityPrefetchHan promises.push(this.filepoolProvider.addFilesToQueue(siteId, files, this.component, module.id)); // Get the list of pages. - promises.push(this.lessonProvider.getPages(lesson.id, password, false, true, siteId).then((pages) => { - const subPromises = []; - let hasRandomBranch = false; + if (this.lessonProvider.isLessonOffline(lesson)) { + promises.push(this.lessonProvider.getPages(lesson.id, password, false, true, siteId).then((pages) => { + const subPromises = []; + let hasRandomBranch = false; - // Get the data for each page. - pages.forEach((data) => { - // Check if any page has a RANDOMBRANCH jump. - if (!hasRandomBranch) { - for (let i = 0; i < data.jumps.length; i++) { - if (data.jumps[i] == AddonModLessonProvider.LESSON_RANDOMBRANCH) { - hasRandomBranch = true; - break; + // Get the data for each page. + pages.forEach((data) => { + // Check if any page has a RANDOMBRANCH jump. + if (!hasRandomBranch) { + for (let i = 0; i < data.jumps.length; i++) { + if (data.jumps[i] == AddonModLessonProvider.LESSON_RANDOMBRANCH) { + hasRandomBranch = true; + break; + } } } - } - // Get the page data. We don't pass accessInfo because we don't need to calculate the offline data. - subPromises.push(this.lessonProvider.getPageData(lesson, data.page.id, password, false, true, false, - true, undefined, undefined, siteId).then((pageData) => { + // Get the page data. We don't pass accessInfo because we don't need to calculate the offline data. + subPromises.push(this.lessonProvider.getPageData(lesson, data.page.id, password, false, true, false, + true, undefined, undefined, siteId).then((pageData) => { - // Download the page files. - let pageFiles = pageData.contentfiles || []; + // Download the page files. + let pageFiles = pageData.contentfiles || []; - pageData.answers.forEach((answer) => { - if (answer.answerfiles && answer.answerfiles.length) { - pageFiles = pageFiles.concat(answer.answerfiles); - } - if (answer.responsefiles && answer.responsefiles.length) { - pageFiles = pageFiles.concat(answer.responsefiles); - } - }); + pageData.answers.forEach((answer) => { + if (answer.answerfiles && answer.answerfiles.length) { + pageFiles = pageFiles.concat(answer.answerfiles); + } + if (answer.responsefiles && answer.responsefiles.length) { + pageFiles = pageFiles.concat(answer.responsefiles); + } + }); - return this.filepoolProvider.addFilesToQueue(siteId, pageFiles, this.component, module.id); + return this.filepoolProvider.addFilesToQueue(siteId, pageFiles, this.component, module.id); + })); + }); + + // Prefetch the list of possible jumps for offline navigation. Do it here because we know hasRandomBranch. + subPromises.push(this.lessonProvider.getPagesPossibleJumps(lesson.id, false, true, siteId).catch((error) => { + if (hasRandomBranch) { + // The WebSevice probably failed because RANDOMBRANCH aren't supported if the user hasn't seen any page. + return Promise.reject(this.translate.instant('addon.mod_lesson.errorprefetchrandombranch')); + } else { + return Promise.reject(error); + } })); - }); - // Prefetch the list of possible jumps for offline navigation. Do it here because we know hasRandomBranch. - subPromises.push(this.lessonProvider.getPagesPossibleJumps(lesson.id, false, true, siteId).catch((error) => { - if (hasRandomBranch) { - // The WebSevice probably failed because RANDOMBRANCH aren't supported if the user hasn't seen any page. - return Promise.reject(this.translate.instant('addon.mod_lesson.errorprefetchrandombranch')); - } else { - return Promise.reject(error); - } + return Promise.all(subPromises); })); - return Promise.all(subPromises); - })); + // Prefetch user timers to be able to calculate timemodified in offline. + promises.push(this.lessonProvider.getTimers(lesson.id, false, true, siteId).catch(() => { + // Ignore errors. + })); - // Prefetch user timers to be able to calculate timemodified in offline. - promises.push(this.lessonProvider.getTimers(lesson.id, false, true, siteId).catch(() => { - // Ignore errors. - })); + // Prefetch viewed pages in last retake to calculate progress. + promises.push(this.lessonProvider.getContentPagesViewedOnline(lesson.id, retake, false, true, siteId)); - // Prefetch viewed pages in last retake to calculate progress. - promises.push(this.lessonProvider.getContentPagesViewedOnline(lesson.id, retake, false, true, siteId)); - - // Prefetch question attempts in last retake for offline calculations. - promises.push(this.lessonProvider.getQuestionsAttemptsOnline(lesson.id, retake, false, undefined, false, true, siteId)); + // Prefetch question attempts in last retake for offline calculations. + promises.push(this.lessonProvider.getQuestionsAttemptsOnline(lesson.id, retake, false, undefined, false, true, + siteId)); + } if (accessInfo.canviewreports) { // Prefetch reports data. @@ -388,7 +394,24 @@ export class AddonModLessonPrefetchHandler extends CoreCourseActivityPrefetchHan } retakePromises.push(this.lessonProvider.getUserRetake(lesson.id, lastRetake.try, student.id, false, - true, siteId)); + true, siteId).then((attempt) => { + if (!attempt || !attempt.answerpages) { + return; + } + + // Download embedded files in essays. + const files = []; + attempt.answerpages.forEach((answerPage) => { + if (answerPage.page.qtype != AddonModLessonProvider.LESSON_PAGE_ESSAY) { + return; + } + answerPage.answerdata.answers.forEach((answer) => { + files.push(...this.domUtils.extractDownloadableFilesFromHtmlAsFakeFileObjects(answer[0])); + }); + }); + + return this.filepoolProvider.addFilesToQueue(siteId, files, this.component, module.id); + })); }); return Promise.all(retakePromises); @@ -429,4 +452,20 @@ export class AddonModLessonPrefetchHandler extends CoreCourseActivityPrefetchHan }); }); } + + /** + * Sync a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + sync(module: any, courseId: number, siteId?: any): Promise { + if (!this.syncProvider) { + this.syncProvider = this.injector.get(AddonModLessonSyncProvider); + } + + return this.syncProvider.syncLesson(module.instance, false, false, siteId); + } } diff --git a/src/addon/mod/lesson/providers/push-click-handler.ts b/src/addon/mod/lesson/providers/push-click-handler.ts new file mode 100644 index 000000000..649436683 --- /dev/null +++ b/src/addon/mod/lesson/providers/push-click-handler.ts @@ -0,0 +1,62 @@ +// (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 { CoreUtilsProvider } from '@providers/utils/utils'; +import { CorePushNotificationsClickHandler } from '@core/pushnotifications/providers/delegate'; +import { CoreGradesProvider } from '@core/grades/providers/grades'; +import { CoreGradesHelperProvider } from '@core/grades/providers/helper'; + +/** + * Handler for lesson push notifications clicks. + */ +@Injectable() +export class AddonModLessonPushClickHandler implements CorePushNotificationsClickHandler { + name = 'AddonModLessonPushClickHandler'; + priority = 200; + featureName = 'CoreCourseModuleDelegate_AddonModLesson'; + + constructor(private utils: CoreUtilsProvider, private gradesHelper: CoreGradesHelperProvider, + private gradesProvider: CoreGradesProvider) {} + + /** + * Check if a notification click is handled by this handler. + * + * @param {any} notification The notification to check. + * @return {boolean} Whether the notification click is handled by this handler + */ + handles(notification: any): boolean | Promise { + if (this.utils.isTrueOrOne(notification.notif) && notification.moodlecomponent == 'mod_lesson' && + notification.name == 'graded_essay') { + + return this.gradesProvider.isPluginEnabledForCourse(Number(notification.courseid), notification.site); + } + + return false; + } + + /** + * Handle the notification click. + * + * @param {any} notification The notification to check. + * @return {Promise} Promise resolved when done. + */ + handleClick(notification: any): Promise { + const data = notification.customdata || {}, + courseId = Number(notification.courseid), + moduleId = Number(data.cmid); + + return this.gradesHelper.goToGrades(courseId, undefined, moduleId, undefined, notification.site); + } +} diff --git a/src/addon/mod/lesson/providers/sync-cron-handler.ts b/src/addon/mod/lesson/providers/sync-cron-handler.ts index 0ede470f1..3d204645a 100644 --- a/src/addon/mod/lesson/providers/sync-cron-handler.ts +++ b/src/addon/mod/lesson/providers/sync-cron-handler.ts @@ -30,10 +30,11 @@ export class AddonModLessonSyncCronHandler implements CoreCronHandler { * Receives the ID of the site affected, undefined for all sites. * * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). * @return {Promise} Promise resolved when done, rejected if failure. */ - execute(siteId?: string): Promise { - return this.lessonSync.syncAllLessons(siteId); + execute(siteId?: string, force?: boolean): Promise { + return this.lessonSync.syncAllLessons(siteId, force); } /** diff --git a/src/addon/mod/lti/components/index/index.ts b/src/addon/mod/lti/components/index/index.ts index 40ccaa4f0..f61c1e50f 100644 --- a/src/addon/mod/lti/components/index/index.ts +++ b/src/addon/mod/lti/components/index/index.ts @@ -95,7 +95,7 @@ export class AddonModLtiIndexComponent extends CoreCourseModuleMainActivityCompo launch(): void { this.ltiProvider.getLtiLaunchData(this.lti.id).then((launchData) => { // "View" LTI. - this.ltiProvider.logView(this.lti.id).then(() => { + this.ltiProvider.logView(this.lti.id, this.lti.name).then(() => { this.checkCompletion(); }).catch((error) => { // Ignore errors. diff --git a/src/addon/mod/lti/providers/lti.ts b/src/addon/mod/lti/providers/lti.ts index 44163818a..b9c1eea85 100644 --- a/src/addon/mod/lti/providers/lti.ts +++ b/src/addon/mod/lti/providers/lti.ts @@ -21,6 +21,7 @@ import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreUrlUtilsProvider } from '@providers/utils/url'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; +import { CoreSite } from '@classes/site'; export interface AddonModLtiParam { name: string; @@ -108,7 +109,8 @@ export class AddonModLtiProvider { courseids: [courseId] }; const preSets: any = { - cacheKey: this.getLtiCacheKey(courseId) + cacheKey: this.getLtiCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return this.sitesProvider.getCurrentSite().read('mod_lti_get_ltis_by_courses', params, preSets).then((response) => { @@ -213,14 +215,15 @@ export class AddonModLtiProvider { * Report the LTI as being viewed. * * @param {string} id LTI id. + * @param {string} [name] Name of the lti. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logView(id: number, siteId?: string): Promise { + logView(id: number, name?: string, siteId?: string): Promise { const params: any = { ltiid: id }; - return this.logHelper.log('mod_lti_view_lti', params, AddonModLtiProvider.COMPONENT, id, siteId); + return this.logHelper.logSingle('mod_lti_view_lti', params, AddonModLtiProvider.COMPONENT, id, name, 'lti', {}, siteId); } } diff --git a/src/addon/mod/lti/providers/module-handler.ts b/src/addon/mod/lti/providers/module-handler.ts index 72ca2f2ba..351fd9020 100644 --- a/src/addon/mod/lti/providers/module-handler.ts +++ b/src/addon/mod/lti/providers/module-handler.ts @@ -91,7 +91,7 @@ export class AddonModLtiModuleHandler implements CoreCourseModuleHandler { this.ltiProvider.getLti(courseId, module.id).then((ltiData) => { return this.ltiProvider.getLtiLaunchData(ltiData.id).then((launchData) => { // "View" LTI. - this.ltiProvider.logView(ltiData.id).then(() => { + this.ltiProvider.logView(ltiData.id, ltiData.name).then(() => { this.courseProvider.checkModuleCompletion(courseId, module.completiondata); }).catch(() => { // Ignore errors. diff --git a/src/addon/mod/page/components/index/addon-mod-page-index.html b/src/addon/mod/page/components/index/addon-mod-page-index.html index 8c2c78a40..819cef352 100644 --- a/src/addon/mod/page/components/index/addon-mod-page-index.html +++ b/src/addon/mod/page/components/index/addon-mod-page-index.html @@ -5,7 +5,7 @@ - + diff --git a/src/addon/mod/page/components/index/index.ts b/src/addon/mod/page/components/index/index.ts index 4cf4ef924..254bc0f38 100644 --- a/src/addon/mod/page/components/index/index.ts +++ b/src/addon/mod/page/components/index/index.ts @@ -53,7 +53,7 @@ export class AddonModPageIndexComponent extends CoreCourseModuleMainResourceComp this.canGetPage = this.pageProvider.isGetPageWSAvailable(); this.loadContent().then(() => { - this.pageProvider.logView(this.module.instance).then(() => { + this.pageProvider.logView(this.module.instance, this.module.name).then(() => { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); }).catch(() => { // Ignore errors. diff --git a/src/addon/mod/page/providers/page.ts b/src/addon/mod/page/providers/page.ts index cfdce8b11..95ac2f311 100644 --- a/src/addon/mod/page/providers/page.ts +++ b/src/addon/mod/page/providers/page.ts @@ -19,6 +19,7 @@ import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; import { CoreFilepoolProvider } from '@providers/filepool'; +import { CoreSite } from '@classes/site'; /** * Service that provides some features for page. @@ -63,7 +64,8 @@ export class AddonModPageProvider { courseids: [courseId] }, preSets = { - cacheKey: this.getPageCacheKey(courseId) + cacheKey: this.getPageCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('mod_page_get_pages_by_courses', params, preSets).then((response) => { @@ -148,14 +150,15 @@ export class AddonModPageProvider { * Report a page as being viewed. * * @param {number} id Module ID. + * @param {string} [name] Name of the page. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logView(id: number, siteId?: string): Promise { + logView(id: number, name?: string, siteId?: string): Promise { const params = { pageid: id }; - return this.logHelper.log('mod_page_view_page', params, AddonModPageProvider.COMPONENT, id, siteId); + return this.logHelper.logSingle('mod_page_view_page', params, AddonModPageProvider.COMPONENT, id, name, 'page', {}, siteId); } } diff --git a/src/addon/mod/quiz/components/index/addon-mod-quiz-index.html b/src/addon/mod/quiz/components/index/addon-mod-quiz-index.html index 5113cd105..bb204717c 100644 --- a/src/addon/mod/quiz/components/index/addon-mod-quiz-index.html +++ b/src/addon/mod/quiz/components/index/addon-mod-quiz-index.html @@ -6,7 +6,7 @@ - + diff --git a/src/addon/mod/quiz/components/index/index.scss b/src/addon/mod/quiz/components/index/index.scss index 1488b3628..69ad61736 100644 --- a/src/addon/mod/quiz/components/index/index.scss +++ b/src/addon/mod/quiz/components/index/index.scss @@ -4,6 +4,10 @@ ion-app.app-root addon-mod-quiz-index { .addon-mod_quiz-table-header .item-inner { background-image: none; font-size: 0.9em; + + .col[text-center] { + @include padding-horizontal(0); + } } .item-inner ion-label { diff --git a/src/addon/mod/quiz/components/index/index.ts b/src/addon/mod/quiz/components/index/index.ts index 2ccfb71f7..32eef2714 100644 --- a/src/addon/mod/quiz/components/index/index.ts +++ b/src/addon/mod/quiz/components/index/index.ts @@ -85,7 +85,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp return; } - this.quizProvider.logViewQuiz(this.quizData.id).then(() => { + this.quizProvider.logViewQuiz(this.quizData.id, this.quizData.name).then(() => { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); }).catch((error) => { // Ignore errors. @@ -511,6 +511,12 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp */ protected showStatus(status: string, previousStatus?: string): void { this.showStatusSpinner = status == CoreConstants.DOWNLOADING; + + if (status == CoreConstants.DOWNLOADED && previousStatus == CoreConstants.DOWNLOADING) { + // Quiz downloaded now, maybe a new attempt was created. Load content again. + this.loaded = false; + this.loadContent(); + } } /** diff --git a/src/addon/mod/quiz/lang/en.json b/src/addon/mod/quiz/lang/en.json index 18a699dda..c5be9879f 100644 --- a/src/addon/mod/quiz/lang/en.json +++ b/src/addon/mod/quiz/lang/en.json @@ -6,6 +6,7 @@ "attemptquiznow": "Attempt quiz now", "attemptstate": "State", "cannotsubmitquizdueto": "This quiz attempt cannot be submitted for the following reasons:", + "clearchoice": "Clear my choice", "comment": "Comment", "completedon": "Completed on", "confirmclose": "Once you submit, you will no longer be able to change your answers for this attempt.", diff --git a/src/addon/mod/quiz/pages/attempt/attempt.ts b/src/addon/mod/quiz/pages/attempt/attempt.ts index ed6abe247..8119dbea7 100644 --- a/src/addon/mod/quiz/pages/attempt/attempt.ts +++ b/src/addon/mod/quiz/pages/attempt/attempt.ts @@ -119,7 +119,12 @@ export class AddonModQuizAttemptPage implements OnInit { accessInfo = quizAccessInfo; if (accessInfo.canreviewmyattempts) { - return this.quizProvider.getAttemptReview(this.attemptId, -1).catch(() => { + // Check if the user can review the attempt. + return this.quizProvider.invalidateAttemptReviewForPage(this.attemptId, -1).catch(() => { + // Ignore errors. + }).then(() => { + return this.quizProvider.getAttemptReview(this.attemptId, -1); + }).catch(() => { // Error getting the review, assume the user cannot review the attempt. accessInfo.canreviewmyattempts = false; }); diff --git a/src/addon/mod/quiz/pages/player/player.html b/src/addon/mod/quiz/pages/player/player.html index 77795828b..9470bec15 100644 --- a/src/addon/mod/quiz/pages/player/player.html +++ b/src/addon/mod/quiz/pages/player/player.html @@ -58,7 +58,7 @@ - +
@@ -135,8 +135,8 @@
- {{ 'core.openinbrowser' | translate }} + diff --git a/src/addon/mod/quiz/pages/player/player.ts b/src/addon/mod/quiz/pages/player/player.ts index 12bf38f2b..75da900d8 100644 --- a/src/addon/mod/quiz/pages/player/player.ts +++ b/src/addon/mod/quiz/pages/player/player.ts @@ -457,7 +457,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { }); // Mark the page as viewed. We'll ignore errors in this call. - this.quizProvider.logViewAttempt(this.attempt.id, page, this.preflightData, this.offline).catch((error) => { + this.quizProvider.logViewAttempt(this.attempt.id, page, this.preflightData, this.offline, this.quiz).catch((error) => { // Ignore errors. }); @@ -484,7 +484,8 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy { this.attempt.dueDateWarning = this.quizProvider.getAttemptDueDateWarning(this.quiz, this.attempt); // Log summary as viewed. - this.quizProvider.logViewAttemptSummary(this.attempt.id, this.preflightData, this.quizId).catch((error) => { + this.quizProvider.logViewAttemptSummary(this.attempt.id, this.preflightData, this.quizId, this.quiz.name) + .catch((error) => { // Ignore errors. }); }); diff --git a/src/addon/mod/quiz/pages/review/review.html b/src/addon/mod/quiz/pages/review/review.html index f7682f1b2..3dba297ac 100644 --- a/src/addon/mod/quiz/pages/review/review.html +++ b/src/addon/mod/quiz/pages/review/review.html @@ -75,7 +75,7 @@ - +
diff --git a/src/addon/mod/quiz/pages/review/review.ts b/src/addon/mod/quiz/pages/review/review.ts index 6d1bc331d..218d7cd60 100644 --- a/src/addon/mod/quiz/pages/review/review.ts +++ b/src/addon/mod/quiz/pages/review/review.ts @@ -81,7 +81,7 @@ export class AddonModQuizReviewPage implements OnInit { */ ngOnInit(): void { this.fetchData().then(() => { - this.quizProvider.logViewAttemptReview(this.attemptId, this.quizId).catch((error) => { + this.quizProvider.logViewAttemptReview(this.attemptId, this.quizId, this.quiz.name).catch((error) => { // Ignore errors. }); }).finally(() => { diff --git a/src/addon/mod/quiz/providers/helper.ts b/src/addon/mod/quiz/providers/helper.ts index af9d466a8..71897ff50 100644 --- a/src/addon/mod/quiz/providers/helper.ts +++ b/src/addon/mod/quiz/providers/helper.ts @@ -13,13 +13,16 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { ModalController } from 'ionic-angular'; +import { ModalController, NavController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; +import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { AddonModQuizProvider } from './quiz'; import { AddonModQuizOfflineProvider } from './quiz-offline'; import { AddonModQuizAccessRuleDelegate } from './access-rules-delegate'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; /** * Helper service that provides some features for quiz. @@ -29,7 +32,9 @@ export class AddonModQuizHelperProvider { constructor(private domUtils: CoreDomUtilsProvider, private translate: TranslateService, private utils: CoreUtilsProvider, private accessRuleDelegate: AddonModQuizAccessRuleDelegate, private quizProvider: AddonModQuizProvider, - private modalCtrl: ModalController, private quizOfflineProvider: AddonModQuizOfflineProvider) { } + private modalCtrl: ModalController, private quizOfflineProvider: AddonModQuizOfflineProvider, + private courseHelper: CoreCourseHelperProvider, private sitesProvider: CoreSitesProvider, + private linkHelper: CoreContentLinksHelperProvider) { } /** * Validate a preflight data or show a modal to input the preflight data if required. @@ -49,7 +54,7 @@ export class AddonModQuizHelperProvider { getAndCheckPreflightData(quiz: any, accessInfo: any, preflightData: any, attempt: any, offline?: boolean, prefetch?: boolean, title?: string, siteId?: string, retrying?: boolean): Promise { - const rules = accessInfo.activerulenames; + const rules = accessInfo && accessInfo.activerulenames; let isPreflightCheckRequired = false; // Check if the user needs to input preflight data. @@ -156,6 +161,76 @@ export class AddonModQuizHelperProvider { return this.domUtils.getContentsOfElement(element, '.grade'); } + /** + * Get a quiz ID by attempt ID. + * + * @param {number} attemptId Attempt ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the quiz ID. + */ + getQuizIdByAttemptId(attemptId: number, siteId?: string): Promise { + // Use getAttemptReview to retrieve the quiz ID. + return this.quizProvider.getAttemptReview(attemptId, undefined, false, siteId).then((reviewData) => { + if (reviewData.attempt && reviewData.attempt.quiz) { + return reviewData.attempt.quiz; + } + + return Promise.reject(null); + }); + } + + /** + * Handle a review link. + * + * @param {NavController} navCtrl Nav controller, can be undefined/null. + * @param {number} attemptId Attempt ID. + * @param {number} [page] Page to load, -1 to all questions in same page. + * @param {number} [courseId] Course ID. + * @param {number} [quizId] Quiz ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + handleReviewLink(navCtrl: NavController, attemptId: number, page?: number, courseId?: number, quizId?: number, + siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const modal = this.domUtils.showModalLoading(); + let promise; + + if (quizId) { + promise = Promise.resolve(quizId); + } else { + // Retrieve the quiz ID using the attempt ID. + promise = this.getQuizIdByAttemptId(attemptId); + } + + return promise.then((id) => { + quizId = id; + + // Get the courseId if we don't have it. + if (courseId) { + return courseId; + } else { + return this.courseHelper.getModuleCourseIdByInstance(quizId, 'quiz', siteId); + } + }).then((courseId) => { + // Go to the review page. + const pageParams = { + quizId: quizId, + attemptId: attemptId, + courseId: courseId, + page: isNaN(page) ? -1 : page + }; + + return this.linkHelper.goInSite(navCtrl, 'AddonModQuizReviewPage', pageParams, siteId); + }).catch((error) => { + + this.domUtils.showErrorModalDefault(error, 'An error occurred while loading the required data.'); + }).finally(() => { + modal.dismiss(); + }); + } + /** * Add some calculated data to the attempt. * diff --git a/src/addon/mod/quiz/providers/prefetch-handler.ts b/src/addon/mod/quiz/providers/prefetch-handler.ts index d292dc958..132dd3f09 100644 --- a/src/addon/mod/quiz/providers/prefetch-handler.ts +++ b/src/addon/mod/quiz/providers/prefetch-handler.ts @@ -239,7 +239,16 @@ export class AddonModQuizPrefetchHandler extends CoreCourseActivityPrefetchHandl * @return {Promise} Promise resolved when done. */ prefetch(module: any, courseId?: number, single?: boolean, dirPath?: string, canStart: boolean = true): Promise { + if (module.attemptFinished) { + // Delete the value so it does not block anything if true. + delete module.attemptFinished; + + // Quiz got synced recently and an attempt has finished. Do not prefetch. + return Promise.resolve(); + } + return this.prefetchPackage(module, courseId, single, this.prefetchQuiz.bind(this), undefined, canStart); + } /** @@ -409,7 +418,7 @@ export class AddonModQuizPrefetchHandler extends CoreCourseActivityPrefetchHandl data.questions.forEach((question) => { questionPromises.push(this.questionHelper.prefetchQuestionFiles( - question, this.component, quiz.coursemodule, siteId)); + question, this.component, quiz.coursemodule, siteId, attempt.uniqueid)); }); return Promise.all(questionPromises); @@ -437,7 +446,7 @@ export class AddonModQuizPrefetchHandler extends CoreCourseActivityPrefetchHandl data.questions.forEach((question) => { questionPromises.push(this.questionHelper.prefetchQuestionFiles( - question, this.component, quiz.coursemodule, siteId)); + question, this.component, quiz.coursemodule, siteId, attempt.uniqueid)); }); return Promise.all(questionPromises); @@ -554,4 +563,30 @@ export class AddonModQuizPrefetchHandler extends CoreCourseActivityPrefetchHandl } }); } + + /** + * Sync a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + sync(module: any, courseId: number, siteId?: any): Promise { + if (!this.syncProvider) { + this.syncProvider = this.injector.get(AddonModQuizSyncProvider); + } + + return this.quizProvider.getQuiz(courseId, module.id).then((quiz) => { + return this.syncProvider.syncQuiz(quiz, false, siteId).then((results) => { + module.attemptFinished = (results && results.attemptFinished) || false; + + return results; + }).catch(() => { + // Ignore errors. + + module.attemptFinished = false; + }); + }); + } } diff --git a/src/addon/mod/quiz/providers/push-click-handler.ts b/src/addon/mod/quiz/providers/push-click-handler.ts new file mode 100644 index 000000000..43397a59b --- /dev/null +++ b/src/addon/mod/quiz/providers/push-click-handler.ts @@ -0,0 +1,75 @@ +// (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 { CoreUrlUtilsProvider } from '@providers/utils/url'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CorePushNotificationsClickHandler } from '@core/pushnotifications/providers/delegate'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { AddonModQuizProvider } from './quiz'; +import { AddonModQuizHelperProvider } from './helper'; + +/** + * Handler for quiz push notifications clicks. + */ +@Injectable() +export class AddonModQuizPushClickHandler implements CorePushNotificationsClickHandler { + name = 'AddonModQuizPushClickHandler'; + priority = 200; + featureName = 'CoreCourseModuleDelegate_AddonModQuiz'; + + protected SUPPORTED_NAMES = ['submission', 'confirmation', 'attempt_overdue']; + + constructor(private utils: CoreUtilsProvider, private quizProvider: AddonModQuizProvider, + private urlUtils: CoreUrlUtilsProvider, private courseHelper: CoreCourseHelperProvider, + private quizHelper: AddonModQuizHelperProvider) {} + + /** + * Check if a notification click is handled by this handler. + * + * @param {any} notification The notification to check. + * @return {boolean} Whether the notification click is handled by this handler + */ + handles(notification: any): boolean | Promise { + return this.utils.isTrueOrOne(notification.notif) && notification.moodlecomponent == 'mod_quiz' && + this.SUPPORTED_NAMES.indexOf(notification.name) != -1; + } + + /** + * Handle the notification click. + * + * @param {any} notification The notification to check. + * @return {Promise} Promise resolved when done. + */ + handleClick(notification: any): Promise { + const contextUrlParams = this.urlUtils.extractUrlParams(notification.contexturl), + data = notification.customdata || {}, + courseId = Number(notification.courseid); + + if (notification.name == 'submission') { + // A student made a submission, go to view the attempt. + return this.quizHelper.handleReviewLink(undefined, Number(contextUrlParams.attempt), Number(contextUrlParams.page), + courseId, Number(data.instance), notification.site); + } else { + // Open the activity. + const moduleId = Number(contextUrlParams.id); + + return this.quizProvider.invalidateContent(moduleId, courseId, notification.site).catch(() => { + // Ignore errors. + }).then(() => { + return this.courseHelper.navigateToModule(moduleId, notification.site, courseId); + }); + } + } +} diff --git a/src/addon/mod/quiz/providers/quiz-sync.ts b/src/addon/mod/quiz/providers/quiz-sync.ts index 854fb8a83..9f7226e9d 100644 --- a/src/addon/mod/quiz/providers/quiz-sync.ts +++ b/src/addon/mod/quiz/providers/quiz-sync.ts @@ -190,19 +190,21 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider * Try to synchronize all the quizzes in a certain site or in all sites. * * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} [force] Wether to force sync not depending on last execution. * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - syncAllQuizzes(siteId?: string): Promise { - return this.syncOnSites('all quizzes', this.syncAllQuizzesFunc.bind(this), [], siteId); + syncAllQuizzes(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all quizzes', this.syncAllQuizzesFunc.bind(this), [force], siteId); } /** * Sync all quizzes on a site. * - * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {string} siteId Site ID to sync. + * @param {boolean} [force] Wether to force sync not depending on last execution. * @param {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - protected syncAllQuizzesFunc(siteId?: string): Promise { + protected syncAllQuizzesFunc(siteId?: string, force?: boolean): Promise { // Get all offline attempts. return this.quizOfflineProvider.getAllAttempts(siteId).then((attempts) => { const quizzes = [], @@ -227,7 +229,9 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider // Quiz not blocked, try to synchronize it. promises.push(this.quizProvider.getQuizById(quiz.courseid, quiz.id, false, false, siteId).then((quiz) => { - return this.syncQuizIfNeeded(quiz, false, siteId).then((data) => { + const promise = force ? this.syncQuiz(quiz, false, siteId) : this.syncQuizIfNeeded(quiz, false, siteId); + + return promise.then((data) => { if (data && data.warnings && data.warnings.length) { // Store the warnings to show them when the user opens the quiz. return this.setSyncWarnings(quiz.id, data.warnings, siteId).then(() => { @@ -388,8 +392,9 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider // Answers sent, now set the current page if the attempt isn't finished. if (!finish) { + // Don't pass the quiz instance because we don't want to trigger a Firebase event in this case. return this.quizProvider.logViewAttempt(onlineAttempt.id, offlineAttempt.currentpage, preflightData, - false).catch(() => { + false, undefined, siteId).catch(() => { // Ignore errors. }); } diff --git a/src/addon/mod/quiz/providers/quiz.ts b/src/addon/mod/quiz/providers/quiz.ts index 8861537f6..cb36b5928 100644 --- a/src/addon/mod/quiz/providers/quiz.ts +++ b/src/addon/mod/quiz/providers/quiz.ts @@ -21,12 +21,13 @@ import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreUtilsProvider } from '@providers/utils/utils'; -import { CoreSiteWSPreSets } from '@classes/site'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreGradesHelperProvider } from '@core/grades/providers/helper'; import { CoreQuestionDelegate } from '@core/question/providers/delegate'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; import { AddonModQuizAccessRuleDelegate } from './access-rules-delegate'; import { AddonModQuizOfflineProvider } from './quiz-offline'; +import { CorePushNotificationsProvider } from '@core/pushnotifications/providers/pushnotifications'; /** * Service that provides some features for quiz. @@ -63,7 +64,8 @@ export class AddonModQuizProvider { private gradesHelper: CoreGradesHelperProvider, private questionDelegate: CoreQuestionDelegate, private filepoolProvider: CoreFilepoolProvider, private timeUtils: CoreTimeUtilsProvider, private accessRulesDelegate: AddonModQuizAccessRuleDelegate, private quizOfflineProvider: AddonModQuizOfflineProvider, - private domUtils: CoreDomUtilsProvider, private logHelper: CoreCourseLogHelperProvider) { + private domUtils: CoreDomUtilsProvider, private logHelper: CoreCourseLogHelperProvider, + protected pushNotificationsProvider: CorePushNotificationsProvider) { this.logger = logger.getInstance('AddonModQuizProvider'); } @@ -403,7 +405,8 @@ export class AddonModQuizProvider { page: page }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getAttemptReviewCacheKey(attemptId, page) + cacheKey: this.getAttemptReviewCacheKey(attemptId, page), + cacheErrors: ['noreview'] }; if (ignoreCache) { @@ -567,7 +570,8 @@ export class AddonModQuizProvider { grade: grade }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getFeedbackForGradeCacheKey(quizId, grade) + cacheKey: this.getFeedbackForGradeCacheKey(quizId, grade), + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (ignoreCache) { @@ -684,7 +688,8 @@ export class AddonModQuizProvider { courseids: [courseId] }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getQuizDataCacheKey(courseId) + cacheKey: this.getQuizDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (forceCache) { @@ -826,7 +831,8 @@ export class AddonModQuizProvider { quizid: quizId }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getQuizRequiredQtypesCacheKey(quizId) + cacheKey: this.getQuizRequiredQtypesCacheKey(quizId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; if (ignoreCache) { @@ -988,7 +994,8 @@ export class AddonModQuizProvider { includepreviews: includePreviews ? 1 : 0 }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getUserAttemptsCacheKey(quizId, userId) + cacheKey: this.getUserAttemptsCacheKey(quizId, userId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; if (offline) { @@ -1532,10 +1539,13 @@ export class AddonModQuizProvider { * @param {number} [page=0] Page number. * @param {any} [preflightData] Preflight required data (like password). * @param {boolean} [offline] Whether attempt is offline. + * @param {string} [quiz] Quiz instance. If set, a Firebase event will be stored. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logViewAttempt(attemptId: number, page: number = 0, preflightData: any = {}, offline?: boolean, siteId?: string): Promise { + logViewAttempt(attemptId: number, page: number = 0, preflightData: any = {}, offline?: boolean, quiz?: any, + siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { const params = { attemptid: attemptId, @@ -1548,6 +1558,10 @@ export class AddonModQuizProvider { if (offline) { promises.push(this.quizOfflineProvider.setAttemptCurrentPage(attemptId, page, site.getId())); } + if (quiz) { + this.pushNotificationsProvider.logViewEvent(quiz.id, quiz.name, 'quiz', 'mod_quiz_view_attempt', + {attemptid: attemptId, page: page}, siteId); + } return Promise.all(promises); }); @@ -1558,15 +1572,17 @@ export class AddonModQuizProvider { * * @param {number} attemptId Attempt ID. * @param {number} quizId Quiz ID. + * @param {string} [name] Name of the quiz. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logViewAttemptReview(attemptId: number, quizId: number, siteId?: string): Promise { + logViewAttemptReview(attemptId: number, quizId: number, name?: string, siteId?: string): Promise { const params = { attemptid: attemptId }; - return this.logHelper.log('mod_quiz_view_attempt_review', params, AddonModQuizProvider.COMPONENT, quizId, siteId); + return this.logHelper.logSingle('mod_quiz_view_attempt_review', params, AddonModQuizProvider.COMPONENT, quizId, name, + 'quiz', params, siteId); } /** @@ -1575,31 +1591,35 @@ export class AddonModQuizProvider { * @param {number} attemptId Attempt ID. * @param {any} preflightData Preflight required data (like password). * @param {number} quizId Quiz ID. + * @param {string} [name] Name of the quiz. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logViewAttemptSummary(attemptId: number, preflightData: any, quizId: number, siteId?: string): Promise { + logViewAttemptSummary(attemptId: number, preflightData: any, quizId: number, name?: string, siteId?: string): Promise { const params = { attemptid: attemptId, preflightdata: this.utils.objectToArrayOfObjects(preflightData, 'name', 'value', true) }; - return this.logHelper.log('mod_quiz_view_attempt_summary', params, AddonModQuizProvider.COMPONENT, quizId, siteId); + return this.logHelper.logSingle('mod_quiz_view_attempt_summary', params, AddonModQuizProvider.COMPONENT, quizId, name, + 'quiz', {attemptid: attemptId}, siteId); } /** * Report a quiz as being viewed. * * @param {number} id Module ID. + * @param {string} [name] Name of the quiz. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logViewQuiz(id: number, siteId?: string): Promise { + logViewQuiz(id: number, name?: string, siteId?: string): Promise { const params = { quizid: id }; - return this.logHelper.log('mod_quiz_view_quiz', params, AddonModQuizProvider.COMPONENT, id, siteId); + return this.logHelper.logSingle('mod_quiz_view_quiz', params, AddonModQuizProvider.COMPONENT, id, name, 'quiz', {}, + siteId); } /** diff --git a/src/addon/mod/quiz/providers/review-link-handler.ts b/src/addon/mod/quiz/providers/review-link-handler.ts index 2aa6dece7..516499fd0 100644 --- a/src/addon/mod/quiz/providers/review-link-handler.ts +++ b/src/addon/mod/quiz/providers/review-link-handler.ts @@ -13,12 +13,10 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; -import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; -import { CoreCourseHelperProvider } from '@core/course/providers/helper'; import { AddonModQuizProvider } from './quiz'; +import { AddonModQuizHelperProvider } from './helper'; /** * Handler to treat links to quiz review. @@ -29,8 +27,7 @@ export class AddonModQuizReviewLinkHandler extends CoreContentLinksHandlerBase { featureName = 'CoreCourseModuleDelegate_AddonModQuiz'; pattern = /\/mod\/quiz\/review\.php.*([\&\?]attempt=\d+)/; - constructor(protected domUtils: CoreDomUtilsProvider, protected quizProvider: AddonModQuizProvider, - protected courseHelper: CoreCourseHelperProvider, protected linkHelper: CoreContentLinksHelperProvider) { + constructor(protected quizProvider: AddonModQuizProvider, protected quizHelper: AddonModQuizHelperProvider) { super(); } @@ -41,65 +38,25 @@ export class AddonModQuizReviewLinkHandler extends CoreContentLinksHandlerBase { * @param {string} url The URL to treat. * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @param {any} [data] Extra data to handle the URL. * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. */ - getActions(siteIds: string[], url: string, params: any, courseId?: number): + getActions(siteIds: string[], url: string, params: any, courseId?: number, data?: any): CoreContentLinksAction[] | Promise { courseId = courseId || params.courseid || params.cid; + data = data || {}; return [{ action: (siteId, navCtrl?): void => { - // Retrieve the quiz ID using the attempt ID. - const modal = this.domUtils.showModalLoading(), - attemptId = parseInt(params.attempt, 10), - page = parseInt(params.page, 10); - let quizId; + const attemptId = parseInt(params.attempt, 10), + page = parseInt(params.page, 10), + quizId = data.instance && parseInt(data.instance, 10); - this.getQuizIdByAttemptId(attemptId).then((id) => { - quizId = id; - - // Get the courseId if we don't have it. - if (courseId) { - return courseId; - } else { - return this.courseHelper.getModuleCourseIdByInstance(quizId, 'quiz', siteId); - } - }).then((courseId) => { - // Go to the review page. - const pageParams = { - quizId: quizId, - attemptId: attemptId, - courseId: courseId, - page: params.showall ? -1 : (isNaN(page) ? -1 : page) - }; - - this.linkHelper.goInSite(navCtrl, 'AddonModQuizReviewPage', pageParams, siteId); - }).catch((error) => { - - this.domUtils.showErrorModalDefault(error, 'An error occurred while loading the required data.'); - }).finally(() => { - modal.dismiss(); - }); + this.quizHelper.handleReviewLink(navCtrl, attemptId, page, courseId, quizId, siteId); } }]; } - /** - * Get a quiz ID by attempt ID. - * - * @param {number} attemptId Attempt ID. - * @return {Promise} Promise resolved with the quiz ID. - */ - protected getQuizIdByAttemptId(attemptId: number): Promise { - // Use getAttemptReview to retrieve the quiz ID. - return this.quizProvider.getAttemptReview(attemptId).then((reviewData) => { - if (reviewData.attempt && reviewData.attempt.quiz) { - return reviewData.attempt.quiz; - } - - return Promise.reject(null); - }); - } /** * Check if the handler is enabled for a certain site (site + user) and a URL. diff --git a/src/addon/mod/quiz/providers/sync-cron-handler.ts b/src/addon/mod/quiz/providers/sync-cron-handler.ts index 2866f3a08..2c3c1bcbe 100644 --- a/src/addon/mod/quiz/providers/sync-cron-handler.ts +++ b/src/addon/mod/quiz/providers/sync-cron-handler.ts @@ -30,10 +30,11 @@ export class AddonModQuizSyncCronHandler implements CoreCronHandler { * Receives the ID of the site affected, undefined for all sites. * * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). * @return {Promise} Promise resolved when done, rejected if failure. */ - execute(siteId?: string): Promise { - return this.quizSync.syncAllQuizzes(siteId); + execute(siteId?: string, force?: boolean): Promise { + return this.quizSync.syncAllQuizzes(siteId, force); } /** diff --git a/src/addon/mod/quiz/quiz.module.ts b/src/addon/mod/quiz/quiz.module.ts index 5f10410cb..93cc7ae2f 100644 --- a/src/addon/mod/quiz/quiz.module.ts +++ b/src/addon/mod/quiz/quiz.module.ts @@ -17,6 +17,7 @@ import { CoreCronDelegate } from '@providers/cron'; import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; +import { CorePushNotificationsDelegate } from '@core/pushnotifications/providers/delegate'; import { AddonModQuizAccessRuleDelegate } from './providers/access-rules-delegate'; import { AddonModQuizProvider } from './providers/quiz'; import { AddonModQuizOfflineProvider } from './providers/quiz-offline'; @@ -29,6 +30,7 @@ import { AddonModQuizIndexLinkHandler } from './providers/index-link-handler'; import { AddonModQuizGradeLinkHandler } from './providers/grade-link-handler'; import { AddonModQuizReviewLinkHandler } from './providers/review-link-handler'; import { AddonModQuizListLinkHandler } from './providers/list-link-handler'; +import { AddonModQuizPushClickHandler } from './providers/push-click-handler'; import { AddonModQuizComponentsModule } from './components/components.module'; import { CoreUpdateManagerProvider } from '@providers/update-manager'; @@ -79,7 +81,8 @@ export const ADDON_MOD_QUIZ_PROVIDERS: any[] = [ AddonModQuizIndexLinkHandler, AddonModQuizGradeLinkHandler, AddonModQuizReviewLinkHandler, - AddonModQuizListLinkHandler + AddonModQuizListLinkHandler, + AddonModQuizPushClickHandler ] }) export class AddonModQuizModule { @@ -88,7 +91,8 @@ export class AddonModQuizModule { cronDelegate: CoreCronDelegate, syncHandler: AddonModQuizSyncCronHandler, linksDelegate: CoreContentLinksDelegate, indexHandler: AddonModQuizIndexLinkHandler, gradeHandler: AddonModQuizGradeLinkHandler, reviewHandler: AddonModQuizReviewLinkHandler, updateManager: CoreUpdateManagerProvider, - listLinkHandler: AddonModQuizListLinkHandler) { + listLinkHandler: AddonModQuizListLinkHandler, + pushNotificationsDelegate: CorePushNotificationsDelegate, pushClickHandler: AddonModQuizPushClickHandler) { moduleDelegate.registerHandler(moduleHandler); prefetchDelegate.registerHandler(prefetchHandler); @@ -97,6 +101,7 @@ export class AddonModQuizModule { linksDelegate.registerHandler(gradeHandler); linksDelegate.registerHandler(reviewHandler); linksDelegate.registerHandler(listLinkHandler); + pushNotificationsDelegate.registerClickHandler(pushClickHandler); // Allow migrating the tables from the old app to the new schema. updateManager.registerSiteTableMigration({ diff --git a/src/addon/mod/resource/components/index/addon-mod-resource-index.html b/src/addon/mod/resource/components/index/addon-mod-resource-index.html index 7a964f220..45056cdcd 100644 --- a/src/addon/mod/resource/components/index/addon-mod-resource-index.html +++ b/src/addon/mod/resource/components/index/addon-mod-resource-index.html @@ -5,7 +5,7 @@ - + diff --git a/src/addon/mod/resource/components/index/index.ts b/src/addon/mod/resource/components/index/index.ts index 2c1283958..ab8ccffac 100644 --- a/src/addon/mod/resource/components/index/index.ts +++ b/src/addon/mod/resource/components/index/index.ts @@ -53,7 +53,7 @@ export class AddonModResourceIndexComponent extends CoreCourseModuleMainResource this.canGetResource = this.resourceProvider.isGetResourceWSAvailable(); this.loadContent().then(() => { - this.resourceProvider.logView(this.module.instance).then(() => { + this.resourceProvider.logView(this.module.instance, this.module.name).then(() => { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); }).catch(() => { // Ignore errors. diff --git a/src/addon/mod/resource/providers/helper.ts b/src/addon/mod/resource/providers/helper.ts index b6d0f3aa6..0f216e4aa 100644 --- a/src/addon/mod/resource/providers/helper.ts +++ b/src/addon/mod/resource/providers/helper.ts @@ -113,11 +113,18 @@ export class AddonModResourceHelperProvider { * @return {boolean} Whether the resource should be displayed embeded. */ isDisplayedEmbedded(module: any, display: number): boolean { - if (!module.contents.length || !this.fileProvider.isAvailable() || this.isNextcloudFile(module)) { + if ((!module.contents.length && !module.contentsinfo) || !this.fileProvider.isAvailable() || + (!this.sitesProvider.getCurrentSite().isVersionGreaterEqualThan('3.7') && this.isNextcloudFile(module))) { return false; } - const ext = this.mimetypeUtils.getFileExtension(module.contents[0].filename); + let ext; + + if (module.contentsinfo) { + ext = this.mimetypeUtils.getExtension(module.contentsinfo.mimetypes[0]); + } else { + ext = this.mimetypeUtils.getFileExtension(module.contents[0].filename); + } return (display == this.DISPLAY_EMBED || display == this.DISPLAY_AUTO) && this.mimetypeUtils.canBeEmbedded(ext); } @@ -129,12 +136,18 @@ export class AddonModResourceHelperProvider { * @return {boolean} Whether the resource should be displayed in an iframe. */ isDisplayedInIframe(module: any): boolean { - if (!module.contents.length || !this.fileProvider.isAvailable()) { + if ((!module.contents.length && !module.contentsinfo) || !this.fileProvider.isAvailable()) { return false; } - const ext = this.mimetypeUtils.getFileExtension(module.contents[0].filename), + let mimetype; + + if (module.contentsinfo) { + mimetype = module.contentsinfo.mimetypes[0]; + } else { + const ext = this.mimetypeUtils.getFileExtension(module.contents[0].filename); mimetype = this.mimetypeUtils.getMimeType(ext); + } return mimetype == 'text/html'; } @@ -146,6 +159,10 @@ export class AddonModResourceHelperProvider { * @return {boolean} Whether it's a Nextcloud file. */ isNextcloudFile(module: any): boolean { + if (module.contentsinfo) { + return module.contentsinfo.repositorytype == 'nextcloud'; + } + return module.contents && module.contents[0] && module.contents[0].repositorytype == 'nextcloud'; } @@ -162,7 +179,7 @@ export class AddonModResourceHelperProvider { // Download and open the file from the resource contents. return this.courseHelper.downloadModuleAndOpenFile(module, courseId, AddonModResourceProvider.COMPONENT, module.id, module.contents).then(() => { - this.resourceProvider.logView(module.instance).then(() => { + this.resourceProvider.logView(module.instance, module.name).then(() => { this.courseProvider.checkModuleCompletion(courseId, module.completiondata); }).catch(() => { // Ignore errors. diff --git a/src/addon/mod/resource/providers/module-handler.ts b/src/addon/mod/resource/providers/module-handler.ts index 5dc520d7b..573546982 100644 --- a/src/addon/mod/resource/providers/module-handler.ts +++ b/src/addon/mod/resource/providers/module-handler.ts @@ -121,8 +121,16 @@ export class AddonModResourceModuleHandler implements CoreCourseModuleHandler { * @return {Promise} Resolved when done. */ protected hideOpenButton(module: any, courseId: number): Promise { - return this.courseProvider.loadModuleContents(module, courseId, undefined, false, false, undefined, this.modName) - .then(() => { + let promise; + + if (module.contentsinfo) { + // No need to load contents. + promise = Promise.resolve(); + } else { + promise = this.courseProvider.loadModuleContents(module, courseId, undefined, false, false, undefined, this.modName); + } + + return promise.then(() => { return this.prefetchDelegate.getModuleStatus(module, courseId).then((status) => { return status !== CoreConstants.DOWNLOADED || this.resourceHelper.isDisplayedInIframe(module); }); @@ -141,7 +149,7 @@ export class AddonModResourceModuleHandler implements CoreCourseModuleHandler { let infoFiles = [], options: any = {}; - // Check if the button needs to be shown or not. This also loads the module contents. + // Check if the button needs to be shown or not. promises.push(this.hideOpenButton(module, courseId).then((hideOpenButton) => { handlerData.buttons[0].hidden = hideOpenButton; })); @@ -164,7 +172,15 @@ export class AddonModResourceModuleHandler implements CoreCourseModuleHandler { }, extra = []; - if (files && files.length) { + if (module.contentsinfo) { + // No need to use the list of files. + const mimetype = module.contentsinfo.mimetypes[0]; + if (mimetype) { + resourceData.icon = this.mimetypeUtils.getMimetypeIcon(mimetype); + } + resourceData.extra = this.textUtils.cleanTags(module.afterlink); + + } else if (files && files.length) { const file = files[0]; resourceData.icon = this.mimetypeUtils.getFileIcon(file.filename); @@ -178,11 +194,12 @@ export class AddonModResourceModuleHandler implements CoreCourseModuleHandler { return result + file.filesize; }, 0); } + extra.push(this.textUtils.bytesToSize(size, 1)); } if (options.showtype) { - // We should take it from options.filedetails.size if avalaible ∫but it's already translated. + // We should take it from options.filedetails.size if avalaible but it's already translated. extra.push(this.mimetypeUtils.getMimetypeDescription(file)); } @@ -203,12 +220,15 @@ export class AddonModResourceModuleHandler implements CoreCourseModuleHandler { {$a: this.timeUtils.userDate(file.timecreated * 1000, 'core.strftimedatetimeshort') })); } } - } - if (resourceData.icon == '') { + if (resourceData.icon == '') { + resourceData.icon = this.courseProvider.getModuleIconSrc(this.modName, module.modicon); + } + resourceData.extra += extra.join(' '); + } else { + // No files, just set the icon. resourceData.icon = this.courseProvider.getModuleIconSrc(this.modName, module.modicon); } - resourceData.extra += extra.join(' '); return resourceData; }); diff --git a/src/addon/mod/resource/providers/prefetch-handler.ts b/src/addon/mod/resource/providers/prefetch-handler.ts index 7c05f8fbc..7c3e7be65 100644 --- a/src/addon/mod/resource/providers/prefetch-handler.ts +++ b/src/addon/mod/resource/providers/prefetch-handler.ts @@ -51,11 +51,18 @@ export class AddonModResourcePrefetchHandler extends CoreCourseResourcePrefetchH * @return {string} Status to display. */ determineStatus(module: any, status: string, canCheck: boolean): string { - if (status == CoreConstants.DOWNLOADED && module && module.contents) { - // If the first file is an external file, always display the module as outdated. - const mainFile = module.contents[0]; - if (mainFile && mainFile.isexternalfile) { - return CoreConstants.OUTDATED; + if (status == CoreConstants.DOWNLOADED && module) { + // If the main file is an external file, always display the module as outdated. + if (module.contentsinfo) { + if (module.contentsinfo.repositorytype) { + // It's an external file. + return CoreConstants.OUTDATED; + } + } else if (module.contents) { + const mainFile = module.contents[0]; + if (mainFile && mainFile.isexternalfile) { + return CoreConstants.OUTDATED; + } } } @@ -130,7 +137,12 @@ export class AddonModResourcePrefetchHandler extends CoreCourseResourcePrefetchH * @return {Promise} Promise resolved with true if downloadable, resolved with false otherwise. */ isDownloadable(module: any, courseId: number): Promise { - // Don't allow downloading Nextcloud files for now. + if (this.sitesProvider.getCurrentSite().isVersionGreaterEqualThan('3.7')) { + // Nextcloud files are downloadable from 3.7 onwards. + return Promise.resolve(true); + } + + // Don't allow downloading Nextcloud files in older sites. return this.loadContents(module, courseId, false).then(() => { return !this.resourceHelper.isNextcloudFile(module); }); diff --git a/src/addon/mod/resource/providers/resource.ts b/src/addon/mod/resource/providers/resource.ts index 725cbc473..3de250b9b 100644 --- a/src/addon/mod/resource/providers/resource.ts +++ b/src/addon/mod/resource/providers/resource.ts @@ -19,6 +19,7 @@ import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; import { CoreFilepoolProvider } from '@providers/filepool'; +import { CoreSite } from '@classes/site'; /** * Service that provides some features for resources. @@ -61,7 +62,8 @@ export class AddonModResourceProvider { courseids: [courseId] }, preSets = { - cacheKey: this.getResourceCacheKey(courseId) + cacheKey: this.getResourceCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('mod_resource_get_resources_by_courses', params, preSets).then((response) => { @@ -150,14 +152,16 @@ export class AddonModResourceProvider { * Report the resource as being viewed. * * @param {number} id Module ID. + * @param {string} [name] Name of the resource. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logView(id: number, siteId?: string): Promise { + logView(id: number, name?: string, siteId?: string): Promise { const params = { resourceid: id }; - return this.logHelper.log('mod_resource_view_resource', params, AddonModResourceProvider.COMPONENT, id, siteId); + return this.logHelper.logSingle('mod_resource_view_resource', params, AddonModResourceProvider.COMPONENT, id, name, + 'resource', {}, siteId); } } diff --git a/src/addon/mod/scorm/components/index/addon-mod-scorm-index.html b/src/addon/mod/scorm/components/index/addon-mod-scorm-index.html index 947e4be78..20ca8751f 100644 --- a/src/addon/mod/scorm/components/index/addon-mod-scorm-index.html +++ b/src/addon/mod/scorm/components/index/addon-mod-scorm-index.html @@ -6,7 +6,7 @@ - + @@ -24,7 +24,7 @@
- +

{{ 'addon.mod_scorm.attempts' | translate }}

@@ -75,7 +75,7 @@
- +

{{ 'addon.mod_scorm.contents' | translate }}

@@ -128,7 +128,7 @@ -
+

{{ 'addon.mod_scorm.mode' | translate }}

@@ -143,14 +143,14 @@
- + {{ 'addon.mod_scorm.newattempt' | translate }} - +

{{ statusMessage | translate }}

diff --git a/src/addon/mod/scorm/components/index/index.ts b/src/addon/mod/scorm/components/index/index.ts index de725b272..6672548b7 100644 --- a/src/addon/mod/scorm/components/index/index.ts +++ b/src/addon/mod/scorm/components/index/index.ts @@ -54,6 +54,8 @@ export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityCom organizations: any[]; // List of organizations. loadingToc: boolean; // Whether the TOC is being loaded. toc: any[]; // Table of contents (structure). + accessInfo: any; // Access information. + skip: boolean; // Launch immediately. protected fetchContentDefaultError = 'addon.mod_scorm.errorgetscorm'; // Default error to show when loading contents. protected syncEventName = AddonModScormSyncProvider.AUTO_SYNCED; @@ -83,7 +85,11 @@ export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityCom return; } - this.scormProvider.logView(this.scorm.id).then(() => { + if (this.skip) { + this.open(); + } + + this.scormProvider.logView(this.scorm.id, this.scorm.name).then(() => { this.checkCompletion(); }).catch((error) => { // Ignore errors. @@ -181,54 +187,72 @@ export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityCom this.syncTime = syncTime; }); - // Get the number of attempts. - return this.scormProvider.getAttemptCount(this.scorm.id); - }).then((attemptsData) => { - this.attempts = attemptsData; - this.hasOffline = !!this.attempts.offline.length; - - // Determine the attempt that will be continued or reviewed. - return this.scormHelper.determineAttemptToContinue(this.scorm, this.attempts); - }).then((attempt) => { - this.lastAttempt = attempt.number; - this.lastIsOffline = attempt.offline; - - if (this.lastAttempt != this.attempts.lastAttempt.number) { - this.attemptToContinue = this.lastAttempt; - } else { - this.attemptToContinue = undefined; - } - - // Check if the last attempt is incomplete. - return this.scormProvider.isAttemptIncomplete(this.scorm.id, this.lastAttempt, this.lastIsOffline); - }).then((incomplete) => { const promises = []; - this.scorm.incomplete = incomplete; - this.scorm.numAttempts = this.attempts.total; - this.scorm.gradeMethodReadable = this.scormProvider.getScormGradeMethod(this.scorm); - this.scorm.attemptsLeft = this.scormProvider.countAttemptsLeft(this.scorm, this.attempts.lastAttempt.number); + // Get access information. + promises.push(this.scormProvider.getAccessInformation(this.scorm.id).then((accessInfo) => { + this.accessInfo = accessInfo; + })); - if (this.scorm.forcenewattempt == AddonModScormProvider.SCORM_FORCEATTEMPT_ALWAYS || - (this.scorm.forcenewattempt && !this.scorm.incomplete)) { - this.scormOptions.newAttempt = true; - } + // Get the number of attempts. + promises.push(this.scormProvider.getAttemptCount(this.scorm.id).then((attemptsData) => { + this.attempts = attemptsData; + this.hasOffline = !!this.attempts.offline.length; - promises.push(this.getReportedGrades()); + // Determine the attempt that will be continued or reviewed. + return this.scormHelper.determineAttemptToContinue(this.scorm, this.attempts); + }).then((attempt) => { + this.lastAttempt = attempt.number; + this.lastIsOffline = attempt.offline; - promises.push(this.fetchStructure()); + if (this.lastAttempt != this.attempts.lastAttempt.number) { + this.attemptToContinue = this.lastAttempt; + } else { + this.attemptToContinue = undefined; + } - if (!this.scorm.packagesize && this.errorMessage === '') { - // SCORM is supported but we don't have package size. Try to calculate it. - promises.push(this.scormProvider.calculateScormSize(this.scorm).then((size) => { - this.scorm.packagesize = size; - })); - } + // Check if the last attempt is incomplete. + return this.scormProvider.isAttemptIncomplete(this.scorm.id, this.lastAttempt, this.lastIsOffline); + }).then((incomplete) => { + const promises = []; - // Handle status. - this.setStatusListener(); + this.scorm.incomplete = incomplete; + this.scorm.numAttempts = this.attempts.total; + this.scorm.gradeMethodReadable = this.scormProvider.getScormGradeMethod(this.scorm); + this.scorm.attemptsLeft = this.scormProvider.countAttemptsLeft(this.scorm, this.attempts.lastAttempt.number); - return Promise.all(promises); + if (this.scorm.forcenewattempt == AddonModScormProvider.SCORM_FORCEATTEMPT_ALWAYS || + (this.scorm.forcenewattempt && !this.scorm.incomplete)) { + this.scormOptions.newAttempt = true; + } + + promises.push(this.getReportedGrades()); + + promises.push(this.fetchStructure()); + + if (!this.scorm.packagesize && this.errorMessage === '') { + // SCORM is supported but we don't have package size. Try to calculate it. + promises.push(this.scormProvider.calculateScormSize(this.scorm).then((size) => { + this.scorm.packagesize = size; + })); + } + + // Handle status. + promises.push(this.setStatusListener()); + + return Promise.all(promises); + })); + + return Promise.all(promises).then(() => { + // Check whether to launch the SCORM immediately. + if (typeof this.skip == 'undefined') { + this.skip = !this.hasOffline && !this.errorMessage && + (!this.scorm.lastattemptlock || this.scorm.attemptsLeft > 0) && + this.accessInfo.canskipview && !this.accessInfo.canviewreport && + this.scorm.skipview >= AddonModScormProvider.SKIPVIEW_FIRST && + (this.scorm.skipview == AddonModScormProvider.SKIPVIEW_ALWAYS || this.lastAttempt == 0); + } + }); }); }).then(() => { // All data obtained, now fill the context menu. @@ -368,6 +392,9 @@ export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityCom ionViewDidLeave(): void { super.ionViewDidLeave(); + // Display the full page when returning to the page. + this.skip = false; + if (this.navCtrl.getActive().component.name == 'AddonModScormPlayerPage') { this.hasPlayed = true; @@ -460,11 +487,17 @@ export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityCom }); } - // Open a SCORM. It will download the SCORM package if it's not downloaded or it has changed. - // The scoId param indicates the SCO that needs to be loaded when the SCORM is opened. If not defined, load first SCO. - open(e: Event, scoId: number): void { - e.preventDefault(); - e.stopPropagation(); + /** + * Open a SCORM. It will download the SCORM package if it's not downloaded or it has changed. + * + * @param {Event} [event] Event. + * @param {string} [scoId] SCO that needs to be loaded when the SCORM is opened. If not defined, load first SCO. + */ + open(event?: Event, scoId?: number): void { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } if (this.downloading) { // Scope is being downloaded, abort. diff --git a/src/addon/mod/scorm/pages/player/player.ts b/src/addon/mod/scorm/pages/player/player.ts index 49f2cd000..63d148aca 100644 --- a/src/addon/mod/scorm/pages/player/player.ts +++ b/src/addon/mod/scorm/pages/player/player.ts @@ -370,7 +370,7 @@ export class AddonModScormPlayerPage implements OnInit, OnDestroy { } // Trigger SCO launch event. - this.scormProvider.logLaunchSco(this.scorm.id, sco.id).catch(() => { + this.scormProvider.logLaunchSco(this.scorm.id, sco.id, this.scorm.name).catch(() => { // Ignore errors. }); } diff --git a/src/addon/mod/scorm/providers/prefetch-handler.ts b/src/addon/mod/scorm/providers/prefetch-handler.ts index c6f19e138..4b4dd8038 100644 --- a/src/addon/mod/scorm/providers/prefetch-handler.ts +++ b/src/addon/mod/scorm/providers/prefetch-handler.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, Injector } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from '@providers/app'; import { CoreFilepoolProvider } from '@providers/filepool'; @@ -24,6 +24,7 @@ import { CoreFileProvider } from '@providers/file'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreCourseActivityPrefetchHandlerBase } from '@core/course/classes/activity-prefetch-handler'; import { AddonModScormProvider } from './scorm'; +import { AddonModScormSyncProvider } from './scorm-sync'; /** * Progress event used when downloading a SCORM. @@ -58,10 +59,12 @@ export class AddonModScormPrefetchHandler extends CoreCourseActivityPrefetchHand component = AddonModScormProvider.COMPONENT; updatesNames = /^configuration$|^.*files$|^tracks$/; + protected syncProvider: AddonModScormSyncProvider; // It will be injected later to prevent circular dependencies. + constructor(translate: TranslateService, appProvider: CoreAppProvider, utils: CoreUtilsProvider, courseProvider: CoreCourseProvider, filepoolProvider: CoreFilepoolProvider, sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, protected fileProvider: CoreFileProvider, protected textUtils: CoreTextUtilsProvider, - protected scormProvider: AddonModScormProvider) { + protected scormProvider: AddonModScormProvider, protected injector: Injector) { super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils); } @@ -120,6 +123,9 @@ export class AddonModScormPrefetchHandler extends CoreCourseActivityPrefetchHand // Ignore errors. })); + // Prefetch access information. + promises.push(this.scormProvider.getAccessInformation(scorm.id)); + return Promise.all(promises); }).then(() => { // Success, return the hash. @@ -423,4 +429,22 @@ export class AddonModScormPrefetchHandler extends CoreCourseActivityPrefetchHand return Promise.all(promises); }); } + + /** + * Sync a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + sync(module: any, courseId: number, siteId?: any): Promise { + if (!this.syncProvider) { + this.syncProvider = this.injector.get(AddonModScormSyncProvider); + } + + return this.scormProvider.getScorm(courseId, module.id, module.url, false, siteId).then((scorm) => { + return this.syncProvider.syncScorm(scorm, siteId); + }); + } } diff --git a/src/addon/mod/scorm/providers/scorm-sync.ts b/src/addon/mod/scorm/providers/scorm-sync.ts index 683cfd450..bb6074179 100644 --- a/src/addon/mod/scorm/providers/scorm-sync.ts +++ b/src/addon/mod/scorm/providers/scorm-sync.ts @@ -444,19 +444,21 @@ export class AddonModScormSyncProvider extends CoreCourseActivitySyncBaseProvide * Try to synchronize all the SCORMs in a certain site or in all sites. * * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} force Wether to force sync not depending on last execution. * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - syncAllScorms(siteId?: string): Promise { - return this.syncOnSites('all SCORMs', this.syncAllScormsFunc.bind(this), [], siteId); + syncAllScorms(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all SCORMs', this.syncAllScormsFunc.bind(this), [force], siteId); } /** * Sync all SCORMs on a site. * - * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {string} siteId Site ID to sync. + * @param {boolean} [force] Wether to force sync not depending on last execution. * @param {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - protected syncAllScormsFunc(siteId?: string): Promise { + protected syncAllScormsFunc(siteId: string, force?: boolean): Promise { // Get all offline attempts. return this.scormOfflineProvider.getAllAttempts(siteId).then((attempts) => { @@ -481,7 +483,9 @@ export class AddonModScormSyncProvider extends CoreCourseActivitySyncBaseProvide if (!this.syncProvider.isBlocked(AddonModScormProvider.COMPONENT, scorm.id, siteId)) { promises.push(this.scormProvider.getScormById(scorm.courseId, scorm.id, '', false, siteId).then((scorm) => { - return this.syncScormIfNeeded(scorm, siteId).then((data) => { + const promise = force ? this.syncScorm(scorm, siteId) : this.syncScormIfNeeded(scorm, siteId); + + return promise.then((data) => { if (typeof data != 'undefined') { // We tried to sync. Send event. this.eventsProvider.trigger(AddonModScormSyncProvider.AUTO_SYNCED, { diff --git a/src/addon/mod/scorm/providers/scorm.ts b/src/addon/mod/scorm/providers/scorm.ts index 0ee62a7f7..850875fc9 100644 --- a/src/addon/mod/scorm/providers/scorm.ts +++ b/src/addon/mod/scorm/providers/scorm.ts @@ -24,7 +24,7 @@ import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { AddonModScormOfflineProvider } from './scorm-offline'; -import { CoreSiteWSPreSets } from '@classes/site'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreConstants } from '@core/constants'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; @@ -83,6 +83,10 @@ export class AddonModScormProvider { static SCORM_FORCEATTEMPT_ONCOMPLETE = 1; static SCORM_FORCEATTEMPT_ALWAYS = 2; + static SKIPVIEW_NEVER = 0; + static SKIPVIEW_FIRST = 1; + static SKIPVIEW_ALWAYS = 2; + // Events. static LAUNCH_NEXT_SCO_EVENT = 'addon_mod_scorm_launch_next_sco'; static LAUNCH_PREV_SCO_EVENT = 'addon_mod_scorm_launch_prev_sco'; @@ -442,6 +446,44 @@ export class AddonModScormProvider { return formatted; } + /** + * Get access information for a given SCORM. + * + * @param {number} scormId SCORM ID. + * @param {boolean} [forceCache] True to always get the value from cache. false otherwise. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Object with access information. + * @since 3.7 + */ + getAccessInformation(scormId: number, forceCache?: boolean, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + if (!site.wsAvailable('mod_scorm_get_scorm_access_information')) { + // Access information not available for 3.6 or older sites. + return Promise.resolve({}); + } + + const params = { + scormid: scormId + }; + const preSets = { + cacheKey: this.getAccessInformationCacheKey(scormId), + omitExpires: forceCache + }; + + return site.read('mod_scorm_get_scorm_access_information', params, preSets); + }); + } + + /** + * Get cache key for access information WS calls. + * + * @param {number} scormId SCORM ID. + * @return {string} Cache key. + */ + protected getAccessInformationCacheKey(scormId: number): string { + return this.ROOT_CACHE_KEY + 'accessInfo:' + scormId; + } + /** * Get the number of attempts done by a user in the given SCORM. * @@ -547,7 +589,8 @@ export class AddonModScormProvider { ignoremissingcompletion: ignoreMissing ? 1 : 0 }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getAttemptCountCacheKey(scormId, userId) + cacheKey: this.getAttemptCountCacheKey(scormId, userId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; if (ignoreCache) { @@ -835,7 +878,8 @@ export class AddonModScormProvider { scormid: scormId }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getScosCacheKey(scormId) + cacheKey: this.getScosCacheKey(scormId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; if (ignoreCache) { @@ -1070,7 +1114,8 @@ export class AddonModScormProvider { courseids: [courseId] }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getScormDataCacheKey(courseId) + cacheKey: this.getScormDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (forceCache) { @@ -1176,6 +1221,19 @@ export class AddonModScormProvider { } } + /** + * Invalidates access information. + * + * @param {number} forumId SCORM ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateAccessInformation(scormId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getAccessInformationCacheKey(scormId)); + }); + } + /** * Invalidates all the data related to a certain SCORM. * @@ -1190,6 +1248,7 @@ export class AddonModScormProvider { promises.push(this.invalidateAttemptCount(scormId, siteId, userId)); promises.push(this.invalidateScos(scormId, siteId)); promises.push(this.invalidateScormUserData(scormId, siteId)); + promises.push(this.invalidateAccessInformation(scormId, siteId)); return Promise.all(promises); } @@ -1421,21 +1480,19 @@ export class AddonModScormProvider { * * @param {number} scormId SCORM ID. * @param {number} scoId SCO ID. + * @param {string} [name] Name of the SCORM. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logLaunchSco(scormId: number, scoId: number, siteId?: string): Promise { + logLaunchSco(scormId: number, scoId: number, name?: string, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { const params = { scormid: scormId, scoid: scoId }; - return site.write('mod_scorm_launch_sco', params).then((response) => { - if (!response || !response.status) { - return Promise.reject(null); - } - }); + return this.logHelper.logSingle('mod_scorm_launch_sco', params, AddonModScormProvider.COMPONENT, scormId, name, + 'scorm', {scoid: scoId}, siteId); }); } @@ -1443,15 +1500,17 @@ export class AddonModScormProvider { * Report a SCORM as being viewed. * * @param {string} id Module ID. + * @param {string} [name] Name of the SCORM. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logView(id: number, siteId?: string): Promise { + logView(id: number, name?: string, siteId?: string): Promise { const params = { scormid: id }; - return this.logHelper.log('mod_scorm_view_scorm', params, AddonModScormProvider.COMPONENT, id, siteId); + return this.logHelper.logSingle('mod_scorm_view_scorm', params, AddonModScormProvider.COMPONENT, id, name, 'scorm', {}, + siteId); } /** diff --git a/src/addon/mod/scorm/providers/sync-cron-handler.ts b/src/addon/mod/scorm/providers/sync-cron-handler.ts index 797eeb4bb..33999d0cb 100644 --- a/src/addon/mod/scorm/providers/sync-cron-handler.ts +++ b/src/addon/mod/scorm/providers/sync-cron-handler.ts @@ -30,10 +30,11 @@ export class AddonModScormSyncCronHandler implements CoreCronHandler { * Receives the ID of the site affected, undefined for all sites. * * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). * @return {Promise} Promise resolved when done, rejected if failure. */ - execute(siteId?: string): Promise { - return this.scormSync.syncAllScorms(siteId); + execute(siteId?: string, force?: boolean): Promise { + return this.scormSync.syncAllScorms(siteId, force); } /** diff --git a/src/addon/mod/survey/components/index/addon-mod-survey-index.html b/src/addon/mod/survey/components/index/addon-mod-survey-index.html index 01645b24b..da07713d4 100644 --- a/src/addon/mod/survey/components/index/addon-mod-survey-index.html +++ b/src/addon/mod/survey/components/index/addon-mod-survey-index.html @@ -6,7 +6,7 @@ - + diff --git a/src/addon/mod/survey/components/index/index.ts b/src/addon/mod/survey/components/index/index.ts index 3a7eb4151..8b6cfbb65 100644 --- a/src/addon/mod/survey/components/index/index.ts +++ b/src/addon/mod/survey/components/index/index.ts @@ -53,7 +53,7 @@ export class AddonModSurveyIndexComponent extends CoreCourseModuleMainActivityCo this.userId = this.sitesProvider.getCurrentSiteUserId(); this.loadContent(false, true).then(() => { - this.surveyProvider.logView(this.survey.id).then(() => { + this.surveyProvider.logView(this.survey.id, this.survey.name).then(() => { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); }).catch(() => { // Ignore errors. diff --git a/src/addon/mod/survey/providers/prefetch-handler.ts b/src/addon/mod/survey/providers/prefetch-handler.ts index e6b785486..83945c844 100644 --- a/src/addon/mod/survey/providers/prefetch-handler.ts +++ b/src/addon/mod/survey/providers/prefetch-handler.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, Injector } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from '@providers/app'; import { CoreFilepoolProvider } from '@providers/filepool'; @@ -22,6 +22,7 @@ import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseActivityPrefetchHandlerBase } from '@core/course/classes/activity-prefetch-handler'; import { AddonModSurveyProvider } from './survey'; +import { AddonModSurveySyncProvider } from './sync'; import { AddonModSurveyHelperProvider } from './helper'; /** @@ -34,10 +35,12 @@ export class AddonModSurveyPrefetchHandler extends CoreCourseActivityPrefetchHan component = AddonModSurveyProvider.COMPONENT; updatesNames = /^configuration$|^.*files$|^answers$/; + protected syncProvider: AddonModSurveySyncProvider; // It will be injected later to prevent circular dependencies. + constructor(translate: TranslateService, appProvider: CoreAppProvider, utils: CoreUtilsProvider, courseProvider: CoreCourseProvider, filepoolProvider: CoreFilepoolProvider, sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, protected surveyProvider: AddonModSurveyProvider, - protected surveyHelper: AddonModSurveyHelperProvider) { + protected surveyHelper: AddonModSurveyHelperProvider, protected injector: Injector) { super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils); } @@ -126,4 +129,20 @@ export class AddonModSurveyPrefetchHandler extends CoreCourseActivityPrefetchHan return Promise.all(promises); }); } + + /** + * Sync a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + sync(module: any, courseId: number, siteId?: any): Promise { + if (!this.syncProvider) { + this.syncProvider = this.injector.get(AddonModSurveySyncProvider); + } + + return this.syncProvider.syncSurvey(module.instance, undefined, siteId); + } } diff --git a/src/addon/mod/survey/providers/survey.ts b/src/addon/mod/survey/providers/survey.ts index ff376e2f8..824b7bd33 100644 --- a/src/addon/mod/survey/providers/survey.ts +++ b/src/addon/mod/survey/providers/survey.ts @@ -20,7 +20,7 @@ import { CoreAppProvider } from '@providers/app'; import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; import { AddonModSurveyOfflineProvider } from './offline'; -import { CoreSiteWSPreSets } from '@classes/site'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; /** * Service that provides some features for surveys. @@ -52,7 +52,8 @@ export class AddonModSurveyProvider { surveyid: surveyId }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getQuestionsCacheKey(surveyId) + cacheKey: this.getQuestionsCacheKey(surveyId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (ignoreCache) { @@ -106,7 +107,8 @@ export class AddonModSurveyProvider { courseids: [courseId] }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getSurveyCacheKey(courseId) + cacheKey: this.getSurveyCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (ignoreCache) { @@ -213,15 +215,17 @@ export class AddonModSurveyProvider { * Report the survey as being viewed. * * @param {number} id Module ID. + * @param {string} [name] Name of the assign. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logView(id: number, siteId?: string): Promise { + logView(id: number, name?: string, siteId?: string): Promise { const params = { surveyid: id }; - return this.logHelper.log('mod_survey_view_survey', params, AddonModSurveyProvider.COMPONENT, id, siteId); + return this.logHelper.logSingle('mod_survey_view_survey', params, AddonModSurveyProvider.COMPONENT, id, name, 'survey', + {}, siteId); } /** diff --git a/src/addon/mod/survey/providers/sync-cron-handler.ts b/src/addon/mod/survey/providers/sync-cron-handler.ts index 1949d9e14..f3e2e9552 100644 --- a/src/addon/mod/survey/providers/sync-cron-handler.ts +++ b/src/addon/mod/survey/providers/sync-cron-handler.ts @@ -30,10 +30,11 @@ export class AddonModSurveySyncCronHandler implements CoreCronHandler { * Receives the ID of the site affected, undefined for all sites. * * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). * @return {Promise} Promise resolved when done, rejected if failure. */ - execute(siteId?: string): Promise { - return this.surveySync.syncAllSurveys(siteId); + execute(siteId?: string, force?: boolean): Promise { + return this.surveySync.syncAllSurveys(siteId, force); } /** diff --git a/src/addon/mod/survey/providers/sync.ts b/src/addon/mod/survey/providers/sync.ts index d38b0a898..0a57a0258 100644 --- a/src/addon/mod/survey/providers/sync.ts +++ b/src/addon/mod/survey/providers/sync.ts @@ -68,23 +68,29 @@ export class AddonModSurveySyncProvider extends CoreCourseActivitySyncBaseProvid * Try to synchronize all the surveys in a certain site or in all sites. * * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} [force] Wether to force sync not depending on last execution. * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - syncAllSurveys(siteId?: string): Promise { - return this.syncOnSites('all surveys', this.syncAllSurveysFunc.bind(this), undefined, siteId); + syncAllSurveys(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all surveys', this.syncAllSurveysFunc.bind(this), [force], siteId); } /** * Sync all pending surveys on a site. - * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * + * @param {string} siteId Site ID to sync. + * @param {boolean} [force] Wether to force sync not depending on last execution. * @param {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - protected syncAllSurveysFunc(siteId?: string): Promise { + protected syncAllSurveysFunc(siteId: string, force?: boolean): Promise { // Get all survey answers pending to be sent in the site. return this.surveyOffline.getAllData(siteId).then((entries) => { // Sync all surveys. const promises = entries.map((entry) => { - return this.syncSurveyIfNeeded(entry.surveyid, entry.userid, siteId).then((result) => { + const promise = force ? this.syncSurvey(entry.surveyid, entry.userid, siteId) : + this.syncSurveyIfNeeded(entry.surveyid, entry.userid, siteId); + + return promise.then((result) => { if (result && result.answersSent) { // Sync successful, send event. this.eventsProvider.trigger(AddonModSurveySyncProvider.AUTO_SYNCED, { @@ -124,91 +130,94 @@ export class AddonModSurveySyncProvider extends CoreCourseActivitySyncBaseProvid * Synchronize a survey. * * @param {number} surveyId Survey ID. - * @param {number} userId User the answers belong to. + * @param {number} [userId] User the answers belong to. If not defined, current user. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved if sync is successful, rejected otherwise. */ - syncSurvey(surveyId: number, userId: number, siteId?: string): Promise { - siteId = siteId || this.sitesProvider.getCurrentSiteId(); + syncSurvey(surveyId: number, userId?: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + siteId = site.getId(); - const syncId = this.getSyncId(surveyId, userId); - if (this.isSyncing(syncId, siteId)) { - // There's already a sync ongoing for this survey and user, return the promise. - return this.getOngoingSync(syncId, siteId); - } - - this.logger.debug(`Try to sync survey '${surveyId}' for user '${userId}'`); - - let courseId; - const result = { - warnings: [], - answersSent: false - }; - - // Sync offline logs. - const syncPromise = this.logHelper.syncIfNeeded(AddonModSurveyProvider.COMPONENT, surveyId, siteId).catch(() => { - // Ignore errors. - }).then(() => { - // Get answers to be sent. - return this.surveyOffline.getSurveyData(surveyId, siteId, userId).catch(() => { - // No offline data found, return empty object. - return {}; - }); - }).then((data) => { - if (!data.answers || !data.answers.length) { - // Nothing to sync. - return; + const syncId = this.getSyncId(surveyId, userId); + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing for this survey and user, return the promise. + return this.getOngoingSync(syncId, siteId); } - if (!this.appProvider.isOnline()) { - // Cannot sync in offline. - return Promise.reject(null); - } + this.logger.debug(`Try to sync survey '${surveyId}' for user '${userId}'`); - courseId = data.courseid; + let courseId; + const result = { + warnings: [], + answersSent: false + }; - // Send the answers. - return this.surveyProvider.submitAnswersOnline(surveyId, data.answers, siteId).then(() => { - result.answersSent = true; - - // Answers sent, delete them. - return this.surveyOffline.deleteSurveyAnswers(surveyId, siteId, userId); - }).catch((error) => { - if (this.utils.isWebServiceError(error)) { - - // The WebService has thrown an error, this means that answers cannot be submitted. Delete them. - result.answersSent = true; - - return this.surveyOffline.deleteSurveyAnswers(surveyId, siteId, userId).then(() => { - // Answers deleted, add a warning. - result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { - component: this.componentTranslate, - name: data.name, - error: this.textUtils.getErrorMessageFromError(error) - })); - }); + // Sync offline logs. + const syncPromise = this.logHelper.syncIfNeeded(AddonModSurveyProvider.COMPONENT, surveyId, siteId).catch(() => { + // Ignore errors. + }).then(() => { + // Get answers to be sent. + return this.surveyOffline.getSurveyData(surveyId, siteId, userId).catch(() => { + // No offline data found, return empty object. + return {}; + }); + }).then((data) => { + if (!data.answers || !data.answers.length) { + // Nothing to sync. + return; } - // Couldn't connect to server, reject. - return Promise.reject(error); - }); - }).then(() => { - if (courseId) { - // Data has been sent to server, update survey data. - return this.courseProvider.getModuleBasicInfoByInstance(surveyId, 'survey', siteId).then((module) => { - return this.prefetchAfterUpdate(module, courseId, undefined, siteId); - }).catch(() => { - // Ignore errors. - }); - } - }).then(() => { - // Sync finished, set sync time. - return this.setSyncTime(syncId, siteId); - }).then(() => { - return result; - }); + if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } - return this.addOngoingSync(syncId, syncPromise, siteId); + courseId = data.courseid; + + // Send the answers. + return this.surveyProvider.submitAnswersOnline(surveyId, data.answers, siteId).then(() => { + result.answersSent = true; + + // Answers sent, delete them. + return this.surveyOffline.deleteSurveyAnswers(surveyId, siteId, userId); + }).catch((error) => { + if (this.utils.isWebServiceError(error)) { + + // The WebService has thrown an error, this means that answers cannot be submitted. Delete them. + result.answersSent = true; + + return this.surveyOffline.deleteSurveyAnswers(surveyId, siteId, userId).then(() => { + // Answers deleted, add a warning. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: data.name, + error: this.textUtils.getErrorMessageFromError(error) + })); + }); + } + + // Couldn't connect to server, reject. + return Promise.reject(error); + }); + }).then(() => { + if (courseId) { + // Data has been sent to server, update survey data. + return this.courseProvider.getModuleBasicInfoByInstance(surveyId, 'survey', siteId).then((module) => { + return this.prefetchAfterUpdate(module, courseId, undefined, siteId); + }).catch(() => { + // Ignore errors. + }); + } + }).then(() => { + // Sync finished, set sync time. + return this.setSyncTime(syncId, siteId); + }).then(() => { + return result; + }); + + return this.addOngoingSync(syncId, syncPromise, siteId); + }); } } diff --git a/src/addon/mod/url/components/index/index.ts b/src/addon/mod/url/components/index/index.ts index d5c92ec80..842d14194 100644 --- a/src/addon/mod/url/components/index/index.ts +++ b/src/addon/mod/url/components/index/index.ts @@ -178,7 +178,7 @@ export class AddonModUrlIndexComponent extends CoreCourseModuleMainResourceCompo * @return {Promise} Promise resolved when done. */ protected logView(): Promise { - return this.urlProvider.logView(this.module.instance).then(() => { + return this.urlProvider.logView(this.module.instance, this.module.name).then(() => { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); }).catch(() => { // Ignore errors. diff --git a/src/addon/mod/url/providers/helper.ts b/src/addon/mod/url/providers/helper.ts index 5a2c0dec9..4620fce98 100644 --- a/src/addon/mod/url/providers/helper.ts +++ b/src/addon/mod/url/providers/helper.ts @@ -33,7 +33,7 @@ export class AddonModUrlHelperProvider { */ open(url: string): void { const modal = this.domUtils.showModalLoading(); - this.contentLinksHelper.handleLink(url).then((treated) => { + this.contentLinksHelper.handleLink(url, undefined, undefined, true, true).then((treated) => { if (!treated) { return this.sitesProvider.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(url); } diff --git a/src/addon/mod/url/providers/module-handler.ts b/src/addon/mod/url/providers/module-handler.ts index 9cf493fd9..fb51ea3bc 100644 --- a/src/addon/mod/url/providers/module-handler.ts +++ b/src/addon/mod/url/providers/module-handler.ts @@ -79,7 +79,7 @@ export class AddonModUrlModuleHandler implements CoreCourseModuleHandler { handler.courseProvider.loadModuleContents(module, courseId, undefined, false, false, undefined, handler.modName) .then(() => { // Check if the URL can be handled by the app. If so, always open it directly. - return handler.contentLinksHelper.canHandleLink(module.contents[0].fileurl, courseId); + return handler.contentLinksHelper.canHandleLink(module.contents[0].fileurl, courseId, undefined, true); }).then((canHandle) => { if (canHandle) { // URL handled by the app, open it directly. @@ -173,7 +173,7 @@ export class AddonModUrlModuleHandler implements CoreCourseModuleHandler { * @param {number} courseId The course ID. */ protected openUrl(module: any, courseId: number): void { - this.urlProvider.logView(module.instance).then(() => { + this.urlProvider.logView(module.instance, module.name).then(() => { this.courseProvider.checkModuleCompletion(courseId, module.completiondata); }).catch(() => { // Ignore errors. diff --git a/src/addon/mod/url/providers/url.ts b/src/addon/mod/url/providers/url.ts index e7e02b85a..13261f104 100644 --- a/src/addon/mod/url/providers/url.ts +++ b/src/addon/mod/url/providers/url.ts @@ -20,6 +20,7 @@ import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; import { CoreConstants } from '@core/constants'; +import { CoreSite } from '@classes/site'; /** * Service that provides some features for urls. @@ -114,7 +115,8 @@ export class AddonModUrlProvider { courseids: [courseId] }, preSets = { - cacheKey: this.getUrlCacheKey(courseId) + cacheKey: this.getUrlCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('mod_url_get_urls_by_courses', params, preSets).then((response) => { @@ -217,14 +219,15 @@ export class AddonModUrlProvider { * Report the url as being viewed. * * @param {number} id Module ID. + * @param {string} [name] Name of the assign. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logView(id: number, siteId?: string): Promise { + logView(id: number, name?: string, siteId?: string): Promise { const params = { urlid: id }; - return this.logHelper.log('mod_url_view_url', params, AddonModUrlProvider.COMPONENT, id, siteId); + return this.logHelper.logSingle('mod_url_view_url', params, AddonModUrlProvider.COMPONENT, id, name, 'url', {}, siteId); } } diff --git a/src/addon/mod/wiki/components/index/addon-mod-wiki-index.html b/src/addon/mod/wiki/components/index/addon-mod-wiki-index.html index 0a8a6c5b1..3d8834595 100644 --- a/src/addon/mod/wiki/components/index/addon-mod-wiki-index.html +++ b/src/addon/mod/wiki/components/index/addon-mod-wiki-index.html @@ -18,7 +18,7 @@ - + diff --git a/src/addon/mod/wiki/components/index/index.ts b/src/addon/mod/wiki/components/index/index.ts index 25bc9af76..941417a68 100644 --- a/src/addon/mod/wiki/components/index/index.ts +++ b/src/addon/mod/wiki/components/index/index.ts @@ -104,13 +104,13 @@ export class AddonModWikiIndexComponent extends CoreCourseModuleMainActivityComp } if (this.isMainPage) { - this.wikiProvider.logView(this.wiki.id).then(() => { + this.wikiProvider.logView(this.wiki.id, this.wiki.name).then(() => { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); }).catch((error) => { // Ignore errors. }); } else { - this.wikiProvider.logPageView(this.pageId, this.wiki.id).catch(() => { + this.wikiProvider.logPageView(this.pageId, this.wiki.id, this.wiki.name).catch(() => { // Ignore errors. }); } @@ -341,7 +341,7 @@ export class AddonModWikiIndexComponent extends CoreCourseModuleMainActivityComp this.currentPage = data.pageId; this.showLoadingAndFetch(true, false).then(() => { - this.wikiProvider.logPageView(this.currentPage, this.wiki.id).catch(() => { + this.wikiProvider.logPageView(this.currentPage, this.wiki.id, this.wiki.name).catch(() => { // Ignore errors. }); }); diff --git a/src/addon/mod/wiki/providers/prefetch-handler.ts b/src/addon/mod/wiki/providers/prefetch-handler.ts index a8848db0c..5aaa5d158 100644 --- a/src/addon/mod/wiki/providers/prefetch-handler.ts +++ b/src/addon/mod/wiki/providers/prefetch-handler.ts @@ -27,6 +27,7 @@ import { CoreCourseHelperProvider } from '@core/course/providers/helper'; import { CoreGradesHelperProvider } from '@core/grades/providers/helper'; import { CoreUserProvider } from '@core/user/providers/user'; import { AddonModWikiProvider } from './wiki'; +import { AddonModWikiSyncProvider } from './wiki-sync'; /** * Handler to prefetch wikis. @@ -42,7 +43,8 @@ export class AddonModWikiPrefetchHandler extends CoreCourseActivityPrefetchHandl courseProvider: CoreCourseProvider, filepoolProvider: CoreFilepoolProvider, sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, protected wikiProvider: AddonModWikiProvider, protected userProvider: CoreUserProvider, protected textUtils: CoreTextUtilsProvider, protected courseHelper: CoreCourseHelperProvider, - protected groupsProvider: CoreGroupsProvider, protected gradesHelper: CoreGradesHelperProvider) { + protected groupsProvider: CoreGroupsProvider, protected gradesHelper: CoreGradesHelperProvider, + protected syncProvider: AddonModWikiSyncProvider) { super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils); } @@ -210,4 +212,16 @@ export class AddonModWikiPrefetchHandler extends CoreCourseActivityPrefetchHandl return Promise.all(promises); }); } + + /** + * Sync a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + sync(module: any, courseId: number, siteId?: any): Promise { + return this.syncProvider.syncWiki(module.instance, module.course, module.id, siteId); + } } diff --git a/src/addon/mod/wiki/providers/sync-cron-handler.ts b/src/addon/mod/wiki/providers/sync-cron-handler.ts index ce1fa6980..c74a439fe 100644 --- a/src/addon/mod/wiki/providers/sync-cron-handler.ts +++ b/src/addon/mod/wiki/providers/sync-cron-handler.ts @@ -30,10 +30,11 @@ export class AddonModWikiSyncCronHandler implements CoreCronHandler { * Receives the ID of the site affected, undefined for all sites. * * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). * @return {Promise} Promise resolved when done, rejected if failure. */ - execute(siteId?: string): Promise { - return this.wikiSync.syncAllWikis(siteId); + execute(siteId?: string, force?: boolean): Promise { + return this.wikiSync.syncAllWikis(siteId, force); } /** diff --git a/src/addon/mod/wiki/providers/wiki-sync.ts b/src/addon/mod/wiki/providers/wiki-sync.ts index cfd37e1a0..9fa0200c9 100644 --- a/src/addon/mod/wiki/providers/wiki-sync.ts +++ b/src/addon/mod/wiki/providers/wiki-sync.ts @@ -143,19 +143,21 @@ export class AddonModWikiSyncProvider extends CoreSyncBaseProvider { * Try to synchronize all the wikis in a certain site or in all sites. * * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} [force] Wether to force sync not depending on last execution. * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - syncAllWikis(siteId?: string): Promise { - return this.syncOnSites('all wikis', this.syncAllWikisFunc.bind(this), [], siteId); + syncAllWikis(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all wikis', this.syncAllWikisFunc.bind(this), [force], siteId); } /** * Sync all wikis on a site. * - * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {string} siteId Site ID to sync. + * @param {boolean} [force] Wether to force sync not depending on last execution. * @param {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - protected syncAllWikisFunc(siteId?: string): Promise { + protected syncAllWikisFunc(siteId: string, force?: boolean): Promise { // Get all the pages created in offline. return this.wikiOfflineProvider.getAllNewPages(siteId).then((pages) => { const promises = [], @@ -171,8 +173,10 @@ export class AddonModWikiSyncProvider extends CoreSyncBaseProvider { for (const id in subwikis) { const subwiki = subwikis[id]; - promises.push(this.syncSubwikiIfNeeded(subwiki.subwikiid, subwiki.wikiid, subwiki.userid, subwiki.groupid, - siteId).then((result) => { + const promise = force ? this.syncSubwiki(subwiki.subwikiid, subwiki.wikiid, subwiki.userid, subwiki.groupid, siteId) + : this.syncSubwikiIfNeeded(subwiki.subwikiid, subwiki.wikiid, subwiki.userid, subwiki.groupid, siteId); + + promises.push(promise.then((result) => { if (result && result.updated) { // Sync successful, send event. diff --git a/src/addon/mod/wiki/providers/wiki.ts b/src/addon/mod/wiki/providers/wiki.ts index 42dcd8b1c..4523f541b 100644 --- a/src/addon/mod/wiki/providers/wiki.ts +++ b/src/addon/mod/wiki/providers/wiki.ts @@ -22,7 +22,7 @@ import { CoreAppProvider } from '@providers/app'; import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper'; import { AddonModWikiOfflineProvider } from './wiki-offline'; -import { CoreSiteWSPreSets } from '@classes/site'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; export interface AddonModWikiSubwikiListData { /** @@ -134,7 +134,8 @@ export class AddonModWikiProvider { pageid: pageId }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getPageContentsCacheKey(pageId) + cacheKey: this.getPageContentsCacheKey(pageId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; if (offline) { @@ -215,7 +216,8 @@ export class AddonModWikiProvider { userid: userId }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getSubwikiFilesCacheKey(wikiId, groupId, userId) + cacheKey: this.getSubwikiFilesCacheKey(wikiId, groupId, userId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; if (offline) { @@ -299,7 +301,8 @@ export class AddonModWikiProvider { }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getSubwikiPagesCacheKey(wikiId, groupId, userId) + cacheKey: this.getSubwikiPagesCacheKey(wikiId, groupId, userId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; if (offline) { @@ -352,7 +355,8 @@ export class AddonModWikiProvider { wikiid: wikiId }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getSubwikisCacheKey(wikiId) + cacheKey: this.getSubwikisCacheKey(wikiId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (offline) { @@ -408,7 +412,8 @@ export class AddonModWikiProvider { courseids: [courseId] }, preSets = { - cacheKey: this.getWikiDataCacheKey(courseId) + cacheKey: this.getWikiDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('mod_wiki_get_wikis_by_courses', params, preSets).then((response) => { @@ -655,30 +660,34 @@ export class AddonModWikiProvider { * * @param {number} id Page ID. * @param {number} wikiId Wiki ID. + * @param {string} [name] Name of the wiki. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logPageView(id: number, wikiId: number, siteId?: string): Promise { + logPageView(id: number, wikiId: number, name?: string, siteId?: string): Promise { const params = { pageid: id }; - return this.logHelper.log('mod_wiki_view_page', params, AddonModWikiProvider.COMPONENT, wikiId, siteId); + return this.logHelper.logSingle('mod_wiki_view_page', params, AddonModWikiProvider.COMPONENT, wikiId, name, 'wiki', + params, siteId); } /** * Report the wiki as being viewed. * * @param {number} id Wiki ID. + * @param {string} [name] Name of the wiki. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the WS call is successful. */ - logView(id: number, siteId?: string): Promise { + logView(id: number, name?: string, siteId?: string): Promise { const params = { wikiid: id }; - return this.logHelper.log('mod_wiki_view_wiki', params, AddonModWikiProvider.COMPONENT, id, siteId); + return this.logHelper.logSingle('mod_wiki_view_wiki', params, AddonModWikiProvider.COMPONENT, id, name, 'wiki', {}, + siteId); } /** diff --git a/src/addon/mod/workshop/assessment/accumulative/providers/handler.ts b/src/addon/mod/workshop/assessment/accumulative/providers/handler.ts index e361727b0..40505af47 100644 --- a/src/addon/mod/workshop/assessment/accumulative/providers/handler.ts +++ b/src/addon/mod/workshop/assessment/accumulative/providers/handler.ts @@ -63,8 +63,6 @@ export class AddonModWorkshopAssessmentStrategyAccumulativeHandler implements Ad field.dimtitle = this.translate.instant( 'addon.mod_workshop_assessment_accumulative.dimensionnumber', {$a: field.number}); - const scale = parseInt(field.grade, 10) < 0 ? form.dimensionsinfo[n].scale : null; - if (!form.current[n]) { form.current[n] = {}; } @@ -76,7 +74,11 @@ export class AddonModWorkshopAssessmentStrategyAccumulativeHandler implements Ad form.current[n].grade = form.current[n].grade ? parseInt(form.current[n].grade, 10) : -1; - promises.push(this.gradesHelper.makeGradesMenu(field.grade, workshopId, defaultGrade, -1, scale).then((grades) => { + const gradingType = parseInt(field.grade, 10); + const dimension = form.dimensionsinfo.find((dimension) => dimension.id == field.dimensionid); + const scale = dimension && gradingType < 0 ? dimension.scale : null; + + promises.push(this.gradesHelper.makeGradesMenu(gradingType, undefined, defaultGrade, -1, scale).then((grades) => { field.grades = grades; originalValues[n].grade = form.current[n].grade; })); diff --git a/src/addon/mod/workshop/components/assessment-strategy/addon-mod-workshop-assessment-strategy.html b/src/addon/mod/workshop/components/assessment-strategy/addon-mod-workshop-assessment-strategy.html index 1ae7628aa..e74d57ded 100644 --- a/src/addon/mod/workshop/components/assessment-strategy/addon-mod-workshop-assessment-strategy.html +++ b/src/addon/mod/workshop/components/assessment-strategy/addon-mod-workshop-assessment-strategy.html @@ -10,7 +10,7 @@ {{ 'addon.mod_workshop.assessmentstrategynotsupported' | translate:{$a: strategy} }}
- +

{{ 'addon.mod_workshop.overallfeedback' | translate }}

diff --git a/src/addon/mod/workshop/components/index/addon-mod-workshop-index.html b/src/addon/mod/workshop/components/index/addon-mod-workshop-index.html index f778b5f19..2c9f9be19 100644 --- a/src/addon/mod/workshop/components/index/addon-mod-workshop-index.html +++ b/src/addon/mod/workshop/components/index/addon-mod-workshop-index.html @@ -6,7 +6,7 @@ - + @@ -127,7 +127,7 @@

{{ 'addon.mod_workshop.assignedassessmentsnone' | translate }}

- +
diff --git a/src/addon/mod/workshop/components/index/index.ts b/src/addon/mod/workshop/components/index/index.ts index 1515e91da..e158670bf 100644 --- a/src/addon/mod/workshop/components/index/index.ts +++ b/src/addon/mod/workshop/components/index/index.ts @@ -107,7 +107,7 @@ export class AddonModWorkshopIndexComponent extends CoreCourseModuleMainActivity return; } - this.workshopProvider.logView(this.workshop.id).then(() => { + this.workshopProvider.logView(this.workshop.id, this.workshop.name).then(() => { this.courseProvider.checkModuleCompletion(this.courseId, this.module.completiondata); }).catch((error) => { // Ignore errors. diff --git a/src/addon/mod/workshop/components/submission/addon-mod-workshop-submission.html b/src/addon/mod/workshop/components/submission/addon-mod-workshop-submission.html index 180f298c8..7a3cf4d86 100644 --- a/src/addon/mod/workshop/components/submission/addon-mod-workshop-submission.html +++ b/src/addon/mod/workshop/components/submission/addon-mod-workshop-submission.html @@ -32,7 +32,7 @@ - +

{{ 'addon.mod_workshop.feedbackby' | translate : {$a: evaluateByProfile.fullname} }}

diff --git a/src/addon/mod/workshop/pages/assessment/assessment.ts b/src/addon/mod/workshop/pages/assessment/assessment.ts index 09b6a1e3f..6c1783e35 100644 --- a/src/addon/mod/workshop/pages/assessment/assessment.ts +++ b/src/addon/mod/workshop/pages/assessment/assessment.ts @@ -182,8 +182,8 @@ export class AddonModWorkshopAssessmentPage implements OnInit, OnDestroy { if (accessData.canoverridegrades) { defaultGrade = this.translate.instant('addon.mod_workshop.notoverridden'); - promise = this.gradesHelper.makeGradesMenu(this.workshop.gradinggrade, this.workshopId, defaultGrade, - -1).then((grades) => { + promise = this.gradesHelper.makeGradesMenu(this.workshop.gradinggrade, undefined, defaultGrade, -1) + .then((grades) => { this.evaluationGrades = grades; }); } else { diff --git a/src/addon/mod/workshop/pages/submission/submission.html b/src/addon/mod/workshop/pages/submission/submission.html index 7a77c3f44..70fdbb714 100644 --- a/src/addon/mod/workshop/pages/submission/submission.html +++ b/src/addon/mod/workshop/pages/submission/submission.html @@ -2,7 +2,7 @@ -

- - -

{{notification.userfromfullname}}

-
-

{{notification.timecreated | coreDateDayOrTime}}

+ + + +

+

+ {{notification.timecreated | coreDateDayOrTime}} + + +

+

- +
diff --git a/src/addon/notifications/pages/list/list.scss b/src/addon/notifications/pages/list/list.scss new file mode 100644 index 000000000..122b8e4dc --- /dev/null +++ b/src/addon/notifications/pages/list/list.scss @@ -0,0 +1,5 @@ +page-addon-notifications-list .core-notification-icon { + width: 34px; + height: 34px; + margin: 10px !important; +} \ No newline at end of file diff --git a/src/addon/notifications/pages/list/list.ts b/src/addon/notifications/pages/list/list.ts index 62add77ad..3b8d1751a 100644 --- a/src/addon/notifications/pages/list/list.ts +++ b/src/addon/notifications/pages/list/list.ts @@ -21,7 +21,8 @@ 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'; +import { AddonNotificationsHelperProvider } from '../../providers/helper'; +import { CorePushNotificationsDelegate } from '@core/pushnotifications/providers/delegate'; /** * Page that displays the list of notifications. @@ -40,15 +41,14 @@ export class AddonNotificationsListPage { canMarkAllNotificationsAsRead = false; loadingMarkAllNotificationsAsRead = 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) { + private pushNotificationsDelegate: CorePushNotificationsDelegate, + private notificationsHelper: AddonNotificationsHelperProvider) { } /** @@ -79,53 +79,17 @@ export class AddonNotificationsListPage { protected fetchNotifications(refresh?: boolean): Promise { this.loadMoreError = false; - if (refresh) { - this.readCount = 0; - this.unreadCount = 0; - } + return this.notificationsHelper.getNotifications(refresh ? [] : this.notifications).then((result) => { + result.notifications.forEach(this.formatText.bind(this)); - const limit = AddonNotificationsProvider.LIST_LIMIT; - - return this.notificationsProvider.getUnreadNotifications(this.unreadCount, limit).then((unread) => { - const promises = []; - - 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; - promises.push(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.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. - } - })); + if (refresh) { + this.notifications = result.notifications; } else { - if (refresh) { - this.notifications = unread; - } else { - this.notifications = this.notifications.concat(unread); - } - this.canLoadMore = true; + this.notifications = this.notifications.concat(result.notifications); } + this.canLoadMore = result.canLoadMore; - return Promise.all(promises).then(() => { - // Mark retrieved notifications as read if they are not. - this.markNotificationsAsRead(unread); - }); + this.markNotificationsAsRead(result.notifications); }).catch((error) => { this.domUtils.showErrorModalDefault(error, 'addon.notifications.errorgetnotifications', true); this.loadMoreError = true; // Set to prevent infinite calls with infinite-loading. @@ -162,6 +126,11 @@ export class AddonNotificationsListPage { if (notifications.length > 0) { const promises = notifications.map((notification) => { + if (notification.read) { + // Already read, don't mark it. + return Promise.resolve(); + } + return this.notificationsProvider.markNotificationRead(notification.id); }); diff --git a/src/addon/notifications/pages/settings/settings.ts b/src/addon/notifications/pages/settings/settings.ts index ed4cabc45..58ab72395 100644 --- a/src/addon/notifications/pages/settings/settings.ts +++ b/src/addon/notifications/pages/settings/settings.ts @@ -55,7 +55,8 @@ export class AddonNotificationsSettingsPage implements OnDestroy { @Optional() private svComponent: CoreSplitViewComponent) { this.notifPrefsEnabled = notificationsProvider.isNotificationPreferencesEnabled(); - this.canChangeSound = localNotificationsProvider.isAvailable() && !appProvider.isDesktop(); + this.canChangeSound = localNotificationsProvider.canDisableSound(); + if (this.canChangeSound) { configProvider.get(CoreConstants.SETTINGS_NOTIFICATION_SOUND, true).then((enabled) => { this.notificationSound = !!enabled; diff --git a/src/addon/notifications/providers/cron-handler.ts b/src/addon/notifications/providers/cron-handler.ts index 78f317c41..e7ae59f17 100644 --- a/src/addon/notifications/providers/cron-handler.ts +++ b/src/addon/notifications/providers/cron-handler.ts @@ -21,6 +21,7 @@ import { CoreSitesProvider } from '@providers/sites'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreEmulatorHelperProvider } from '@core/emulator/providers/helper'; import { AddonNotificationsProvider } from './notifications'; +import { AddonNotificationsHelperProvider } from './helper'; /** * Notifications cron handler. @@ -32,7 +33,7 @@ export class AddonNotificationsCronHandler implements CoreCronHandler { constructor(private appProvider: CoreAppProvider, private eventsProvider: CoreEventsProvider, private sitesProvider: CoreSitesProvider, private localNotifications: CoreLocalNotificationsProvider, private notificationsProvider: AddonNotificationsProvider, private textUtils: CoreTextUtilsProvider, - private emulatorHelper: CoreEmulatorHelperProvider) {} + private emulatorHelper: CoreEmulatorHelperProvider, private notificationsHelper: AddonNotificationsHelperProvider) {} /** * Get the time between consecutive executions. @@ -65,12 +66,14 @@ export class AddonNotificationsCronHandler implements CoreCronHandler { /** * Execute the process. + * Receives the ID of the site affected, undefined for all sites. * - * @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. + * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). + * @return {Promise} Promise resolved when done, rejected if failure. If the promise is rejected, this function + * will be called again often, it shouldn't be abused. */ - execute(siteId?: string): Promise { + execute(siteId?: string, force?: boolean): Promise { if (this.sitesProvider.isCurrentSite(siteId)) { this.eventsProvider.trigger(AddonNotificationsProvider.READ_CRON_EVENT, {}, this.sitesProvider.getCurrentSiteId()); } @@ -91,7 +94,9 @@ export class AddonNotificationsCronHandler implements CoreCronHandler { * @return {Promise} Promise resolved with the notifications. */ protected fetchNotifications(siteId: string): Promise { - return this.notificationsProvider.getUnreadNotifications(0, undefined, true, false, true, siteId); + return this.notificationsHelper.getNotifications([], undefined, true, false, true, siteId).then((result) => { + return result.notifications; + }); } /** @@ -102,7 +107,7 @@ export class AddonNotificationsCronHandler implements CoreCronHandler { */ protected getTitleAndText(notification: any): Promise { const data = { - title: notification.userfromfullname, + title: notification.subject || notification.userfromfullname, text: notification.mobiletext.replace(/-{4,}/ig, '') }; data.text = this.textUtils.replaceNewLines(data.text, '
'); diff --git a/src/addon/notifications/providers/helper.ts b/src/addon/notifications/providers/helper.ts new file mode 100644 index 000000000..f9c9e80bf --- /dev/null +++ b/src/addon/notifications/providers/helper.ts @@ -0,0 +1,93 @@ +// (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 { CoreSitesProvider } from '@providers/sites'; +import { AddonNotificationsProvider } from './notifications'; + +/** + * Service that provides some helper functions for notifications. + */ +@Injectable() +export class AddonNotificationsHelperProvider { + + constructor(private notificationsProvider: AddonNotificationsProvider, private sitesProvider: CoreSitesProvider) { + } + + /** + * Get some notifications. It will try to use the new WS if available. + * + * @param {any[]} notifications Current list of loaded notifications. It's used to calculate the offset. + * @param {number} [limit] Number of notifications to get. Defaults to LIST_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<{notifications: any[], canLoadMore: boolean}>} Promise resolved with notifications and if can load more. + */ + getNotifications(notifications: any[], limit?: number, toDisplay: boolean = true, forceCache?: boolean, ignoreCache?: boolean, + siteId?: string): Promise<{notifications: any[], canLoadMore: boolean}> { + + notifications = notifications || []; + limit = limit || AddonNotificationsProvider.LIST_LIMIT; + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.notificationsProvider.isPopupAvailable(siteId).then((available) => { + + if (available) { + return this.notificationsProvider.getPopupNotifications(notifications.length, limit, toDisplay, forceCache, + ignoreCache, siteId); + + } else { + // Fallback to get_messages. We need 2 calls, one for read and the other one for unread. + const unreadFrom = notifications.reduce((total, current) => { + return total + (current.read ? 0 : 1); + }, 0); + + return this.notificationsProvider.getUnreadNotifications(unreadFrom, limit, toDisplay, forceCache, ignoreCache, + siteId).then((unread) => { + + let promise; + + if (unread.length < limit) { + // Limit not reached. Get read notifications until reach the limit. + const readLimit = limit - unread.length, + readFrom = notifications.length - unreadFrom; + + promise = this.notificationsProvider.getReadNotifications(readFrom, readLimit, toDisplay, forceCache, + ignoreCache, siteId).then((read) => { + return unread.concat(read); + }).catch((error): any => { + if (unread.length > 0) { + // We were able to get some unread, return only the unread ones. + return unread; + } + + return Promise.reject(error); + }); + } else { + promise = Promise.resolve(unread); + } + + return promise.then((notifications) => { + return { + notifications: notifications, + canLoadMore: notifications.length >= limit + }; + }); + }); + } + }); + } +} diff --git a/src/addon/notifications/providers/mainmenu-handler.ts b/src/addon/notifications/providers/mainmenu-handler.ts index 8866d14a8..a73fd6065 100644 --- a/src/addon/notifications/providers/mainmenu-handler.ts +++ b/src/addon/notifications/providers/mainmenu-handler.ts @@ -18,8 +18,8 @@ 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'; +import { CorePushNotificationsProvider } from '@core/pushnotifications/providers/pushnotifications'; +import { CorePushNotificationsDelegate } from '@core/pushnotifications/providers/delegate'; /** * Handler to inject an option into main menu. @@ -41,8 +41,8 @@ export class AddonNotificationsMainMenuHandler implements CoreMainMenuHandler { constructor(eventsProvider: CoreEventsProvider, private sitesProvider: CoreSitesProvider, utils: CoreUtilsProvider, private notificationsProvider: AddonNotificationsProvider, - private pushNotificationsProvider: AddonPushNotificationsProvider, - pushNotificationsDelegate: AddonPushNotificationsDelegate) { + private pushNotificationsProvider: CorePushNotificationsProvider, + pushNotificationsDelegate: CorePushNotificationsDelegate) { eventsProvider.on(AddonNotificationsProvider.READ_CHANGED_EVENT, (data) => { this.updateBadge(data.siteId); diff --git a/src/addon/notifications/providers/notifications.ts b/src/addon/notifications/providers/notifications.ts index ebe5a1359..db8b5b62b 100644 --- a/src/addon/notifications/providers/notifications.ts +++ b/src/addon/notifications/providers/notifications.ts @@ -16,10 +16,12 @@ import { Injectable } from '@angular/core'; import { CoreAppProvider } from '@providers/app'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreUserProvider } from '@core/user/providers/user'; import { CoreEmulatorHelperProvider } from '@core/emulator/providers/helper'; import { AddonMessagesProvider } from '@addon/messages/providers/messages'; +import { CoreSite } from '@classes/site'; /** * Service to handle notifications. @@ -37,7 +39,8 @@ export class AddonNotificationsProvider { constructor(logger: CoreLoggerProvider, private appProvider: CoreAppProvider, private sitesProvider: CoreSitesProvider, private timeUtils: CoreTimeUtilsProvider, private userProvider: CoreUserProvider, - private emulatorHelper: CoreEmulatorHelperProvider, private messageProvider: AddonMessagesProvider) { + private emulatorHelper: CoreEmulatorHelperProvider, private messageProvider: AddonMessagesProvider, + private textUtils: CoreTextUtilsProvider) { this.logger = logger.getInstance('AddonNotificationsProvider'); } @@ -45,25 +48,47 @@ export class AddonNotificationsProvider { * Function to format notification data. * * @param {any[]} notifications List of notifications. + * @param {boolean} [read] Whether the notifications are read or unread. * @return {Promise} Promise resolved with notifications. */ - protected formatNotificationsData(notifications: any[]): Promise { + protected formatNotificationsData(notifications: any[], read?: boolean): Promise { const promises = notifications.map((notification) => { + // Set message to show. - if (notification.contexturl && notification.contexturl.indexOf('/mod/forum/') >= 0) { + if (notification.component && notification.component == 'mod_forum') { notification.mobiletext = notification.smallmessage; + } else if (notification.component && notification.component == 'moodle' && notification.name == 'insights') { + notification.mobiletext = notification.fullmessagehtml; } 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]; + + notification.moodlecomponent = notification.component; + notification.notification = 1; + notification.notif = 1; + if (typeof read != 'undefined') { + notification.read = read; } + + if (typeof notification.customdata == 'string') { + notification.customdata = this.textUtils.parseJSON(notification.customdata, {}); + } + + // Try to set courseid the notification belongs to. + if (notification.customdata && notification.customdata.courseid) { + notification.courseid = notification.customdata.courseid; + } else if (!notification.courseid) { + const cid = notification.fullmessagehtml.match(/course\/view\.php\?id=([^"]*)/); + if (cid && cid[1]) { + notification.courseid = parseInt(cid[1], 10); + } + } + if (notification.useridfrom > 0) { // Try to get the profile picture of the user. return this.userProvider.getProfile(notification.useridfrom, notification.courseid, true).then((user) => { notification.profileimageurlfrom = user.profileimageurl; + notification.userfromfullname = user.fullname; return notification; }).catch(() => { @@ -97,7 +122,8 @@ export class AddonNotificationsProvider { return this.sitesProvider.getSite(siteId).then((site) => { const preSets = { - cacheKey: this.getNotificationPreferencesCacheKey() + cacheKey: this.getNotificationPreferencesCacheKey(), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; return site.read('core_message_get_user_notification_preferences', {}, preSets).then((data) => { @@ -154,7 +180,7 @@ export class AddonNotificationsProvider { if (response.messages) { const notifications = response.messages; - return this.formatNotificationsData(notifications).then(() => { + return this.formatNotificationsData(notifications, read).then(() => { if (this.appProvider.isDesktop() && toDisplay && !read && limitFrom === 0) { // Store the last received notification. Don't block the user for this. this.emulatorHelper.storeLastReceivedNotification( @@ -170,6 +196,67 @@ export class AddonNotificationsProvider { }); } + /** + * Get notifications from site using the new WebService. + * + * @param {number} offset Position of the first notification to get. + * @param {number} [limit] Number of notifications to get. Defaults to LIST_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<{notifications: any[], canLoadMore: boolean}>} Promise resolved with notifications and if can load more. + * @since 3.2 + */ + getPopupNotifications(offset: number, limit?: number, toDisplay: boolean = true, forceCache?: boolean, ignoreCache?: boolean, + siteId?: string): Promise<{notifications: any[], canLoadMore: boolean}> { + + limit = limit || AddonNotificationsProvider.LIST_LIMIT; + + this.logger.debug('Get popup notifications from ' + offset + '. Limit: ' + limit); + + return this.sitesProvider.getSite(siteId).then((site) => { + const data = { + useridto: site.getUserId(), + newestfirst: 1, + offset: offset, + limit: limit + 1 // Get one more to calculate canLoadMore. + }, + preSets = { + cacheKey: this.getNotificationsCacheKey(), + omitExpires: forceCache, + getFromCache: forceCache || !ignoreCache, + emergencyCache: forceCache || !ignoreCache, + }; + + // Get notifications. + return site.read('message_popup_get_popup_notifications', data, preSets).then((response) => { + if (response.notifications) { + const result: any = { + canLoadMore: response.notifications.length > limit + }, + notifications = response.notifications.slice(0, limit); + + result.notifications = notifications; + + return this.formatNotificationsData(notifications).then(() => { + const first = notifications[0]; + + if (this.appProvider.isDesktop() && toDisplay && offset === 0 && first && !first.read) { + // Store the last received notification. Don't block the user for this. + this.emulatorHelper.storeLastReceivedNotification( + AddonNotificationsProvider.PUSH_SIMULATION_COMPONENT, first, siteId); + } + + return result; + }); + } else { + return Promise.reject(null); + } + }); + }); + } + /** * Get read notifications from site. * @@ -244,6 +331,18 @@ export class AddonNotificationsProvider { }); } + /** + * Returns whether or not popup WS is available for a certain site. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with true if available, resolved with false or rejected otherwise. + */ + isPopupAvailable(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.wsAvailable('message_popup_get_popup_notifications'); + }); + } + /** * Mark all message notification as read. * @@ -262,23 +361,25 @@ export class AddonNotificationsProvider { * Mark a single notification as read. * * @param {number} notificationId ID of notification to mark as read + * @param {string} [siteId] Site ID. If not defined, current site. * @returns {Promise} Resolved when done. * @since 3.5 */ - markNotificationRead(notificationId: number): Promise { - const currentSite = this.sitesProvider.getCurrentSite(); + markNotificationRead(notificationId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { - if (currentSite.wsAvailable('core_message_mark_notification_read')) { - const params = { - notificationid: notificationId, - timeread: this.timeUtils.timestamp() - }; + if (site.wsAvailable('core_message_mark_notification_read')) { + const params = { + notificationid: notificationId, + timeread: this.timeUtils.timestamp() + }; - return currentSite.write('core_message_mark_notification_read', params); - } else { - // Fallback for versions prior to 3.5. - return this.messageProvider.markMessageRead(notificationId); - } + return site.write('core_message_mark_notification_read', params); + } else { + // Fallback for versions prior to 3.5. + return this.messageProvider.markMessageRead(notificationId, site.id); + } + }); } /** diff --git a/src/addon/notifications/providers/push-click-handler.ts b/src/addon/notifications/providers/push-click-handler.ts new file mode 100644 index 000000000..ce39f5d17 --- /dev/null +++ b/src/addon/notifications/providers/push-click-handler.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 { CoreEventsProvider } from '@providers/events'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CorePushNotificationsClickHandler } from '@core/pushnotifications/providers/delegate'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { AddonNotificationsProvider } from './notifications'; + +/** + * Handler for non-messaging push notifications clicks. + */ +@Injectable() +export class AddonNotificationsPushClickHandler implements CorePushNotificationsClickHandler { + name = 'AddonNotificationsPushClickHandler'; + priority = 0; // Low priority so it's used as a fallback if no other handler treats the notification. + featureName = 'CoreMainMenuDelegate_AddonNotifications'; + + constructor(private utils: CoreUtilsProvider, private notificationsProvider: AddonNotificationsProvider, + private linkHelper: CoreContentLinksHelperProvider, private eventsProvider: CoreEventsProvider) {} + + /** + * Check if a notification click is handled by this handler. + * + * @param {any} notification The notification to check. + * @return {boolean} Whether the notification click is handled by this handler + */ + handles(notification: any): boolean | Promise { + if (this.utils.isTrueOrOne(notification.notif)) { + // Notification clicked, mark as read. Don't block for this. + const notifId = notification.savedmessageid || notification.id; + + this.notificationsProvider.markNotificationRead(notifId, notification.site).then(() => { + this.eventsProvider.trigger(AddonNotificationsProvider.READ_CHANGED_EVENT, null, notification.site); + }).catch(() => { + // Ignore errors. + }); + + return true; + } + + return false; + } + + /** + * Handle the notification click. + * + * @param {any} notification The notification to check. + * @return {Promise} Promise resolved when done. + */ + handleClick(notification: any): Promise { + let promise; + + // Try to handle the appurl first. + if (notification.customdata && notification.customdata.appurl) { + promise = this.linkHelper.handleLink(notification.customdata.appurl, undefined, undefined, true); + } else { + promise = Promise.resolve(false); + } + + return promise.then((treated) => { + + if (!treated) { + // No link or cannot be handled by the app. Try to handle the contexturl now. + if (notification.contexturl) { + return this.linkHelper.handleLink(notification.contexturl); + } else { + return false; + } + } + + return true; + }).then((treated) => { + + if (!treated) { + // No link or cannot be handled by the app. Open the notifications page. + return this.notificationsProvider.invalidateNotificationsList(notification.site).catch(() => { + // Ignore errors. + }).then(() => { + return this.linkHelper.goInSite(undefined, 'AddonNotificationsListPage', undefined, notification.site); + }); + } + }); + } +} diff --git a/src/addon/pushnotifications/providers/delegate.ts b/src/addon/pushnotifications/providers/delegate.ts deleted file mode 100644 index 61ca92ce5..000000000 --- a/src/addon/pushnotifications/providers/delegate.ts +++ /dev/null @@ -1,107 +0,0 @@ -// (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 { Subject } from 'rxjs'; - -/** - * Service to handle push notifications actions to perform when clicked and received. - */ -@Injectable() -export class AddonPushNotificationsDelegate { - - protected logger; - protected observables: { [s: string]: Subject } = {}; - protected counterHandlers: { [s: string]: string } = {}; - - constructor(loggerProvider: CoreLoggerProvider) { - this.logger = loggerProvider.getInstance('AddonPushNotificationsDelegate'); - this.observables['click'] = new Subject(); - this.observables['receive'] = new Subject(); - } - - /** - * Function called when a push notification is clicked. Sends notification to handlers. - * - * @param {any} notification Notification clicked. - */ - clicked(notification: any): void { - this.observables['click'].next(notification); - } - - /** - * Function called when a push notification is received in foreground (cannot tell when it's received in background). - * Sends notification to all handlers. - * - * @param {any} notification Notification received. - */ - received(notification: any): void { - this.observables['receive'].next(notification); - } - - /** - * Register a push notifications observable for click and receive notification event. - * When a notification is clicked or received, the observable will receive a notification to treat. - * let observer = pushNotificationsDelegate.on('click').subscribe((notification) => { - * ... - * observer.unsuscribe(); - * - * @param {string} eventName Only click and receive are permitted. - * @return {Subject} Observer to subscribe. - */ - on(eventName: string): Subject { - if (typeof this.observables[eventName] == 'undefined') { - const eventNames = Object.keys(this.observables).join(', '); - this.logger.warn(`'${eventName}' event name is not allowed. Use one of the following: '${eventNames}'.`); - - return new Subject(); - } - - return this.observables[eventName]; - } - - /** - * Register a push notifications handler for update badge counter. - * - * @param {string} name Handler's name. - */ - registerCounterHandler(name: string): void { - if (typeof this.counterHandlers[name] == 'undefined') { - this.logger.debug(`Registered handler '${name}' as badge counter handler.`); - this.counterHandlers[name] = name; - } else { - this.logger.log(`Handler '${name}' as badge counter handler already registered.`); - } - } - - /** - * Check if a counter handler is present. - * - * @param {string} name Handler's name. - * @return {boolean} If handler name is present. - */ - isCounterHandlerRegistered(name: string): boolean { - return typeof this.counterHandlers[name] != 'undefined'; - } - - /** - * Get all counter badge handlers. - * - * @return {any} with all the handler names. - */ - getCounterHandlers(): any { - return this.counterHandlers; - } -} diff --git a/src/addon/qtype/ddmarker/providers/handler.ts b/src/addon/qtype/ddmarker/providers/handler.ts index aaf0c781f..38ecfd1ba 100644 --- a/src/addon/qtype/ddmarker/providers/handler.ts +++ b/src/addon/qtype/ddmarker/providers/handler.ts @@ -112,10 +112,11 @@ export class AddonQtypeDdMarkerHandler implements CoreQuestionHandler { * Get the list of files that needs to be downloaded in addition to the files embedded in the HTML. * * @param {any} question Question. + * @param {number} usageId Usage ID. * @return {string[]} List of URLs. */ - getAdditionalDownloadableFiles(question: any): string[] { - this.questionHelper.extractQuestionScripts(question); + getAdditionalDownloadableFiles(question: any, usageId: number): string[] { + this.questionHelper.extractQuestionScripts(question, usageId); if (question.amdArgs && typeof question.amdArgs[1] !== 'undefined') { // Moodle 3.6+. diff --git a/src/addon/qtype/match/component/match.scss b/src/addon/qtype/match/component/match.scss index 89e49a50b..91f127a0c 100644 --- a/src/addon/qtype/match/component/match.scss +++ b/src/addon/qtype/match/component/match.scss @@ -7,4 +7,11 @@ ion-app.app-root addon-qtype-match { bottom: 50%; margin-bottom: -7px; } + + ion-col.col { + align-self: center; + > p { + margin: 0; + } + } } diff --git a/src/addon/qtype/multichoice/component/addon-qtype-multichoice.html b/src/addon/qtype/multichoice/component/addon-qtype-multichoice.html index c41b376b9..56ac05cc3 100644 --- a/src/addon/qtype/multichoice/component/addon-qtype-multichoice.html +++ b/src/addon/qtype/multichoice/component/addon-qtype-multichoice.html @@ -32,6 +32,9 @@ +
+ +
diff --git a/src/addon/qtype/multichoice/component/multichoice.ts b/src/addon/qtype/multichoice/component/multichoice.ts index ec23880d5..e481c0f2f 100644 --- a/src/addon/qtype/multichoice/component/multichoice.ts +++ b/src/addon/qtype/multichoice/component/multichoice.ts @@ -35,4 +35,11 @@ export class AddonQtypeMultichoiceComponent extends CoreQuestionBaseComponent im ngOnInit(): void { this.initMultichoiceComponent(); } + + /** + * Clear selected choices. + */ + clear(): void { + this.question.singleChoiceModel = null; + } } diff --git a/src/addon/remotethemes/providers/remotethemes.ts b/src/addon/remotethemes/providers/remotethemes.ts index f0555d656..7b30b31ce 100644 --- a/src/addon/remotethemes/providers/remotethemes.ts +++ b/src/addon/remotethemes/providers/remotethemes.ts @@ -14,6 +14,7 @@ import { Injectable } from '@angular/core'; import { Http } from '@angular/http'; +import { CoreAppProvider } from '@providers/app'; import { CoreFileProvider } from '@providers/file'; import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreLoggerProvider } from '@providers/logger'; @@ -34,7 +35,8 @@ export class AddonRemoteThemesProvider { protected stylesEls: {[siteId: string]: {element: HTMLStyleElement, hash: string}} = {}; constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private fileProvider: CoreFileProvider, - private filepoolProvider: CoreFilepoolProvider, private http: Http, private utils: CoreUtilsProvider) { + private filepoolProvider: CoreFilepoolProvider, private http: Http, private utils: CoreUtilsProvider, + private appProvider: CoreAppProvider) { this.logger = logger.getInstance('AddonRemoteThemesProvider'); } @@ -75,6 +77,9 @@ export class AddonRemoteThemesProvider { styles.forEach((style) => { this.disableElement(style, true); }); + + // Set StatusBar properties. + this.appProvider.setStatusBarColor(); } /** @@ -91,6 +96,10 @@ export class AddonRemoteThemesProvider { } else { element.disabled = false; element.removeAttribute('disabled'); + + if (element.innerHTML != '') { + this.appProvider.resetStatusBarColor(); + } } } diff --git a/src/addon/storagemanager/lang/en.json b/src/addon/storagemanager/lang/en.json new file mode 100644 index 000000000..a3491b8b9 --- /dev/null +++ b/src/addon/storagemanager/lang/en.json @@ -0,0 +1,7 @@ +{ + "deletecourse": "Offload all course data", + "deletedatafrom": "Offload data from {{name}}", + "info": "Files stored on your device make the app work faster and enable the app to be used offline. You can safely offload files if you need to free up storage space.", + "managestorage": "Manage storage", + "storageused": "File storage used:" +} diff --git a/src/addon/storagemanager/pages/course-storage/course-storage.html b/src/addon/storagemanager/pages/course-storage/course-storage.html new file mode 100644 index 000000000..8dd91ea1e --- /dev/null +++ b/src/addon/storagemanager/pages/course-storage/course-storage.html @@ -0,0 +1,62 @@ + + + {{ 'addon.storagemanager.managestorage' | translate }} + + + + + + +

{{ course.displayname }}

+

{{ 'addon.storagemanager.info' | translate }}

+ + + + {{ 'addon.storagemanager.storageused' | translate }} + {{ totalSize | coreBytesToSize }} + + + +
+
+ + + + + +

{{ section.name }}

+
+ + + {{ section.totalSize | coreBytesToSize }} + + +
+
+ + +
+ + + {{ module.name }} + + + + {{ module.totalSize | coreBytesToSize }} + + + +
+
+
+
+
+
+
diff --git a/src/addon/storagemanager/pages/course-storage/course-storage.module.ts b/src/addon/storagemanager/pages/course-storage/course-storage.module.ts new file mode 100644 index 000000000..19db12630 --- /dev/null +++ b/src/addon/storagemanager/pages/course-storage/course-storage.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 { 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 { AddonStorageManagerCourseStoragePage } from './course-storage'; + +@NgModule({ + declarations: [ + AddonStorageManagerCourseStoragePage, + ], + imports: [ + CoreComponentsModule, + CoreDirectivesModule, + CorePipesModule, + IonicPageModule.forChild(AddonStorageManagerCourseStoragePage), + TranslateModule.forChild() + ], +}) +export class AddonStorageManagerCourseStoragePageModule { +} diff --git a/src/addon/storagemanager/pages/course-storage/course-storage.scss b/src/addon/storagemanager/pages/course-storage/course-storage.scss new file mode 100644 index 000000000..2c03e4984 --- /dev/null +++ b/src/addon/storagemanager/pages/course-storage/course-storage.scss @@ -0,0 +1,28 @@ +ion-app.app-root page-addon-storagemanager-course-storage { + .item-md.item-block .item-inner { + padding-right: 0; + padding-left: 0; + } + ion-card.section ion-card-header.card-header { + border-bottom: 1px solid $list-border-color; + margin-bottom: 8px; + padding-top: 8px; + padding-bottom: 8px; + } + ion-card.section h2 { + font-weight: bold; + font-size: 2rem; + } + .size { + margin-top: 4px; + } + .size ion-icon { + margin-right: 4px; + } + .core-module-icon { + margin-right: 4px; + width: 16px; + height: 16px; + display: inline; + } +} diff --git a/src/addon/storagemanager/pages/course-storage/course-storage.ts b/src/addon/storagemanager/pages/course-storage/course-storage.ts new file mode 100644 index 000000000..18769c1c9 --- /dev/null +++ b/src/addon/storagemanager/pages/course-storage/course-storage.ts @@ -0,0 +1,167 @@ +// (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, ViewChild } from '@angular/core'; +import { IonicPage, Content, NavParams } from 'ionic-angular'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { TranslateService } from '@ngx-translate/core'; + +/** + * Page that displays the amount of file storage used by each activity on the course, and allows + * the user to delete these files. + */ +@IonicPage({ segment: 'addon-storagemanager-course-storage' }) +@Component({ + selector: 'page-addon-storagemanager-course-storage', + templateUrl: 'course-storage.html', +}) +export class AddonStorageManagerCourseStoragePage { + @ViewChild(Content) content: Content; + + course: any; + loaded: boolean; + sections: any; + totalSize: number; + + constructor(navParams: NavParams, + private courseProvider: CoreCourseProvider, + private prefetchDelegate: CoreCourseModulePrefetchDelegate, + private courseHelperProvider: CoreCourseHelperProvider, + private domUtils: CoreDomUtilsProvider, + private translate: TranslateService) { + + this.course = navParams.get('course'); + } + + /** + * View loaded. + */ + ionViewDidLoad(): void { + this.courseProvider.getSections(this.course.id, false, true).then((sections) => { + this.courseHelperProvider.addHandlerDataForModules(sections, this.course.id); + this.sections = sections; + this.totalSize = 0; + + const allPromises = []; + this.sections.forEach((section) => { + section.totalSize = 0; + section.modules.forEach((module) => { + module.parentSection = section; + module.totalSize = 0; + // Note: This function only gets the size for modules which are downloadable. + // For other modules it always returns 0, even if they have downloaded some files. + // However there is no 100% reliable way to actually track the files in this case. + // You can maybe guess it based on the component and componentid. + // But these aren't necessarily consistent, for example mod_frog vs mmaModFrog. + // There is nothing enforcing correct values. + // Most modules which have large files are downloadable, so I think this is sufficient. + const promise = this.prefetchDelegate.getModuleDownloadedSize(module, this.course.id). + then((size) => { + // There are some cases where the return from this is not a valid number. + if (!isNaN(size)) { + module.totalSize = Number(size); + section.totalSize += size; + this.totalSize += size; + } + }); + allPromises.push(promise); + }); + }); + + Promise.all(allPromises).then(() => { + this.loaded = true; + }); + }); + } + + /** + * The user has requested a delete for the whole course data. + * + * (This works by deleting data for each module on the course that has data.) + */ + deleteForCourse(): void { + const modules = []; + this.sections.forEach((section) => { + section.modules.forEach((module) => { + if (module.totalSize > 0) { + modules.push(module); + } + }); + }); + + this.deleteModules(modules); + } + + /** + * The user has requested a delete for a section's data. + * + * (This works by deleting data for each module in the section that has data.) + * + * @param {any} section Section object with information about section and modules + */ + deleteForSection(section: any): void { + const modules = []; + section.modules.forEach((module) => { + if (module.totalSize > 0) { + modules.push(module); + } + }); + + this.deleteModules(modules); + } + + /** + * The user has requested a delete for a module's data + * + * @param {any} module Module details + */ + deleteForModule(module: any): void { + if (module.totalSize > 0) { + this.deleteModules([module]); + } + } + + /** + * Deletes the specified modules, showing the loading overlay while it happens. + * + * @param {any[]} modules Modules to delete + * @return Promise Once deleting has finished + */ + protected deleteModules(modules: any[]): Promise { + const modal = this.domUtils.showModalLoading(); + + const promises = []; + modules.forEach((module) => { + // Remove the files. + const promise = this.prefetchDelegate.removeModuleFiles(module, this.course.id).then(() => { + // When the files are removed, update the size. + module.parentSection.totalSize -= module.totalSize; + this.totalSize -= module.totalSize; + module.totalSize = 0; + }); + promises.push(promise); + }); + + return Promise.all(promises).then(() => { + modal.dismiss(); + }).catch((error) => { + modal.dismiss(); + + this.domUtils.showErrorModalDefault(error, this.translate.instant('core.errordeletefile')); + }); + } +} diff --git a/src/addon/storagemanager/providers/coursemenu-handler.ts b/src/addon/storagemanager/providers/coursemenu-handler.ts new file mode 100644 index 000000000..e2aad3def --- /dev/null +++ b/src/addon/storagemanager/providers/coursemenu-handler.ts @@ -0,0 +1,62 @@ +// (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 { CoreCourseOptionsMenuHandler, CoreCourseOptionsMenuHandlerData } from '@core/course/providers/options-delegate'; + +/** + * Handler to inject an option into course menu so that user can get to the manage storage page. + */ +@Injectable() +export class AddonStorageManagerCourseMenuHandler implements CoreCourseOptionsMenuHandler { + name = 'AddonStorageManager'; + priority = 500; + isMenuHandler = true; + + /** + * Checks if the handler is enabled for specified course. This handler is always available. + * + * @param {number} courseId Course id + * @param {any} accessData Access data + * @param {any} [navOptions] Navigation options if any + * @param {any} [admOptions] Admin options if any + * @return {boolean | Promise} True + */ + isEnabledForCourse(courseId: number, accessData: any, navOptions?: any, admOptions?: any): boolean | Promise { + return true; + } + + /** + * 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 { + return true; + } + + /** + * Returns the data needed to render the handler. + * + * @return {CoreCourseOptionsMenuHandlerData} Data needed to render the handler. + */ + getMenuDisplayData(): CoreCourseOptionsMenuHandlerData { + return { + icon: 'cube', + title: 'addon.storagemanager.managestorage', + page: 'AddonStorageManagerCourseStoragePage', + class: 'addon-storagemanager-coursemenu-handler' + }; + } +} diff --git a/src/addon/storagemanager/storagemanager.module.ts b/src/addon/storagemanager/storagemanager.module.ts new file mode 100644 index 000000000..9590abb60 --- /dev/null +++ b/src/addon/storagemanager/storagemanager.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 { AddonStorageManagerCourseMenuHandler } from '@addon/storagemanager/providers/coursemenu-handler'; +import { CoreCourseOptionsDelegate } from '@core/course/providers/options-delegate'; + +@NgModule({ + declarations: [], + imports: [ + ], + providers: [ + AddonStorageManagerCourseMenuHandler + ], + exports: [] +}) +export class AddonStorageManagerModule { + constructor(private courseOptionsDelegate: CoreCourseOptionsDelegate, + private courseMenuHandler: AddonStorageManagerCourseMenuHandler) { + // Register handlers. + this.courseOptionsDelegate.registerHandler(this.courseMenuHandler); + } +} diff --git a/src/app/app.component.ts b/src/app/app.component.ts index dec433beb..1be81e471 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -14,12 +14,14 @@ import { Component, OnInit, NgZone } from '@angular/core'; import { Platform, IonicApp } from 'ionic-angular'; -import { StatusBar } from '@ionic-native/status-bar'; import { CoreAppProvider } from '@providers/app'; import { CoreEventsProvider } from '@providers/events'; import { CoreLangProvider } from '@providers/lang'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; +import { CoreUrlUtilsProvider } from '@providers/utils/url'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreCustomURLSchemesProvider } from '@providers/urlschemes'; import { CoreLoginHelperProvider } from '@core/login/providers/helper'; import { Keyboard } from '@ionic-native/keyboard'; import { ScreenOrientation } from '@ionic-native/screen-orientation'; @@ -33,21 +35,21 @@ export class MoodleMobileApp implements OnInit { rootPage: any = 'CoreLoginInitPage'; protected logger; protected lastUrls = {}; + protected lastInAppUrl: string; - constructor(private platform: Platform, statusBar: StatusBar, logger: CoreLoggerProvider, keyboard: Keyboard, + constructor(private platform: Platform, logger: CoreLoggerProvider, keyboard: Keyboard, private eventsProvider: CoreEventsProvider, private loginHelper: CoreLoginHelperProvider, private zone: NgZone, private appProvider: CoreAppProvider, private langProvider: CoreLangProvider, private sitesProvider: CoreSitesProvider, - private screenOrientation: ScreenOrientation, app: IonicApp) { + private screenOrientation: ScreenOrientation, app: IonicApp, private urlSchemesProvider: CoreCustomURLSchemesProvider, + private utils: CoreUtilsProvider, private urlUtils: CoreUrlUtilsProvider) { this.logger = logger.getInstance('AppComponent'); platform.ready().then(() => { // Okay, so the platform is ready and our plugins are available. // Here you can do any higher level native things you might need. - if (platform.is('android')) { - statusBar.styleLightContent(); - } else { - statusBar.styleDefault(); - } + + // Set StatusBar properties. + this.appProvider.setStatusBarColor(); keyboard.hideFormAccessoryBar(false); @@ -101,13 +103,39 @@ export class MoodleMobileApp implements OnInit { // Check URLs loaded in any InAppBrowser. this.eventsProvider.on(CoreEventsProvider.IAB_LOAD_START, (event) => { - this.loginHelper.inAppBrowserLoadStart(event.url); + // URLs with a custom scheme can be prefixed with "http://" or "https://", we need to remove this. + const url = event.url.replace(/^https?:\/\//, ''); + + if (this.urlSchemesProvider.isCustomURL(url)) { + // Close the browser if it's a valid SSO URL. + this.urlSchemesProvider.handleCustomURL(url); + this.utils.closeInAppBrowser(false); + + } else if (this.platform.is('android')) { + // Check if the URL has a custom URL scheme. In Android they need to be opened manually. + const urlScheme = this.urlUtils.getUrlProtocol(url); + if (urlScheme && urlScheme !== 'file' && urlScheme !== 'cdvfile') { + // Open in browser should launch the right app if found and do nothing if not found. + this.utils.openInBrowser(url); + + // At this point the InAppBrowser is showing a "Webpage not available" error message. + // Try to navigate to last loaded URL so this error message isn't found. + if (this.lastInAppUrl) { + this.utils.openInApp(this.lastInAppUrl); + } else { + // No last URL loaded, close the InAppBrowser. + this.utils.closeInAppBrowser(false); + } + } else { + this.lastInAppUrl = url; + } + } }); // Check InAppBrowser closed. this.eventsProvider.on(CoreEventsProvider.IAB_EXIT, () => { this.loginHelper.waitingForBrowser = false; - this.loginHelper.lastInAppUrl = ''; + this.lastInAppUrl = ''; this.loginHelper.checkLogout(); }); @@ -134,14 +162,10 @@ export class MoodleMobileApp implements OnInit { this.lastUrls[url] = Date.now(); this.eventsProvider.trigger(CoreEventsProvider.APP_LAUNCHED_URL, url); + this.urlSchemesProvider.handleCustomURL(url); }); }; - // Listen for app launched URLs. If we receive one, check if it's a SSO authentication. - this.eventsProvider.on(CoreEventsProvider.APP_LAUNCHED_URL, (url) => { - this.loginHelper.appLaunchedByURL(url); - }); - // Load custom lang strings. This cannot be done inside the lang provider because it causes circular dependencies. const loadCustomStrings = (): void => { const currentSite = this.sitesProvider.getCurrentSite(), diff --git a/src/app/app.ios.scss b/src/app/app.ios.scss index a2f7085af..2655a2276 100644 --- a/src/app/app.ios.scss +++ b/src/app/app.ios.scss @@ -25,6 +25,18 @@ ion-app.app-root.ios { @include margin($item-ios-padding-icon-top, null, $item-ios-padding-icon-bottom, 0); } + .item-ios ion-icon[item-start] + .item-inner, + .item-ios ion-icon[item-start] + .item-input, + .item-ios img[item-start] + .item-inner, + .item-ios img[item-start] + .item-input { + @include margin-horizontal($item-ios-padding-start, null); + } + + .item-ios ion-avatar[item-start] + .item-inner, + .item-ios ion-avatar[item-start] + .item-input { + @include margin-horizontal(0, null); + } + @each $color-name, $color-base, $color-contrast in get-colors($colors-ios) { .core-#{$color-name}-card { @@ -86,4 +98,17 @@ ion-app.app-root.ios { border-color: $checkbox-ios-icon-border-color-off; background-color: $checkbox-ios-background-color-off; } + + // File Uploader + // In iOS the input is 1 level higher, so the styles are different. + .action-sheet-ios input.core-fileuploader-file-handler-input { + position: absolute; + @include position(null, 0, null, 0); + min-width: 100%; + min-height: $action-sheet-ios-button-min-height; + opacity: 0; + outline: none; + z-index: 100; + cursor: pointer; + } } \ No newline at end of file diff --git a/src/app/app.md.scss b/src/app/app.md.scss index 5322bbc19..8a7745585 100644 --- a/src/app/app.md.scss +++ b/src/app/app.md.scss @@ -21,9 +21,16 @@ ion-app.app-root.md { @include margin-horizontal($item-md-padding-start + ($item-md-padding-start / 2) - 1, null); } + .item-md ion-icon[item-start] + .item-inner, + .item-md ion-icon[item-start] + .item-input, .item-md img[item-start] + .item-inner, .item-md img[item-start] + .item-input { - @include margin-horizontal($item-md-padding-start + ($item-md-padding-start / 2), null); + @include margin-horizontal($item-md-padding-start, null); + } + + .item-md ion-avatar[item-start] + .item-inner, + .item-md ion-avatar[item-start] + .item-input { + @include margin-horizontal(0, null); } @each $color-name, $color-base, $color-contrast in get-colors($colors-md) { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index fbcdc4cb5..4a174e542 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -59,6 +59,7 @@ import { CoreUpdateManagerProvider } from '@providers/update-manager'; import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; import { CoreSyncProvider } from '@providers/sync'; import { CoreFileHelperProvider } from '@providers/file-helper'; +import { CoreCustomURLSchemesProvider } from '@providers/urlschemes'; // Core modules. import { CoreComponentsModule } from '@components/components.module'; @@ -121,11 +122,12 @@ import { AddonMessageOutputModule } from '@addon/messageoutput/messageoutput.mod 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 { CorePushNotificationsModule } from '@core/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'; +import { AddonStorageManagerModule } from '@addon/storagemanager/storagemanager.module'; // For translate loader. AoT requires an exported function for factories. export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { @@ -160,7 +162,8 @@ export const CORE_PROVIDERS: any[] = [ CoreUpdateManagerProvider, CorePluginFileDelegate, CoreSyncProvider, - CoreFileHelperProvider + CoreFileHelperProvider, + CoreCustomURLSchemesProvider ]; @NgModule({ @@ -201,6 +204,7 @@ export const CORE_PROVIDERS: any[] = [ CoreCommentsModule, CoreBlockModule, CoreRatingModule, + CorePushNotificationsModule, AddonBadgesModule, AddonBlogModule, AddonCalendarModule, @@ -241,10 +245,10 @@ export const CORE_PROVIDERS: any[] = [ AddonMessagesModule, AddonNotesModule, AddonNotificationsModule, - AddonPushNotificationsModule, AddonRemoteThemesModule, AddonQbehaviourModule, - AddonQtypeModule + AddonQtypeModule, + AddonStorageManagerModule ], bootstrap: [IonicApp], entryComponents: [ @@ -278,6 +282,7 @@ export const CORE_PROVIDERS: any[] = [ CorePluginFileDelegate, CoreSyncProvider, CoreFileHelperProvider, + CoreCustomURLSchemesProvider, { provide: HTTP_INTERCEPTORS, useClass: CoreInterceptor, diff --git a/src/app/app.scss b/src/app/app.scss index f9c07d4f9..41759e8ef 100644 --- a/src/app/app.scss +++ b/src/app/app.scss @@ -103,6 +103,11 @@ ion-app.app-root { border: 0; } + .item h2 { + text-overflow: inherit; + overflow: inherit; + } + .core-nav-item-selected, .item.core-nav-item-selected { @include core-selected-item($core-splitview-selected); } @@ -219,6 +224,8 @@ ion-app.app-root { /** Styles of elements inside the directive should be placed in format-text.scss */ core-format-text { user-select: text; + word-break: break-word; + word-wrap: break-word; &[maxHeight], &[ng-reflect-max-height] { @@ -383,7 +390,7 @@ ion-app.app-root { color: $core-select-placeholder-color; } - &.select-disabled, .select-icon .select-icon-inner { + &.select-disabled .select-icon .select-icon-inner { color: $text-color; } @each $color-name, $color-base, $color-contrast in get-colors($colors) { @@ -595,6 +602,25 @@ ion-app.app-root { .alert-message { overflow-y: auto; } + ion-alert.core-nohead { + + &.alert-md .alert-message { + padding-top: $alert-md-message-padding-bottom; + } + &.alert-ios .alert-message { + padding-top: $alert-ios-message-padding-bottom; + } + &.alert-wp .alert-message { + padding-top: $alert-wp-message-padding-bottom; + } + .alert-head { + display: none; + } + } + + ion-alert .alert-checkbox-group { + border: 0; + } ion-toast.core-toast-success .toast-wrapper{ background: $green-dark; @@ -652,7 +678,7 @@ ion-app.app-root { > ion-icon { color: $color-base; position: absolute; - @include position(0, null, null, 16px) + @include position(0, null, null, 16px); height: 100%; font-size: 24px; display: flex; @@ -915,6 +941,12 @@ ion-app.app-root { pointer-events: initial; } + // Avoid scroll bouncing on iOS if disabled. + &.disable-scroll .ion-page .content-ios .scroll-content::before, + &.disable-scroll .ion-page .content-ios .scroll-content::after { + content: none; + } + .core-iframe-offline-disabled { display: none !important; } @@ -963,6 +995,10 @@ ion-app.app-root { line-height: 1; } } + + ion-alert .alert-checkbox-button .alert-checkbox-label { + white-space: normal; + } } @each $color-name, $color-base, $color-contrast in get-colors($colors) { @@ -991,7 +1027,7 @@ body.keyboard-is-open { } } - core-ion-tabs .tabbar { + core-ion-tabs[tabsplacement="bottom"] .tabbar { display: none; } } @@ -1050,6 +1086,11 @@ ion-modal, contain: size layout style; } +// Highlight text. +.matchtext { + background-color: $core-text-hightlight-background-color; +} + // Styles for desktop apps only. ion-app.platform-desktop { video::-webkit-media-text-track-display { @@ -1062,3 +1103,52 @@ ion-app.platform-desktop { } } } + +// Fix text wrapping in block buttons. +.button-block[text-wrap] { + height: auto; + + // Changed from "strict" because the size depends on child elements. + contain: content; + + // Add vertical padding, we cannot rely on a fixed height + centering like in normal buttons. + .item-md & { + padding-top: .5357em; + padding-bottom: .5357em; + } + .item-md &.item-button { + padding-top: .6em; + padding-bottom: .6em; + } + .item-ios & { + padding-top: .9em; + padding-bottom: .9em; + } + .item-ios &.item-button { + padding-top: .7846em; + padding-bottom: .7846em; + } + + // Keep a consistent height with normal buttons if text does not wrap. + display: flex; + flex-flow: row; + align-items: center; + &.button-md { + min-height: $button-md-height; + } + &.button-large-md { + min-height: $button-md-large-height; + } + &.button-small-md { + min-height: $button-md-small-height; + } + &.button-ios { + min-height: $button-ios-height; + } + &.button-large-ios { + min-height: $button-ios-large-height; + } + &.button-small-ios { + min-height: $button-ios-small-height; + } +} diff --git a/src/app/app.wp.scss b/src/app/app.wp.scss index 0acc39941..b91fb4f10 100644 --- a/src/app/app.wp.scss +++ b/src/app/app.wp.scss @@ -18,11 +18,18 @@ ion-app.app-root.wp { .item-wp ion-spinner[item-start] + .item-inner, .item-wp ion-spinner[item-start] + .item-input, + .item-wp ion-icon[item-start] + .item-inner, + .item-wp ion-icon[item-start] + .item-input, .item-wp img[item-start] + .item-inner, .item-wp img[item-start] + .item-input { @include margin-horizontal(($item-wp-padding-start / 2), null); } + .item-wp ion-avatar[item-start] + .item-inner, + .item-wp ion-avatar[item-start] + .item-input { + @include margin-horizontal(0, null); + } + @each $color-name, $color-base, $color-contrast in get-colors($colors-wp) { .core-#{$color-name}-card { @extend .card-wp ; diff --git a/src/assets/fonts/slash-icon.woff b/src/assets/fonts/slash-icon.woff new file mode 100644 index 000000000..5e02178e4 Binary files /dev/null and b/src/assets/fonts/slash-icon.woff differ diff --git a/src/assets/icon/favicon.ico b/src/assets/icon/favicon.ico index 4c516e7b5..483c9647c 100644 Binary files a/src/assets/icon/favicon.ico and b/src/assets/icon/favicon.ico differ diff --git a/src/assets/icon/icon.png b/src/assets/icon/icon.png index de5b0f4c6..f41b1f803 100644 Binary files a/src/assets/icon/icon.png and b/src/assets/icon/icon.png differ diff --git a/src/assets/img/icons/calendar.png b/src/assets/img/icons/calendar.png new file mode 100644 index 000000000..e825eeee4 Binary files /dev/null and b/src/assets/img/icons/calendar.png differ diff --git a/src/assets/img/login_logo.png b/src/assets/img/login_logo.png index 8a2e142b2..0cbb69d0e 100644 Binary files a/src/assets/img/login_logo.png and b/src/assets/img/login_logo.png differ diff --git a/src/assets/img/splash_logo.png b/src/assets/img/splash_logo.png index 280aa87f0..8c9fdfa9a 100644 Binary files a/src/assets/img/splash_logo.png and b/src/assets/img/splash_logo.png differ diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 8fd9e1499..ac7f5821e 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -111,9 +111,11 @@ "addon.competency.myplans": "My learning plans", "addon.competency.noactivities": "No activities", "addon.competency.nocompetencies": "No competencies", + "addon.competency.nocompetenciesincourse": "No competencies have been linked to this course.", "addon.competency.nocrossreferencedcompetencies": "No other competencies have been cross-referenced to this competency.", "addon.competency.noevidence": "No evidence", "addon.competency.noplanswerecreated": "No learning plans were created.", + "addon.competency.nouserplanswithcompetency": "No learning plans contain this competency.", "addon.competency.path": "Path:", "addon.competency.planstatusactive": "Active", "addon.competency.planstatuscomplete": "Complete", @@ -126,6 +128,7 @@ "addon.competency.reviewstatus": "Review status", "addon.competency.status": "Status", "addon.competency.template": "Learning plan template", + "addon.competency.uponcoursecompletion": "Upon course completion:", "addon.competency.usercompetencystatus_idle": "Idle", "addon.competency.usercompetencystatus_inreview": "In review", "addon.competency.usercompetencystatus_waitingforreview": "Waiting for review", @@ -177,9 +180,12 @@ "addon.messages.contactname": "Contact name", "addon.messages.contactrequestsent": "Contact request sent", "addon.messages.contacts": "Contacts", + "addon.messages.conversationactions": "Conversation actions menu", "addon.messages.decline": "Decline", "addon.messages.deleteallconfirm": "Are you sure you would like to delete this entire conversation? This will not delete it for other conversation participants.", + "addon.messages.deleteallselfconfirm": "Are you sure you would like to delete this entire personal conversation?", "addon.messages.deleteconversation": "Delete conversation", + "addon.messages.deleteforeveryone": "Delete for me and everyone", "addon.messages.deletemessage": "Delete message", "addon.messages.deletemessageconfirmation": "Are you sure you want to delete this message? It will only be deleted from your messaging history and will still be viewable by the user who sent or received the message.", "addon.messages.errordeletemessage": "Error while deleting the message.", @@ -196,6 +202,8 @@ "addon.messages.messagenotsent": "The message was not sent. Please try again later.", "addon.messages.messagepreferences": "Message preferences", "addon.messages.messages": "Messages", + "addon.messages.muteconversation": "Mute", + "addon.messages.mutedconversation": "Muted conversation", "addon.messages.newmessage": "New message", "addon.messages.newmessages": "New messages", "addon.messages.nocontactrequests": "No contact requests", @@ -214,9 +222,8 @@ "addon.messages.requests": "Requests", "addon.messages.requirecontacttomessage": "You need to request {{$a}} to add you as a contact to be able to message them.", "addon.messages.searchcombined": "Search people and messages", - "addon.messages.searchnocontactsfound": "No contacts found", - "addon.messages.searchnomessagesfound": "No messages found", - "addon.messages.searchnononcontactsfound": "No non contacts found", + "addon.messages.selfconversation": "Personal space", + "addon.messages.selfconversationdefaultmessage": "Save draft messages, links, notes etc. to access later.", "addon.messages.sendcontactrequest": "Send contact request", "addon.messages.showdeletemessages": "Show delete messages", "addon.messages.type_blocked": "Blocked", @@ -227,6 +234,7 @@ "addon.messages.unabletomessage": "You are unable to message this user", "addon.messages.unblockuser": "Unblock user", "addon.messages.unblockuserconfirm": "Are you sure you want to unblock {{$a}}?", + "addon.messages.unmuteconversation": "Unmute", "addon.messages.useentertosend": "Use enter to send", "addon.messages.useentertosenddescdesktop": "If disabled, you can use Ctrl+Enter to send the message.", "addon.messages.useentertosenddescmac": "If disabled, you can use Cmd+Enter to send the message.", @@ -448,6 +456,8 @@ "addon.mod_feedback.feedbackclose": "Allow answers to", "addon.mod_feedback.feedbackopen": "Allow answers from", "addon.mod_feedback.mapcourses": "Map feedback to courses", + "addon.mod_feedback.maximal": "Maximum", + "addon.mod_feedback.minimal": "Minimum", "addon.mod_feedback.mode": "Mode", "addon.mod_feedback.modulenameplural": "Feedback", "addon.mod_feedback.next_page": "Next page", @@ -474,11 +484,20 @@ "addon.mod_forum.addanewdiscussion": "Add a new discussion topic", "addon.mod_forum.addanewquestion": "Add a new question", "addon.mod_forum.addanewtopic": "Add a new topic", + "addon.mod_forum.addtofavourites": "Star this discussion", + "addon.mod_forum.advanced": "Advanced", "addon.mod_forum.cannotadddiscussion": "Adding discussions to this forum requires group membership.", "addon.mod_forum.cannotadddiscussionall": "You do not have permission to add a new discussion topic for all participants.", "addon.mod_forum.cannotcreatediscussion": "Could not create new discussion", "addon.mod_forum.couldnotadd": "Could not add your post due to an unknown error", + "addon.mod_forum.cutoffdatereached": "The cut-off date for posting to this forum is reached so you can no longer post to it.", "addon.mod_forum.discussion": "Discussion", + "addon.mod_forum.discussionlistsortbycreatedasc": "Sort by creation date in ascending order", + "addon.mod_forum.discussionlistsortbycreateddesc": "Sort by creation date in descending order", + "addon.mod_forum.discussionlistsortbylastpostasc": "Sort by last post creation date in ascending order", + "addon.mod_forum.discussionlistsortbylastpostdesc": "Sort by last post creation date in descending order", + "addon.mod_forum.discussionlistsortbyrepliesasc": "Sort by number of replies in ascending order", + "addon.mod_forum.discussionlistsortbyrepliesdesc": "Sort by number of replies in descending order", "addon.mod_forum.discussionlocked": "This discussion has been locked so you can no longer reply to it.", "addon.mod_forum.discussionpinned": "Pinned", "addon.mod_forum.discussionsubscription": "Discussion subscription", @@ -487,8 +506,12 @@ "addon.mod_forum.erroremptysubject": "Post subject cannot be empty.", "addon.mod_forum.errorgetforum": "Error getting forum data.", "addon.mod_forum.errorgetgroups": "Error getting group settings.", + "addon.mod_forum.errorposttoallgroups": "Could not create new discussion in all groups.", + "addon.mod_forum.favouriteupdated": "Your star option has been updated.", "addon.mod_forum.forumnodiscussionsyet": "There are no discussions yet in this forum.", "addon.mod_forum.group": "Group", + "addon.mod_forum.lockdiscussion": "Lock this discussion", + "addon.mod_forum.lockupdated": "The lock option has been updated.", "addon.mod_forum.message": "Message", "addon.mod_forum.modeflatnewestfirst": "Display replies flat, with newest first", "addon.mod_forum.modeflatoldestfirst": "Display replies flat, with oldest first", @@ -496,12 +519,23 @@ "addon.mod_forum.modulenameplural": "Forums", "addon.mod_forum.numdiscussions": "{{numdiscussions}} discussions", "addon.mod_forum.numreplies": "{{numreplies}} replies", + "addon.mod_forum.pindiscussion": "Pin this discussion", + "addon.mod_forum.pinupdated": "The pin option has been updated.", + "addon.mod_forum.postisprivatereply": "This post was made privately and is not visible to all users.", "addon.mod_forum.posttoforum": "Post to forum", + "addon.mod_forum.posttomygroups": "Post a copy to all groups", + "addon.mod_forum.privatereply": "Reply privately", "addon.mod_forum.re": "Re:", "addon.mod_forum.refreshdiscussions": "Refresh discussions", "addon.mod_forum.refreshposts": "Refresh posts", + "addon.mod_forum.removefromfavourites": "Unstar this discussion", "addon.mod_forum.reply": "Reply", + "addon.mod_forum.replyplaceholder": "Write your reply...", "addon.mod_forum.subject": "Subject", + "addon.mod_forum.thisforumhasduedate": "The due date for posting to this forum is {{$a}}.", + "addon.mod_forum.thisforumisdue": "The due date for posting to this forum was {{$a}}.", + "addon.mod_forum.unlockdiscussion": "Unlock this discussion", + "addon.mod_forum.unpindiscussion": "Unpin this discussion", "addon.mod_forum.unread": "Unread", "addon.mod_forum.unreadpostsnumber": "{{$a}} unread posts", "addon.mod_glossary.addentry": "Add a new entry", @@ -632,6 +666,7 @@ "addon.mod_quiz.attemptquiznow": "Attempt quiz now", "addon.mod_quiz.attemptstate": "State", "addon.mod_quiz.cannotsubmitquizdueto": "This quiz attempt cannot be submitted for the following reasons:", + "addon.mod_quiz.clearchoice": "Clear my choice", "addon.mod_quiz.comment": "Comment", "addon.mod_quiz.completedon": "Completed on", "addon.mod_quiz.confirmclose": "Once you submit, you will no longer be able to change your answers for this attempt.", @@ -878,6 +913,11 @@ "addon.notifications.notifications": "Notifications", "addon.notifications.playsound": "Play sound", "addon.notifications.therearentnotificationsyet": "There are no notifications.", + "addon.storagemanager.deletecourse": "Offload all course data", + "addon.storagemanager.deletedatafrom": "Offload data from {{name}}", + "addon.storagemanager.info": "Files stored on your device make the app work faster and enable the app to be used offline. You can safely offload files if you need to free up storage space.", + "addon.storagemanager.managestorage": "Manage storage", + "addon.storagemanager.storageused": "File storage used:", "assets.countries.AD": "Andorra", "assets.countries.AE": "United Arab Emirates", "assets.countries.AF": "Afghanistan", @@ -1184,6 +1224,7 @@ "core.agelocationverification": "Age and location verification", "core.ago": "{{$a}} ago", "core.all": "All", + "core.allgroups": "All groups", "core.allparticipants": "All participants", "core.android": "Android", "core.answer": "Answer", @@ -1229,6 +1270,7 @@ "core.contentlinks.confirmurlothersite": "This link belongs to another site. Do you want to open it?", "core.contentlinks.errornoactions": "Couldn't find an action to perform with this link.", "core.contentlinks.errornosites": "Couldn't find any site to handle this link.", + "core.contentlinks.errorredirectothersite": "The redirect URL cannot point to a different site.", "core.continue": "Continue", "core.copiedtoclipboard": "Text copied to clipboard", "core.course": "Course", @@ -1237,10 +1279,12 @@ "core.course.activitynotyetviewablesiteupgradeneeded": "Your organisation's Moodle installation needs to be updated.", "core.course.allsections": "All sections", "core.course.askadmintosupport": "Contact the site administrator and tell them you want to use this activity with the Moodle Mobile app.", + "core.course.availablespace": " You currently have about {{available}} free space.", "core.course.confirmdeletemodulefiles": "Are you sure you want to delete these files?", - "core.course.confirmdownload": "You are about to download {{size}}. Are you sure you want to continue?", - "core.course.confirmdownloadunknownsize": "It was not possible to calculate the size of the download. Are you sure you want to continue?", - "core.course.confirmpartialdownloadsize": "You are about to download at least {{size}}. Are you sure you want to continue?", + "core.course.confirmdownload": "You are about to download {{size}}.{{availableSpace}} Are you sure you want to continue?", + "core.course.confirmdownloadunknownsize": "It was not possible to calculate the size of the download.{{availableSpace}} Are you sure you want to continue?", + "core.course.confirmlimiteddownload": "You are not currently connected to Wi-Fi. ", + "core.course.confirmpartialdownloadsize": "You are about to download at least {{size}}.{{availableSpace}} Are you sure you want to continue?", "core.course.contents": "Contents", "core.course.couldnotloadsectioncontent": "Could not load the section content. Please try again later.", "core.course.couldnotloadsections": "Could not load the sections. Please try again later.", @@ -1251,6 +1295,8 @@ "core.course.errorgetmodule": "Error getting activity data.", "core.course.hiddenfromstudents": "Hidden from students", "core.course.hiddenoncoursepage": "Available but not shown on course page", + "core.course.insufficientavailablequota": "Your device could not allocate space to save this download. It may be reserving space for app and system updates. Please clear some storage space first.", + "core.course.insufficientavailablespace": "You are trying to download {{size}}. This will leave your device with insufficient space to operate normally. Please clear some storage space first.", "core.course.manualcompletionnotsynced": "Manual completion not synchronised.", "core.course.nocontentavailable": "No content available at the moment.", "core.course.overriddennotice": "Your final grade from this activity was manually adjusted.", @@ -1319,6 +1365,7 @@ "core.dismiss": "Dismiss", "core.done": "Done", "core.download": "Download", + "core.downloaded": "Downloaded", "core.downloading": "Downloading", "core.edit": "Edit", "core.emptysplit": "This page will appear blank if the left panel is empty or is loading.", @@ -1342,6 +1389,7 @@ "core.favourites": "Starred", "core.filename": "Filename", "core.filenameexist": "File name already exists: {{$a}}", + "core.filenotfound": "File not found, sorry.", "core.fileuploader.addfiletext": "Add file", "core.fileuploader.audio": "Audio", "core.fileuploader.camera": "Camera", @@ -1555,6 +1603,7 @@ "core.nopermissions": "Sorry, but you do not currently have permissions to do that ({{$a}}).", "core.noresults": "No results", "core.notapplicable": "n/a", + "core.notenrolledprofile": "This profile is not available because this user is not enrolled in this course.", "core.notice": "Notice", "core.notingroup": "Sorry, but you need to be part of a group to see this page.", "core.notsent": "Not sent", @@ -1612,6 +1661,7 @@ "core.resourcedisplayopen": "Open", "core.resources": "Resources", "core.restore": "Restore", + "core.restricted": "Restricted", "core.retry": "Retry", "core.save": "Save", "core.search": "Search", @@ -1636,7 +1686,7 @@ "core.settings.currentlanguage": "Current language", "core.settings.debugdisplay": "Display debug messages", "core.settings.debugdisplaydescription": "If enabled, error modals will display more data about the error if possible.", - "core.settings.deletesitefiles": "Are you sure that you want to delete the downloaded files from the site '{{sitename}}'?", + "core.settings.deletesitefiles": "Are you sure that you want to delete the downloaded files and cached data from the site '{{sitename}}'? You won't be able to use the app in offline mode.", "core.settings.deletesitefilestitle": "Delete site files", "core.settings.deviceinfo": "Device info", "core.settings.deviceos": "Device OS", @@ -1648,6 +1698,7 @@ "core.settings.enablerichtexteditor": "Enable text editor", "core.settings.enablerichtexteditordescription": "If enabled, a text editor will be available when entering content.", "core.settings.enablesyncwifi": "Allow sync only when on Wi-Fi", + "core.settings.entriesincache": "{{$a}} entries in cache", "core.settings.errordeletesitefiles": "Error deleting site files.", "core.settings.errorsyncsite": "Error synchronising site data. Please check your Internet connection and try again.", "core.settings.estimatedfreespace": "Estimated free space", @@ -1698,6 +1749,7 @@ "core.sizemb": "MB", "core.sizetb": "TB", "core.sorry": "Sorry...", + "core.sort": "Sort", "core.sortby": "Sort by", "core.start": "Start", "core.strftimedate": "%d %B %Y", diff --git a/src/classes/delegate.ts b/src/classes/delegate.ts index 61fd14b76..5710cbdd9 100644 --- a/src/classes/delegate.ts +++ b/src/classes/delegate.ts @@ -15,6 +15,7 @@ import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; import { CoreEventsProvider } from '@providers/events'; +import { CoreSite } from '@classes/site'; export interface CoreDelegateHandler { /** @@ -162,6 +163,23 @@ export class CoreDelegate { return enabled ? this.enabledHandlers[handlerName] : this.handlers[handlerName]; } + /** + * Gets the handler full name for a given name. This is useful when the handlerNameProperty is different than "name". + * E.g. blocks are indexed by blockName. If you call this function passing the blockName it will return the name. + * + * @param {string} name Name used to indentify the handler. + * @return {string} Full name of corresponding handler. + */ + getHandlerName(name: string): string { + const handler = this.getHandler(name, true); + + if (!handler) { + return ''; + } + + return handler.name; + } + /** * Check if function exists on a handler. * @@ -272,10 +290,10 @@ export class CoreDelegate { * Check if feature is enabled or disabled in the site, depending on the feature prefix and the handler name. * * @param {CoreDelegateHandler} handler Handler to check. - * @param {any} site Site to check. - * @return {boolean} Whether is enabled or disabled in site. + * @param {CoreSite} site Site to check. + * @return {boolean} Whether is enabled or disabled in site. */ - protected isFeatureDisabled(handler: CoreDelegateHandler, site: any): boolean { + protected isFeatureDisabled(handler: CoreDelegateHandler, site: CoreSite): boolean { return typeof this.featurePrefix != 'undefined' && site.isFeatureDisabled(this.featurePrefix + handler.name); } diff --git a/src/classes/site.ts b/src/classes/site.ts index b9068bd13..9253f83e6 100644 --- a/src/classes/site.ts +++ b/src/classes/site.ts @@ -26,7 +26,7 @@ import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreUrlUtilsProvider } from '@providers/utils/url'; -import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreUtilsProvider, PromiseDefer } from '@providers/utils/utils'; import { CoreConstants } from '@core/constants'; import { CoreConfigConstants } from '../configconstants'; import { Md5 } from 'ts-md5/dist/md5'; @@ -113,6 +113,32 @@ export interface CoreSiteWSPreSets { * @type {string} */ typeExpected?: string; + + /** + * Wehther a pending request in the queue matching the same function and arguments can be reused instead of adding + * a new request to the queue. Defaults to true for read requests. + * @type {boolean} + */ + reusePending?: boolean; + + /** + * Whether the request will be be sent immediately as a single request. Defaults to false. + * @type {boolean} + */ + skipQueue?: boolean; + + /** + * Cache the response if it returns an errorcode present in this list. + */ + cacheErrors?: string[]; + + /** + * Update frequency. This value determines how often the cached data will be updated. Possible values: + * CoreSite.FREQUENCY_USUALLY, CoreSite.FREQUENCY_OFTEN, CoreSite.FREQUENCY_SOMETIMES, CoreSite.FREQUENCY_RARELY. + * Defaults to CoreSite.FREQUENCY_USUALLY. + * @type {number} + */ + updateFrequency?: number; } /** @@ -144,6 +170,18 @@ export interface LocalMobileResponse { coreSupported?: boolean; } +/** + * Info of a request waiting in the queue. + */ +interface RequestQueueItem { + cacheId: string; + method: string; + data: any; + preSets: CoreSiteWSPreSets; + wsPreSets: CoreWSPreSets; + deferred: PromiseDefer; +} + /** * Class that represents a site (combination of site + user). * It will have all the site data and provide utility functions regarding a site. @@ -151,6 +189,16 @@ export interface LocalMobileResponse { * the tables are created in all the sites, not just the current one. */ export class CoreSite { + static REQUEST_QUEUE_DELAY = 50; // Maximum number of miliseconds to wait before processing the queue. + static REQUEST_QUEUE_LIMIT = 10; // Maximum number of requests allowed in the queue. + static REQUEST_QUEUE_FORCE_WS = false; // Use "tool_mobile_call_external_functions" even for calling a single function. + + // Constants for cache update frequency. + static FREQUENCY_USUALLY = 0; + static FREQUENCY_OFTEN = 1; + static FREQUENCY_SOMETIMES = 2; + static FREQUENCY_RARELY = 3; + // List of injected services. This class isn't injectable, so it cannot use DI. protected appProvider: CoreAppProvider; protected dbProvider: CoreDbProvider; @@ -176,9 +224,18 @@ export class CoreSite { 3.3: 2017051503, 3.4: 2017111300, 3.5: 2018051700, - 3.6: 2018120300 + 3.6: 2018120300, + 3.7: 2019052000 }; + // Possible cache update frequencies. + protected UPDATE_FREQUENCIES = [ + CoreConfigConstants.cache_update_frequency_usually || 420000, + CoreConfigConstants.cache_update_frequency_often || 1200000, + CoreConfigConstants.cache_update_frequency_sometimes || 3600000, + CoreConfigConstants.cache_update_frequency_rarely || 43200000 + ]; + // Rest of variables. protected logger; protected db: SQLiteDB; @@ -186,6 +243,8 @@ export class CoreSite { protected lastAutoLogin = 0; protected offlineDisabled = false; protected ongoingRequests: { [cacheId: string]: Promise } = {}; + protected requestQueue: RequestQueueItem[] = []; + protected requestQueueTimeout = null; /** * Create a site. @@ -217,6 +276,7 @@ export class CoreSite { this.wsProvider = injector.get(CoreWSProvider); this.logger = logger.getInstance('CoreWSProvider'); + this.setInfo(infos); this.calculateOfflineDisabled(); if (this.id) { @@ -305,6 +365,20 @@ export class CoreSite { return this.infos && this.infos.siteid || 1; } + /** + * Get site name. + * + * @return {string} Site name. + */ + getSiteName(): string { + if (CoreConfigConstants.sitename) { + // Overridden by config. + return CoreConfigConstants.sitename; + } else { + return this.infos && this.infos.sitename || ''; + } + } + /** * Set site ID. * @@ -349,6 +423,14 @@ export class CoreSite { */ setInfo(infos: any): void { this.infos = infos; + + // Index function by name to speed up wsAvailable method. + if (infos && infos.functions) { + infos.functionsByName = {}; + infos.functions.forEach((func) => { + infos.functionsByName[func.name] = func; + }); + } } /** @@ -442,7 +524,8 @@ export class CoreSite { // The get_site_info WS call won't be cached. const preSets = { getFromCache: false, - saveToCache: false + saveToCache: false, + skipQueue: true }; // Reset clean Unicode to check if it's supported again. @@ -467,6 +550,9 @@ export class CoreSite { if (typeof preSets.saveToCache == 'undefined') { preSets.saveToCache = true; } + if (typeof preSets.reusePending == 'undefined') { + preSets.reusePending = true; + } return this.request(method, data, preSets); } @@ -564,10 +650,9 @@ export class CoreSite { const originalData = data; - // Convert the values to string before starting the cache process. - try { - data = this.wsProvider.convertValuesToString(data, wsPreSets.cleanUnicode); - } catch (e) { + // Convert arguments to strings before starting the cache process. + data = this.wsProvider.convertValuesToString(data, wsPreSets.cleanUnicode); + if (data == null) { // Empty cleaned text found. return Promise.reject(this.utils.createFakeWSError('core.unicodenotsupportedcleanerror', true)); } @@ -584,7 +669,7 @@ export class CoreSite { const promise = this.getFromCache(method, data, preSets, false, originalData).catch(() => { // Do not pass those options to the core WS factory. - return this.wsProvider.call(method, data, wsPreSets).then((response) => { + return this.callOrEnqueueRequest(method, data, preSets, wsPreSets).then((response) => { if (preSets.saveToCache) { this.saveToCache(method, data, response, preSets); } @@ -595,6 +680,8 @@ export class CoreSite { (error.errorcode == 'accessexception' && error.message.indexOf('Invalid token - token expired') > -1)) { if (initialToken !== this.token && !retrying) { // Token has changed, retry with the new token. + preSets.getFromCache = false; // Don't check cache now. Also, it will skip ongoingRequests. + return this.request(method, data, preSets, true); } else if (this.appProvider.isSSOAuthenticationOngoing()) { // There's an SSO authentication ongoing, wait for it to finish and try again. @@ -656,6 +743,11 @@ export class CoreSite { } else if (typeof preSets.emergencyCache !== 'undefined' && !preSets.emergencyCache) { this.logger.debug(`WS call '${method}' failed. Emergency cache is forbidden, rejecting.`); + return Promise.reject(error); + } else if (preSets.cacheErrors && preSets.cacheErrors.indexOf(error.errorcode) != -1) { + // Save the error instead of deleting the cache entry so the same content is displayed in offline. + this.saveToCache(method, data, error, preSets); + return Promise.reject(error); } @@ -678,7 +770,7 @@ export class CoreSite { }); }).then((response) => { // Check if the response is an error, this happens if the error was stored in the cache. - if (response && typeof response.exception !== 'undefined') { + if (response && (typeof response.exception != 'undefined' || typeof response.errorcode != 'undefined')) { return Promise.reject(response); } @@ -699,6 +791,158 @@ export class CoreSite { }); } + /** + * Adds a request to the queue or calls it immediately when not using the queue. + * + * @param {string} method The WebService method to be called. + * @param {any} data Arguments to pass to the method. + * @param {CoreSiteWSPreSets} preSets Extra options related to the site. + * @param {CoreWSPreSets} wsPreSets Extra options related to the WS call. + * @returns {Promise} Promise resolved with the response when the WS is called. + */ + protected callOrEnqueueRequest(method: string, data: any, preSets: CoreSiteWSPreSets, wsPreSets: CoreWSPreSets): Promise { + if (preSets.skipQueue || !this.wsAvailable('tool_mobile_call_external_functions')) { + return this.wsProvider.call(method, data, wsPreSets); + } + + const cacheId = this.getCacheId(method, data); + + // Check if there is an identical request waiting in the queue (read requests only by default). + if (preSets.reusePending) { + const request = this.requestQueue.find((request) => request.cacheId == cacheId); + if (request) { + return request.deferred.promise; + } + } + + const request: RequestQueueItem = { + cacheId, + method, + data, + preSets, + wsPreSets, + deferred: {} + }; + + request.deferred.promise = new Promise((resolve, reject): void => { + request.deferred.resolve = resolve; + request.deferred.reject = reject; + }); + + return this.enqueueRequest(request); + } + + /** + * Adds a request to the queue. + * + * @param {RequestQueueItem} request The request to enqueue. + * @returns {Promise} Promise resolved with the response when the WS is called. + */ + protected enqueueRequest(request: RequestQueueItem): Promise { + + this.requestQueue.push(request); + + if (this.requestQueue.length >= CoreSite.REQUEST_QUEUE_LIMIT) { + this.processRequestQueue(); + } else if (!this.requestQueueTimeout) { + this.requestQueueTimeout = setTimeout(this.processRequestQueue.bind(this), CoreSite.REQUEST_QUEUE_DELAY); + } + + return request.deferred.promise; + } + + /** + * Call the enqueued web service requests. + */ + protected processRequestQueue(): void { + this.logger.debug(`Processing request queue (${this.requestQueue.length} requests)`); + + // Clear timeout if set. + if (this.requestQueueTimeout) { + clearTimeout(this.requestQueueTimeout); + this.requestQueueTimeout = null; + } + + // Extract all requests from the queue. + const requests = this.requestQueue; + this.requestQueue = []; + + if (requests.length == 1 && !CoreSite.REQUEST_QUEUE_FORCE_WS) { + // Only one request, do a regular web service call. + this.wsProvider.call(requests[0].method, requests[0].data, requests[0].wsPreSets).then((data) => { + requests[0].deferred.resolve(data); + }).catch((error) => { + requests[0].deferred.reject(error); + }); + + return; + } + + const data = { + requests: requests.map((request) => { + const args = {}; + const settings = {}; + + // Separate WS settings from function arguments. + Object.keys(request.data).forEach((key) => { + let value = request.data[key]; + const match = /^moodlews(setting.*)$/.exec(key); + if (match) { + if (match[1] == 'settingfilter' || match[1] == 'settingfileurl') { + // Undo special treatment of these settings in CoreWSProvider.convertValuesToString. + value = (value == 'true' ? '1' : '0'); + } + settings[match[1]] = value; + } else { + args[key] = value; + } + }); + + return { + function: request.method, + arguments: JSON.stringify(args), + ...settings + }; + }) + }; + + const wsPresets: CoreWSPreSets = { + siteUrl: this.siteUrl, + wsToken: this.token, + }; + + this.wsProvider.call('tool_mobile_call_external_functions', data, wsPresets).then((data) => { + if (!data || !data.responses) { + return Promise.reject(null); + } + + requests.forEach((request, i) => { + const response = data.responses[i]; + + if (!response) { + // Request not executed, enqueue again. + this.enqueueRequest(request); + } else if (response.error) { + request.deferred.reject(this.textUtils.parseJSON(response.exception)); + } else { + let responseData = this.textUtils.parseJSON(response.data); + // Match the behaviour of CoreWSProvider.call when no response is expected. + const responseExpected = typeof wsPresets.responseExpected == 'undefined' || wsPresets.responseExpected; + if (!responseExpected && (responseData == null || responseData === '')) { + responseData = {}; + } + request.deferred.resolve(responseData); + } + }); + + }).catch((error) => { + // Error not specific to a single request, reject all promises. + requests.forEach((request) => { + request.deferred.reject(error); + }); + }); + } + /** * Check if a WS is available in this site. * @@ -711,11 +955,8 @@ export class CoreSite { return false; } - for (let i = 0; i < this.infos.functions.length; i++) { - const func = this.infos.functions[i]; - if (func.name == method) { - return true; - } + if (this.infos.functionsByName[method]) { + return true; } // Let's try again with the compatibility prefix. @@ -800,11 +1041,22 @@ export class CoreSite { return promise.then((entry) => { const now = Date.now(); + let expirationTime; preSets.omitExpires = preSets.omitExpires || !this.appProvider.isOnline(); if (!preSets.omitExpires) { - if (now > entry.expirationTime) { + let expirationDelay = this.UPDATE_FREQUENCIES[preSets.updateFrequency] || + this.UPDATE_FREQUENCIES[CoreSite.FREQUENCY_USUALLY]; + + if (this.appProvider.isNetworkAccessLimited()) { + // Not WiFi, increase the expiration delay a 50% to decrease the data usage in this case. + expirationDelay *= 1.5; + } + + expirationTime = entry.expirationTime + expirationDelay; + + if (now > expirationTime) { this.logger.debug('Cached element found, but it is expired'); return Promise.reject(null); @@ -812,8 +1064,12 @@ export class CoreSite { } if (typeof entry != 'undefined' && typeof entry.data != 'undefined') { - const expires = (entry.expirationTime - now) / 1000; - this.logger.info(`Cached element found, id: ${id} expires in ${expires} seconds`); + if (!expirationTime) { + this.logger.info(`Cached element found, id: ${id}. Expiration time ignored.`); + } else { + const expires = (expirationTime - now) / 1000; + this.logger.info(`Cached element found, id: ${id}. Expires in expires in ${expires} seconds`); + } return this.textUtils.parseJSON(entry.data, {}); } @@ -848,15 +1104,15 @@ export class CoreSite { } return promise.then(() => { + // Since 3.7, the expiration time contains the time the entry is modified instead of the expiration time. + // We decided to reuse this field to prevent modifying the database table. const id = this.getCacheId(method, data), entry: any = { id: id, - data: JSON.stringify(response) + data: JSON.stringify(response), + expirationTime: Date.now() }; - let cacheExpirationTime = CoreConfigConstants.cache_expiration_time; - cacheExpirationTime = isNaN(cacheExpirationTime) ? 300000 : cacheExpirationTime; - entry.expirationTime = new Date().getTime() + cacheExpirationTime; if (preSets.cacheKey) { entry.key = preSets.cacheKey; } @@ -1164,7 +1420,7 @@ export class CoreSite { return false; } - const siteUrl = this.urlUtils.removeProtocolAndWWW(this.siteUrl); + const siteUrl = this.textUtils.removeEndingSlash(this.urlUtils.removeProtocolAndWWW(this.siteUrl)); url = this.urlUtils.removeProtocolAndWWW(url); return url.indexOf(siteUrl) == 0; diff --git a/src/components/bs-tooltip/bs-tooltip.scss b/src/components/bs-tooltip/bs-tooltip.scss new file mode 100644 index 000000000..54640d936 --- /dev/null +++ b/src/components/bs-tooltip/bs-tooltip.scss @@ -0,0 +1,2 @@ +ion-app.app-root core-bs-tooltip { +} diff --git a/src/components/bs-tooltip/bs-tooltip.ts b/src/components/bs-tooltip/bs-tooltip.ts new file mode 100644 index 000000000..8248fa9f1 --- /dev/null +++ b/src/components/bs-tooltip/bs-tooltip.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 { Component } from '@angular/core'; +import { NavParams } from 'ionic-angular'; + +/** + * Component to display a Bootstrap Tooltip in a popover. + */ +@Component({ + selector: 'core-bs-tooltip', + templateUrl: 'core-bs-tooltip.html' +}) +export class CoreBSTooltipComponent { + content: string; + html: boolean; + + constructor(navParams: NavParams) { + this.content = navParams.get('content') || ''; + this.html = !!navParams.get('html'); + } +} diff --git a/src/components/bs-tooltip/core-bs-tooltip.html b/src/components/bs-tooltip/core-bs-tooltip.html new file mode 100644 index 000000000..198b17159 --- /dev/null +++ b/src/components/bs-tooltip/core-bs-tooltip.html @@ -0,0 +1,4 @@ + +

+

{{content}}

+
\ No newline at end of file diff --git a/src/components/components.module.ts b/src/components/components.module.ts index e72475548..c47653a06 100644 --- a/src/components/components.module.ts +++ b/src/components/components.module.ts @@ -34,6 +34,7 @@ import { CoreContextMenuPopoverComponent } from './context-menu/context-menu-pop import { CoreCoursePickerMenuPopoverComponent } from './course-picker-menu/course-picker-menu-popover'; import { CoreChartComponent } from './chart/chart'; import { CoreChronoComponent } from './chrono/chrono'; +import { CoreDownloadRefreshComponent } from './download-refresh/download-refresh'; import { CoreLocalFileComponent } from './local-file/local-file'; import { CoreSitePickerComponent } from './site-picker/site-picker'; import { CoreTabsComponent } from './tabs/tabs'; @@ -52,6 +53,7 @@ import { CoreIonTabComponent } from './ion-tabs/ion-tab'; import { CoreInfiniteLoadingComponent } from './infinite-loading/infinite-loading'; import { CoreUserAvatarComponent } from './user-avatar/user-avatar'; import { CoreStyleComponent } from './style/style'; +import { CoreBSTooltipComponent } from './bs-tooltip/bs-tooltip'; @NgModule({ declarations: [ @@ -72,6 +74,7 @@ import { CoreStyleComponent } from './style/style'; CoreCoursePickerMenuPopoverComponent, CoreChartComponent, CoreChronoComponent, + CoreDownloadRefreshComponent, CoreLocalFileComponent, CoreSitePickerComponent, CoreTabsComponent, @@ -89,12 +92,14 @@ import { CoreStyleComponent } from './style/style'; CoreIonTabComponent, CoreInfiniteLoadingComponent, CoreUserAvatarComponent, - CoreStyleComponent + CoreStyleComponent, + CoreBSTooltipComponent ], entryComponents: [ CoreContextMenuPopoverComponent, CoreCoursePickerMenuPopoverComponent, - CoreRecaptchaModalComponent + CoreRecaptchaModalComponent, + CoreBSTooltipComponent ], imports: [ IonicModule, @@ -118,6 +123,7 @@ import { CoreStyleComponent } from './style/style'; CoreContextMenuItemComponent, CoreChartComponent, CoreChronoComponent, + CoreDownloadRefreshComponent, CoreLocalFileComponent, CoreSitePickerComponent, CoreTabsComponent, @@ -134,7 +140,8 @@ import { CoreStyleComponent } from './style/style'; CoreIonTabComponent, CoreInfiniteLoadingComponent, CoreUserAvatarComponent, - CoreStyleComponent + CoreStyleComponent, + CoreBSTooltipComponent ] }) export class CoreComponentsModule {} diff --git a/src/components/context-menu/context-menu-item.ts b/src/components/context-menu/context-menu-item.ts index e748dee34..0870cbcd3 100644 --- a/src/components/context-menu/context-menu-item.ts +++ b/src/components/context-menu/context-menu-item.ts @@ -39,6 +39,7 @@ export class CoreContextMenuItemComponent implements OnInit, OnDestroy, OnChange // If is "spinner" an spinner will be shown. // If no icon or spinner is selected, no action or link will work. // If href but no iconAction is provided arrow-right will be used. + @Input() iconSlash?: boolean; // Display a red slash over the icon. @Input() ariaDescription?: string; // Aria label to add to iconDescription. @Input() ariaAction?: string; // Aria label to add to iconAction. If not set, it will be equal to content. @Input() href?: string; // Link to go if no action provided. diff --git a/src/components/context-menu/context-menu.ts b/src/components/context-menu/context-menu.ts index 1f674eebd..ff6413b67 100644 --- a/src/components/context-menu/context-menu.ts +++ b/src/components/context-menu/context-menu.ts @@ -31,10 +31,10 @@ import { Subject } from 'rxjs'; }) export class CoreContextMenuComponent implements OnInit, OnDestroy { @Input() icon?: string; // Icon to be shown on the navigation bar. Default: Kebab menu icon. - @Input() title?: string; // Aria label and text to be shown on the top of the popover. + @Input() title?: string; // Text to be shown on the top of the popover. + @Input('aria-label') ariaLabel?: string; // Aria label to be shown on the top of the popover. hideMenu = true; // It will be unhidden when items are added. - ariaLabel: string; expanded = false; protected items: CoreContextMenuItemComponent[] = []; protected itemsMovedToParent: CoreContextMenuItemComponent[] = []; @@ -70,7 +70,7 @@ export class CoreContextMenuComponent implements OnInit, OnDestroy { */ ngOnInit(): void { this.icon = this.icon || 'more'; - this.ariaLabel = this.title || this.translate.instant('core.info'); + this.ariaLabel = this.ariaLabel || this.title || this.translate.instant('core.info'); } /** diff --git a/src/components/context-menu/core-context-menu-popover.html b/src/components/context-menu/core-context-menu-popover.html index 18905549d..69ffdadf7 100644 --- a/src/components/context-menu/core-context-menu-popover.html +++ b/src/components/context-menu/core-context-menu-popover.html @@ -1,9 +1,9 @@ {{title}} - + - + {{item.badge}} diff --git a/src/components/download-refresh/core-download-refresh.html b/src/components/download-refresh/core-download-refresh.html new file mode 100644 index 000000000..c9b23a15a --- /dev/null +++ b/src/components/download-refresh/core-download-refresh.html @@ -0,0 +1,17 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/download-refresh/download-refresh.scss b/src/components/download-refresh/download-refresh.scss new file mode 100644 index 000000000..2ac72a335 --- /dev/null +++ b/src/components/download-refresh/download-refresh.scss @@ -0,0 +1,20 @@ +ion-app.app-root core-download-refresh { + font-size: 1.4rem; + display: flex; + flex-flow: row; + align-items: center; + z-index: 1; + justify-content: space-around; + align-content: center; + min-height: 44px; + + button, ion-icon { + cursor: pointer; + pointer-events: auto; + text-align: center; + } + + ion-icon, .core-icon-downloaded { + font-size: 1.8em; + } +} diff --git a/src/components/download-refresh/download-refresh.ts b/src/components/download-refresh/download-refresh.ts new file mode 100644 index 000000000..87d964c9b --- /dev/null +++ b/src/components/download-refresh/download-refresh.ts @@ -0,0 +1,55 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { CoreConstants } from '@core/constants'; + +/** + * Component to show a download button with refresh option, the spinner and the status of it. + * + * Usage: + * + */ +@Component({ + selector: 'core-download-refresh', + templateUrl: 'core-download-refresh.html' +}) +export class CoreDownloadRefreshComponent { + @Input() status: string; // Download status. + @Input() enabled = false; // Whether the download is enabled. + @Input() loading = true; // Force loading status when is not downloading. + @Input() canTrustDownload = false; // If false, refresh will be shown if downloaded. + @Output() action: EventEmitter; // Will emit an event when the item clicked. + + statusDownloaded = CoreConstants.DOWNLOADED; + statusNotDownloaded = CoreConstants.NOT_DOWNLOADED; + statusOutdated = CoreConstants.OUTDATED; + statusDownloading = CoreConstants.DOWNLOADING; + + constructor() { + this.action = new EventEmitter(); + } + + /** + * Download clicked. + * + * @param {Event} e Click event. + * @param {boolean} refresh Whether it's refreshing. + */ + download(e: Event, refresh: boolean): void { + e.preventDefault(); + e.stopPropagation(); + this.action.emit(refresh); + } +} diff --git a/src/components/file/core-file.html b/src/components/file/core-file.html index 1ae020dfa..071deec11 100644 --- a/src/components/file/core-file.html +++ b/src/components/file/core-file.html @@ -3,13 +3,12 @@

{{fileName}}

{{ fileSizeReadable }}

{{ timemodified * 1000 | coreFormatDate }}

+
- + +
- diff --git a/src/components/file/file.scss b/src/components/file/file.scss index a39dcde10..227c565b8 100644 --- a/src/components/file/file.scss +++ b/src/components/file/file.scss @@ -7,6 +7,10 @@ ion-app.app-root { .card-ios core-file + core-file > .item-ios.item-block > .item-inner, core-file + core-file > .item-ios.item-block > .item-inner { border-top: $hairlines-width solid $list-ios-border-color; + .buttons { + min-height: 53px; + min-width: 58px; + } } .card-wp core-file + core-file > .item-wp.item-block > .item-inner, @@ -16,5 +20,16 @@ ion-app.app-root { core-file > .item.item-block > .item-inner { border-bottom: 0; + @include padding(null, 0, null, null); + .buttons { + display: flex; + flex-flow: row; + align-items: center; + z-index: 1; + justify-content: space-around; + align-content: center; + min-height: 52px; + min-width: 53px; + } } } \ No newline at end of file diff --git a/src/components/file/file.ts b/src/components/file/file.ts index 53fb53aa6..22d0c98af 100644 --- a/src/components/file/file.ts +++ b/src/components/file/file.ts @@ -44,9 +44,7 @@ export class CoreFileComponent implements OnInit, OnDestroy { @Input() showTime?: boolean | string = true; // Whether show file time modified. @Output() onDelete?: EventEmitter; // Will notify when the delete button is clicked. - isDownloaded: boolean; isDownloading: boolean; - showDownload: boolean; fileIcon: string; fileName: string; fileSizeReadable: string; @@ -110,13 +108,10 @@ export class CoreFileComponent implements OnInit, OnDestroy { */ protected calculateState(): Promise { return this.filepoolProvider.getFileStateByUrl(this.siteId, this.fileUrl, this.timemodified).then((state) => { - const canDownload = this.sitesProvider.getCurrentSite().canDownloadFiles(); + this.canDownload = this.sitesProvider.getCurrentSite().canDownloadFiles(); this.state = state; - this.isDownloaded = state === CoreConstants.DOWNLOADED || state === CoreConstants.OUTDATED; - this.isDownloading = canDownload && state === CoreConstants.DOWNLOADING; - this.showDownload = canDownload && (state === CoreConstants.NOT_DOWNLOADED || state === CoreConstants.OUTDATED || - (this.alwaysDownload && state === CoreConstants.DOWNLOADED)); + this.isDownloading = this.canDownload && state === CoreConstants.DOWNLOADING; }); } @@ -139,12 +134,12 @@ export class CoreFileComponent implements OnInit, OnDestroy { /** * Download a file and, optionally, open it afterwards. * - * @param {Event} e Click event. + * @param {Event} [e] Click event. * @param {boolean} openAfterDownload Whether the file should be opened after download. */ - download(e: Event, openAfterDownload: boolean): void { - e.preventDefault(); - e.stopPropagation(); + download(e?: Event, openAfterDownload: boolean = false): void { + e && e.preventDefault(); + e && e.stopPropagation(); let promise; @@ -168,7 +163,8 @@ export class CoreFileComponent implements OnInit, OnDestroy { return; } - if (!this.appProvider.isOnline() && (!openAfterDownload || (openAfterDownload && !this.isDownloaded))) { + if (!this.appProvider.isOnline() && (!openAfterDownload || (openAfterDownload && + !(this.state === CoreConstants.DOWNLOADED || this.state === CoreConstants.OUTDATED)))) { this.domUtils.showErrorModal('core.networkerrormsg', true); return; diff --git a/src/components/icon/core-icon.html b/src/components/icon/core-icon.html index 8e4a85f3a..7c89b545c 100644 --- a/src/components/icon/core-icon.html +++ b/src/components/icon/core-icon.html @@ -1 +1 @@ - \ No newline at end of file +
diff --git a/src/components/icon/icon.scss b/src/components/icon/icon.scss index bacc56f37..f909e876e 100644 --- a/src/components/icon/icon.scss +++ b/src/components/icon/icon.scss @@ -13,4 +13,43 @@ -webkit-transform: scale(-1, 1); transform: scale(-1, 1); } -} \ No newline at end of file +} + +// Center font awesome icons + +.icon.fa::before { + width: 1em; + text-align: center; +} + +// Slash + +@font-face { + font-family: "Moodle Slash Icon"; + font-style: normal; + font-weight: 400; + src: url("#{$font-path}/slash-icon.woff") format("woff"); +} + +.icon-slash { + position: relative; +} + +.icon-slash::after { + content: "/"; + font-family: "Moodle Slash Icon"; + font-size: 0.75em; + margin-top: 0.125em; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + text-align: center; + color: color($colors, danger); +} + +.icon-slash.fa::after { + font-size: 1em; + margin-top: 0; +} diff --git a/src/components/icon/icon.ts b/src/components/icon/icon.ts index 0321a6485..82e53d165 100644 --- a/src/components/icon/icon.ts +++ b/src/components/icon/icon.ts @@ -12,7 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, OnInit, OnDestroy, ElementRef } from '@angular/core'; +import { Component, Input, OnChanges, OnDestroy, ElementRef, SimpleChange } from '@angular/core'; +import { Config } from 'ionic-angular'; /** * Core Icon is a component that enabled a posibility to add fontawesome icon to the html. It's recommended if both fontawesome @@ -24,10 +25,11 @@ import { Component, Input, OnInit, OnDestroy, ElementRef } from '@angular/core'; selector: 'core-icon', templateUrl: 'core-icon.html', }) -export class CoreIconComponent implements OnInit, OnDestroy { +export class CoreIconComponent implements OnChanges, OnDestroy { // Common params. @Input() name: string; @Input('color') color?: string; + @Input('slash') slash?: boolean; // Display a red slash over the icon. // Ionicons params. @Input('isActive') isActive?: boolean; @@ -42,17 +44,25 @@ export class CoreIconComponent implements OnInit, OnDestroy { protected element: HTMLElement; protected newElement: HTMLElement; - constructor(el: ElementRef) { + constructor(el: ElementRef, private config: Config) { this.element = el.nativeElement; } /** - * Component being initialized. + * Detect changes on input properties. */ - ngOnInit(): void { + ngOnChanges(changes: {[name: string]: SimpleChange}): void { + if (!changes.name || !this.name) { + return; + } + + const oldElement = this.newElement ? this.newElement : this.element; + + // Use a new created element to avoid ion-icon working. + // This is necessary to make the FontAwesome stuff work. + // It is also required to stop Ionic overriding the aria-label attribute. + this.newElement = document.createElement('ion-icon'); if (this.name.startsWith('fa-')) { - // Use a new created element to avoid ion-icon working. - this.newElement = document.createElement('ion-icon'); this.newElement.classList.add('icon'); this.newElement.classList.add('fa'); this.newElement.classList.add(this.name); @@ -63,7 +73,10 @@ export class CoreIconComponent implements OnInit, OnDestroy { this.newElement.classList.add('fa-' + this.color); } } else { - this.newElement = this.element.firstElementChild; + const mode = this.config.get('iconMode'); + this.newElement.classList.add('icon'); + this.newElement.classList.add('icon-' + mode); + this.newElement.classList.add('ion-' + mode + '-' + this.name); } !this.ariaLabel && this.newElement.setAttribute('aria-hidden', 'true'); @@ -88,7 +101,11 @@ export class CoreIconComponent implements OnInit, OnDestroy { } } - this.element.parentElement.replaceChild(this.newElement, this.element); + if (this.slash) { + this.newElement.classList.add('icon-slash'); + } + + oldElement.parentElement.replaceChild(this.newElement, oldElement); } /** diff --git a/src/components/infinite-loading/core-infinite-loading.html b/src/components/infinite-loading/core-infinite-loading.html index 35a8285a8..8775a5b60 100644 --- a/src/components/infinite-loading/core-infinite-loading.html +++ b/src/components/infinite-loading/core-infinite-loading.html @@ -1,5 +1,5 @@ -
+
@@ -9,12 +9,12 @@
- + -
+
@@ -24,6 +24,6 @@
-
+
\ No newline at end of file diff --git a/src/components/infinite-loading/infinite-loading.ts b/src/components/infinite-loading/infinite-loading.ts index 101e303d4..977eba6b7 100644 --- a/src/components/infinite-loading/infinite-loading.ts +++ b/src/components/infinite-loading/infinite-loading.ts @@ -12,8 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, Output, EventEmitter, OnChanges, SimpleChange, Optional } from '@angular/core'; +import { Component, Input, Output, EventEmitter, OnChanges, SimpleChange, Optional, ViewChild, ElementRef } from '@angular/core'; import { InfiniteScroll, Content } from 'ionic-angular'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; /** * Component to show a infinite loading trigger and spinner while more data is being loaded. @@ -31,11 +32,16 @@ export class CoreInfiniteLoadingComponent implements OnChanges { @Input() position = 'bottom'; @Output() action: EventEmitter<() => void>; // Will emit an event when triggered. + @ViewChild('topbutton') topButton: ElementRef; + @ViewChild('infinitescroll') infiniteEl: ElementRef; + @ViewChild('bottombutton') bottomButton: ElementRef; + @ViewChild('spinnercontainer') spinnerContainer: ElementRef; + loadingMore = false; // Hide button and avoid loading more. protected infiniteScroll: InfiniteScroll; - constructor(@Optional() private content: Content) { + constructor(@Optional() private content: Content, private domUtils: CoreDomUtilsProvider) { this.action = new EventEmitter(); } @@ -77,6 +83,18 @@ export class CoreInfiniteLoadingComponent implements OnChanges { * Complete loading. */ complete(): void { + if (this.position == 'top') { + // Wait a bit before allowing loading more, otherwise it could be re-triggered automatically when it shouldn't. + setTimeout(this.completeLoadMore.bind(this), 400); + } else { + this.completeLoadMore(); + } + } + + /** + * Complete loading. + */ + protected completeLoadMore(): void { this.loadingMore = false; this.infiniteScroll && this.infiniteScroll.complete(); this.infiniteScroll = undefined; @@ -89,4 +107,28 @@ export class CoreInfiniteLoadingComponent implements OnChanges { }); } + /** + * Get the height of the element. + * + * @return {number} Height. + */ + getHeight(): number { + return this.getElementHeight(this.topButton) + this.getElementHeight(this.infiniteEl) + + this.getElementHeight(this.bottomButton) + this.getElementHeight(this.spinnerContainer); + } + + /** + * Get the height of an element. + * + * @param {ElementRef} element Element ref. + * @return {number} Height. + */ + protected getElementHeight(element: ElementRef): number { + if (element && element.nativeElement) { + return this.domUtils.getElementHeight(element.nativeElement, true, true, true); + } + + return 0; + } + } diff --git a/src/components/ion-tabs/core-ion-tabs.html b/src/components/ion-tabs/core-ion-tabs.html index e255871b6..123d340c1 100644 --- a/src/components/ion-tabs/core-ion-tabs.html +++ b/src/components/ion-tabs/core-ion-tabs.html @@ -1,5 +1,5 @@
- +
@@ -7,7 +7,7 @@
-
+
diff --git a/src/components/ion-tabs/ion-tabs.scss b/src/components/ion-tabs/ion-tabs.scss index 22cf41ef9..fabba1828 100644 --- a/src/components/ion-tabs/ion-tabs.scss +++ b/src/components/ion-tabs/ion-tabs.scss @@ -1,16 +1,17 @@ +$core-sidetab-size: 60px !default; + ion-app.app-root core-ion-tabs { .tabbar { z-index: 101; // For some reason, the regular z-index isn't enough with our tabs, use a higher one. .core-ion-tabs-loading { + height: 100%; width: 100%; - display: table; + display: flex; + align-items: center; + justify-content: center; .core-ion-tabs-loading-spinner { - display: table-cell; - text-align: center; - vertical-align: middle; - .spinner circle, .spinner line { stroke: $white; } @@ -21,6 +22,38 @@ ion-app.app-root core-ion-tabs { .tab-badge.badge { background-color: $ion-tabs-badge-color; } + + &[tabsplacement="side"] { + .tabbar { + @include float(start); + width: $core-sidetab-size; + height: 100%; + flex-direction: column; + .tab-button { + width: 100%; + .tab-badge.badge { + @include position(calc(50% - 30px), 2px, null, null); + } + } + } + + .tabbar[hidden] + .tabcontent { + width: 100%; + core-ion-tab { + @include position(0, 0, 0, 0); + } + } + + .tabcontent { + width: calc(100% - #{($core-sidetab-size)}); + position: absolute; + @include position(0, 0, 0, 0); + core-ion-tab { + @include position(0, 0, 0, $core-sidetab-size); + position: relative; + } + } + } } ion-app.app-root.ios core-ion-tabs .core-ion-tabs-loading { diff --git a/src/components/rich-text-editor/rich-text-editor.ts b/src/components/rich-text-editor/rich-text-editor.ts index 5d2da5941..59fdabe77 100644 --- a/src/components/rich-text-editor/rich-text-editor.ts +++ b/src/components/rich-text-editor/rich-text-editor.ts @@ -148,7 +148,10 @@ export class CoreRichTextEditorComponent implements AfterContentInit, OnDestroy // Editor is ready, adjust Height if needed. let height; - if (this.platform.is('ios') && this.kbHeight > 0) { + if (this.platform.is('android')) { + // Android, ignore keyboard height because web view is resized. + height = this.domUtils.getContentHeight(this.content) - this.getSurroundingHeight(this.element); + } else if (this.platform.is('ios') && this.kbHeight > 0) { // Keyboard open in iOS. // In this case, the header disappears or is scrollable, so we need to adjust the calculations. height = window.innerHeight - this.getSurroundingHeight(this.element); diff --git a/src/components/tabs/tab.ts b/src/components/tabs/tab.ts index e47563acc..48b8a3dcb 100644 --- a/src/components/tabs/tab.ts +++ b/src/components/tabs/tab.ts @@ -66,7 +66,7 @@ export class CoreTabComponent implements OnInit, OnDestroy { @Output() ionSelect: EventEmitter = new EventEmitter(); @ContentChild(TemplateRef) template: TemplateRef; // Template defined by the content. - @ContentChild(Content) scroll: Content; + @ContentChild(Content) content: Content; element: HTMLElement; // The core-tab element. loaded = false; @@ -122,10 +122,15 @@ export class CoreTabComponent implements OnInit, OnDestroy { // Setup tab scrolling. setTimeout(() => { - if (this.scroll) { - this.scroll.getScrollElement().onscroll = (e): void => { - this.tabs.showHideTabs(e); + // Workaround to solve undefined this.scroll on tab change. + const scroll: HTMLElement = this.content ? this.content.getScrollElement() : + this.element.querySelector('ion-content > .scroll-content'); + + if (scroll) { + scroll.onscroll = (e): void => { + this.tabs.showHideTabs(e.target); }; + this.tabs.showHideTabs(scroll); } }, 1); } diff --git a/src/components/tabs/tabs.scss b/src/components/tabs/tabs.scss index 8d6c3a5e1..5dfa3c960 100644 --- a/src/components/tabs/tabs.scss +++ b/src/components/tabs/tabs.scss @@ -36,13 +36,6 @@ ion-app.app-root .core-tabs-bar { @include margin(null, 5px, null, null); } - ion-badge.tab-badge { - position: relative; - @include position(auto, auto, auto, auto); - @include margin(null, null, null, 5px); - - } - &[aria-selected=true] { color: $core-top-tabs-color-active !important; border-bottom-color: $core-top-tabs-border-active !important; @@ -118,8 +111,10 @@ ion-app.app-root core-tabs { &.tabs-hidden { .core-tabs-bar { display: none !important; + transform: translateY(0) !important; } .core-tabs-content-container { + transform: translateY(0) !important; padding-bottom: 0 !important; } } diff --git a/src/components/tabs/tabs.ts b/src/components/tabs/tabs.ts index d15eec81a..5d457bfd8 100644 --- a/src/components/tabs/tabs.ts +++ b/src/components/tabs/tabs.ts @@ -63,6 +63,7 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe numTabsShown = 0; direction = 'ltr'; description = ''; + lastScroll = 0; protected originalTabsContainer: HTMLElement; // The container of the original tabs. It will include each tab's content. protected initialized = false; @@ -406,22 +407,38 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe /** * Show or hide the tabs. This is used when the user is scrolling inside a tab. * - * @param {any} e Scroll event. + * @param {any} scrollElement Scroll element to check scroll position. */ - showHideTabs(e: any): void { + showHideTabs(scrollElement: any): void { if (!this.tabBarHeight) { // We don't have the tab bar height, this means the tab bar isn't shown. return; } - if (this.tabsShown && e.target.scrollTop - this.tabBarHeight > this.tabBarHeight) { - this.tabBarElement.classList.add('tabs-hidden'); + const scroll = parseInt(scrollElement.scrollTop, 10); + if (scroll == this.lastScroll) { + // Ensure scroll has been modified to avoid flicks. + return; + } + + if (this.tabsShown && scroll > this.tabBarHeight) { this.tabsShown = false; - } else if (!this.tabsShown && e.target.scrollTop < this.tabBarHeight) { - this.tabBarElement.classList.remove('tabs-hidden'); + + // Hide tabs. + this.tabBarElement.classList.add('tabs-hidden'); + } else if (!this.tabsShown && scroll <= this.tabBarHeight) { this.tabsShown = true; + this.tabBarElement.classList.remove('tabs-hidden'); this.calculateSlides(); } + + if (this.tabsShown) { + // Smooth translation. + this.topTabsElement.style.transform = 'translateY(-' + scroll + 'px)'; + this.originalTabsContainer.style.transform = 'translateY(-' + scroll + 'px)'; + this.originalTabsContainer.style.paddingBottom = this.tabBarHeight - scroll + 'px'; + } + this.lastScroll = scroll; } /** diff --git a/src/components/user-avatar/core-user-avatar.html b/src/components/user-avatar/core-user-avatar.html index 15b7a35f9..21783350d 100644 --- a/src/components/user-avatar/core-user-avatar.html +++ b/src/components/user-avatar/core-user-avatar.html @@ -1,4 +1,5 @@ + \ No newline at end of file diff --git a/src/components/user-avatar/user-avatar.scss b/src/components/user-avatar/user-avatar.scss index 5962d9658..c5296a850 100644 --- a/src/components/user-avatar/user-avatar.scss +++ b/src/components/user-avatar/user-avatar.scss @@ -12,6 +12,16 @@ ion-avatar[core-user-avatar] { background-color: $core-online-color; } } + + .core-avatar-extra-icon { + margin: 0 !important; + border-radius: 0 !important; + background: none; + position: absolute; + @include position(null, -4px, -4px, null); + width: 24px; + height: 24px; + } } .toolbar ion-avatar[core-user-avatar] .contact-status { diff --git a/src/components/user-avatar/user-avatar.ts b/src/components/user-avatar/user-avatar.ts index cb1243e1d..298ace24a 100644 --- a/src/components/user-avatar/user-avatar.ts +++ b/src/components/user-avatar/user-avatar.ts @@ -38,13 +38,13 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges, OnDestroy { @Input() protected userId?: number; // If provided or found it will be used to link the image to the profile. @Input() protected courseId?: number; @Input() checkOnline = false; // If want to check and show online status. + @Input() extraIcon?: string; // Extra icon to show near the avatar. avatarUrl?: string; // Variable to check if we consider this user online or not. // @TODO: Use setting when available (see MDL-63972) so we can use site setting. protected timetoshowusers = 300000; // Miliseconds default. - protected myUser = false; protected currentUserId: number; protected pictureObs; @@ -91,9 +91,6 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges, OnDestroy { this.userId = this.userId || (this.user && (this.user.userid || this.user.id)); this.courseId = this.courseId || (this.user && this.user.courseid); - - // If not available we cannot ensure the avatar is from the current user. - this.myUser = this.userId && this.userId == this.currentUserId; } /** @@ -102,7 +99,7 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges, OnDestroy { * @return boolean */ isOnline(): boolean { - if (this.myUser || this.utils.isFalseOrZero(this.user.isonline)) { + if (this.utils.isFalseOrZero(this.user.isonline)) { return false; } diff --git a/src/config.json b/src/config.json index 94700c03c..762c0709c 100644 --- a/src/config.json +++ b/src/config.json @@ -2,9 +2,12 @@ "app_id": "com.moodle.moodlemobile", "appname": "Moodle Mobile", "desktopappname": "Moodle Desktop", - "versioncode": 3610, - "versionname": "3.6.1", - "cache_expiration_time": 300000, + "versioncode": 3700, + "versionname": "3.7.0", + "cache_update_frequency_usually": 420000, + "cache_update_frequency_often": 1200000, + "cache_update_frequency_sometimes": 3600000, + "cache_update_frequency_rarely": 43200000, "default_lang": "en", "languages": { "ar": "عربي", @@ -67,8 +70,19 @@ }, "customurlscheme": "moodlemobile", "siteurl": "", + "sitename": "", "multisitesdisplay": "", "skipssoconfirmation": false, "forcedefaultlanguage": false, - "privacypolicy": "https:\/\/moodle.org\/mod\/page\/view.php?id=8148" + "privacypolicy": "https:\/\/moodle.org\/mod\/page\/view.php?id=8148", + "notificoncolor": "#f98012", + "statusbarbg": false, + "statusbarlighttext": false, + "statusbarbgios": "#f98012", + "statusbarlighttextios": true, + "statusbarbgandroid": "#df7310", + "statusbarlighttextandroid": true, + "statusbarbgremotetheme": "#000000", + "statusbarlighttextremotetheme": true, + "enableanalytics": false } diff --git a/src/core/block/components/block/block.ts b/src/core/block/components/block/block.ts index 4d6b67dc0..fffba5683 100644 --- a/src/core/block/components/block/block.ts +++ b/src/core/block/components/block/block.ts @@ -12,9 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, OnInit, Injector, ViewChild } from '@angular/core'; +import { Component, Input, OnInit, Injector, ViewChild, OnDestroy } from '@angular/core'; import { CoreBlockDelegate } from '../../providers/delegate'; import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; +import { Subscription } from 'rxjs'; +import { CoreEventsProvider } from '@providers/events'; /** * Component to render a block. @@ -23,7 +25,7 @@ import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-comp selector: 'core-block', templateUrl: 'core-block.html' }) -export class CoreBlockComponent implements OnInit { +export class CoreBlockComponent implements OnInit, OnDestroy { @ViewChild(CoreDynamicComponent) dynamicComponent: CoreDynamicComponent; @Input() block: any; // The block to render. @@ -37,7 +39,10 @@ export class CoreBlockComponent implements OnInit { class: string; // CSS class to apply to the block. loaded = false; - constructor(protected injector: Injector, protected blockDelegate: CoreBlockDelegate) { } + blockSubscription: Subscription; + + constructor(protected injector: Injector, protected blockDelegate: CoreBlockDelegate, + protected eventsProvider: CoreEventsProvider) { } /** * Component being initialized. @@ -50,9 +55,28 @@ export class CoreBlockComponent implements OnInit { } // Get the data to render the block. + this.initBlock(); + } + + /** + * Get block display data and initialises the block once this is available. If the block is not + * supported at the moment, try again if the available blocks are updated (because it comes + * from a site plugin). + */ + initBlock(): void { this.blockDelegate.getBlockDisplayData(this.injector, this.block, this.contextLevel, this.instanceId).then((data) => { if (!data) { - // Block not supported, don't render it. + // Block not supported, don't render it. But, site plugins might not have finished loading. + // Subscribe to the observable in block delegate that will tell us if blocks are updated. + // We can retry init later if that happens. + this.blockSubscription = this.blockDelegate.blocksUpdateObservable.subscribe( + (): void => { + this.blockSubscription.unsubscribe(); + delete this.blockSubscription; + this.initBlock(); + } + ); + return; } @@ -73,6 +97,16 @@ export class CoreBlockComponent implements OnInit { }); } + /** + * On destroy of the component, clear up any subscriptions. + */ + ngOnDestroy(): void { + if (this.blockSubscription) { + this.blockSubscription.unsubscribe(); + delete this.blockSubscription; + } + } + /** * Refresh the data. * diff --git a/src/core/block/providers/delegate.ts b/src/core/block/providers/delegate.ts index 363dff591..47b90ecda 100644 --- a/src/core/block/providers/delegate.ts +++ b/src/core/block/providers/delegate.ts @@ -18,6 +18,9 @@ import { CoreEventsProvider } from '@providers/events'; import { CoreSitesProvider } from '@providers/sites'; import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; import { CoreBlockDefaultHandler } from './default-block-handler'; +import { CoreSite } from '@classes/site'; +import { CoreSitePluginsProvider } from '@core/siteplugins/providers/siteplugins'; +import { Subject } from 'rxjs'; /** * Interface that all blocks must implement. @@ -82,9 +85,36 @@ export class CoreBlockDelegate extends CoreDelegate { protected featurePrefix = 'CoreBlockDelegate_'; + blocksUpdateObservable: Subject; + constructor(logger: CoreLoggerProvider, sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider, - protected defaultHandler: CoreBlockDefaultHandler) { + protected defaultHandler: CoreBlockDefaultHandler, protected sitePluginsProvider: CoreSitePluginsProvider) { super('CoreBlockDelegate', logger, sitesProvider, eventsProvider); + this.blocksUpdateObservable = new Subject(); + } + + /** + * Check if blocks are disabled in a certain site. + * + * @param {CoreSite} [site] Site. If not defined, use current site. + * @return {boolean} Whether it's disabled. + */ + areBlocksDisabledInSite(site?: CoreSite): boolean { + site = site || this.sitesProvider.getCurrentSite(); + + return site.isFeatureDisabled('NoDelegate_SiteBlocks'); + } + + /** + * Check if blocks are disabled in a certain site. + * + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + areBlocksDisabled(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.areBlocksDisabledInSite(site); + }); } /** @@ -121,4 +151,23 @@ export class CoreBlockDelegate extends CoreDelegate { isBlockSupported(name: string): boolean { return this.hasHandler(name, true); } + + /** + * Check if feature is enabled or disabled in the site, depending on the feature prefix and the handler name. + * + * @param {CoreDelegateHandler} handler Handler to check. + * @param {CoreSite} site Site to check. + * @return {boolean} Whether is enabled or disabled in site. + */ + protected isFeatureDisabled(handler: CoreDelegateHandler, site: CoreSite): boolean { + return this.areBlocksDisabledInSite(site) || super.isFeatureDisabled(handler, site); + } + + /** + * Called when there are new block handlers available. Informs anyone who subscribed to the + * observable. + */ + updateData(): void { + this.blocksUpdateObservable.next(); + } } diff --git a/src/core/comments/components/comments/comments.ts b/src/core/comments/components/comments/comments.ts index 079716675..6538a0874 100644 --- a/src/core/comments/components/comments/comments.ts +++ b/src/core/comments/components/comments/comments.ts @@ -12,9 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, OnChanges, SimpleChange } from '@angular/core'; -import { NavParams, NavController } from 'ionic-angular'; +import { Component, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChange } from '@angular/core'; +import { NavController } from 'ionic-angular'; import { CoreCommentsProvider } from '../../providers/comments'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; /** * Component that displays the count of comments. @@ -23,7 +25,7 @@ import { CoreCommentsProvider } from '../../providers/comments'; selector: 'core-comments', templateUrl: 'core-comments.html', }) -export class CoreCommentsCommentsComponent implements OnChanges { +export class CoreCommentsCommentsComponent implements OnChanges, OnDestroy { @Input() contextLevel: string; @Input() instanceId: number; @Input() component: string; @@ -31,11 +33,32 @@ export class CoreCommentsCommentsComponent implements OnChanges { @Input() area = ''; @Input() page = 0; @Input() title?: string; + @Input() displaySpinner = true; // Whether to display the loading spinner. + @Output() onLoading: EventEmitter; // Eevent that indicates whether the component is loading data. commentsLoaded = false; commentsCount: number; + disabled = false; - constructor(navParams: NavParams, private navCtrl: NavController, private commentsProvider: CoreCommentsProvider) {} + protected updateSiteObserver; + + constructor(private navCtrl: NavController, private commentsProvider: CoreCommentsProvider, + sitesProvider: CoreSitesProvider, eventsProvider: CoreEventsProvider) { + this.onLoading = new EventEmitter(); + + this.disabled = this.commentsProvider.areCommentsDisabledInSite(); + + // Update visibility if current site info is updated. + this.updateSiteObserver = eventsProvider.on(CoreEventsProvider.SITE_UPDATED, () => { + const wasDisabled = this.disabled; + + this.disabled = this.commentsProvider.areCommentsDisabledInSite(); + + if (wasDisabled && !this.disabled) { + this.fetchData(); + } + }, sitesProvider.getCurrentSiteId()); + } /** * View loaded. @@ -55,7 +78,12 @@ export class CoreCommentsCommentsComponent implements OnChanges { } protected fetchData(): void { + if (this.disabled) { + return; + } + this.commentsLoaded = false; + this.onLoading.emit(true); this.commentsProvider.getComments(this.contextLevel, this.instanceId, this.component, this.itemId, this.area, this.page) .then((comments) => { @@ -64,6 +92,7 @@ export class CoreCommentsCommentsComponent implements OnChanges { this.commentsCount = -1; }).finally(() => { this.commentsLoaded = true; + this.onLoading.emit(false); }); } @@ -71,7 +100,7 @@ export class CoreCommentsCommentsComponent implements OnChanges { * Opens the comments page. */ openComments(): void { - if (this.commentsCount > 0) { + if (!this.disabled && this.commentsCount > 0) { // Open a new state with the interpolated contents. this.navCtrl.push('CoreCommentsViewerPage', { contextLevel: this.contextLevel, @@ -84,4 +113,11 @@ export class CoreCommentsCommentsComponent implements OnChanges { }); } } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.updateSiteObserver && this.updateSiteObserver.off(); + } } diff --git a/src/core/comments/components/comments/core-comments.html b/src/core/comments/components/comments/core-comments.html index 1b30e656a..e7b71e041 100644 --- a/src/core/comments/components/comments/core-comments.html +++ b/src/core/comments/components/comments/core-comments.html @@ -1,4 +1,4 @@ - +
{{ 'core.commentscount' | translate : {'$a': commentsCount} }}
diff --git a/src/core/comments/providers/comments.ts b/src/core/comments/providers/comments.ts index db3978029..9808a0c83 100644 --- a/src/core/comments/providers/comments.ts +++ b/src/core/comments/providers/comments.ts @@ -14,6 +14,7 @@ import { Injectable } from '@angular/core'; import { CoreSitesProvider } from '@providers/sites'; +import { CoreSite } from '@classes/site'; /** * Service that provides some features regarding comments. @@ -25,6 +26,30 @@ export class CoreCommentsProvider { constructor(private sitesProvider: CoreSitesProvider) {} + /** + * Check if Calendar is disabled in a certain site. + * + * @param {CoreSite} [site] Site. If not defined, use current site. + * @return {boolean} Whether it's disabled. + */ + areCommentsDisabledInSite(site?: CoreSite): boolean { + site = site || this.sitesProvider.getCurrentSite(); + + return site.isFeatureDisabled('NoDelegate_CoreComments'); + } + + /** + * Check if comments are disabled in a certain site. + * + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + areCommentsDisabled(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.areCommentsDisabledInSite(site); + }); + } + /** * Get cache key for get comments data WS calls. * @@ -77,7 +102,8 @@ export class CoreCommentsProvider { }; const preSets = { - cacheKey: this.getCommentsCacheKey(contextLevel, instanceId, component, itemId, area, page) + cacheKey: this.getCommentsCacheKey(contextLevel, instanceId, component, itemId, area, page), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES }; return site.read('core_comment_get_comments', params, preSets).then((response) => { diff --git a/src/core/compile/components/compile-html/compile-html.ts b/src/core/compile/components/compile-html/compile-html.ts index fd7b82ec9..a263241d9 100644 --- a/src/core/compile/components/compile-html/compile-html.ts +++ b/src/core/compile/components/compile-html/compile-html.ts @@ -48,6 +48,7 @@ export class CoreCompileHtmlComponent implements OnChanges, OnDestroy, DoCheck { @Input() extraProviders: any[] = []; // Extra providers. @Input() forceCompile: string | boolean; // Set it to true to force compile even if the text/javascript hasn't changed. @Output() created: EventEmitter = new EventEmitter(); // Will emit an event when the component is instantiated. + @Output() compiling: EventEmitter = new EventEmitter(); // Event that indicates whether the template is being compiled. // Get the container where to put the content. @ViewChild('dynamicComponent', { read: ViewContainerRef }) container: ViewContainerRef; @@ -58,6 +59,7 @@ export class CoreCompileHtmlComponent implements OnChanges, OnDestroy, DoCheck { protected componentRef: ComponentRef; protected element; protected differ: any; // To detect changes in the jsData input. + protected creatingComponent = false; constructor(protected compileProvider: CoreCompileProvider, protected cdr: ChangeDetectorRef, element: ElementRef, @Optional() protected navCtrl: NavController, differs: KeyValueDiffers, protected domUtils: CoreDomUtilsProvider, @@ -70,7 +72,7 @@ export class CoreCompileHtmlComponent implements OnChanges, OnDestroy, DoCheck { * Detect and act upon changes that Angular can’t or won’t detect on its own (objects and arrays). */ ngDoCheck(): void { - if (this.componentInstance) { + if (this.componentInstance && !this.creatingComponent) { // Check if there's any change in the jsData object. const changes = this.differ.diff(this.jsData); if (changes) { @@ -91,6 +93,8 @@ export class CoreCompileHtmlComponent implements OnChanges, OnDestroy, DoCheck { this.text) { // Create a new component and a new module. + this.creatingComponent = true; + this.compiling.emit(true); this.compileProvider.createAndCompileComponent(this.text, this.getComponentClass(), this.extraImports) .then((factory) => { // Destroy previous components. @@ -107,6 +111,9 @@ export class CoreCompileHtmlComponent implements OnChanges, OnDestroy, DoCheck { this.domUtils.showErrorModal(error); this.loaded = true; + }).finally(() => { + this.creatingComponent = false; + this.compiling.emit(false); }); } } diff --git a/src/core/compile/providers/compile.ts b/src/core/compile/providers/compile.ts index a17d62a8a..7b76555ce 100644 --- a/src/core/compile/providers/compile.ts +++ b/src/core/compile/providers/compile.ts @@ -35,6 +35,7 @@ import { CORE_QUESTION_PROVIDERS } from '@core/question/question.module'; import { CORE_SHAREDFILES_PROVIDERS } from '@core/sharedfiles/sharedfiles.module'; import { CORE_SITEHOME_PROVIDERS } from '@core/sitehome/sitehome.module'; import { CORE_USER_PROVIDERS } from '@core/user/user.module'; +import { CORE_PUSHNOTIFICATIONS_PROVIDERS } from '@core/pushnotifications/pushnotifications.module'; import { IONIC_NATIVE_PROVIDERS } from '@core/emulator/emulator.module'; // Import only this provider to prevent circular dependencies. @@ -77,6 +78,7 @@ import { CoreBlockComponentsModule } from '@core/block/components/components.mod import { CoreCourseUnsupportedModuleComponent } from '@core/course/components/unsupported-module/unsupported-module'; import { CoreCourseFormatSingleActivityComponent } from '@core/course/formats/singleactivity/components/singleactivity'; import { CoreSitePluginsModuleIndexComponent } from '@core/siteplugins/components/module-index/module-index'; +import { CoreSitePluginsBlockComponent } from '@core/siteplugins/components/block/block'; import { CoreSitePluginsCourseOptionComponent } from '@core/siteplugins/components/course-option/course-option'; import { CoreSitePluginsCourseFormatComponent } from '@core/siteplugins/components/course-format/course-format'; import { CoreSitePluginsQuestionComponent } from '@core/siteplugins/components/question/question'; @@ -114,7 +116,6 @@ import { ADDON_MOD_WIKI_PROVIDERS } from '@addon/mod/wiki/wiki.module'; import { ADDON_MOD_WORKSHOP_PROVIDERS } from '@addon/mod/workshop/workshop.module'; import { ADDON_NOTES_PROVIDERS } from '@addon/notes/notes.module'; import { ADDON_NOTIFICATIONS_PROVIDERS } from '@addon/notifications/notifications.module'; -import { ADDON_PUSHNOTIFICATIONS_PROVIDERS } from '@addon/pushnotifications/pushnotifications.module'; import { ADDON_REMOTETHEMES_PROVIDERS } from '@addon/remotethemes/remotethemes.module'; // Import some addon modules that define components, directives and pipes. Only import the important ones. @@ -233,7 +234,7 @@ export class CoreCompileProvider { .concat(ADDON_MOD_QUIZ_PROVIDERS).concat(ADDON_MOD_RESOURCE_PROVIDERS).concat(ADDON_MOD_SCORM_PROVIDERS) .concat(ADDON_MOD_SURVEY_PROVIDERS).concat(ADDON_MOD_URL_PROVIDERS).concat(ADDON_MOD_WIKI_PROVIDERS) .concat(ADDON_MOD_WORKSHOP_PROVIDERS).concat(ADDON_NOTES_PROVIDERS).concat(ADDON_NOTIFICATIONS_PROVIDERS) - .concat(ADDON_PUSHNOTIFICATIONS_PROVIDERS).concat(ADDON_REMOTETHEMES_PROVIDERS).concat(CORE_BLOCK_PROVIDERS); + .concat(CORE_PUSHNOTIFICATIONS_PROVIDERS).concat(ADDON_REMOTETHEMES_PROVIDERS).concat(CORE_BLOCK_PROVIDERS); // We cannot inject anything to this constructor. Use the Injector to inject all the providers into the instance. for (const i in providers) { @@ -269,6 +270,7 @@ export class CoreCompileProvider { instance['CoreCourseUnsupportedModuleComponent'] = CoreCourseUnsupportedModuleComponent; instance['CoreCourseFormatSingleActivityComponent'] = CoreCourseFormatSingleActivityComponent; instance['CoreSitePluginsModuleIndexComponent'] = CoreSitePluginsModuleIndexComponent; + instance['CoreSitePluginsBlockComponent'] = CoreSitePluginsBlockComponent; instance['CoreSitePluginsCourseOptionComponent'] = CoreSitePluginsCourseOptionComponent; instance['CoreSitePluginsCourseFormatComponent'] = CoreSitePluginsCourseFormatComponent; instance['CoreSitePluginsQuestionComponent'] = CoreSitePluginsQuestionComponent; diff --git a/src/core/constants.ts b/src/core/constants.ts index 2fc8bfc79..ea038eece 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -23,6 +23,8 @@ export class CoreConstants { static SECONDS_MINUTE = 60; static WIFI_DOWNLOAD_THRESHOLD = 104857600; // 100MB. static DOWNLOAD_THRESHOLD = 10485760; // 10MB. + static MINIMUM_FREE_SPACE = 10485760; // 10MB. + static IOS_FREE_SPACE_THRESHOLD = 524288000; // 500MB. static DONT_SHOW_ERROR = 'CoreDontShowError'; static NO_SITE_ID = 'NoSite'; diff --git a/src/core/contentlinks/lang/en.json b/src/core/contentlinks/lang/en.json index 833ba3e3a..460c6acac 100644 --- a/src/core/contentlinks/lang/en.json +++ b/src/core/contentlinks/lang/en.json @@ -3,5 +3,6 @@ "chooseaccounttoopenlink": "Choose an account to open the link with.", "confirmurlothersite": "This link belongs to another site. Do you want to open it?", "errornoactions": "Couldn't find an action to perform with this link.", - "errornosites": "Couldn't find any site to handle this link." + "errornosites": "Couldn't find any site to handle this link.", + "errorredirectothersite": "The redirect URL cannot point to a different site." } \ No newline at end of file diff --git a/src/core/contentlinks/pages/choose-site/choose-site.html b/src/core/contentlinks/pages/choose-site/choose-site.html index 9124e5e7c..c6a401175 100644 --- a/src/core/contentlinks/pages/choose-site/choose-site.html +++ b/src/core/contentlinks/pages/choose-site/choose-site.html @@ -10,10 +10,12 @@

{{ 'core.contentlinks.chooseaccounttoopenlink' | translate }}

{{ url }}

- - + + + {{ 'core.pictureof' | translate:{$a: site.fullname} }} +

{{site.fullName}}

-

+

{{site.siteUrl}}

diff --git a/src/core/contentlinks/pages/choose-site/choose-site.ts b/src/core/contentlinks/pages/choose-site/choose-site.ts index 64bfceead..b10a36bc7 100644 --- a/src/core/contentlinks/pages/choose-site/choose-site.ts +++ b/src/core/contentlinks/pages/choose-site/choose-site.ts @@ -14,10 +14,12 @@ import { Component, OnInit } from '@angular/core'; import { IonicPage, NavController, NavParams } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreContentLinksDelegate, CoreContentLinksAction } from '../../providers/delegate'; import { CoreContentLinksHelperProvider } from '../../providers/helper'; +import { CoreLoginHelperProvider } from '@core/login/providers/helper'; /** * Page to display the list of sites to choose one to perform a content link action. @@ -33,10 +35,11 @@ export class CoreContentLinksChooseSitePage implements OnInit { sites: any[]; loaded: boolean; protected action: CoreContentLinksAction; + protected isRootURL: boolean; constructor(private navCtrl: NavController, navParams: NavParams, private contentLinksDelegate: CoreContentLinksDelegate, - private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, - private contentLinksHelper: CoreContentLinksHelperProvider) { + private sitesProvider: CoreSitesProvider, private domUtils: CoreDomUtilsProvider, private translate: TranslateService, + private contentLinksHelper: CoreContentLinksHelperProvider, private loginHelper: CoreLoginHelperProvider) { this.url = navParams.get('url'); } @@ -48,19 +51,35 @@ export class CoreContentLinksChooseSitePage implements OnInit { return this.leaveView(); } - // Get the action to perform. - this.contentLinksDelegate.getActionsFor(this.url).then((actions) => { - this.action = this.contentLinksHelper.getFirstValidAction(actions); - if (!this.action) { - return Promise.reject(null); - } + // Check if it's the root URL. + this.sitesProvider.isStoredRootURL(this.url).then((data): any => { + if (data.site) { + // It's the root URL. + this.isRootURL = true; + return data.siteIds; + } else if (data.siteIds.length) { + // Not root URL, but the URL belongs to at least 1 site. Check if there is any action to treat the link. + return this.contentLinksDelegate.getActionsFor(this.url).then((actions): any => { + this.action = this.contentLinksHelper.getFirstValidAction(actions); + if (!this.action) { + return Promise.reject(this.translate.instant('core.contentlinks.errornoactions')); + } + + return this.action.sites; + }); + } else { + // No sites to treat the URL. + return Promise.reject(this.translate.instant('core.contentlinks.errornosites')); + } + }).then((siteIds) => { // Get the sites that can perform the action. - return this.sitesProvider.getSites(this.action.sites).then((sites) => { - this.sites = sites; - }); - }).catch(() => { - this.domUtils.showErrorModal('core.contentlinks.errornosites', true); + return this.sitesProvider.getSites(siteIds); + }).then((sites) => { + this.sites = sites; + + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.contentlinks.errornosites', true); this.leaveView(); }).finally(() => { this.loaded = true; @@ -80,7 +99,11 @@ export class CoreContentLinksChooseSitePage implements OnInit { * @param {string} siteId Site ID. */ siteClicked(siteId: string): void { - this.action.action(siteId, this.navCtrl); + if (this.isRootURL) { + this.loginHelper.redirect('', {}, siteId); + } else { + this.action.action(siteId, this.navCtrl); + } } /** diff --git a/src/core/contentlinks/providers/delegate.ts b/src/core/contentlinks/providers/delegate.ts index 21b1da364..b753ac02e 100644 --- a/src/core/contentlinks/providers/delegate.ts +++ b/src/core/contentlinks/providers/delegate.ts @@ -56,9 +56,10 @@ export interface CoreContentLinksHandler { * @param {string} url The URL to treat. * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @param {any} [data] Extra data to handle the URL. * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. */ - getActions(siteIds: string[], url: string, params: any, courseId?: number): + getActions(siteIds: string[], url: string, params: any, courseId?: number, data?: any): CoreContentLinksAction[] | Promise; /** @@ -157,9 +158,10 @@ export class CoreContentLinksDelegate { * @param {string} url URL to handle. * @param {number} [courseId] Course ID related to the URL. Optional but recommended. * @param {string} [username] Username to use to filter sites. + * @param {any} [data] Extra data to handle the URL. * @return {Promise} Promise resolved with the actions. */ - getActionsFor(url: string, courseId?: number, username?: string): Promise { + getActionsFor(url: string, courseId?: number, username?: string, data?: any): Promise { if (!url) { return Promise.resolve([]); } @@ -187,7 +189,7 @@ export class CoreContentLinksDelegate { return; } - return Promise.resolve(handler.getActions(siteIds, url, params, courseId)).then((actions) => { + return Promise.resolve(handler.getActions(siteIds, url, params, courseId, data)).then((actions) => { if (actions && actions.length) { // Set default values if any value isn't supplied. actions.forEach((action) => { diff --git a/src/core/contentlinks/providers/helper.ts b/src/core/contentlinks/providers/helper.ts index e36e5a20b..bfd8b47b2 100644 --- a/src/core/contentlinks/providers/helper.ts +++ b/src/core/contentlinks/providers/helper.ts @@ -23,11 +23,13 @@ import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreUrlUtilsProvider } from '@providers/utils/url'; +import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreLoginHelperProvider } from '@core/login/providers/helper'; import { CoreContentLinksDelegate, CoreContentLinksAction } from './delegate'; import { CoreConstants } from '@core/constants'; import { CoreConfigConstants } from '../../../configconstants'; import { CoreSitePluginsProvider } from '@core/siteplugins/providers/siteplugins'; +import { CoreSite } from '@classes/site'; /** * Service that provides some features regarding content links. @@ -40,11 +42,8 @@ export class CoreContentLinksHelperProvider { private contentLinksDelegate: CoreContentLinksDelegate, private appProvider: CoreAppProvider, private domUtils: CoreDomUtilsProvider, private urlUtils: CoreUrlUtilsProvider, private translate: TranslateService, private initDelegate: CoreInitDelegate, eventsProvider: CoreEventsProvider, private textUtils: CoreTextUtilsProvider, - private sitePluginsProvider: CoreSitePluginsProvider, private zone: NgZone) { + private sitePluginsProvider: CoreSitePluginsProvider, private zone: NgZone, private utils: CoreUtilsProvider) { this.logger = logger.getInstance('CoreContentLinksHelperProvider'); - - // Listen for app launched URLs. If we receive one, check if it's a content link. - eventsProvider.on(CoreEventsProvider.APP_LAUNCHED_URL, this.handleCustomUrl.bind(this)); } /** @@ -53,11 +52,27 @@ export class CoreContentLinksHelperProvider { * @param {string} url URL to handle. * @param {number} [courseId] Course ID related to the URL. Optional but recommended. * @param {string} [username] Username to use to filter sites. + * @param {boolean} [checkRoot] Whether to check if the URL is the root URL of a site. * @return {Promise} Promise resolved with a boolean: whether the URL can be handled. */ - canHandleLink(url: string, courseId?: number, username?: string): Promise { - return this.contentLinksDelegate.getActionsFor(url, undefined, username).then((actions) => { - return !!this.getFirstValidAction(actions); + canHandleLink(url: string, courseId?: number, username?: string, checkRoot?: boolean): Promise { + let promise; + + if (checkRoot) { + promise = this.sitesProvider.isStoredRootURL(url, username); + } else { + promise = Promise.resolve({}); + } + + return promise.then((data) => { + if (data.site) { + // URL is the root of the site, can handle it. + return true; + } + + return this.contentLinksDelegate.getActionsFor(url, undefined, username).then((actions) => { + return !!this.getFirstValidAction(actions); + }); }).catch(() => { return false; }); @@ -88,18 +103,23 @@ export class CoreContentLinksHelperProvider { * @param {string} pageName Name of the page to go. * @param {any} [pageParams] Params to send to the page. * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. */ - goInSite(navCtrl: NavController, pageName: string, pageParams: any, siteId?: string): void { + goInSite(navCtrl: NavController, pageName: string, pageParams: any, siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); + const deferred = this.utils.promiseDefer(); + // Execute the code in the Angular zone, so change detection doesn't stop working. this.zone.run(() => { if (navCtrl && siteId == this.sitesProvider.getCurrentSiteId()) { - navCtrl.push(pageName, pageParams); + navCtrl.push(pageName, pageParams).then(deferred.resolve, deferred.reject); } else { - this.loginHelper.redirect(pageName, pageParams, siteId); + this.loginHelper.redirect(pageName, pageParams, siteId).then(deferred.resolve, deferred.reject); } }); + + return deferred.promise; } /** @@ -116,6 +136,7 @@ export class CoreContentLinksHelperProvider { * * @param {string} url URL to handle. * @return {boolean} True if the URL should be handled by this component, false otherwise. + * @deprecated Please use CoreCustomURLSchemesProvider.handleCustomURL instead. */ handleCustomUrl(url: string): boolean { const contentLinksScheme = CoreConfigConstants.customurlscheme + '://link'; @@ -142,10 +163,16 @@ export class CoreContentLinksHelperProvider { // Wait for the app to be ready. this.initDelegate.ready().then(() => { - // Check if the site is stored. - return this.sitesProvider.getSiteIdsFromUrl(url, false, username); - }).then((siteIds) => { - if (siteIds.length) { + // Check if it's the root URL. + return this.sitesProvider.isStoredRootURL(url, username); + }).then((data) => { + + if (data.site) { + // Root URL. + modal.dismiss(); + + return this.handleRootURL(data.site, false); + } else if (data.siteIds.length > 0) { modal.dismiss(); // Dismiss modal so it doesn't collide with confirms. return this.handleLink(url, username).then((treated) => { @@ -155,11 +182,13 @@ export class CoreContentLinksHelperProvider { }); } else { // Get the site URL. - const siteUrl = this.contentLinksDelegate.getSiteUrl(url); - if (!siteUrl) { - this.domUtils.showErrorModal('core.login.invalidsite', true); + let siteUrl = this.contentLinksDelegate.getSiteUrl(url), + urlToOpen = url; - return; + if (!siteUrl) { + // Site URL not found, use the original URL since it could be the root URL of the site. + siteUrl = url; + urlToOpen = undefined; } // Check that site exists. @@ -170,7 +199,7 @@ export class CoreContentLinksHelperProvider { pageParams = { siteUrl: result.siteUrl, username: username, - urlToOpen: url, + urlToOpen: urlToOpen, siteConfig: result.config }; let promise, @@ -204,14 +233,12 @@ export class CoreContentLinksHelperProvider { this.loginHelper.confirmAndOpenBrowserForSSOLogin( result.siteUrl, result.code, result.service, result.config && result.config.launchurl); } else if (!hasSitePluginsLoaded) { - this.appProvider.getRootNavController().setRoot(pageName, pageParams); + return this.loginHelper.goToNoSitePage(undefined, pageName, pageParams); } }); }).catch((error) => { - if (error) { - this.domUtils.showErrorModal(error); - } + this.domUtils.showErrorModalDefault(error, this.translate.instant('core.login.invalidsite')); }); } }).finally(() => { @@ -228,42 +255,87 @@ export class CoreContentLinksHelperProvider { * @param {string} [username] Username related with the URL. E.g. in 'http://myuser@m.com', url would be 'http://m.com' and * the username 'myuser'. Don't use it if you don't want to filter by username. * @param {NavController} [navCtrl] Nav Controller to use to navigate. + * @param {boolean} [checkRoot] Whether to check if the URL is the root URL of a site. + * @param {boolean} [openBrowserRoot] Whether to open in browser if it's root URL and it belongs to current site. * @return {Promise} Promise resolved with a boolean: true if URL was treated, false otherwise. */ - handleLink(url: string, username?: string, navCtrl?: NavController): Promise { - // Check if the link should be treated by some component/addon. - return this.contentLinksDelegate.getActionsFor(url, undefined, username).then((actions) => { - const action = this.getFirstValidAction(actions); - if (action) { - if (!this.sitesProvider.isLoggedIn()) { - // No current site. Perform the action if only 1 site found, choose the site otherwise. - if (action.sites.length == 1) { - action.action(action.sites[0], navCtrl); - } else { - this.goToChooseSite(url); - } - } else if (action.sites.length == 1 && action.sites[0] == this.sitesProvider.getCurrentSiteId()) { - // Current site. - action.action(action.sites[0], navCtrl); - } else { - // Not current site or more than one site. Ask for confirmation. - this.domUtils.showConfirm(this.translate.instant('core.contentlinks.confirmurlothersite')).then(() => { + handleLink(url: string, username?: string, navCtrl?: NavController, checkRoot?: boolean, openBrowserRoot?: boolean) + : Promise { + let promise; + + if (checkRoot) { + promise = this.sitesProvider.isStoredRootURL(url, username); + } else { + promise = Promise.resolve({}); + } + + return promise.then((data) => { + if (data.site) { + // URL is the root of the site. + this.handleRootURL(data.site, openBrowserRoot); + + return true; + } + + // Check if the link should be treated by some component/addon. + return this.contentLinksDelegate.getActionsFor(url, undefined, username).then((actions) => { + const action = this.getFirstValidAction(actions); + if (action) { + if (!this.sitesProvider.isLoggedIn()) { + // No current site. Perform the action if only 1 site found, choose the site otherwise. if (action.sites.length == 1) { action.action(action.sites[0], navCtrl); } else { this.goToChooseSite(url); } - }).catch(() => { - // User canceled. - }); + } else if (action.sites.length == 1 && action.sites[0] == this.sitesProvider.getCurrentSiteId()) { + // Current site. + action.action(action.sites[0], navCtrl); + } else { + // Not current site or more than one site. Ask for confirmation. + this.domUtils.showConfirm(this.translate.instant('core.contentlinks.confirmurlothersite')).then(() => { + if (action.sites.length == 1) { + action.action(action.sites[0], navCtrl); + } else { + this.goToChooseSite(url); + } + }).catch(() => { + // User canceled. + }); + } + + return true; } - return true; - } - - return false; - }).catch(() => { - return false; + return false; + }).catch(() => { + return false; + }); }); } + + /** + * Handle a root URL of a site. + * + * @param {CoreSite} site Site to handle. + * @param {boolean} [openBrowserRoot] Whether to open in browser if it's root URL and it belongs to current site. + * @param {boolean} [checkToken] Whether to check that token is the same to verify it's current site. If false or not defined, + * only the URL will be checked. + * @return {Promise} Promise resolved when done. + */ + handleRootURL(site: CoreSite, openBrowserRoot?: boolean, checkToken?: boolean): Promise { + const currentSite = this.sitesProvider.getCurrentSite(); + + if (currentSite && currentSite.getURL() == site.getURL() && (!checkToken || currentSite.getToken() == site.getToken())) { + // Already logged in. + if (openBrowserRoot) { + return site.openInBrowserWithAutoLogin(site.getURL()); + } + + return Promise.resolve(); + } else { + // Login in the site. + return this.loginHelper.redirect('', {}, site.getId()); + } + } } diff --git a/src/core/course/classes/main-activity-component.ts b/src/core/course/classes/main-activity-component.ts index 16b6e8dd4..925f9109b 100644 --- a/src/core/course/classes/main-activity-component.ts +++ b/src/core/course/classes/main-activity-component.ts @@ -254,8 +254,10 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR /** * Watch for changes on the status. + * + * @return {Promise} Promise resolved when done. */ - protected setStatusListener(): void { + protected setStatusListener(): Promise { if (typeof this.statusObserver == 'undefined') { // Listen for changes on this module status. this.statusObserver = this.eventsProvider.on(CoreEventsProvider.PACKAGE_STATUS_CHANGED, (data) => { @@ -269,11 +271,13 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR }, this.siteId); // Also, get the current status. - this.modulePrefetchDelegate.getModuleStatus(this.module, this.courseId).then((status) => { + return this.modulePrefetchDelegate.getModuleStatus(this.module, this.courseId).then((status) => { this.currentStatus = status; this.showStatus(status); }); } + + return Promise.resolve(); } /** diff --git a/src/core/course/classes/main-resource-component.ts b/src/core/course/classes/main-resource-component.ts index 42727c13f..b2a153add 100644 --- a/src/core/course/classes/main-resource-component.ts +++ b/src/core/course/classes/main-resource-component.ts @@ -251,9 +251,11 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, /** * Prefetch the module. + * + * @param {Function} [done] Function to call when done. */ - prefetch(): void { - this.courseHelper.contextMenuPrefetch(this, this.module, this.courseId); + prefetch(done?: () => void): void { + this.courseHelper.contextMenuPrefetch(this, this.module, this.courseId, done); } /** diff --git a/src/core/course/components/format/core-course-format.html b/src/core/course/components/format/core-course-format.html index cf1712652..9ba99a96c 100644 --- a/src/core/course/components/format/core-course-format.html +++ b/src/core/course/components/format/core-course-format.html @@ -10,7 +10,7 @@ -
+
- - {{section.count}} / {{section.total}} - - + +
diff --git a/src/core/course/components/format/format.scss b/src/core/course/components/format/format.scss index 2533ecdba..75426f095 100644 --- a/src/core/course/components/format/format.scss +++ b/src/core/course/components/format/format.scss @@ -61,6 +61,14 @@ ion-app.app-root core-course-format { core-format-text { line-height: 44px; } + + &.core-section-download .label{ + @include margin(null, 0, null, null); + } + } + + div.core-section-download { + @include padding(null, 0, null, null); } .core-course-section-nav-buttons { diff --git a/src/core/course/components/format/format.ts b/src/core/course/components/format/format.ts index 8c5bdbffe..1c87d2aed 100644 --- a/src/core/course/components/format/format.ts +++ b/src/core/course/components/format/format.ts @@ -119,7 +119,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { this.courseHelper.calculateSectionStatus(section, this.course.id, false).then(() => { if (section.isDownloading && !prefetchDelegate.isBeingDownloaded(downloadId)) { // All the modules are now downloading, set a download all promise. - this.prefetch(section, false); + this.prefetch(section); } }); } @@ -339,13 +339,10 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { /** * Confirm and prefetch a section. If the section is "all sections", prefetch all the sections. * - * @param {Event} e Click event. * @param {any} section Section to download. + * @param {boolean} refresh Refresh clicked (not used). */ - prefetch(e: Event, section: any): void { - e.preventDefault(); - e.stopPropagation(); - + prefetch(section: any, refresh: boolean = false): void { section.isCalculating = true; this.courseHelper.confirmDownloadSizeSection(this.course.id, section, this.sections).then(() => { this.prefetchSection(section, true); diff --git a/src/core/course/components/module-completion/module-completion.scss b/src/core/course/components/module-completion/module-completion.scss index 95674dc1c..23a677924 100644 --- a/src/core/course/components/module-completion/module-completion.scss +++ b/src/core/course/components/module-completion/module-completion.scss @@ -1,4 +1,6 @@ ion-app.app-root core-course-module-completion a { + display: block; + img { padding: 5px; width: 30px; diff --git a/src/core/course/components/module/core-course-module.html b/src/core/course/components/module/core-course-module.html index af880f447..55686efd0 100644 --- a/src/core/course/components/module/core-course-module.html +++ b/src/core/course/components/module/core-course-module.html @@ -10,33 +10,25 @@
- - - - - + - - -
-
+
{{ 'core.course.hiddenfromstudents' | translate }} {{ 'core.course.hiddenoncoursepage' | translate }} - +
+ {{ 'core.restricted' | translate }} + +
{{ 'core.course.manualcompletionnotsynced' | translate }}
diff --git a/src/core/course/components/module/module.scss b/src/core/course/components/module/module.scss index 2541dd63a..410482e76 100644 --- a/src/core/course/components/module/module.scss +++ b/src/core/course/components/module/module.scss @@ -36,6 +36,8 @@ ion-app.app-root core-course-module { flex-flow: row; align-items: center; z-index: 1; + justify-content: space-around; + align-content: center; } .core-module-buttons core-course-module-completion, @@ -44,9 +46,21 @@ ion-app.app-root core-course-module { pointer-events: auto; } - .core-module-buttons-more .spinner { - @include position(null, 13px, null, null); - position: absolute; + .core-module-buttons core-course-module-completion { + text-align: center; + } + } + + .core-module-more-info { + ion-badge { + @include text-align('start'); + } + + .core-module-availabilityinfo { + font-size: 90%; + ul { + margin-block-start: 0.5em; + } } } diff --git a/src/core/course/components/module/module.ts b/src/core/course/components/module/module.ts index a22125152..b4d8cba41 100644 --- a/src/core/course/components/module/module.ts +++ b/src/core/course/components/module/module.ts @@ -21,7 +21,6 @@ import { CoreCourseHelperProvider } from '../../providers/helper'; import { CoreCourseProvider } from '../../providers/course'; import { CoreCourseModuleHandlerButton } from '../../providers/module-delegate'; import { CoreCourseModulePrefetchDelegate, CoreCourseModulePrefetchHandler } from '../../providers/module-prefetch-delegate'; -import { CoreConstants } from '../../../constants'; /** * Component to display a module entry in a list of modules. @@ -52,9 +51,9 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { } @Output() completionChanged?: EventEmitter; // Will emit an event when the module completion changes. - showDownload: boolean; // Whether to display the download button. - showRefresh: boolean; // Whether to display the refresh button. - spinner: boolean; // Whether to display a spinner. + downloadStatus: string; + canCheckUpdates: boolean; + spinner: boolean; // Whether to display a loading spinner. downloadEnabled: boolean; // Whether the download of sections and modules is enabled. protected prefetchHandler: CoreCourseModulePrefetchHandler; @@ -81,17 +80,21 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { if (this.module.handlerData.showDownloadButton) { // Listen for changes on this module status, even if download isn't enabled. this.prefetchHandler = this.prefetchDelegate.getPrefetchHandlerFor(this.module); + this.canCheckUpdates = this.prefetchDelegate.canCheckUpdates(); this.statusObserver = this.eventsProvider.on(CoreEventsProvider.PACKAGE_STATUS_CHANGED, (data) => { if (data.componentId === this.module.id && this.prefetchHandler && data.component === this.prefetchHandler.component) { + // Call determineModuleStatus to get the right status to display. + const status = this.prefetchDelegate.determineModuleStatus(this.module, data.status); + if (this.downloadEnabled) { // Download is enabled, show the status. - this.showStatus(data.status); + this.showStatus(status); } else if (this.module.handlerData.updateStatus) { // Download isn't enabled but the handler defines a updateStatus function, call it anyway. - this.module.handlerData.updateStatus(data.status); + this.module.handlerData.updateStatus(status); } } }, this.sitesProvider.getCurrentSiteId()); @@ -132,13 +135,9 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { /** * Download the module. * - * @param {Event} event Click event. * @param {boolean} refresh Whether it's refreshing. */ - download(event: Event, refresh: boolean): void { - event.preventDefault(); - event.stopPropagation(); - + download(refresh: boolean): void { if (!this.prefetchHandler) { return; } @@ -165,10 +164,8 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { */ protected showStatus(status: string): void { if (status) { - this.spinner = status === CoreConstants.DOWNLOADING; - this.showDownload = status === CoreConstants.NOT_DOWNLOADED; - this.showRefresh = status === CoreConstants.OUTDATED || - (!this.prefetchDelegate.canCheckUpdates() && status === CoreConstants.DOWNLOADED); + this.spinner = false; + this.downloadStatus = status; if (this.module.handlerData.updateStatus) { this.module.handlerData.updateStatus(status); diff --git a/src/core/course/lang/en.json b/src/core/course/lang/en.json index 0a2229b22..2d2d0156f 100644 --- a/src/core/course/lang/en.json +++ b/src/core/course/lang/en.json @@ -4,10 +4,12 @@ "activitynotyetviewablesiteupgradeneeded": "Your organisation's Moodle installation needs to be updated.", "allsections": "All sections", "askadmintosupport": "Contact the site administrator and tell them you want to use this activity with the Moodle Mobile app.", + "availablespace": " You currently have about {{available}} free space.", "confirmdeletemodulefiles": "Are you sure you want to delete these files?", - "confirmdownload": "You are about to download {{size}}. Are you sure you want to continue?", - "confirmdownloadunknownsize": "It was not possible to calculate the size of the download. Are you sure you want to continue?", - "confirmpartialdownloadsize": "You are about to download at least {{size}}. Are you sure you want to continue?", + "confirmdownload": "You are about to download {{size}}.{{availableSpace}} Are you sure you want to continue?", + "confirmdownloadunknownsize": "It was not possible to calculate the size of the download.{{availableSpace}} Are you sure you want to continue?", + "confirmpartialdownloadsize": "You are about to download at least {{size}}.{{availableSpace}} Are you sure you want to continue?", + "confirmlimiteddownload": "You are not currently connected to Wi-Fi. ", "contents": "Contents", "couldnotloadsectioncontent": "Could not load the section content. Please try again later.", "couldnotloadsections": "Could not load the sections. Please try again later.", @@ -18,6 +20,8 @@ "errorgetmodule": "Error getting activity data.", "hiddenfromstudents": "Hidden from students", "hiddenoncoursepage": "Available but not shown on course page", + "insufficientavailablespace": "You are trying to download {{size}}. This will leave your device with insufficient space to operate normally. Please clear some storage space first.", + "insufficientavailablequota": "Your device could not allocate space to save this download. It may be reserving space for app and system updates. Please clear some storage space first.", "manualcompletionnotsynced": "Manual completion not synchronised.", "nocontentavailable": "No content available at the moment.", "overriddennotice": "Your final grade from this activity was manually adjusted.", diff --git a/src/core/course/pages/list-mod-type/list-mod-type.html b/src/core/course/pages/list-mod-type/list-mod-type.html index b131fb912..7315af183 100644 --- a/src/core/course/pages/list-mod-type/list-mod-type.html +++ b/src/core/course/pages/list-mod-type/list-mod-type.html @@ -12,7 +12,7 @@ - + diff --git a/src/core/course/pages/list-mod-type/list-mod-type.ts b/src/core/course/pages/list-mod-type/list-mod-type.ts index 2a1694243..37220ae4a 100644 --- a/src/core/course/pages/list-mod-type/list-mod-type.ts +++ b/src/core/course/pages/list-mod-type/list-mod-type.ts @@ -18,6 +18,7 @@ import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreCourseProvider } from '../../providers/course'; import { CoreCourseModuleDelegate } from '../../providers/module-delegate'; import { CoreCourseHelperProvider } from '../../providers/helper'; +import { CoreSitesProvider } from '@providers/sites'; import { CoreConstants } from '@core/constants'; /** @@ -33,13 +34,15 @@ export class CoreCourseListModTypePage { modules = []; title: string; loaded = false; + downloadEnabled = false; protected courseId: number; protected modName: string; protected archetypes = {}; // To speed up the check of modules. constructor(navParams: NavParams, private courseProvider: CoreCourseProvider, private moduleDelegate: CoreCourseModuleDelegate, - private domUtils: CoreDomUtilsProvider, private courseHelper: CoreCourseHelperProvider) { + private domUtils: CoreDomUtilsProvider, private courseHelper: CoreCourseHelperProvider, + private sitesProvider: CoreSitesProvider) { this.title = navParams.get('title'); this.courseId = navParams.get('courseId'); @@ -50,6 +53,8 @@ export class CoreCourseListModTypePage { * View loaded. */ ionViewDidLoad(): void { + this.downloadEnabled = !this.sitesProvider.getCurrentSite().isOfflineDisabled(); + this.fetchData().finally(() => { this.loaded = true; }); diff --git a/src/core/course/pages/section-selector/section-selector.scss b/src/core/course/pages/section-selector/section-selector.scss index c35e93f93..fa325a001 100644 --- a/src/core/course/pages/section-selector/section-selector.scss +++ b/src/core/course/pages/section-selector/section-selector.scss @@ -10,6 +10,6 @@ ion-app.app-root page-core-course-section-selector { } ion-badge { - text-align: left; + @include text-align('start'); } } \ No newline at end of file diff --git a/src/core/course/pages/section-selector/section-selector.ts b/src/core/course/pages/section-selector/section-selector.ts index f438ceef9..6186fe8ca 100644 --- a/src/core/course/pages/section-selector/section-selector.ts +++ b/src/core/course/pages/section-selector/section-selector.ts @@ -36,7 +36,8 @@ export class CoreCourseSectionSelectorPage { this.selected = navParams.get('selected'); const course = navParams.get('course'); - if (course && course.enablecompletion && course.courseformatoptions && course.courseformatoptions.coursedisplay == 1) { + if (course && course.enablecompletion && course.courseformatoptions && course.courseformatoptions.coursedisplay == 1 && + course.completionusertracked !== false) { this.sections.forEach((section) => { let complete = 0, total = 0; diff --git a/src/core/course/pages/section/section.html b/src/core/course/pages/section/section.html index 2f47b9f38..ea12ab46f 100644 --- a/src/core/course/pages/section/section.html +++ b/src/core/course/pages/section/section.html @@ -15,6 +15,7 @@ + diff --git a/src/core/course/pages/section/section.ts b/src/core/course/pages/section/section.ts index 7246d3fd9..c2d67c3f1 100644 --- a/src/core/course/pages/section/section.ts +++ b/src/core/course/pages/section/section.ts @@ -24,7 +24,8 @@ import { CoreCourseProvider } from '../../providers/course'; import { CoreCourseHelperProvider } from '../../providers/helper'; import { CoreCourseFormatDelegate } from '../../providers/format-delegate'; import { CoreCourseModulePrefetchDelegate } from '../../providers/module-prefetch-delegate'; -import { CoreCourseOptionsDelegate, CoreCourseOptionsHandlerToDisplay } from '../../providers/options-delegate'; +import { CoreCourseOptionsDelegate, CoreCourseOptionsHandlerToDisplay, + CoreCourseOptionsMenuHandlerToDisplay } from '../../providers/options-delegate'; import { CoreCourseSyncProvider } from '../../providers/sync'; import { CoreCourseFormatComponent } from '../../components/format/format'; import { CoreCoursesProvider } from '@core/courses/providers/courses'; @@ -49,8 +50,9 @@ export class CoreCourseSectionPage implements OnDestroy { sectionId: number; sectionNumber: number; courseHandlers: CoreCourseOptionsHandlerToDisplay[]; + courseMenuHandlers: CoreCourseOptionsMenuHandlerToDisplay[] = []; dataLoaded: boolean; - downloadEnabled: boolean; + downloadEnabled = false; downloadEnabledIcon = 'square-outline'; // Disabled by default. prefetchCourseData = { prefetchCourseIcon: 'spinner', @@ -85,7 +87,8 @@ export class CoreCourseSectionPage implements OnDestroy { // Get the title to display. We dont't have sections yet. this.title = courseFormatDelegate.getCourseTitle(this.course); - this.displayEnableDownload = courseFormatDelegate.displayEnableDownload(this.course); + this.displayEnableDownload = !sitesProvider.getCurrentSite().isOfflineDisabled() && + courseFormatDelegate.displayEnableDownload(this.course); this.downloadCourseEnabled = !this.coursesProvider.isDownloadCourseDisabledInSite(); // Check if the course format requires the view to be refreshed when completion changes. @@ -112,7 +115,7 @@ export class CoreCourseSectionPage implements OnDestroy { if (this.downloadCourseEnabled) { // Listen for changes in course status. this.courseStatusObserver = eventsProvider.on(CoreEventsProvider.COURSE_STATUS_CHANGED, (data) => { - if (data.courseId == this.course.id) { + if (data.courseId == this.course.id || data.courseId == CoreCourseProvider.ALL_COURSES_CLEARED) { this.updateCourseStatus(data.status); } }, sitesProvider.getCurrentSiteId()); @@ -211,7 +214,7 @@ export class CoreCourseSectionPage implements OnDestroy { let promise; // Add log in Moodle. - this.courseProvider.logView(this.course.id, this.sectionNumber).catch(() => { + this.courseProvider.logView(this.course.id, this.sectionNumber, undefined, this.course.fullname).catch(() => { // Ignore errors. }); @@ -301,12 +304,17 @@ export class CoreCourseSectionPage implements OnDestroy { } })); + // Load the course menu handlers. + promises.push(this.courseOptionsDelegate.getMenuHandlersToDisplay(this.injector, this.course).then((handlers) => { + this.courseMenuHandlers = handlers; + })); + // Load the course format options when course completion is enabled to show completion progress on sections. if (this.course.enablecompletion && this.coursesProvider.isGetCoursesByFieldAvailable()) { - promises.push(this.coursesProvider.getCoursesByField('id', this.course.id).catch(() => { + promises.push(this.coursesProvider.getCourseByField('id', this.course.id).catch(() => { // Ignore errors. - }).then((courses) => { - courses && courses[0] && Object.assign(this.course, courses[0]); + }).then((course) => { + course && Object.assign(this.course, course); if (this.course.courseformatoptions) { this.course.courseformatoptions = this.utils.objectToKeyValueMap(this.course.courseformatoptions, @@ -417,7 +425,8 @@ export class CoreCourseSectionPage implements OnDestroy { * Prefetch the whole course. */ prefetchCourse(): void { - this.courseHelper.confirmAndPrefetchCourse(this.prefetchCourseData, this.course, this.sections, this.courseHandlers) + this.courseHelper.confirmAndPrefetchCourse(this.prefetchCourseData, this.course, this.sections, + this.courseHandlers, this.courseMenuHandlers) .then(() => { if (this.downloadEnabled) { // Recalculate the status. @@ -459,6 +468,16 @@ export class CoreCourseSectionPage implements OnDestroy { this.navCtrl.push('CoreCoursesCoursePreviewPage', {course: this.course, avoidOpenCourse: true}); } + /** + * Opens a menu item registered to the delegate. + * + * @param {CoreCourseMenuHandlerToDisplay} item Item to open + */ + openMenuItem(item: CoreCourseOptionsMenuHandlerToDisplay): void { + const params = Object.assign({ course: this.course}, item.data.pageParams); + this.navCtrl.push(item.data.page, params); + } + /** * Page destroyed. */ diff --git a/src/core/course/providers/course.ts b/src/core/course/providers/course.ts index e16318b09..bb4a55c5a 100644 --- a/src/core/course/providers/course.ts +++ b/src/core/course/providers/course.ts @@ -13,16 +13,21 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { NavController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from '@providers/app'; import { CoreEventsProvider } from '@providers/events'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreSiteWSPreSets, CoreSite } from '@classes/site'; import { CoreConstants } from '../../constants'; import { CoreCourseOfflineProvider } from './course-offline'; +import { CoreSitePluginsProvider } from '@core/siteplugins/providers/siteplugins'; +import { CoreCourseFormatDelegate } from './format-delegate'; +import { CorePushNotificationsProvider } from '@core/pushnotifications/providers/pushnotifications'; /** * Service that provides some features regarding a course. @@ -33,6 +38,7 @@ export class CoreCourseProvider { static STEALTH_MODULES_SECTION_ID = -1; static ACCESS_GUEST = 'courses_access_guest'; static ACCESS_DEFAULT = 'courses_access_default'; + static ALL_COURSES_CLEARED = -1; static COMPLETION_TRACKING_NONE = 0; static COMPLETION_TRACKING_MANUAL = 1; @@ -96,7 +102,9 @@ export class CoreCourseProvider { constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private eventsProvider: CoreEventsProvider, private utils: CoreUtilsProvider, private timeUtils: CoreTimeUtilsProvider, private translate: TranslateService, - private courseOffline: CoreCourseOfflineProvider, private appProvider: CoreAppProvider) { + private courseOffline: CoreCourseOfflineProvider, private appProvider: CoreAppProvider, + private courseFormatDelegate: CoreCourseFormatDelegate, private sitePluginsProvider: CoreSitePluginsProvider, + private domUtils: CoreDomUtilsProvider, protected pushNotificationsProvider: CorePushNotificationsProvider) { this.logger = logger.getInstance('CoreCourseProvider'); this.sitesProvider.registerSiteSchema(this.siteSchema); @@ -151,7 +159,7 @@ export class CoreCourseProvider { this.logger.debug('Clear all course status for site ' + site.id); return site.getDb().deleteRecords(this.COURSE_STATUS_TABLE).then(() => { - this.triggerCourseStatusChanged(-1, CoreConstants.NOT_DOWNLOADED, site.id); + this.triggerCourseStatusChanged(CoreCourseProvider.ALL_COURSES_CLEARED, CoreConstants.NOT_DOWNLOADED, site.id); }); }); } @@ -250,7 +258,8 @@ export class CoreCourseProvider { courseid: courseId }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getCourseBlocksCacheKey(courseId) + cacheKey: this.getCourseBlocksCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('core_block_get_course_blocks', params, preSets).then((result) => { @@ -328,7 +337,8 @@ export class CoreCourseProvider { options: [] }; const preSets: CoreSiteWSPreSets = { - omitExpires: preferCache + omitExpires: preferCache, + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (includeStealth) { @@ -438,7 +448,8 @@ export class CoreCourseProvider { cmid: moduleId }, preSets = { - cacheKey: this.getModuleCacheKey(moduleId) + cacheKey: this.getModuleCacheKey(moduleId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('core_course_get_course_module', params, preSets).then((response) => { @@ -456,6 +467,8 @@ export class CoreCourseProvider { /** * Gets a module basic grade info by module ID. * + * If the user does not have permision to manage the activity false is returned. + * * @param {number} moduleId Module ID. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved with the module's grade info. @@ -494,7 +507,8 @@ export class CoreCourseProvider { module: module }, preSets = { - cacheKey: this.getModuleBasicInfoByInstanceCacheKey(id, module) + cacheKey: this.getModuleBasicInfoByInstanceCacheKey(id, module), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('core_course_get_course_module_by_instance', params, preSets).then((response) => { @@ -621,6 +635,7 @@ export class CoreCourseProvider { return this.sitesProvider.getSite(siteId).then((site) => { preSets = preSets || {}; preSets.cacheKey = this.getSectionsCacheKey(courseId); + preSets.updateFrequency = preSets.updateFrequency || CoreSite.FREQUENCY_RARELY; const params = { courseid: courseId, @@ -805,18 +820,22 @@ export class CoreCourseProvider { * @param {number} courseId Course ID. * @param {number} [sectionNumber] Section number. * @param {string} [siteId] Site ID. If not defined, current site. + * @param {string} [name] Name of the course. * @return {Promise} Promise resolved when the WS call is successful. */ - logView(courseId: number, sectionNumber?: number, siteId?: string): Promise { + logView(courseId: number, sectionNumber?: number, siteId?: string, name?: string): Promise { const params: any = { - courseid: courseId - }; + courseid: courseId + }, + wsName = 'core_course_view_course'; if (typeof sectionNumber != 'undefined') { params.sectionnumber = sectionNumber; } return this.sitesProvider.getSite(siteId).then((site) => { + this.pushNotificationsProvider.logViewEvent(courseId, name, 'course', wsName, {sectionnumber: sectionNumber}, siteId); + return site.write('core_course_view_course', params).then((response) => { if (!response.status) { return Promise.reject(null); @@ -899,6 +918,61 @@ export class CoreCourseProvider { return !!module.url; } + /** + * Wait for any course format plugin to load, and open the course page. + * + * If the plugin's promise is resolved, the course page will be opened. If it is rejected, they will see an error. + * If the promise for the plugin is still in progress when the user tries to open the course, a loader + * will be displayed until it is complete, before the course page is opened. If the promise is already complete, + * they will see the result immediately. + * + * This function must be in here instead of course helper to prevent circular dependencies. + * + * @param {NavController} navCtrl The nav controller to use. If not defined, the course will be opened in main menu. + * @param {any} course Course to open + * @param {any} [params] Other params to pass to the course page. + * @return {Promise} Promise resolved when done. + */ + openCourse(navCtrl: NavController, course: any, params?: any): Promise { + const loading = this.domUtils.showModalLoading(); + + // Wait for site plugins to be fetched. + return this.sitePluginsProvider.waitFetchPlugins().then(() => { + if (this.sitePluginsProvider.sitePluginPromiseExists('format_' + course.format)) { + // This course uses a custom format plugin, wait for the format plugin to finish loading. + + return this.sitePluginsProvider.sitePluginLoaded('format_' + course.format).then(() => { + // The format loaded successfully, but the handlers wont be registered until all site plugins have loaded. + if (this.sitePluginsProvider.sitePluginsFinishedLoading) { + return this.courseFormatDelegate.openCourse(navCtrl, course, params); + } else { + // Wait for plugins to be loaded. + const deferred = this.utils.promiseDefer(), + observer = this.eventsProvider.on(CoreEventsProvider.SITE_PLUGINS_LOADED, () => { + observer && observer.off(); + + this.courseFormatDelegate.openCourse(navCtrl, course, params).then((response) => { + deferred.resolve(response); + }).catch((error) => { + deferred.reject(error); + }); + }); + + return deferred.promise; + } + }).catch(() => { + // The site plugin failed to load. The user needs to restart the app to try loading it again. + this.domUtils.showErrorModal('core.courses.errorloadplugins', true); + }); + } else { + // No custom format plugin. We don't need to wait for anything. + return this.courseFormatDelegate.openCourse(navCtrl, course, params); + } + }).finally(() => { + loading.dismiss(); + }); + } + /** * Change the course status, setting it to the previous status. * diff --git a/src/core/course/providers/default-format.ts b/src/core/course/providers/default-format.ts index 5fe027ce5..e03341376 100644 --- a/src/core/course/providers/default-format.ts +++ b/src/core/course/providers/default-format.ts @@ -12,9 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, Injector } from '@angular/core'; import { NavController } from 'ionic-angular'; import { CoreCoursesProvider } from '@core/courses/providers/courses'; +import { CoreLoginHelperProvider } from '@core/login/providers/helper'; import { CoreCourseFormatHandler } from './format-delegate'; /** @@ -25,7 +26,9 @@ export class CoreCourseFormatDefaultHandler implements CoreCourseFormatHandler { name = 'CoreCourseFormatDefault'; format = 'default'; - constructor(private coursesProvider: CoreCoursesProvider) { } + protected loginHelper: CoreLoginHelperProvider; // Inject it later to prevent circular dependencies. + + constructor(protected coursesProvider: CoreCoursesProvider, protected injector: Injector) { } /** * Whether or not the handler is enabled on a site level. @@ -113,10 +116,10 @@ export class CoreCourseFormatDefaultHandler implements CoreCourseFormatHandler { return sections[0]; } else { // Try to retrieve the marker. - promise = this.coursesProvider.getCoursesByField('id', course.id).catch(() => { + promise = this.coursesProvider.getCourseByField('id', course.id).catch(() => { // Ignore errors. - }).then((courses) => { - return courses && courses[0] && courses[0].marker; + }).then((course) => { + return course && course.marker; }); } @@ -154,12 +157,26 @@ export class CoreCourseFormatDefaultHandler implements CoreCourseFormatHandler { * getCourseFormatComponent because it will display the course handlers at the top. * Your page should include the course handlers using CoreCoursesDelegate. * - * @param {NavController} navCtrl The NavController instance to use. + * @param {NavController} navCtrl The NavController instance to use. If not defined, please use loginHelper.redirect. * @param {any} course The course to open. It should contain a "format" attribute. + * @param {any} [params] Params to pass to the course page. * @return {Promise} Promise resolved when done. */ - openCourse(navCtrl: NavController, course: any): Promise { - return navCtrl.push('CoreCourseSectionPage', { course: course }); + openCourse(navCtrl: NavController, course: any, params?: any): Promise { + params = params || {}; + Object.assign(params, { course: course }); + + if (navCtrl) { + // Don't return the .push promise, we don't want to display a loading modal during the page transition. + navCtrl.push('CoreCourseSectionPage', params); + + return Promise.resolve(); + } else { + // Open the course in the "phantom" tab. + this.loginHelper = this.loginHelper || this.injector.get(CoreLoginHelperProvider); + + return this.loginHelper.redirect('CoreCourseSectionPage', params); + } } /** diff --git a/src/core/course/providers/format-delegate.ts b/src/core/course/providers/format-delegate.ts index 40a2668d4..b65560ced 100644 --- a/src/core/course/providers/format-delegate.ts +++ b/src/core/course/providers/format-delegate.ts @@ -92,9 +92,10 @@ export interface CoreCourseFormatHandler extends CoreDelegateHandler { * * @param {NavController} navCtrl The NavController instance to use. * @param {any} course The course to open. It should contain a "format" attribute. + * @param {any} [params] Params to pass to the course page. * @return {Promise} Promise resolved when done. */ - openCourse?(navCtrl: NavController, course: any): Promise; + openCourse?(navCtrl: NavController, course: any, params?: any): Promise; /** * Return the Component to use to display the course format instead of using the default one. @@ -337,10 +338,11 @@ export class CoreCourseFormatDelegate extends CoreDelegate { * * @param {NavController} navCtrl The NavController instance to use. * @param {any} course The course to open. It should contain a "format" attribute. + * @param {any} [params] Params to pass to the course page. * @return {Promise} Promise resolved when done. */ - openCourse(navCtrl: NavController, course: any): Promise { - return this.executeFunctionOnEnabled(course.format, 'openCourse', [navCtrl, course]); + openCourse(navCtrl: NavController, course: any, params?: any): Promise { + return this.executeFunctionOnEnabled(course.format, 'openCourse', [navCtrl, course, params]); } /** diff --git a/src/core/course/providers/helper.ts b/src/core/course/providers/helper.ts index a79546849..0a7b95247 100644 --- a/src/core/course/providers/helper.ts +++ b/src/core/course/providers/helper.ts @@ -25,7 +25,8 @@ import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreUtilsProvider } from '@providers/utils/utils'; -import { CoreCourseOptionsDelegate, CoreCourseOptionsHandlerToDisplay } from './options-delegate'; +import { CoreCourseOptionsDelegate, CoreCourseOptionsHandlerToDisplay, + CoreCourseOptionsMenuHandlerToDisplay } from './options-delegate'; import { CoreSiteHomeProvider } from '@core/sitehome/providers/sitehome'; import { CoreCoursesProvider } from '@core/courses/providers/courses'; import { CoreCourseProvider } from './course'; @@ -36,8 +37,6 @@ import { CoreLoginHelperProvider } from '@core/login/providers/helper'; import { CoreConstants } from '@core/constants'; import { CoreSite } from '@classes/site'; import * as moment from 'moment'; -import { CoreSitePluginsProvider } from '@core/siteplugins/providers/siteplugins'; -import { CoreCourseFormatDelegate } from '@core/course/providers/format-delegate'; /** * Prefetch info of a module. @@ -125,8 +124,7 @@ export class CoreCourseHelperProvider { private courseOptionsDelegate: CoreCourseOptionsDelegate, private siteHomeProvider: CoreSiteHomeProvider, private eventsProvider: CoreEventsProvider, private fileHelper: CoreFileHelperProvider, private appProvider: CoreAppProvider, private fileProvider: CoreFileProvider, private injector: Injector, - private coursesProvider: CoreCoursesProvider, private courseOffline: CoreCourseOfflineProvider, - private courseFormatDelegate: CoreCourseFormatDelegate, private sitePluginsProvider: CoreSitePluginsProvider) { } + private coursesProvider: CoreCoursesProvider, private courseOffline: CoreCourseOfflineProvider) { } /** * This function treats every module on the sections provided to load the handler data, treat completion @@ -199,11 +197,9 @@ export class CoreCourseHelperProvider { result.status = CoreConstants.DOWNLOADING; } + section.downloadStatus = result.status; + section.canCheckUpdates = this.prefetchDelegate.canCheckUpdates(); // Set this section data. - section.showDownload = result.status === CoreConstants.NOT_DOWNLOADED; - section.showRefresh = result.status === CoreConstants.OUTDATED || - (!this.prefetchDelegate.canCheckUpdates() && result.status === CoreConstants.DOWNLOADED); - if (result.status !== CoreConstants.DOWNLOADING || !this.prefetchDelegate.isBeingDownloaded(section.id)) { section.isDownloading = false; section.total = 0; @@ -252,9 +248,8 @@ export class CoreCourseHelperProvider { return Promise.all(promises).then(() => { if (allSectionsSection) { // Set "All sections" data. - allSectionsSection.showDownload = allSectionsStatus === CoreConstants.NOT_DOWNLOADED; - allSectionsSection.showRefresh = allSectionsStatus === CoreConstants.OUTDATED || - (!this.prefetchDelegate.canCheckUpdates() && allSectionsStatus === CoreConstants.DOWNLOADED); + allSectionsSection.downloadStatus = allSectionsStatus; + allSectionsSection.canCheckUpdates = this.prefetchDelegate.canCheckUpdates(); allSectionsSection.isDownloading = allSectionsStatus === CoreConstants.DOWNLOADING; } }).finally(() => { @@ -269,20 +264,22 @@ export class CoreCourseHelperProvider { * This function will set the icon to "spinner" when starting and it will also set it back to the initial icon if the * user cancels. All the other updates of the icon should be made when CoreEventsProvider.COURSE_STATUS_CHANGED is received. * - * @param {any} data An object where to store the course icon and title: "prefetchCourseIcon" and "title". + * @param {any} data An object where to store the course icon and title: "prefetchCourseIcon", "title" and "downloadSucceeded". * @param {any} course Course to prefetch. * @param {any[]} [sections] List of course sections. * @param {CoreCourseOptionsHandlerToDisplay[]} courseHandlers List of course handlers. + * @param {CoreCourseOptionsMenuHandlerToDisplay[]} menuHandlers List of course menu handlers. * @return {Promise} Promise resolved when the download finishes, rejected if an error occurs or the user cancels. */ - confirmAndPrefetchCourse(data: any, course: any, sections?: any[], courseHandlers?: CoreCourseOptionsHandlerToDisplay[]) - : Promise { + confirmAndPrefetchCourse(data: any, course: any, sections?: any[], courseHandlers?: CoreCourseOptionsHandlerToDisplay[], + menuHandlers?: CoreCourseOptionsMenuHandlerToDisplay[]): Promise { const initialIcon = data.prefetchCourseIcon, initialTitle = data.title, siteId = this.sitesProvider.getCurrentSiteId(); let promise; + data.downloadSucceeded = false; data.prefetchCourseIcon = 'spinner'; data.title = 'core.downloading'; @@ -294,20 +291,31 @@ export class CoreCourseHelperProvider { } return promise.then((sections) => { + // Confirm the download. return this.confirmDownloadSizeSection(course.id, undefined, sections, true).then(() => { // User confirmed, get the course handlers if needed. - if (courseHandlers) { - promise = Promise.resolve(courseHandlers); - } else { - promise = this.courseOptionsDelegate.getHandlersToDisplay(this.injector, course); + const subPromises = []; + if (!courseHandlers) { + subPromises.push(this.courseOptionsDelegate.getHandlersToDisplay(this.injector, course) + .then((cHandlers) => { + courseHandlers = cHandlers; + })); + } + if (!menuHandlers) { + subPromises.push(this.courseOptionsDelegate.getMenuHandlersToDisplay(this.injector, course) + .then((mHandlers) => { + menuHandlers = mHandlers; + })); } - return promise.then((handlers: CoreCourseOptionsHandlerToDisplay[]) => { + return Promise.all(subPromises).then(() => { // Now we have all the data, download the course. - return this.prefetchCourse(course, sections, handlers, siteId); + return this.prefetchCourse(course, sections, courseHandlers, menuHandlers, siteId); }).then(() => { // Download successful. + data.downloadSucceeded = true; + return true; }); }, (error): any => { @@ -340,6 +348,7 @@ export class CoreCourseHelperProvider { const subPromises = []; let sections, handlers, + menuHandlers, success = true; // Get the sections and the handlers. @@ -349,9 +358,12 @@ export class CoreCourseHelperProvider { subPromises.push(this.courseOptionsDelegate.getHandlersToDisplay(this.injector, course).then((cHandlers) => { handlers = cHandlers; })); + subPromises.push(this.courseOptionsDelegate.getMenuHandlersToDisplay(this.injector, course).then((mHandlers) => { + menuHandlers = mHandlers; + })); promises.push(Promise.all(subPromises).then(() => { - return this.prefetchCourse(course, sections, handlers, siteId); + return this.prefetchCourse(course, sections, handlers, menuHandlers, siteId); }).catch((error) => { success = false; @@ -453,9 +465,10 @@ export class CoreCourseHelperProvider { * @param {any} instance The component instance that has the context menu. It should have prefetchStatusIcon and isDestroyed. * @param {any} module Module to be prefetched * @param {number} courseId Course ID the module belongs to. + * @param {Function} [done] Function to call when done. It will close the context menu. * @return {Promise} Promise resolved when done. */ - contextMenuPrefetch(instance: any, module: any, courseId: number): Promise { + contextMenuPrefetch(instance: any, module: any, courseId: number, done?: () => void): Promise { const initialIcon = instance.prefetchStatusIcon; instance.prefetchStatusIcon = 'spinner'; // Show spinner since this operation might take a while. @@ -465,6 +478,9 @@ export class CoreCourseHelperProvider { return this.domUtils.confirmDownloadSize(size).then(() => { return this.prefetchDelegate.prefetchModule(module, courseId, true); }); + }).then(() => { + // Success, close menu. + done && done(); }).catch((error) => { instance.prefetchStatusIcon = initialIcon; @@ -794,6 +810,30 @@ export class CoreCourseHelperProvider { }); } + /** + * Get a course, wait for any course format plugin to load, and open the course page. It basically chains the functions + * getCourse and openCourse. + * + * @param {NavController} navCtrl The nav controller to use. If not defined, the course will be opened in main menu. + * @param {number} courseId Course ID. + * @param {any} [params] Other params to pass to the course page. + * @param {string} [siteId] Site ID. If not defined, current site. + */ + getAndOpenCourse(navCtrl: NavController, courseId: number, params?: any, siteId?: string): Promise { + const modal = this.domUtils.showModalLoading(); + + return this.getCourse(courseId, siteId).then((data) => { + return data.course; + }).catch(() => { + // Cannot get course, return a "fake". + return { id: courseId }; + }).then((course) => { + modal.dismiss(); + + return this.openCourse(navCtrl, course, params, siteId); + }); + } + /** * Check if the course has a block with that name. * @@ -945,7 +985,7 @@ export class CoreCourseHelperProvider { */ getCourseStatusIconAndTitleFromStatus(status: string): {icon: string, title: string} { if (status == CoreConstants.DOWNLOADED) { - // Always show refresh icon, we cannot knew if there's anything new in course options. + // Always show refresh icon, we cannot know if there's anything new in course options. return { icon: 'refresh', title: 'core.course.refreshcourse' @@ -1121,14 +1161,17 @@ export class CoreCourseHelperProvider { // Check if site home is available. return this.siteHomeProvider.isAvailable().then(() => { this.loginHelper.redirect('CoreSiteHomeIndexPage', params, siteId); + }).finally(() => { + modal.dismiss(); }); } else { - this.loginHelper.redirect('CoreCourseSectionPage', params, siteId); + modal.dismiss(); + + return this.getAndOpenCourse(undefined, courseId, params, siteId); } }).catch((error) => { - this.domUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); - }).finally(() => { modal.dismiss(); + this.domUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); }); } @@ -1162,11 +1205,12 @@ export class CoreCourseHelperProvider { * @param {any} course The course to prefetch. * @param {any[]} sections List of course sections. * @param {CoreCourseOptionsHandlerToDisplay[]} courseHandlers List of course options handlers. + * @param {CoreCourseOptionsMenuHandlerToDisplay[]} courseMenuHandlers List of course menu handlers. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the download finishes. */ - prefetchCourse(course: any, sections: any[], courseHandlers: CoreCourseOptionsHandlerToDisplay[], siteId?: string) - : Promise { + prefetchCourse(course: any, sections: any[], courseHandlers: CoreCourseOptionsHandlerToDisplay[], + courseMenuHandlers: CoreCourseOptionsMenuHandlerToDisplay[], siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); if (this.courseDwnPromises[siteId] && this.courseDwnPromises[siteId][course.id]) { @@ -1195,6 +1239,11 @@ export class CoreCourseHelperProvider { promises.push(handler.prefetch(course)); } }); + courseMenuHandlers.forEach((handler) => { + if (handler.prefetch) { + promises.push(handler.prefetch(course)); + } + }); // Prefetch other data needed to render the course. if (this.coursesProvider.isGetCoursesByFieldAvailable()) { @@ -1244,7 +1293,7 @@ export class CoreCourseHelperProvider { return promise.catch(() => { // Ignore errors. }).then(() => { - return handler.prefetch(module, courseId, true); + return this.prefetchDelegate.prefetchModule(module, courseId, true); }); }); } @@ -1285,9 +1334,8 @@ export class CoreCourseHelperProvider { return this.utils.allPromises(promises).then(() => { // Set "All sections" data. - section.showDownload = allSectionsStatus === CoreConstants.NOT_DOWNLOADED; - section.showRefresh = allSectionsStatus === CoreConstants.OUTDATED || - (!this.prefetchDelegate.canCheckUpdates() && allSectionsStatus === CoreConstants.DOWNLOADED); + section.downloadStatus = allSectionsStatus; + section.canCheckUpdates = this.prefetchDelegate.canCheckUpdates(); section.isDownloading = allSectionsStatus === CoreConstants.DOWNLOADING; }).finally(() => { section.isDownloading = false; @@ -1317,18 +1365,22 @@ export class CoreCourseHelperProvider { section.isDownloading = true; - // Validate the section needs to be downloaded and calculate amount of modules that need to be downloaded. - promises.push(this.prefetchDelegate.getModulesStatus(section.modules, courseId, section.id).then((result) => { - if (result.status == CoreConstants.DOWNLOADED || result.status == CoreConstants.NOT_DOWNLOADABLE) { - // Section is downloaded or not downloadable, nothing to do. - return; - } + // Sync the modules first. + promises.push(this.prefetchDelegate.syncModules(section.modules, courseId).then(() => { + // Validate the section needs to be downloaded and calculate amount of modules that need to be downloaded. + return this.prefetchDelegate.getModulesStatus(section.modules, courseId, section.id).then((result) => { + if (result.status == CoreConstants.DOWNLOADED || result.status == CoreConstants.NOT_DOWNLOADABLE) { + // Section is downloaded or not downloadable, nothing to do. - return this.prefetchSingleSection(section, result, courseId); - }, (error) => { - section.isDownloading = false; + return ; + } - return Promise.reject(error); + return this.prefetchSingleSection(section, result, courseId); + }, (error) => { + section.isDownloading = false; + + return Promise.reject(error); + }); })); // Download the files in the section description. @@ -1399,33 +1451,22 @@ export class CoreCourseHelperProvider { * will be displayed until it is complete, before the course page is opened. If the promise is already complete, * they will see the result immediately. * - * @param {NavController} navCtrl The nav controller to use. + * @param {NavController} navCtrl The nav controller to use. If not defined, the course will be opened in main menu. * @param {any} course Course to open + * @param {any} [params] Params to pass to the course page. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. */ - openCourse(navCtrl: NavController, course: any): void { - if (this.sitePluginsProvider.sitePluginPromiseExists('format_' + course.format)) { - // This course uses a custom format plugin, wait for the format plugin to finish loading. - const loading = this.domUtils.showModalLoading(); - this.sitePluginsProvider.sitePluginLoaded('format_' + course.format).then(() => { - // The format loaded successfully, but the handlers wont be registered until all site plugins have loaded. - if (this.sitePluginsProvider.sitePluginsFinishedLoading) { - loading.dismiss(); - this.courseFormatDelegate.openCourse(navCtrl, course); - } else { - const observer = this.eventsProvider.on(CoreEventsProvider.SITE_PLUGINS_LOADED, () => { - loading.dismiss(); - this.courseFormatDelegate.openCourse(navCtrl, course); - observer && observer.off(); - }); - } - }).catch(() => { - // The site plugin failed to load. The user needs to restart the app to try loading it again. - loading.dismiss(); - this.domUtils.showErrorModal('core.courses.errorloadplugins', true); - }); + openCourse(navCtrl: NavController, course: any, params?: any, siteId?: string): Promise { + if (!siteId || siteId == this.sitesProvider.getCurrentSiteId()) { + // Current site, we can open the course. + return this.courseProvider.openCourse(navCtrl, course, params); } else { - // No custom format plugin. We don't need to wait for anything. - this.courseFormatDelegate.openCourse(navCtrl, course); + // We need to load the site first. + params = params || {}; + Object.assign(params, { course: course }); + + return this.loginHelper.redirect(CoreLoginHelperProvider.OPEN_COURSE, params, siteId); } } } diff --git a/src/core/course/providers/log-cron-handler.ts b/src/core/course/providers/log-cron-handler.ts index c04dea1b7..e708cf056 100644 --- a/src/core/course/providers/log-cron-handler.ts +++ b/src/core/course/providers/log-cron-handler.ts @@ -28,14 +28,15 @@ export class CoreCourseLogCronHandler implements CoreCronHandler { /** * Execute the process. - * Receives the ID of the site affected, undefined for the current site. + * Receives the ID of the site affected, undefined for all sites. * - * @param {string} [siteId] ID of the site affected, undefined for the current site. + * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). * @return {Promise} Promise resolved when done, rejected if failure. */ - execute(siteId?: string): Promise { + execute(siteId?: string, force?: boolean): Promise { return this.sitesProvider.getSite(siteId).then((site) => { - return this.courseProvider.logView(site.getSiteHomeId(), undefined, site.getId()); + return this.courseProvider.logView(site.getSiteHomeId(), undefined, site.getId(), site.getInfo().sitename); }); } diff --git a/src/core/course/providers/log-helper.ts b/src/core/course/providers/log-helper.ts index 9ec8ea25b..36ccf4ab8 100644 --- a/src/core/course/providers/log-helper.ts +++ b/src/core/course/providers/log-helper.ts @@ -18,6 +18,7 @@ import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreAppProvider } from '@providers/app'; +import { CorePushNotificationsProvider } from '@core/pushnotifications/providers/pushnotifications'; /** * Helper to manage logging to Moodle. @@ -62,7 +63,7 @@ export class CoreCourseLogHelperProvider { constructor(protected sitesProvider: CoreSitesProvider, protected timeUtils: CoreTimeUtilsProvider, protected textUtils: CoreTextUtilsProvider, protected utils: CoreUtilsProvider, - protected appProvider: CoreAppProvider) { + protected appProvider: CoreAppProvider, protected pushNotificationsProvider: CorePushNotificationsProvider) { this.sitesProvider.registerSiteSchema(this.siteSchema); } @@ -196,6 +197,47 @@ export class CoreCourseLogHelperProvider { }); } + /** + * Perform log online. Data will be saved offline for syncing. + * It also triggers a Firebase view_item event. + * + * @param {string} ws WS name. + * @param {any} data Data to send to the WS. + * @param {string} component Component name. + * @param {number} componentId Component ID. + * @param {string} [name] Name of the viewed item. + * @param {string} [category] Category of the viewed item. + * @param {string} [eventData] Data to pass to the Firebase event. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + logSingle(ws: string, data: any, component: string, componentId: number, name?: string, category?: string, eventData?: any, + siteId?: string): Promise { + this.pushNotificationsProvider.logViewEvent(componentId, name, category, ws, eventData, siteId); + + return this.log(ws, data, component, componentId, siteId); + } + + /** + * Perform log online. Data will be saved offline for syncing. + * It also triggers a Firebase view_item_list event. + * + * @param {string} ws WS name. + * @param {any} data Data to send to the WS. + * @param {string} component Component name. + * @param {number} componentId Component ID. + * @param {string} category Category of the viewed item. + * @param {string} [eventData] Data to pass to the Firebase event. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + logList(ws: string, data: any, component: string, componentId: number, category: string, eventData?: any, siteId?: string) + : Promise { + this.pushNotificationsProvider.logViewListEvent(category, ws, eventData, siteId); + + return this.log(ws, data, component, componentId, siteId); + } + /** * Save activity log for offline sync. * @@ -227,7 +269,7 @@ export class CoreCourseLogHelperProvider { * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when done. */ - syncAll(siteId?: string): Promise { + syncSite(siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { const siteId = site.getId(); diff --git a/src/core/course/providers/module-prefetch-delegate.ts b/src/core/course/providers/module-prefetch-delegate.ts index 24ca8c933..efb256400 100644 --- a/src/core/course/providers/module-prefetch-delegate.ts +++ b/src/core/course/providers/module-prefetch-delegate.ts @@ -206,6 +206,16 @@ export interface CoreCourseModulePrefetchHandler extends CoreDelegateHandler { * @return {Promise} Promise resolved when done. */ removeFiles?(module: any, courseId: number): Promise; + + /** + * Sync a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + sync?(module: any, courseId: number, siteId?: any): Promise; } /** @@ -377,6 +387,8 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { } } else if (handler.determineStatus) { // The handler implements a determineStatus function. Apply it. + canCheck = canCheck || this.canCheckUpdates(); + return handler.determineStatus(module, status, canCheck); } } @@ -1139,7 +1151,51 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { // Check if the module has a prefetch handler. if (handler) { - return handler.prefetch(module, courseId, single); + return this.syncModule(module, courseId).then(() => { + return handler.prefetch(module, courseId, single); + }); + } + + return Promise.resolve(); + } + + /** + * Sync a group of modules. + * + * @param {any[]} modules Array of modules to sync. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when finished. + */ + syncModules(modules: any[], courseId: number): Promise { + return Promise.all(modules.map((module) => { + return this.syncModule(module, courseId).then(() => { + // Invalidate course updates. + return this.invalidateCourseUpdates(courseId).catch(() => { + // Ignore errors. + }); + }); + })); + } + + /** + * Sync a module. + * + * @param {any} module Module to sync. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when finished. + */ + syncModule(module: any, courseId: number): Promise { + const handler = this.getPrefetchHandlerFor(module); + + if (handler && handler.sync) { + return handler.sync(module, courseId).then((result) => { + // Always invalidate status cache for this module. We cannot know if data was sent to server or not. + this.invalidateModuleStatusCache(module); + + return result; + }).catch(() => { + // Ignore errors. + }); } return Promise.resolve(); diff --git a/src/core/course/providers/options-delegate.ts b/src/core/course/providers/options-delegate.ts index 961c61939..cccfde281 100644 --- a/src/core/course/providers/options-delegate.ts +++ b/src/core/course/providers/options-delegate.ts @@ -31,6 +31,12 @@ export interface CoreCourseOptionsHandler extends CoreDelegateHandler { */ priority: number; + /** + * True if this handler should appear in menu rather than as a tab. + * @type {boolean} + */ + isMenuHandler?: boolean; + /** * Whether or not the handler is enabled for a certain course. * @@ -70,6 +76,21 @@ export interface CoreCourseOptionsHandler extends CoreDelegateHandler { prefetch?(course: any): Promise; } +/** + * Interface that course options handlers implement if they appear in the menu rather than as a tab. + */ +export interface CoreCourseOptionsMenuHandler extends CoreCourseOptionsHandler { + /** + * Returns the data needed to render the handler. + * + * @param {Injector} injector Injector. + * @param {number} courseId The course ID. + * @return {CoreCourseOptionsMenuHandlerData|Promise} Data or promise resolved with data. + */ + getMenuDisplayData(injector: Injector, courseId: number): + CoreCourseOptionsMenuHandlerData | Promise; +} + /** * Data needed to render a course handler. It's returned by the handler. */ @@ -99,6 +120,41 @@ export interface CoreCourseOptionsHandlerData { componentData?: any; } +/** + * Data needed to render a course menu handler. It's returned by the handler. + */ +export interface CoreCourseOptionsMenuHandlerData { + /** + * Title to display for the handler. + * @type {string} + */ + title: string; + + /** + * Class to add to the displayed handler. + * @type {string} + */ + class?: string; + + /** + * Name of the page to load for the handler. + * @type {string} + */ + page: string; + + /** + * Params to pass to the page (other than 'course' which is always sent). + * @type {any} + */ + pageParams?: any; + + /** + * Name of the icon to display for the handler. + * @type {string} + */ + icon: string; // Name of the icon to display in the tab. +} + /** * Data returned by the delegate for each handler. */ @@ -130,6 +186,37 @@ export interface CoreCourseOptionsHandlerToDisplay { prefetch?(course: any): Promise; } +/** + * Additional data returned if it is a menu item. + */ +export interface CoreCourseOptionsMenuHandlerToDisplay { + /** + * Data to display. + * @type {CoreCourseOptionsMenuHandlerData} + */ + data: CoreCourseOptionsMenuHandlerData; + + /** + * Name of the handler, or name and sub context (AddonMessages, AddonMessages:blockContact, ...). + * @type {string} + */ + name: string; + + /** + * The highest priority is displayed first. + * @type {number} + */ + priority?: number; + + /** + * Called when a course is downloaded. It should prefetch all the data to be able to see the addon in offline. + * + * @param {any} course The course. + * @return {Promise} Promise resolved when done. + */ + prefetch?(course: any): Promise; +} + /** * Service to interact with plugins to be shown in each course (participants, learning plans, ...). */ @@ -139,7 +226,8 @@ export class CoreCourseOptionsDelegate extends CoreDelegate { protected lastUpdateHandlersForCoursesStart: any = {}; protected coursesHandlers: { [courseId: number]: { - access?: any, navOptions?: any, admOptions?: any, deferred?: PromiseDefer, enabledHandlers?: CoreCourseOptionsHandler[] + access?: any, navOptions?: any, admOptions?: any, deferred?: PromiseDefer, + enabledHandlers?: CoreCourseOptionsHandler[], enabledMenuHandlers?: CoreCourseOptionsMenuHandler[] } } = {}; @@ -258,6 +346,43 @@ export class CoreCourseOptionsDelegate extends CoreDelegate { */ getHandlersToDisplay(injector: Injector, course: any, refresh?: boolean, isGuest?: boolean, navOptions?: any, admOptions?: any): Promise { + return > this.getHandlersToDisplayInternal( + false, injector, course, refresh, isGuest, navOptions, admOptions); + } + + /** + * Get the list of menu handlers that should be displayed for a course. + * This function should be called only when the handlers need to be displayed, since it can call several WebServices. + * + * @param {Injector} injector Injector. + * @param {any} course The course object. + * @param {boolean} [refresh] True if it should refresh the list. + * @param {boolean} [isGuest] Whether it's guest. + * @param {any} [navOptions] Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param {any} [admOptions] Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return {Promise} Promise resolved with array of handlers. + */ + getMenuHandlersToDisplay(injector: Injector, course: any, refresh?: boolean, isGuest?: boolean, + navOptions?: any, admOptions?: any): Promise { + return > this.getHandlersToDisplayInternal( + true, injector, course, refresh, isGuest, navOptions, admOptions); + } + + /** + * Get the list of menu handlers that should be displayed for a course. + * This function should be called only when the handlers need to be displayed, since it can call several WebServices. + * + * @param {boolean} menu If true, gets menu handlers; false, gets tab handlers + * @param {Injector} injector Injector. + * @param {any} course The course object. + * @param {boolean} refresh True if it should refresh the list. + * @param {boolean} isGuest Whether it's guest. + * @param {any} navOptions Course navigation options for current user. See CoreCoursesProvider.getUserNavigationOptions. + * @param {any} admOptions Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. + * @return {Promise} Promise resolved with array of handlers. + */ + protected getHandlersToDisplayInternal(menu: boolean, injector: Injector, course: any, refresh: boolean, isGuest: boolean, + navOptions: any, admOptions: any): Promise { course.id = parseInt(course.id, 10); const accessData = { @@ -278,8 +403,16 @@ export class CoreCourseOptionsDelegate extends CoreDelegate { }).then(() => { const promises = []; - this.coursesHandlers[course.id].enabledHandlers.forEach((handler) => { - promises.push(Promise.resolve(handler.getDisplayData(injector, course)).then((data) => { + let handlerList; + if (menu) { + handlerList = this.coursesHandlers[course.id].enabledMenuHandlers; + } else { + handlerList = this.coursesHandlers[course.id].enabledHandlers; + } + + handlerList.forEach((handler) => { + const getFunction = menu ? handler.getMenuDisplayData : handler.getDisplayData; + promises.push(Promise.resolve(getFunction.call(handler, injector, course)).then((data) => { handlersToDisplay.push({ data: data, priority: handler.priority, @@ -444,6 +577,7 @@ export class CoreCourseOptionsDelegate extends CoreDelegate { updateHandlersForCourse(courseId: number, accessData: any, navOptions?: any, admOptions?: any): Promise { const promises = [], enabledForCourse = [], + enabledForCourseMenu = [], siteId = this.sitesProvider.getCurrentSiteId(), now = Date.now(); @@ -456,7 +590,11 @@ export class CoreCourseOptionsDelegate extends CoreDelegate { promises.push(Promise.resolve(handler.isEnabledForCourse(courseId, accessData, navOptions, admOptions)) .then((enabled) => { if (enabled) { - enabledForCourse.push(handler); + if (handler.isMenuHandler) { + enabledForCourseMenu.push( handler); + } else { + enabledForCourse.push(handler); + } } else { return Promise.reject(null); } @@ -476,6 +614,7 @@ export class CoreCourseOptionsDelegate extends CoreDelegate { if (this.isLastUpdateCourseCall(courseId, now) && this.sitesProvider.getCurrentSiteId() === siteId) { // Update the coursesHandlers array with the new enabled addons. this.coursesHandlers[courseId].enabledHandlers = enabledForCourse; + this.coursesHandlers[courseId].enabledMenuHandlers = enabledForCourseMenu; this.loaded[courseId] = true; // Resolve the promise. diff --git a/src/core/course/providers/sync-cron-handler.ts b/src/core/course/providers/sync-cron-handler.ts index 4e85dd398..8f3adc3a6 100644 --- a/src/core/course/providers/sync-cron-handler.ts +++ b/src/core/course/providers/sync-cron-handler.ts @@ -15,7 +15,6 @@ import { Injectable } from '@angular/core'; import { CoreCronHandler } from '@providers/cron'; import { CoreCourseSyncProvider } from './sync'; -import { CoreCourseLogHelperProvider } from './log-helper'; /** * Synchronization cron handler. @@ -24,24 +23,18 @@ import { CoreCourseLogHelperProvider } from './log-helper'; export class CoreCourseSyncCronHandler implements CoreCronHandler { name = 'CoreCourseSyncCronHandler'; - constructor(private courseSync: CoreCourseSyncProvider, private logHelper: CoreCourseLogHelperProvider) {} + constructor(private courseSync: CoreCourseSyncProvider) {} /** * Execute the process. * Receives the ID of the site affected, undefined for all sites. * - * @param {string} [siteId] ID of the site affected, undefined for all sites. - * @return {Promise} Promise resolved when done, rejected if failure. + * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). + * @return {Promise} Promise resolved when done, rejected if failure. */ - execute(siteId?: string): Promise { - const promises = []; - // Sync activity logs even if the activity does not have sync handler. - // This will sync all the activity logs even if there's nothing else to sync and also recources. - promises.push(this.logHelper.syncAll(siteId)); - - promises.push(this.courseSync.syncAllCourses(siteId)); - - return Promise.all(promises); + execute(siteId?: string, force?: boolean): Promise { + return this.courseSync.syncAllCourses(siteId); } /** diff --git a/src/core/course/providers/sync.ts b/src/core/course/providers/sync.ts index 058fba729..2c091a863 100644 --- a/src/core/course/providers/sync.ts +++ b/src/core/course/providers/sync.ts @@ -13,18 +13,19 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { CoreLoggerProvider } from '@providers/logger'; +import { TranslateService } from '@ngx-translate/core'; import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; import { CoreAppProvider } from '@providers/app'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreSyncProvider } from '@providers/sync'; import { CoreCourseOfflineProvider } from './course-offline'; import { CoreCourseProvider } from './course'; -import { CoreEventsProvider } from '@providers/events'; -import { TranslateService } from '@ngx-translate/core'; -import { CoreSyncProvider } from '@providers/sync'; +import { CoreCourseLogHelperProvider } from './log-helper'; /** * Service to sync course offline data. This only syncs the offline data of the course itself, not the offline data of @@ -39,7 +40,7 @@ export class CoreCourseSyncProvider extends CoreSyncBaseProvider { protected appProvider: CoreAppProvider, private courseOffline: CoreCourseOfflineProvider, private eventsProvider: CoreEventsProvider, private courseProvider: CoreCourseProvider, translate: TranslateService, private utils: CoreUtilsProvider, protected textUtils: CoreTextUtilsProvider, - syncProvider: CoreSyncProvider, timeUtils: CoreTimeUtilsProvider) { + syncProvider: CoreSyncProvider, timeUtils: CoreTimeUtilsProvider, protected logHelper: CoreCourseLogHelperProvider) { super('CoreCourseSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate, timeUtils); } @@ -48,25 +49,32 @@ export class CoreCourseSyncProvider extends CoreSyncBaseProvider { * Try to synchronize all the courses in a certain site or in all sites. * * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - syncAllCourses(siteId?: string): Promise { - return this.syncOnSites('courses', this.syncAllCoursesFunc.bind(this), undefined, siteId); + syncAllCourses(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('courses', this.syncAllCoursesFunc.bind(this), [force], siteId); } /** * Sync all courses on a site. * - * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {string} siteId Site ID to sync. If not defined, sync all sites. + * @param {boolean} force Wether the execution is forced (manual sync). * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. */ - protected syncAllCoursesFunc(siteId?: string): Promise { - return this.courseOffline.getAllManualCompletions(siteId).then((completions) => { - const promises = []; + protected syncAllCoursesFunc(siteId: string, force: boolean): Promise { + const p1 = []; + p1.push(this.logHelper.syncSite(siteId)); + + p1.push(this.courseOffline.getAllManualCompletions(siteId).then((completions) => { // Sync all courses. - completions.forEach((completion) => { - promises.push(this.syncCourseIfNeeded(completion.courseid, siteId).then((result) => { + const p2 = completions.map((completion) => { + const promise = force ? this.syncCourse(completion.courseid, siteId) : + this.syncCourseIfNeeded(completion.courseid, siteId); + + return promise.then((result) => { if (result && result.updated) { // Sync successful, send event. this.eventsProvider.trigger(CoreCourseSyncProvider.AUTO_SYNCED, { @@ -74,9 +82,13 @@ export class CoreCourseSyncProvider extends CoreSyncBaseProvider { warnings: result.warnings }, siteId); } - })); + }); }); - }); + + return Promise.all(p2); + })); + + return Promise.all(p1); } /** diff --git a/src/core/courses/components/components.module.ts b/src/core/courses/components/components.module.ts index 45b2671cb..ccb458973 100644 --- a/src/core/courses/components/components.module.ts +++ b/src/core/courses/components/components.module.ts @@ -19,15 +19,17 @@ 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 { CoreCoursesCourseProgressComponent } from '../components/course-progress/course-progress'; -import { CoreCoursesCourseListItemComponent } from '../components/course-list-item/course-list-item'; -import { CoreCoursesCourseOptionsMenuComponent } from '../components/course-options-menu/course-options-menu'; +import { CoreCoursesCourseProgressComponent } from './course-progress/course-progress'; +import { CoreCoursesCourseListItemComponent } from './course-list-item/course-list-item'; +import { CoreCoursesCourseOptionsMenuComponent } from './course-options-menu/course-options-menu'; +import { CoreCoursesMyCoursesComponent } from './my-courses/my-courses'; @NgModule({ declarations: [ CoreCoursesCourseProgressComponent, CoreCoursesCourseListItemComponent, - CoreCoursesCourseOptionsMenuComponent + CoreCoursesCourseOptionsMenuComponent, + CoreCoursesMyCoursesComponent ], imports: [ CommonModule, @@ -42,7 +44,8 @@ import { CoreCoursesCourseOptionsMenuComponent } from '../components/course-opti exports: [ CoreCoursesCourseProgressComponent, CoreCoursesCourseListItemComponent, - CoreCoursesCourseOptionsMenuComponent + CoreCoursesCourseOptionsMenuComponent, + CoreCoursesMyCoursesComponent ], entryComponents: [ CoreCoursesCourseOptionsMenuComponent diff --git a/src/core/courses/components/course-options-menu/core-courses-course-options-menu.html b/src/core/courses/components/course-options-menu/core-courses-course-options-menu.html index 7be6638ba..9e17624b8 100644 --- a/src/core/courses/components/course-options-menu/core-courses-course-options-menu.html +++ b/src/core/courses/components/course-options-menu/core-courses-course-options-menu.html @@ -16,7 +16,7 @@

{{ 'core.courses.addtofavourites' | translate }}

- +

{{ 'core.courses.removefromfavourites' | translate }}

diff --git a/src/core/courses/components/course-progress/core-courses-course-progress.html b/src/core/courses/components/course-progress/core-courses-course-progress.html index 4e2757ebb..985d92008 100644 --- a/src/core/courses/components/course-progress/core-courses-course-progress.html +++ b/src/core/courses/components/course-progress/core-courses-course-progress.html @@ -14,7 +14,8 @@
@@ -24,13 +25,16 @@ + + +
- + diff --git a/src/core/courses/components/course-progress/course-progress.scss b/src/core/courses/components/course-progress/course-progress.scss index fe3712586..230838b1f 100644 --- a/src/core/courses/components/course-progress/course-progress.scss +++ b/src/core/courses/components/course-progress/course-progress.scss @@ -1,5 +1,3 @@ -$core-star-color: $core-color !default; - ion-app.app-root core-courses-course-progress { ion-card.card { display: flex; @@ -74,6 +72,12 @@ ion-app.app-root core-courses-course-progress { vertical-align: middle; } + .core-button-spinner .core-icon-downloaded { + font-size: 28.8px; + margin-top: 8px; + vertical-align: top; + } + .item-button[icon-only] { min-width: 50px; width: 50px; diff --git a/src/core/courses/components/course-progress/course-progress.ts b/src/core/courses/components/course-progress/course-progress.ts index f91778759..814b00c55 100644 --- a/src/core/courses/components/course-progress/course-progress.ts +++ b/src/core/courses/components/course-progress/course-progress.ts @@ -42,6 +42,7 @@ export class CoreCoursesCourseProgressComponent implements OnInit, OnDestroy { isDownloading: boolean; prefetchCourseData = { + downloadSucceeded: false, prefetchCourseIcon: 'spinner', title: 'core.course.downloadcourse' }; @@ -97,7 +98,7 @@ export class CoreCoursesCourseProgressComponent implements OnInit, OnDestroy { // Listen for status change in course. this.courseStatusObserver = this.eventsProvider.on(CoreEventsProvider.COURSE_STATUS_CHANGED, (data) => { - if (data.courseId == this.course.id) { + if (data.courseId == this.course.id || data.courseId == CoreCourseProvider.ALL_COURSES_CLEARED) { this.updateCourseStatus(data.status); } }, this.sitesProvider.getCurrentSiteId()); diff --git a/src/core/courses/components/my-courses/my-courses.html b/src/core/courses/components/my-courses/my-courses.html new file mode 100644 index 000000000..06ca202b8 --- /dev/null +++ b/src/core/courses/components/my-courses/my-courses.html @@ -0,0 +1,14 @@ + + + + + + + + + + + +

{{ 'core.courses.searchcoursesadvice' | translate }}

+
+
diff --git a/src/core/courses/components/my-courses/my-courses.ts b/src/core/courses/components/my-courses/my-courses.ts new file mode 100644 index 000000000..fc0817350 --- /dev/null +++ b/src/core/courses/components/my-courses/my-courses.ts @@ -0,0 +1,242 @@ +// (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, OnInit, OnDestroy, ViewChild } from '@angular/core'; +import { Searchbar } from 'ionic-angular'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreCoursesProvider } from '../../providers/courses'; +import { CoreCoursesHelperProvider } from '../../providers/helper'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { CoreCourseOptionsDelegate } from '@core/course/providers/options-delegate'; + +/** + * Component that displays the list of courses the user is enrolled in. + */ +@Component({ + selector: 'core-courses-my-courses', + templateUrl: 'my-courses.html', +}) +export class CoreCoursesMyCoursesComponent implements OnInit, OnDestroy { + @ViewChild('searchbar') searchbar: Searchbar; + + courses: any[]; + filteredCourses: any[]; + searchEnabled: boolean; + filter = ''; + showFilter = false; + coursesLoaded = false; + prefetchCoursesData: any = {}; + downloadAllCoursesEnabled: boolean; + + protected prefetchIconInitialized = false; + protected myCoursesObserver; + protected siteUpdatedObserver; + protected isDestroyed = false; + protected courseIds = ''; + + constructor(private coursesProvider: CoreCoursesProvider, + private domUtils: CoreDomUtilsProvider, private eventsProvider: CoreEventsProvider, + private sitesProvider: CoreSitesProvider, private courseHelper: CoreCourseHelperProvider, + private courseOptionsDelegate: CoreCourseOptionsDelegate, private coursesHelper: CoreCoursesHelperProvider) { } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.searchEnabled = !this.coursesProvider.isSearchCoursesDisabledInSite(); + this.downloadAllCoursesEnabled = !this.coursesProvider.isDownloadCoursesDisabledInSite(); + + this.fetchCourses().finally(() => { + this.coursesLoaded = true; + }); + + this.myCoursesObserver = this.eventsProvider.on(CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, () => { + this.fetchCourses(); + }, this.sitesProvider.getCurrentSiteId()); + + // Refresh the enabled flags if site is updated. + this.siteUpdatedObserver = this.eventsProvider.on(CoreEventsProvider.SITE_UPDATED, () => { + const wasEnabled = this.downloadAllCoursesEnabled; + + this.searchEnabled = !this.coursesProvider.isSearchCoursesDisabledInSite(); + this.downloadAllCoursesEnabled = !this.coursesProvider.isDownloadCoursesDisabledInSite(); + + if (!wasEnabled && this.downloadAllCoursesEnabled && this.coursesLoaded) { + // Download all courses is enabled now, initialize it. + this.initPrefetchCoursesIcon(); + } + }, this.sitesProvider.getCurrentSiteId()); + } + + /** + * Fetch the user courses. + * + * @return {Promise} Promise resolved when done. + */ + protected fetchCourses(): Promise { + return this.coursesProvider.getUserCourses().then((courses) => { + const promises = [], + courseIds = courses.map((course) => { + return course.id; + }); + + this.courseIds = courseIds.join(','); + + promises.push(this.coursesHelper.loadCoursesExtraInfo(courses)); + + if (this.coursesProvider.canGetAdminAndNavOptions()) { + promises.push(this.coursesProvider.getCoursesAdminAndNavOptions(courseIds).then((options) => { + courses.forEach((course) => { + course.navOptions = options.navOptions[course.id]; + course.admOptions = options.admOptions[course.id]; + }); + })); + } + + return Promise.all(promises).then(() => { + this.courses = courses; + this.filteredCourses = this.courses; + this.filter = ''; + + this.initPrefetchCoursesIcon(); + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.courses.errorloadcourses', true); + }); + } + + /** + * Refresh the courses. + * + * @param {any} refresher Refresher. + */ + refreshCourses(refresher: any): void { + const promises = []; + + promises.push(this.coursesProvider.invalidateUserCourses()); + promises.push(this.courseOptionsDelegate.clearAndInvalidateCoursesOptions()); + if (this.courseIds) { + promises.push(this.coursesProvider.invalidateCoursesByField('ids', this.courseIds)); + } + + Promise.all(promises).finally(() => { + + this.prefetchIconInitialized = false; + this.fetchCourses().finally(() => { + refresher.complete(); + }); + }); + } + + /** + * Show or hide the filter. + */ + switchFilter(): void { + this.filter = ''; + this.showFilter = !this.showFilter; + this.filteredCourses = this.courses; + if (this.showFilter) { + setTimeout(() => { + this.searchbar.setFocus(); + }, 500); + } + } + + /** + * The filter has changed. + * + * @param {any} Received Event. + */ + filterChanged(event: any): void { + const newValue = event.target.value && event.target.value.trim().toLowerCase(); + if (!newValue || !this.courses) { + this.filteredCourses = this.courses; + } else { + // Use displayname if avalaible, or fullname if not. + if (this.courses.length > 0 && typeof this.courses[0].displayname != 'undefined') { + this.filteredCourses = this.courses.filter((course) => { + return course.displayname.toLowerCase().indexOf(newValue) > -1; + }); + } else { + this.filteredCourses = this.courses.filter((course) => { + return course.fullname.toLowerCase().indexOf(newValue) > -1; + }); + } + } + } + + /** + * Prefetch all the courses. + * + * @return {Promise} Promise resolved when done. + */ + prefetchCourses(): Promise { + const initialIcon = this.prefetchCoursesData.icon; + + this.prefetchCoursesData.icon = 'spinner'; + this.prefetchCoursesData.badge = ''; + + return this.courseHelper.confirmAndPrefetchCourses(this.courses, (progress) => { + this.prefetchCoursesData.badge = progress.count + ' / ' + progress.total; + }).then(() => { + this.prefetchCoursesData.icon = 'ion-android-refresh'; + }).catch((error) => { + if (!this.isDestroyed) { + this.domUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); + this.prefetchCoursesData.icon = initialIcon; + } + }).finally(() => { + this.prefetchCoursesData.badge = ''; + }); + } + + /** + * Initialize the prefetch icon for the list of courses. + */ + protected initPrefetchCoursesIcon(): void { + if (this.prefetchIconInitialized || !this.downloadAllCoursesEnabled) { + // Already initialized. + return; + } + + this.prefetchIconInitialized = true; + + if (!this.courses || this.courses.length < 2) { + // Not enough courses. + this.prefetchCoursesData.icon = ''; + + return; + } + + this.courseHelper.determineCoursesStatus(this.courses).then((status) => { + let icon = this.courseHelper.getCourseStatusIconAndTitleFromStatus(status).icon; + if (icon == 'spinner') { + // It seems all courses are being downloaded, show a download button instead. + icon = 'cloud-download'; + } + this.prefetchCoursesData.icon = icon; + }); + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.isDestroyed = true; + this.myCoursesObserver && this.myCoursesObserver.off(); + this.siteUpdatedObserver && this.siteUpdatedObserver.off(); + } +} diff --git a/src/core/courses/courses.module.ts b/src/core/courses/courses.module.ts index 81f632e9f..a166ad5db 100644 --- a/src/core/courses/courses.module.ts +++ b/src/core/courses/courses.module.ts @@ -20,8 +20,11 @@ import { CoreCoursesDashboardProvider } from './providers/dashboard'; import { CoreCoursesCourseLinkHandler } from './providers/course-link-handler'; import { CoreCoursesIndexLinkHandler } from './providers/courses-index-link-handler'; import { CoreCoursesDashboardLinkHandler } from './providers/dashboard-link-handler'; +import { CoreCoursesEnrolPushClickHandler } from './providers/enrol-push-click-handler'; +import { CoreCoursesRequestPushClickHandler } from './providers/request-push-click-handler'; import { CoreMainMenuDelegate } from '@core/mainmenu/providers/delegate'; import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; +import { CorePushNotificationsDelegate } from '@core/pushnotifications/providers/delegate'; // List of providers (without handlers). export const CORE_COURSES_PROVIDERS: any[] = [ @@ -41,18 +44,24 @@ export const CORE_COURSES_PROVIDERS: any[] = [ CoreDashboardMainMenuHandler, CoreCoursesCourseLinkHandler, CoreCoursesIndexLinkHandler, - CoreCoursesDashboardLinkHandler + CoreCoursesDashboardLinkHandler, + CoreCoursesEnrolPushClickHandler, + CoreCoursesRequestPushClickHandler ], exports: [] }) export class CoreCoursesModule { constructor(mainMenuDelegate: CoreMainMenuDelegate, contentLinksDelegate: CoreContentLinksDelegate, mainMenuHandler: CoreDashboardMainMenuHandler, courseLinkHandler: CoreCoursesCourseLinkHandler, - indexLinkHandler: CoreCoursesIndexLinkHandler, dashboardLinkHandler: CoreCoursesDashboardLinkHandler) { + indexLinkHandler: CoreCoursesIndexLinkHandler, dashboardLinkHandler: CoreCoursesDashboardLinkHandler, + pushNotificationsDelegate: CorePushNotificationsDelegate, enrolPushClickHandler: CoreCoursesEnrolPushClickHandler, + requestPushClickHandler: CoreCoursesRequestPushClickHandler) { mainMenuDelegate.registerHandler(mainMenuHandler); contentLinksDelegate.registerHandler(courseLinkHandler); contentLinksDelegate.registerHandler(indexLinkHandler); contentLinksDelegate.registerHandler(dashboardLinkHandler); + pushNotificationsDelegate.registerClickHandler(enrolPushClickHandler); + pushNotificationsDelegate.registerClickHandler(requestPushClickHandler); } } diff --git a/src/core/courses/pages/course-preview/course-preview.html b/src/core/courses/pages/course-preview/course-preview.html index 3dc0e4701..e379c0b21 100644 --- a/src/core/courses/pages/course-preview/course-preview.html +++ b/src/core/courses/pages/course-preview/course-preview.html @@ -32,6 +32,16 @@ + + + +
+ : + +
+
+
+
@@ -48,7 +58,8 @@

{{ 'core.courses.notenrollable' | translate }}

- + +

{{ 'core.course.downloadcourse' | translate }}

diff --git a/src/core/courses/pages/course-preview/course-preview.scss b/src/core/courses/pages/course-preview/course-preview.scss index 3994f5cc9..9b34624df 100644 --- a/src/core/courses/pages/course-preview/course-preview.scss +++ b/src/core/courses/pages/course-preview/course-preview.scss @@ -15,4 +15,8 @@ ion-app.app-root page-core-courses-course-preview { width: 100%; } } + .core-customfieldvalue core-format-text { + display: inline; + } + } diff --git a/src/core/courses/pages/course-preview/course-preview.ts b/src/core/courses/pages/course-preview/course-preview.ts index 51d2253e4..6e1569c2d 100644 --- a/src/core/courses/pages/course-preview/course-preview.ts +++ b/src/core/courses/pages/course-preview/course-preview.ts @@ -44,6 +44,7 @@ export class CoreCoursesCoursePreviewPage implements OnDestroy { dataLoaded: boolean; avoidOpenCourse = false; prefetchCourseData = { + downloadSucceeded: false, prefetchCourseIcon: 'spinner', title: 'core.course.downloadcourse' }; @@ -81,7 +82,7 @@ export class CoreCoursesCoursePreviewPage implements OnDestroy { if (this.downloadCourseEnabled) { // Listen for status change in course. this.courseStatusObserver = this.eventsProvider.on(CoreEventsProvider.COURSE_STATUS_CHANGED, (data) => { - if (data.courseId == this.course.id) { + if (data.courseId == this.course.id || data.courseId == CoreCourseProvider.ALL_COURSES_CLEARED) { this.updateCourseStatus(data.status); } }, this.sitesProvider.getCurrentSiteId()); @@ -233,6 +234,18 @@ export class CoreCoursesCoursePreviewPage implements OnDestroy { this.canAccessCourse = false; }); }); + }).finally(() => { + if (!this.sitesProvider.getCurrentSite().isVersionGreaterEqualThan('3.7')) { + return this.coursesProvider.isGetCoursesByFieldAvailableInSite().then((available) => { + if (available) { + return this.coursesProvider.getCourseByField('id', this.course.id).then((course) => { + this.course.customfields = course.customfields; + }); + } + }).catch(() => { + // Ignore errors. + }); + } }).finally(() => { this.dataLoaded = true; }); @@ -386,6 +399,9 @@ export class CoreCoursesCoursePreviewPage implements OnDestroy { promises.push(this.coursesProvider.invalidateCourse(this.course.id)); promises.push(this.coursesProvider.invalidateCourseEnrolmentMethods(this.course.id)); promises.push(this.courseOptionsDelegate.clearAndInvalidateCoursesOptions(this.course.id)); + if (this.sitesProvider.getCurrentSite().isVersionGreaterEqualThan('3.7')) { + promises.push(this.coursesProvider.invalidateCoursesByField('id', this.course.id)); + } if (this.guestInstanceId) { promises.push(this.coursesProvider.invalidateCourseGuestEnrolmentInfo(this.guestInstanceId)); } diff --git a/src/core/courses/pages/dashboard/dashboard.html b/src/core/courses/pages/dashboard/dashboard.html index d648f0cbe..f9eff8228 100644 --- a/src/core/courses/pages/dashboard/dashboard.html +++ b/src/core/courses/pages/dashboard/dashboard.html @@ -7,7 +7,12 @@ - + + + + + + @@ -46,5 +51,17 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/core/courses/pages/dashboard/dashboard.ts b/src/core/courses/pages/dashboard/dashboard.ts index fccd91e7b..94bd5243a 100644 --- a/src/core/courses/pages/dashboard/dashboard.ts +++ b/src/core/courses/pages/dashboard/dashboard.ts @@ -24,6 +24,7 @@ import { CoreSiteHomeProvider } from '@core/sitehome/providers/sitehome'; import { CoreSiteHomeIndexComponent } from '@core/sitehome/components/index/index'; import { CoreCoursesProvider } from '../../providers/courses'; import { CoreCoursesDashboardProvider } from '../../providers/dashboard'; +import { CoreCoursesMyCoursesComponent } from '../../components/my-courses/my-courses'; /** * Page that displays the dashboard. @@ -37,6 +38,7 @@ export class CoreCoursesDashboardPage implements OnDestroy { @ViewChild(CoreTabsComponent) tabsComponent: CoreTabsComponent; @ViewChild(CoreSiteHomeIndexComponent) siteHomeComponent: CoreSiteHomeIndexComponent; @ViewChildren(CoreBlockComponent) blocksComponents: QueryList; + @ViewChild(CoreCoursesMyCoursesComponent) mcComponent: CoreCoursesMyCoursesComponent; firstSelectedTab: number; siteHomeEnabled = false; @@ -131,7 +133,7 @@ export class CoreCoursesDashboardPage implements OnDestroy { * Load the site name. */ protected loadSiteName(): void { - this.siteName = this.sitesProvider.getCurrentSite().getInfo().sitename; + this.siteName = this.sitesProvider.getCurrentSite().getSiteName(); } /** @@ -140,8 +142,8 @@ export class CoreCoursesDashboardPage implements OnDestroy { * @return {Promise} Promise resolved when done. */ protected loadDashboardContent(): Promise { - return this.dashboardProvider.isAvailable().then((enabled) => { - if (enabled) { + return this.dashboardProvider.isAvailable().then((available) => { + if (available) { this.userId = this.sitesProvider.getCurrentSiteUserId(); return this.dashboardProvider.getDashboardBlocks().then((blocks) => { @@ -152,10 +154,14 @@ export class CoreCoursesDashboardPage implements OnDestroy { // Cannot get the blocks, just show dashboard if needed. this.loadFallbackBlocks(); }); + } else if (!this.dashboardProvider.isDisabledInSite()) { + // Not available, but not disabled either. Use fallback. + this.loadFallbackBlocks(); + } else { + // Disabled. + this.blocks = []; } - // Not enabled, check separated tabs. - this.loadFallbackBlocks(); }).finally(() => { this.dashboardEnabled = this.blockDelegate.hasSupportedBlock(this.blocks); this.dashboardLoaded = true; @@ -186,6 +192,26 @@ export class CoreCoursesDashboardPage implements OnDestroy { }); } + /** + * Refresh the dashboard data and My Courses. + * + * @param {any} refresher Refresher. + */ + refreshMyCourses(refresher: any): void { + // First of all, refresh dashboard blocks, maybe a new block was added and now we can display the dashboard. + this.dashboardProvider.invalidateDashboardBlocks().finally(() => { + return this.loadDashboardContent(); + }).finally(() => { + if (!this.dashboardEnabled) { + // Dashboard still not enabled. Refresh my courses. + this.mcComponent && this.mcComponent.refreshCourses(refresher); + } else { + this.tabsComponent.selectTab(1); + refresher.complete(); + } + }); + } + /** * Toggle download enabled. */ diff --git a/src/core/courses/pages/my-courses/my-courses.html b/src/core/courses/pages/my-courses/my-courses.html index 2129adec0..c23f5cc59 100644 --- a/src/core/courses/pages/my-courses/my-courses.html +++ b/src/core/courses/pages/my-courses/my-courses.html @@ -3,33 +3,20 @@ {{ 'core.courses.mycourses' | translate }} - - - - + + + - + - - - - - - - - - - - -

{{ 'core.courses.searchcoursesadvice' | translate }}

-
-
+
diff --git a/src/core/courses/pages/my-courses/my-courses.ts b/src/core/courses/pages/my-courses/my-courses.ts index bf874e962..3ca35b8a9 100644 --- a/src/core/courses/pages/my-courses/my-courses.ts +++ b/src/core/courses/pages/my-courses/my-courses.ts @@ -12,15 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnDestroy, ViewChild } from '@angular/core'; -import { IonicPage, Searchbar, NavController } from 'ionic-angular'; -import { CoreEventsProvider } from '@providers/events'; -import { CoreSitesProvider } from '@providers/sites'; -import { CoreDomUtilsProvider } from '@providers/utils/dom'; -import { CoreCoursesProvider } from '../../providers/courses'; -import { CoreCoursesHelperProvider } from '../../providers/helper'; -import { CoreCourseHelperProvider } from '@core/course/providers/helper'; -import { CoreCourseOptionsDelegate } from '@core/course/providers/options-delegate'; +import { Component, ViewChild } from '@angular/core'; +import { IonicPage, NavController } from 'ionic-angular'; +import { CoreCoursesMyCoursesComponent } from '../../components/my-courses/my-courses'; /** * Page that displays the list of courses the user is enrolled in. @@ -30,131 +24,10 @@ import { CoreCourseOptionsDelegate } from '@core/course/providers/options-delega selector: 'page-core-courses-my-courses', templateUrl: 'my-courses.html', }) -export class CoreCoursesMyCoursesPage implements OnDestroy { - @ViewChild('searchbar') searchbar: Searchbar; +export class CoreCoursesMyCoursesPage { + @ViewChild(CoreCoursesMyCoursesComponent) mcComponent: CoreCoursesMyCoursesComponent; - courses: any[]; - filteredCourses: any[]; - searchEnabled: boolean; - filter = ''; - showFilter = false; - coursesLoaded = false; - prefetchCoursesData: any = {}; - downloadAllCoursesEnabled: boolean; - - protected prefetchIconInitialized = false; - protected myCoursesObserver; - protected siteUpdatedObserver; - protected isDestroyed = false; - protected courseIds = ''; - - constructor(private navCtrl: NavController, private coursesProvider: CoreCoursesProvider, - private domUtils: CoreDomUtilsProvider, private eventsProvider: CoreEventsProvider, - private sitesProvider: CoreSitesProvider, private courseHelper: CoreCourseHelperProvider, - private courseOptionsDelegate: CoreCourseOptionsDelegate, private coursesHelper: CoreCoursesHelperProvider) { } - - /** - * View loaded. - */ - ionViewDidLoad(): void { - this.searchEnabled = !this.coursesProvider.isSearchCoursesDisabledInSite(); - this.downloadAllCoursesEnabled = !this.coursesProvider.isDownloadCoursesDisabledInSite(); - - this.fetchCourses().finally(() => { - this.coursesLoaded = true; - }); - - this.myCoursesObserver = this.eventsProvider.on(CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, () => { - this.fetchCourses(); - }, this.sitesProvider.getCurrentSiteId()); - - // Refresh the enabled flags if site is updated. - this.siteUpdatedObserver = this.eventsProvider.on(CoreEventsProvider.SITE_UPDATED, () => { - const wasEnabled = this.downloadAllCoursesEnabled; - - this.searchEnabled = !this.coursesProvider.isSearchCoursesDisabledInSite(); - this.downloadAllCoursesEnabled = !this.coursesProvider.isDownloadCoursesDisabledInSite(); - - if (!wasEnabled && this.downloadAllCoursesEnabled && this.coursesLoaded) { - // Download all courses is enabled now, initialize it. - this.initPrefetchCoursesIcon(); - } - }, this.sitesProvider.getCurrentSiteId()); - } - - /** - * Fetch the user courses. - * - * @return {Promise} Promise resolved when done. - */ - protected fetchCourses(): Promise { - return this.coursesProvider.getUserCourses().then((courses) => { - const promises = [], - courseIds = courses.map((course) => { - return course.id; - }); - - this.courseIds = courseIds.join(','); - - promises.push(this.coursesHelper.loadCoursesExtraInfo(courses)); - - if (this.coursesProvider.canGetAdminAndNavOptions()) { - promises.push(this.coursesProvider.getCoursesAdminAndNavOptions(courseIds).then((options) => { - courses.forEach((course) => { - course.navOptions = options.navOptions[course.id]; - course.admOptions = options.admOptions[course.id]; - }); - })); - } - - return Promise.all(promises).then(() => { - this.courses = courses; - this.filteredCourses = this.courses; - this.filter = ''; - - this.initPrefetchCoursesIcon(); - }); - }).catch((error) => { - this.domUtils.showErrorModalDefault(error, 'core.courses.errorloadcourses', true); - }); - } - - /** - * Refresh the courses. - * - * @param {any} refresher Refresher. - */ - refreshCourses(refresher: any): void { - const promises = []; - - promises.push(this.coursesProvider.invalidateUserCourses()); - promises.push(this.courseOptionsDelegate.clearAndInvalidateCoursesOptions()); - if (this.courseIds) { - promises.push(this.coursesProvider.invalidateCoursesByField('ids', this.courseIds)); - } - - Promise.all(promises).finally(() => { - - this.prefetchIconInitialized = false; - this.fetchCourses().finally(() => { - refresher.complete(); - }); - }); - } - - /** - * Show or hide the filter. - */ - switchFilter(): void { - this.filter = ''; - this.showFilter = !this.showFilter; - this.filteredCourses = this.courses; - if (this.showFilter) { - setTimeout(() => { - this.searchbar.setFocus(); - }, 500); - } - } + constructor(private navCtrl: NavController) { } /** * Go to search courses. @@ -162,89 +35,4 @@ export class CoreCoursesMyCoursesPage implements OnDestroy { openSearch(): void { this.navCtrl.push('CoreCoursesSearchPage'); } - - /** - * The filter has changed. - * - * @param {any} Received Event. - */ - filterChanged(event: any): void { - const newValue = event.target.value && event.target.value.trim().toLowerCase(); - if (!newValue || !this.courses) { - this.filteredCourses = this.courses; - } else { - // Use displayname if avalaible, or fullname if not. - if (this.courses.length > 0 && typeof this.courses[0].displayname != 'undefined') { - this.filteredCourses = this.courses.filter((course) => { - return course.displayname.toLowerCase().indexOf(newValue) > -1; - }); - } else { - this.filteredCourses = this.courses.filter((course) => { - return course.fullname.toLowerCase().indexOf(newValue) > -1; - }); - } - } - } - - /** - * Prefetch all the courses. - * - * @return {Promise} Promise resolved when done. - */ - prefetchCourses(): Promise { - const initialIcon = this.prefetchCoursesData.icon; - - this.prefetchCoursesData.icon = 'spinner'; - this.prefetchCoursesData.badge = ''; - - return this.courseHelper.confirmAndPrefetchCourses(this.courses, (progress) => { - this.prefetchCoursesData.badge = progress.count + ' / ' + progress.total; - }).then(() => { - this.prefetchCoursesData.icon = 'ion-android-refresh'; - }).catch((error) => { - if (!this.isDestroyed) { - this.domUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); - this.prefetchCoursesData.icon = initialIcon; - } - }).finally(() => { - this.prefetchCoursesData.badge = ''; - }); - } - - /** - * Initialize the prefetch icon for the list of courses. - */ - protected initPrefetchCoursesIcon(): void { - if (this.prefetchIconInitialized || !this.downloadAllCoursesEnabled) { - // Already initialized. - return; - } - - this.prefetchIconInitialized = true; - - if (!this.courses || this.courses.length < 2) { - // Not enough courses. - this.prefetchCoursesData.icon = ''; - - return; - } - - this.courseHelper.determineCoursesStatus(this.courses).then((status) => { - let icon = this.courseHelper.getCourseStatusIconAndTitleFromStatus(status).icon; - if (icon == 'spinner') { - // It seems all courses are being downloaded, show a download button instead. - icon = 'cloud-download'; - } - this.prefetchCoursesData.icon = icon; - }); - } - - /** - * Page destroyed. - */ - ngOnDestroy(): void { - this.isDestroyed = true; - this.myCoursesObserver && this.myCoursesObserver.off(); - this.siteUpdatedObserver && this.siteUpdatedObserver.off(); - } } diff --git a/src/core/courses/providers/course-link-handler.ts b/src/core/courses/providers/course-link-handler.ts index 092ac130d..010dcdbb2 100644 --- a/src/core/courses/providers/course-link-handler.ts +++ b/src/core/courses/providers/course-link-handler.ts @@ -19,8 +19,8 @@ import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; -import { CoreLoginHelperProvider } from '@core/login/providers/helper'; import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; import { CoreCoursesProvider } from './courses'; /** @@ -34,9 +34,9 @@ export class CoreCoursesCourseLinkHandler extends CoreContentLinksHandlerBase { protected waitStart = 0; constructor(private sitesProvider: CoreSitesProvider, private coursesProvider: CoreCoursesProvider, - private loginHelper: CoreLoginHelperProvider, private domUtils: CoreDomUtilsProvider, + private domUtils: CoreDomUtilsProvider, private translate: TranslateService, private courseProvider: CoreCourseProvider, - private textUtils: CoreTextUtilsProvider) { + private textUtils: CoreTextUtilsProvider, private courseHelper: CoreCourseHelperProvider) { super(); } @@ -55,7 +55,6 @@ export class CoreCoursesCourseLinkHandler extends CoreContentLinksHandlerBase { const sectionId = params.sectionid ? parseInt(params.sectionid, 10) : null, pageParams: any = { - course: { id: courseId }, sectionId: sectionId || null }; let sectionNumber = typeof params.section != 'undefined' ? parseInt(params.section, 10) : NaN; @@ -80,8 +79,8 @@ export class CoreCoursesCourseLinkHandler extends CoreContentLinksHandlerBase { // Ignore errors. }); } else { - // Use redirect to make the course the new history root (to avoid "loops" in history). - this.loginHelper.redirect('CoreCourseSectionPage', pageParams, siteId); + // Don't pass the navCtrl to make the course the new history root (to avoid "loops" in history). + this.courseHelper.getAndOpenCourse(undefined, courseId, pageParams, siteId); } } }]; @@ -121,9 +120,12 @@ export class CoreCoursesCourseLinkHandler extends CoreContentLinksHandlerBase { protected actionEnrol(courseId: number, url: string, pageParams: any): Promise { const modal = this.domUtils.showModalLoading(), isEnrolUrl = !!url.match(/(\/enrol\/index\.php)|(\/course\/enrol\.php)/); + let course; // Check if user is enrolled in the course. - return this.coursesProvider.getUserCourse(courseId).catch(() => { + return this.coursesProvider.getUserCourse(courseId).then((courseObj) => { + course = courseObj; + }).catch(() => { // User is not enrolled in the course. Check if can self enrol. return this.canSelfEnrol(courseId).then(() => { modal.dismiss(); @@ -134,7 +136,9 @@ export class CoreCoursesCourseLinkHandler extends CoreContentLinksHandlerBase { return promise.then(() => { // Enrol URL or user confirmed. - return this.selfEnrol(courseId).catch((error) => { + return this.selfEnrol(courseId).then((courseObj) => { + course = courseObj; + }).catch((error) => { if (error) { this.domUtils.showErrorModal(error); } @@ -170,10 +174,22 @@ export class CoreCoursesCourseLinkHandler extends CoreContentLinksHandlerBase { }); }); }).then(() => { + // Check if we need to retrieve the course. + if (!course) { + return this.courseHelper.getCourse(courseId).then((data) => { + return data.course; + }).catch(() => { + // Cannot get course, return a "fake". + return { id: courseId }; + }); + } + + return course; + }).then((course) => { modal.dismiss(); - // Use redirect to make the course the new history root (to avoid "loops" in history). - this.loginHelper.redirect('CoreCourseSectionPage', pageParams, this.sitesProvider.getCurrentSiteId()); + // Now open the course. + this.courseHelper.openCourse(undefined, course, pageParams); }); } diff --git a/src/core/courses/providers/courses.ts b/src/core/courses/providers/courses.ts index 2760b2f0c..c50cc9656 100644 --- a/src/core/courses/providers/courses.ts +++ b/src/core/courses/providers/courses.ts @@ -63,7 +63,8 @@ export class CoreCoursesProvider { addsubcategories: addSubcategories ? 1 : 0 }, preSets = { - cacheKey: this.getCategoriesCacheKey(categoryId, addSubcategories) + cacheKey: this.getCategoriesCacheKey(categoryId, addSubcategories), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('core_course_get_categories', data, preSets); @@ -166,7 +167,7 @@ export class CoreCoursesProvider { isDownloadCourseDisabledInSite(site?: CoreSite): boolean { site = site || this.sitesProvider.getCurrentSite(); - return site.isFeatureDisabled('NoDelegate_CoreCourseDownload'); + return site.isOfflineDisabled() || site.isFeatureDisabled('NoDelegate_CoreCourseDownload'); } /** @@ -190,7 +191,7 @@ export class CoreCoursesProvider { isDownloadCoursesDisabledInSite(site?: CoreSite): boolean { site = site || this.sitesProvider.getCurrentSite(); - return site.isFeatureDisabled('NoDelegate_CoreCoursesDownload'); + return site.isOfflineDisabled() || site.isFeatureDisabled('NoDelegate_CoreCoursesDownload'); } /** @@ -271,7 +272,8 @@ export class CoreCoursesProvider { courseid: id }, preSets = { - cacheKey: this.getCourseEnrolmentMethodsCacheKey(id) + cacheKey: this.getCourseEnrolmentMethodsCacheKey(id), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('core_enrol_get_course_enrolment_methods', params, preSets); @@ -301,7 +303,8 @@ export class CoreCoursesProvider { instanceid: instanceId }, preSets = { - cacheKey: this.getCourseGuestEnrolmentInfoCacheKey(instanceId) + cacheKey: this.getCourseGuestEnrolmentInfoCacheKey(instanceId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('enrol_guest_get_instance_info', params, preSets).then((response) => { @@ -343,7 +346,8 @@ export class CoreCoursesProvider { } }, preSets = { - cacheKey: this.getCoursesCacheKey(ids) + cacheKey: this.getCoursesCacheKey(ids), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('core_course_get_courses', data, preSets); @@ -445,7 +449,8 @@ export class CoreCoursesProvider { value: field ? value : '' }, preSets = { - cacheKey: this.getCoursesByFieldCacheKey(field, value) + cacheKey: this.getCoursesByFieldCacheKey(field, value), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('core_course_get_courses_by_field', data, preSets).then((courses) => { @@ -604,7 +609,8 @@ export class CoreCoursesProvider { courseids: courseIds }, preSets = { - cacheKey: this.getUserAdministrationOptionsCacheKey(courseIds) + cacheKey: this.getUserAdministrationOptionsCacheKey(courseIds), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('core_course_get_user_administration_options', params, preSets).then((response) => { @@ -650,7 +656,8 @@ export class CoreCoursesProvider { courseids: courseIds }, preSets = { - cacheKey: this.getUserNavigationOptionsCacheKey(courseIds) + cacheKey: this.getUserNavigationOptionsCacheKey(courseIds), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('core_course_get_user_navigation_options', params, preSets).then((response) => { @@ -722,14 +729,20 @@ export class CoreCoursesProvider { return this.sitesProvider.getSite(siteId).then((site) => { const userId = site.getUserId(), - data = { + data: any = { userid: userId }, preSets = { cacheKey: this.getUserCoursesCacheKey(), - omitExpires: !!preferCache + getCacheUsingCacheKey: true, + omitExpires: !!preferCache, + updateFrequency: CoreSite.FREQUENCY_RARELY }; + if (site.isVersionGreaterEqualThan('3.7')) { + data.returnusercount = 0; + } + return site.read('core_enrol_get_users_courses', data, preSets); }); } diff --git a/src/core/courses/providers/dashboard-link-handler.ts b/src/core/courses/providers/dashboard-link-handler.ts index bd325c80e..26f00164e 100644 --- a/src/core/courses/providers/dashboard-link-handler.ts +++ b/src/core/courses/providers/dashboard-link-handler.ts @@ -16,6 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; import { CoreLoginHelperProvider } from '@core/login/providers/helper'; +import { CoreDashboardMainMenuHandler } from './mainmenu-handler'; /** * Handler to treat links to my overview. @@ -23,10 +24,9 @@ import { CoreLoginHelperProvider } from '@core/login/providers/helper'; @Injectable() export class CoreCoursesDashboardLinkHandler extends CoreContentLinksHandlerBase { name = 'CoreCoursesMyOverviewLinkHandler'; - featureName = 'CoreMainMenuDelegate_CoreCourses'; pattern = /\/my\/?$/; - constructor(private loginHelper: CoreLoginHelperProvider) { + constructor(private loginHelper: CoreLoginHelperProvider, private mainMenuHandler: CoreDashboardMainMenuHandler) { super(); } @@ -48,4 +48,17 @@ export class CoreCoursesDashboardLinkHandler extends CoreContentLinksHandlerBase } }]; } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * + * @param {string} siteId The site ID. + * @param {string} url The URL to treat. + * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @return {boolean|Promise} Whether the handler is enabled for the URL and site. + */ + isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { + return this.mainMenuHandler.isEnabledForSite(siteId); + } } diff --git a/src/core/courses/providers/dashboard.ts b/src/core/courses/providers/dashboard.ts index 7270bf539..24ac80e2c 100644 --- a/src/core/courses/providers/dashboard.ts +++ b/src/core/courses/providers/dashboard.ts @@ -14,6 +14,7 @@ import { Injectable } from '@angular/core'; import { CoreSitesProvider } from '@providers/sites'; +import { CoreSite } from '@classes/site'; /** * Service that provides some features regarding course overview. @@ -48,7 +49,8 @@ export class CoreCoursesDashboardProvider { const params = { }, preSets = { - cacheKey: this.getDashboardBlocksCacheKey(userId) + cacheKey: this.getDashboardBlocksCacheKey(userId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (userId) { @@ -83,7 +85,36 @@ export class CoreCoursesDashboardProvider { */ isAvailable(siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { + // First check if it's disabled. + if (this.isDisabledInSite(site)) { + return false; + } + return site.wsAvailable('core_block_get_dashboard_blocks'); }); } + + /** + * Check if Site Home is disabled in a certain site. + * + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + isDisabled(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.isDisabledInSite(site); + }); + } + + /** + * Check if Site Home is disabled in a certain site. + * + * @param {CoreSite} [site] Site. If not defined, use current site. + * @return {boolean} Whether it's disabled. + */ + isDisabledInSite(site?: CoreSite): boolean { + site = site || this.sitesProvider.getCurrentSite(); + + return site.isFeatureDisabled('CoreMainMenuDelegate_CoreCoursesDashboard'); + } } diff --git a/src/core/courses/providers/enrol-push-click-handler.ts b/src/core/courses/providers/enrol-push-click-handler.ts new file mode 100644 index 000000000..795b0c603 --- /dev/null +++ b/src/core/courses/providers/enrol-push-click-handler.ts @@ -0,0 +1,79 @@ +// (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 { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CorePushNotificationsClickHandler } from '@core/pushnotifications/providers/delegate'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { CoreLoginHelperProvider } from '@core/login/providers/helper'; + +/** + * Handler for enrol push notifications clicks. + */ +@Injectable() +export class CoreCoursesEnrolPushClickHandler implements CorePushNotificationsClickHandler { + name = 'CoreCoursesEnrolPushClickHandler'; + priority = 200; + + constructor(private utils: CoreUtilsProvider, private domUtils: CoreDomUtilsProvider, + private courseHelper: CoreCourseHelperProvider, private loginHelper: CoreLoginHelperProvider) {} + + /** + * Check if a notification click is handled by this handler. + * + * @param {any} notification The notification to check. + * @return {boolean} Whether the notification click is handled by this handler + */ + handles(notification: any): boolean | Promise { + return this.utils.isTrueOrOne(notification.notif) && notification.moodlecomponent.indexOf('enrol_') === 0 && + notification.name == 'expiry_notification'; + } + + /** + * Handle the notification click. + * + * @param {any} notification The notification to check. + * @return {Promise} Promise resolved when done. + */ + handleClick(notification: any): Promise { + const courseId = Number(notification.courseid), + modal = this.domUtils.showModalLoading(); + + return this.courseHelper.getCourse(courseId, notification.site).then((result) => { + const params: any = { + course: result.course + }; + let page; + + if (notification.contexturl && notification.contexturl.indexOf('user/index.php') != -1) { + // Open the participants tab. + page = 'CoreCourseSectionPage'; + params.selectedTab = 'CoreUserParticipants'; + } else if (result.enrolled) { + // User is still enrolled, open the course. + page = 'CoreCourseSectionPage'; + } else { + // User not enrolled anymore, open the preview page. + page = 'CoreCoursesCoursePreviewPage'; + } + + return this.loginHelper.redirect(page, params, notification.site); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting course.'); + }).finally(() => { + modal.dismiss(); + }); + } +} diff --git a/src/core/courses/providers/mainmenu-handler.ts b/src/core/courses/providers/mainmenu-handler.ts index 6f4679e48..4b32ced23 100644 --- a/src/core/courses/providers/mainmenu-handler.ts +++ b/src/core/courses/providers/mainmenu-handler.ts @@ -13,22 +13,25 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { CoreSitesProvider } from '@providers/sites'; import { CoreCoursesProvider } from './courses'; import { CoreMainMenuHandler, CoreMainMenuHandlerData } from '@core/mainmenu/providers/delegate'; import { CoreCoursesDashboardProvider } from '../providers/dashboard'; import { CoreSiteHomeProvider } from '@core/sitehome/providers/sitehome'; import { AddonBlockTimelineProvider } from '@addon/block/timeline/providers/timeline'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; /** * Handler to add Dashboard into main menu. */ @Injectable() export class CoreDashboardMainMenuHandler implements CoreMainMenuHandler { - name = 'CoreDashboard'; // Old name CoreCourses cannot be used because it would be all disabled by site. + name = 'CoreHome'; // This handler contains several different features, so we use a generic name like "CoreHome". priority = 1100; constructor(private coursesProvider: CoreCoursesProvider, private dashboardProvider: CoreCoursesDashboardProvider, - private siteHomeProvider: CoreSiteHomeProvider, private timelineProvider: AddonBlockTimelineProvider) { } + private siteHomeProvider: CoreSiteHomeProvider, private timelineProvider: AddonBlockTimelineProvider, + private blockDelegate: CoreBlockDelegate, private sitesProvider: CoreSitesProvider) { } /** * Check if the handler is enabled on a site level. @@ -36,15 +39,40 @@ export class CoreDashboardMainMenuHandler implements CoreMainMenuHandler { * @return {boolean | Promise} Whether or not the handler is enabled on a site level. */ isEnabled(): boolean | Promise { + return this.isEnabledForSite(); + } + + /** + * Check if the handler is enabled on a certain site. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {boolean | Promise} Whether or not the handler is enabled on a site level. + */ + isEnabledForSite(siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const promises = []; + let blocksEnabled, + dashboardAvailable; + + // Check if blocks and 3.6 dashboard is enabled. + promises.push(this.blockDelegate.areBlocksDisabled(siteId).then((disabled) => { + blocksEnabled = !disabled; + })); + + promises.push(this.dashboardProvider.isAvailable().then((available) => { + dashboardAvailable = available; + })); + // Check if 3.6 dashboard is enabled. - return this.dashboardProvider.isAvailable().then((enabled) => { - if (enabled) { + return Promise.all(promises).then(() => { + if (dashboardAvailable && blocksEnabled) { return true; } // Check if my overview is enabled. return this.timelineProvider.isAvailable().then((enabled) => { - if (enabled) { + if (enabled && blocksEnabled) { return true; } diff --git a/src/core/courses/providers/request-push-click-handler.ts b/src/core/courses/providers/request-push-click-handler.ts new file mode 100644 index 000000000..af8688605 --- /dev/null +++ b/src/core/courses/providers/request-push-click-handler.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 { CoreSitesProvider } from '@providers/sites'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CorePushNotificationsClickHandler } from '@core/pushnotifications/providers/delegate'; +import { CoreCoursesProvider } from '@core/courses/providers/courses'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { CoreLoginHelperProvider } from '@core/login/providers/helper'; + +/** + * Handler for course request push notifications clicks. + */ +@Injectable() +export class CoreCoursesRequestPushClickHandler implements CorePushNotificationsClickHandler { + name = 'CoreCoursesRequestPushClickHandler'; + priority = 200; + + protected SUPPORTED_NAMES = ['courserequested', 'courserequestapproved', 'courserequestrejected']; + + constructor(private utils: CoreUtilsProvider, private domUtils: CoreDomUtilsProvider, private sitesProvider: CoreSitesProvider, + private courseHelper: CoreCourseHelperProvider, private loginHelper: CoreLoginHelperProvider, + private textUtils: CoreTextUtilsProvider, private coursesProvider: CoreCoursesProvider) {} + + /** + * Check if a notification click is handled by this handler. + * + * @param {any} notification The notification to check. + * @return {boolean} Whether the notification click is handled by this handler + */ + handles(notification: any): boolean | Promise { + // Don't support 'courserequestrejected', that way the app will open the notifications page. + return this.utils.isTrueOrOne(notification.notif) && notification.moodlecomponent == 'moodle' && + (notification.name == 'courserequested' || notification.name == 'courserequestapproved'); + } + + /** + * Handle the notification click. + * + * @param {any} notification The notification to check. + * @return {Promise} Promise resolved when done. + */ + handleClick(notification: any): Promise { + const courseId = Number(notification.courseid); + + if (notification.name == 'courserequested') { + // Feature not supported in the app, open in browser. + return this.sitesProvider.getSite(notification.site).then((site) => { + const url = this.textUtils.concatenatePaths(site.getURL(), 'course/pending.php'); + + return site.openInBrowserWithAutoLogin(url); + }); + } else { + // Open the course. + const modal = this.domUtils.showModalLoading(); + + return this.coursesProvider.invalidateUserCourses(notification.site).catch(() => { + // Ignore errors. + }).then(() => { + return this.courseHelper.getCourse(courseId, notification.site); + }).then((result) => { + const params: any = { + course: result.course + }; + let page; + + if (result.enrolled) { + // User is still enrolled, open the course. + page = 'CoreCourseSectionPage'; + } else { + // User not enrolled (shouldn't happen), open the preview page. + page = 'CoreCoursesCoursePreviewPage'; + } + + return this.loginHelper.redirect(page, params, notification.site); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'Error getting course.'); + }).finally(() => { + modal.dismiss(); + }); + } + } +} diff --git a/src/core/emulator/classes/inappbrowserobject.ts b/src/core/emulator/classes/inappbrowserobject.ts index 95b23898f..1f45c7ba1 100644 --- a/src/core/emulator/classes/inappbrowserobject.ts +++ b/src/core/emulator/classes/inappbrowserobject.ts @@ -224,31 +224,38 @@ export class InAppBrowserObjectMock { } }; - switch (name) { - case 'loadstart': - this.window.webContents.on('did-start-loading', received); + if (!this.window.isDestroyed() && !this.window.webContents.isDestroyed()) { + switch (name) { + case 'loadstart': + this.window.webContents.on('did-start-loading', received); - if (this.isSSO) { - // Linux doesn't support custom URL Schemes. Check if launch page is loaded. - this.window.webContents.on('did-finish-load', finishLoad); - } - break; + if (this.isSSO) { + // Linux doesn't support custom URL Schemes. Check if launch page is loaded. + this.window.webContents.on('did-finish-load', finishLoad); + } + break; - case 'loadstop': - this.window.webContents.on('did-finish-load', received); - break; + case 'loadstop': + this.window.webContents.on('did-finish-load', received); + break; - case 'loaderror': - this.window.webContents.on('did-fail-load', received); - break; - case 'exit': - this.window.on('close', received); - break; - default: + case 'loaderror': + this.window.webContents.on('did-fail-load', received); + break; + case 'exit': + this.window.on('close', received); + break; + default: + } } return (): void => { // Unsubscribing. We need to remove the listeners. + if (this.window.isDestroyed() || this.window.webContents.isDestroyed()) { + // Page has been destroyed already, no need to remove listeners. + return; + } + switch (name) { case 'loadstart': this.window.webContents.removeListener('did-start-loading', received); diff --git a/src/core/emulator/emulator.module.ts b/src/core/emulator/emulator.module.ts index a74809625..fd903e89b 100644 --- a/src/core/emulator/emulator.module.ts +++ b/src/core/emulator/emulator.module.ts @@ -211,7 +211,8 @@ export const IONIC_NATIVE_PROVIDERS = [ ] }) export class CoreEmulatorModule { - constructor(appProvider: CoreAppProvider, initDelegate: CoreInitDelegate, helper: CoreEmulatorHelperProvider) { + constructor(appProvider: CoreAppProvider, initDelegate: CoreInitDelegate, helper: CoreEmulatorHelperProvider, + platform: Platform) { const win = window; // Convert the "window" to "any" type to be able to use non-standard properties. // Emulate Custom URL Scheme plugin in desktop apps. @@ -224,7 +225,7 @@ export class CoreEmulatorModule { // Listen for 'resume' events. require('electron').ipcRenderer.on('coreAppFocused', () => { - document.dispatchEvent(new Event('resume')); + platform.resume.emit(); }); } diff --git a/src/core/emulator/providers/helper.ts b/src/core/emulator/providers/helper.ts index fc10a32a2..fc64ed0eb 100644 --- a/src/core/emulator/providers/helper.ts +++ b/src/core/emulator/providers/helper.ts @@ -161,17 +161,15 @@ export class CoreEmulatorHelperProvider implements CoreInitHandler { // There is a new notification, show it. return getDataFn(notification).then((titleAndText) => { + // Set some calculated data. + notification.site = siteId; + notification.name = notification.name || notification.eventtype; + const localNotif: ILocalNotification = { id: 1, - trigger: { - at: new Date() - }, title: titleAndText.title, text: titleAndText.text, - data: { - notif: notification, - site: siteId - } + data: notification }; return this.localNotifProvider.schedule(localNotif, component, siteId); diff --git a/src/core/emulator/providers/local-notifications.ts b/src/core/emulator/providers/local-notifications.ts index 3b943aba1..73d6ebfc4 100644 --- a/src/core/emulator/providers/local-notifications.ts +++ b/src/core/emulator/providers/local-notifications.ts @@ -630,7 +630,7 @@ export class LocalNotificationsMock extends LocalNotifications { * @return {number} Trigger time. */ protected getNotificationTriggerAt(notification: ILocalNotification): number { - const triggerAt = (notification.trigger && notification.trigger.at) || 0; + const triggerAt = (notification.trigger && notification.trigger.at) || new Date(); if (typeof triggerAt != 'number') { return triggerAt.getTime(); diff --git a/src/core/fileuploader/providers/file-handler.ts b/src/core/fileuploader/providers/file-handler.ts index 4479880f0..36bda2bf4 100644 --- a/src/core/fileuploader/providers/file-handler.ts +++ b/src/core/fileuploader/providers/file-handler.ts @@ -71,6 +71,7 @@ export class CoreFileUploaderFileHandler implements CoreFileUploaderHandler { if (element) { const input = document.createElement('input'); input.setAttribute('type', 'file'); + input.classList.add('core-fileuploader-file-handler-input'); if (mimetypes && mimetypes.length && (!this.platform.is('android') || mimetypes.length == 1)) { // Don't use accept attribute in Android with several mimetypes, it's not supported. input.setAttribute('accept', mimetypes.join(', ')); @@ -112,7 +113,22 @@ export class CoreFileUploaderFileHandler implements CoreFileUploaderHandler { }); }); - element.appendChild(input); + if (this.platform.is('ios')) { + // In iOS, the click on the input stopped working for some reason. We need to put it 1 level higher. + element.parentElement.appendChild(input); + + // Animate the button when the input is clicked. + input.addEventListener('mousedown', () => { + element.classList.add('activated'); + }); + input.addEventListener('mouseup', () => { + this.platform.timeout(() => { + element.classList.remove('activated'); + }, 80); + }); + } else { + element.appendChild(input); + } } } }; diff --git a/src/core/grades/providers/course-option-handler.ts b/src/core/grades/providers/course-option-handler.ts index 7b253f723..ae04b5574 100644 --- a/src/core/grades/providers/course-option-handler.ts +++ b/src/core/grades/providers/course-option-handler.ts @@ -16,7 +16,6 @@ import { Injectable, Injector } from '@angular/core'; import { CoreCourseOptionsHandler, CoreCourseOptionsHandlerData } from '@core/course/providers/options-delegate'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreGradesProvider } from './grades'; -import { CoreGradesHelperProvider } from './helper'; import { CoreCoursesProvider } from '@core/courses/providers/courses'; import { CoreGradesCourseComponent } from '../components/course/course'; @@ -28,8 +27,7 @@ export class CoreGradesCourseOptionHandler implements CoreCourseOptionsHandler { name = 'CoreGrades'; priority = 400; - constructor(private gradesProvider: CoreGradesProvider, private coursesProvider: CoreCoursesProvider, - private gradesHelper: CoreGradesHelperProvider) {} + constructor(private gradesProvider: CoreGradesProvider, private coursesProvider: CoreCoursesProvider) {} /** * Should invalidate the data to determine if the handler is enabled for a certain course. @@ -100,20 +98,6 @@ export class CoreGradesCourseOptionHandler implements CoreCourseOptionsHandler { * @return {Promise} Promise resolved when done. */ prefetch(course: any): Promise { - return this.gradesProvider.getCourseGradesTable(course.id, undefined, undefined, true).then((table) => { - const promises = []; - - table = this.gradesHelper.formatGradesTable(table); - - if (table && table.rows) { - table.rows.forEach((row) => { - if (row.itemtype != 'category') { - promises.push(this.gradesHelper.getGradeItem(course.id, row.id, undefined, undefined, true)); - } - }); - } - - return Promise.all(promises); - }); + return this.gradesProvider.getCourseGradesTable(course.id, undefined, undefined, true); } } diff --git a/src/core/grades/providers/grades.ts b/src/core/grades/providers/grades.ts index 4647b6ed6..59b25b1d2 100644 --- a/src/core/grades/providers/grades.ts +++ b/src/core/grades/providers/grades.ts @@ -16,6 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; import { CoreCoursesProvider } from '@core/courses/providers/courses'; +import { CorePushNotificationsProvider } from '@core/pushnotifications/providers/pushnotifications'; /** * Service to provide grade functionalities. @@ -33,7 +34,7 @@ export class CoreGradesProvider { protected logger; constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, - private coursesProvider: CoreCoursesProvider) { + private coursesProvider: CoreCoursesProvider, protected pushNotificationsProvider: CorePushNotificationsProvider) { this.logger = logger.getInstance('CoreGradesProvider'); } @@ -326,12 +327,25 @@ export class CoreGradesProvider { * * @param {number} courseId Course ID. * @param {number} userId User ID. + * @param {string} [name] Course name. If not set, it will be calculated. * @return {Promise} Promise resolved when done. */ - logCourseGradesView(courseId: number, userId: number): Promise { + logCourseGradesView(courseId: number, userId: number, name?: string): Promise { userId = userId || this.sitesProvider.getCurrentSiteUserId(); - return this.sitesProvider.getCurrentSite().write('gradereport_user_view_grade_report', { + const wsName = 'gradereport_user_view_grade_report'; + + if (!name) { + this.coursesProvider.getUserCourse(courseId, true).catch(() => { + return {}; + }).then((course) => { + this.pushNotificationsProvider.logViewEvent(courseId, course.fullname || '', 'grades', wsName, {userid: userId}); + }); + } else { + this.pushNotificationsProvider.logViewEvent(courseId, name, 'grades', wsName, {userid: userId}); + } + + return this.sitesProvider.getCurrentSite().write(wsName, { courseid: courseId, userid: userId }); @@ -348,8 +362,12 @@ export class CoreGradesProvider { courseId = this.sitesProvider.getCurrentSiteHomeId(); } - return this.sitesProvider.getCurrentSite().write('gradereport_overview_view_grade_report', { + const params = { courseid: courseId - }); + }; + + this.pushNotificationsProvider.logViewListEvent('grades', 'gradereport_overview_view_grade_report', params); + + return this.sitesProvider.getCurrentSite().write('gradereport_overview_view_grade_report', params); } } diff --git a/src/core/grades/providers/helper.ts b/src/core/grades/providers/helper.ts index 8f206340d..5df58c9dd 100644 --- a/src/core/grades/providers/helper.ts +++ b/src/core/grades/providers/helper.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { NavController } from 'ionic-angular'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; import { CoreCoursesProvider } from '@core/courses/providers/courses'; @@ -22,6 +23,9 @@ import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreUrlUtilsProvider } from '@providers/utils/url'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { CoreLoginHelperProvider } from '@core/login/providers/helper'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; /** * Service that provides some features regarding grades information. @@ -33,7 +37,9 @@ export class CoreGradesHelperProvider { constructor(logger: CoreLoggerProvider, private coursesProvider: CoreCoursesProvider, private gradesProvider: CoreGradesProvider, private sitesProvider: CoreSitesProvider, private textUtils: CoreTextUtilsProvider, private courseProvider: CoreCourseProvider, - private domUtils: CoreDomUtilsProvider, private urlUtils: CoreUrlUtilsProvider, private utils: CoreUtilsProvider) { + private domUtils: CoreDomUtilsProvider, private urlUtils: CoreUrlUtilsProvider, private utils: CoreUtilsProvider, + private linkHelper: CoreContentLinksHelperProvider, private loginHelper: CoreLoginHelperProvider, + private courseHelper: CoreCourseHelperProvider) { this.logger = logger.getInstance('CoreGradesHelperProvider'); } @@ -381,6 +387,95 @@ export class CoreGradesHelperProvider { return []; } + /** + * Go to view grades. + * + * @param {number} courseId Course ID t oview. + * @param {number} [userId] User to view. If not defined, current user. + * @param {number} [moduleId] Module to view. If not defined, view all course grades. + * @param {NavController} [navCtrl] NavController to use. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + goToGrades(courseId: number, userId?: number, moduleId?: number, navCtrl?: NavController, siteId?: string): Promise { + + const modal = this.domUtils.showModalLoading(); + let currentUserId; + + return this.sitesProvider.getSite(siteId).then((site) => { + siteId = site.id; + currentUserId = site.getUserId(); + + if (moduleId) { + // Try to open the module grade directly. Check if it's possible. + return this.gradesProvider.isGradeItemsAvalaible(siteId).then((getGrades) => { + if (!getGrades) { + return Promise.reject(null); + } + }); + } else { + return Promise.reject(null); + } + + }).then(() => { + + // Can get grades. Do it. + return this.gradesProvider.getGradeItems(courseId, userId, undefined, siteId).then((items) => { + // Find the item of the module. + const item = items.find((item) => { + return moduleId == item.cmid; + }); + + if (item) { + // Open the item directly. + const pageParams: any = { + courseId: courseId, + userId: userId, + gradeId: item.id + }; + + return this.linkHelper.goInSite(navCtrl, 'CoreGradesGradePage', pageParams, siteId).catch(() => { + // Ignore errors. + }); + } + + return Promise.reject(null); + }); + + }).catch(() => { + + // Cannot get grade items or there's no need to. + if (userId && userId != currentUserId) { + // View another user grades. Open the grades page directly. + const pageParams = { + course: {id: courseId}, + userId: userId + }; + + return this.linkHelper.goInSite(navCtrl, 'CoreGradesCoursePage', pageParams, siteId).catch(() => { + // Ignore errors. + }); + } + + // View own grades. Open the course with the grades tab selected. + return this.courseHelper.getCourse(courseId, siteId).then((result) => { + const pageParams: any = { + course: result.course, + selectedTab: 'CoreGrades' + }; + + return this.loginHelper.redirect('CoreCourseSectionPage', pageParams, siteId).catch(() => { + // Ignore errors. + }); + }); + }).catch(() => { + // Cannot get course for some reason, just open the grades page. + return this.linkHelper.goInSite(navCtrl, 'CoreGradesCoursePage', {course: {id: courseId}}, siteId); + }).finally(() => { + modal.dismiss(); + }); + } + /** * Invalidate the grade items for a certain module. * @@ -449,8 +544,8 @@ export class CoreGradesHelperProvider { row['image'] = src[1]; } else if (text.indexOf(' -1) { row['itemtype'] = 'unknown'; - const src = text.match(/class="fa-([^ ]*)"/); - row['icon'] = src[1]; + const src = text.match(/} Array with objects with value and label to create a propper HTML select. */ - makeGradesMenu(gradingType: number, moduleId: number, defaultLabel: string = '', defaultValue: any = '', scale?: string): + makeGradesMenu(gradingType: number, moduleId?: number, defaultLabel: string = '', defaultValue: any = '', scale?: string): Promise { if (gradingType < 0) { if (scale) { return Promise.resolve(this.utils.makeMenuFromList(scale, defaultLabel, undefined, defaultValue)); - } else { + } else if (moduleId) { return this.courseProvider.getModuleBasicGradeInfo(moduleId).then((gradeInfo) => { if (gradeInfo.scale) { return this.utils.makeMenuFromList(gradeInfo.scale, defaultLabel, undefined, defaultValue); @@ -483,6 +579,8 @@ export class CoreGradesHelperProvider { return []; }); + } else { + return Promise.resolve([]); } } diff --git a/src/core/grades/providers/user-link-handler.ts b/src/core/grades/providers/user-link-handler.ts index 91e0d4f19..738c1c0db 100644 --- a/src/core/grades/providers/user-link-handler.ts +++ b/src/core/grades/providers/user-link-handler.ts @@ -15,8 +15,8 @@ import { Injectable } from '@angular/core'; import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; -import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { CoreGradesProvider } from './grades'; +import { CoreGradesHelperProvider } from './helper'; /** * Handler to treat links to user grades. @@ -26,7 +26,7 @@ export class CoreGradesUserLinkHandler extends CoreContentLinksHandlerBase { name = 'CoreGradesUserLinkHandler'; pattern = /\/grade\/report\/user\/index.php/; - constructor(private linkHelper: CoreContentLinksHelperProvider, private gradesProvider: CoreGradesProvider) { + constructor(private gradesProvider: CoreGradesProvider, private gradesHelper: CoreGradesHelperProvider) { super(); } @@ -37,18 +37,20 @@ export class CoreGradesUserLinkHandler extends CoreContentLinksHandlerBase { * @param {string} url The URL to treat. * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} * @param {number} [courseId] Course ID related to the URL. Optional but recommended. + * @param {any} [data] Extra data to handle the URL. * @return {CoreContentLinksAction[]|Promise} List of (or promise resolved with list of) actions. */ - getActions(siteIds: string[], url: string, params: any, courseId?: number): + getActions(siteIds: string[], url: string, params: any, courseId?: number, data?: any): CoreContentLinksAction[] | Promise { + courseId = courseId || params.id; + data = data || {}; + return [{ action: (siteId, navCtrl?): void => { - const pageParams = { - course: {id: courseId}, - userId: params.userid ? parseInt(params.userid, 10) : false, - }; - // Always use redirect to make it the new history root (to avoid "loops" in history). - this.linkHelper.goInSite(navCtrl, 'CoreGradesCoursePage', pageParams, siteId); + const userId = params.userid && parseInt(params.userid, 10), + moduleId = data.cmid && parseInt(data.cmid, 10); + + this.gradesHelper.goToGrades(courseId, userId, moduleId, navCtrl, siteId); } }]; } @@ -64,10 +66,10 @@ export class CoreGradesUserLinkHandler extends CoreContentLinksHandlerBase { * @return {boolean|Promise} Whether the handler is enabled for the URL and site. */ isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise { - if (!courseId) { + if (!courseId && !params.id) { return false; } - return this.gradesProvider.isPluginEnabledForCourse(courseId, siteId); + return this.gradesProvider.isPluginEnabledForCourse(courseId || params.id, siteId); } } diff --git a/src/core/login/pages/credentials/credentials.html b/src/core/login/pages/credentials/credentials.html index 84a2e5c8a..dcadd2e23 100644 --- a/src/core/login/pages/credentials/credentials.html +++ b/src/core/login/pages/credentials/credentials.html @@ -23,7 +23,7 @@

{{siteUrl}}

-
+ diff --git a/src/core/login/pages/credentials/credentials.ts b/src/core/login/pages/credentials/credentials.ts index 6297b353b..20b3d2116 100644 --- a/src/core/login/pages/credentials/credentials.ts +++ b/src/core/login/pages/credentials/credentials.ts @@ -21,9 +21,8 @@ import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreLoginHelperProvider } from '../../providers/helper'; -import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; -import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { CoreConfigConstants } from '../../../../configconstants'; /** * Page to enter the user credentials. @@ -55,8 +54,7 @@ export class CoreLoginCredentialsPage { constructor(private navCtrl: NavController, navParams: NavParams, fb: FormBuilder, private appProvider: CoreAppProvider, private sitesProvider: CoreSitesProvider, private loginHelper: CoreLoginHelperProvider, private domUtils: CoreDomUtilsProvider, private translate: TranslateService, private utils: CoreUtilsProvider, - private eventsProvider: CoreEventsProvider, private contentLinksDelegate: CoreContentLinksDelegate, - private contentLinksHelper: CoreContentLinksHelperProvider) { + private eventsProvider: CoreEventsProvider) { this.siteUrl = navParams.get('siteUrl'); this.siteConfig = navParams.get('siteConfig'); @@ -142,7 +140,7 @@ export class CoreLoginCredentialsPage { */ protected treatSiteConfig(): void { if (this.siteConfig) { - this.siteName = this.siteConfig.sitename; + this.siteName = CoreConfigConstants.sitename ? CoreConfigConstants.sitename : this.siteConfig.sitename; this.logoUrl = this.siteConfig.logourl || this.siteConfig.compactlogourl; this.authInstructions = this.siteConfig.authinstructions || this.translate.instant('core.login.loginsteps'); this.canSignup = this.siteConfig.registerauth == 'email' && !this.loginHelper.isEmailSignupDisabled(this.siteConfig); @@ -219,20 +217,7 @@ export class CoreLoginCredentialsPage { this.siteId = id; - if (this.urlToOpen) { - // There's a content link to open. - return this.contentLinksDelegate.getActionsFor(this.urlToOpen, undefined, username).then((actions) => { - const action = this.contentLinksHelper.getFirstValidAction(actions); - if (action && action.sites.length) { - // Action should only have 1 site because we're filtering by username. - action.action(action.sites[0]); - } else { - return this.loginHelper.goToSiteInitialPage(); - } - }); - } else { - return this.loginHelper.goToSiteInitialPage(); - } + return this.loginHelper.goToSiteInitialPage(undefined, undefined, undefined, undefined, this.urlToOpen); }); }).catch((error) => { this.loginHelper.treatUserTokenError(siteUrl, error, username, password); diff --git a/src/core/login/pages/email-signup/email-signup.ts b/src/core/login/pages/email-signup/email-signup.ts index b038da362..128a37f67 100644 --- a/src/core/login/pages/email-signup/email-signup.ts +++ b/src/core/login/pages/email-signup/email-signup.ts @@ -23,6 +23,7 @@ import { CoreWSProvider } from '@providers/ws'; import { CoreLoginHelperProvider } from '../../providers/helper'; import { FormBuilder, FormGroup, Validators, FormControl } from '@angular/forms'; import { CoreUserProfileFieldDelegate } from '@core/user/providers/user-profile-field-delegate'; +import { CoreConfigConstants } from '../../../../configconstants'; /** * Page to signup using email. @@ -191,7 +192,7 @@ export class CoreLoginEmailSignupPage { */ protected treatSiteConfig(siteConfig: any): boolean { if (siteConfig && siteConfig.registerauth == 'email' && !this.loginHelper.isEmailSignupDisabled(siteConfig)) { - this.siteName = siteConfig.sitename; + this.siteName = CoreConfigConstants.sitename ? CoreConfigConstants.sitename : siteConfig.sitename; this.authInstructions = siteConfig.authinstructions; this.ageDigitalConsentVerification = siteConfig.agedigitalconsentverification; this.supportName = siteConfig.supportname; diff --git a/src/core/login/pages/init/init.ts b/src/core/login/pages/init/init.ts index 3758d04c3..a33c0b2d5 100644 --- a/src/core/login/pages/init/init.ts +++ b/src/core/login/pages/init/init.ts @@ -43,7 +43,7 @@ export class CoreLoginInitPage { this.initDelegate.ready().then(() => { // Check if there was a pending redirect. const redirectData = this.appProvider.getRedirect(); - if (redirectData.siteId && redirectData.page) { + if (redirectData.siteId) { // Unset redirect data. this.appProvider.storeRedirect('', '', ''); @@ -63,8 +63,8 @@ export class CoreLoginInitPage { return this.loadPage(); }); } else { - // No site to load, just open the state. - return this.navCtrl.setRoot(redirectData.page, redirectData.params, { animate: false }); + // No site to load, open the page. + return this.loginHelper.goToNoSitePage(this.navCtrl, redirectData.page, redirectData.params); } } } diff --git a/src/core/login/pages/reconnect/reconnect.html b/src/core/login/pages/reconnect/reconnect.html index a99821964..ce0edcde6 100644 --- a/src/core/login/pages/reconnect/reconnect.html +++ b/src/core/login/pages/reconnect/reconnect.html @@ -25,7 +25,7 @@ {{ 'core.login.reconnectdescription' | translate }}

- + diff --git a/src/core/sitehome/components/index/core-sitehome-index.html b/src/core/sitehome/components/index/core-sitehome-index.html index 6472f3418..a8a1ed1bb 100644 --- a/src/core/sitehome/components/index/core-sitehome-index.html +++ b/src/core/sitehome/components/index/core-sitehome-index.html @@ -7,7 +7,7 @@ - + diff --git a/src/core/sitehome/components/index/index.ts b/src/core/sitehome/components/index/index.ts index 9bf86850d..34aa44057 100644 --- a/src/core/sitehome/components/index/index.ts +++ b/src/core/sitehome/components/index/index.ts @@ -20,6 +20,7 @@ import { CoreCourseHelperProvider } from '@core/course/providers/helper'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { CoreBlockDelegate } from '@core/block/providers/delegate'; import { CoreBlockComponent } from '@core/block/components/block/block'; +import { CoreSite } from '@classes/site'; /** * Component that displays site home index. @@ -37,18 +38,22 @@ export class CoreSiteHomeIndexComponent implements OnInit { hasSupportedBlock: boolean; items: any[] = []; siteHomeId: number; + currentSite: CoreSite; blocks = []; + downloadEnabled: boolean; - constructor(private domUtils: CoreDomUtilsProvider, private sitesProvider: CoreSitesProvider, + constructor(private domUtils: CoreDomUtilsProvider, sitesProvider: CoreSitesProvider, private courseProvider: CoreCourseProvider, private courseHelper: CoreCourseHelperProvider, private prefetchDelegate: CoreCourseModulePrefetchDelegate, private blockDelegate: CoreBlockDelegate) { - this.siteHomeId = sitesProvider.getCurrentSite().getSiteHomeId(); + this.currentSite = sitesProvider.getCurrentSite(); + this.siteHomeId = this.currentSite.getSiteHomeId(); } /** * Component being initialized. */ ngOnInit(): void { + this.downloadEnabled = !this.currentSite.isOfflineDisabled(); this.loadContent().finally(() => { this.dataLoaded = true; }); @@ -60,14 +65,13 @@ export class CoreSiteHomeIndexComponent implements OnInit { * @param {any} refresher Refresher. */ doRefresh(refresher: any): void { - const promises = [], - currentSite = this.sitesProvider.getCurrentSite(); + const promises = []; promises.push(this.courseProvider.invalidateSections(this.siteHomeId)); - promises.push(currentSite.invalidateConfig().then(() => { + promises.push(this.currentSite.invalidateConfig().then(() => { // Config invalidated, fetch it again. - return currentSite.getConfig().then((config) => { - currentSite.setConfig(config); + return this.currentSite.getConfig().then((config) => { + this.currentSite.setConfig(config); }); })); @@ -102,7 +106,7 @@ export class CoreSiteHomeIndexComponent implements OnInit { protected loadContent(): Promise { this.hasContent = false; - const config = this.sitesProvider.getCurrentSite().getStoredConfig() || { numsections: 1 }; + const config = this.currentSite.getStoredConfig() || { numsections: 1 }; if (config.frontpageloggedin) { // Items with index 1 and 3 were removed on 2.5 and not being supported in the app. @@ -135,14 +139,15 @@ export class CoreSiteHomeIndexComponent implements OnInit { return this.courseProvider.getSections(this.siteHomeId, false, true).then((sections) => { // Check "Include a topic section" setting from numsections. - this.section = config.numsections ? sections[1] : false; + this.section = config.numsections ? sections.find((section) => section.section == 1) : false; if (this.section) { this.section.hasContent = this.courseHelper.sectionHasContent(this.section); this.hasContent = this.courseHelper.addHandlerDataForModules([this.section], this.siteHomeId) || this.hasContent; } // Add log in Moodle. - this.courseProvider.logView(this.siteHomeId).catch(() => { + this.courseProvider.logView(this.siteHomeId, undefined, undefined, + this.currentSite && this.currentSite.getInfo().sitename).catch(() => { // Ignore errors. }); @@ -161,7 +166,8 @@ export class CoreSiteHomeIndexComponent implements OnInit { this.blocks = []; // Cannot get the blocks, just show site main menu if needed. - if (sections[0] && this.courseHelper.sectionHasContent(sections[0])) { + const section = sections.find((section) => section.section == 0); + if (section && this.courseHelper.sectionHasContent(section)) { this.blocks.push({ name: 'site_main_menu' }); diff --git a/src/core/siteplugins/classes/handlers/block-handler.ts b/src/core/siteplugins/classes/handlers/block-handler.ts new file mode 100644 index 000000000..8ee9dd059 --- /dev/null +++ b/src/core/siteplugins/classes/handlers/block-handler.ts @@ -0,0 +1,60 @@ +// (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 { Injector } from '@angular/core'; +import { CoreSitePluginsBaseHandler } from './base-handler'; +import { CoreBlockHandler, CoreBlockHandlerData } from '@core/block/providers/delegate'; +import { CoreSitePluginsBlockComponent } from '@core/siteplugins/components/block/block'; + +/** + * Handler to support a block using a site plugin. + */ +export class CoreSitePluginsBlockHandler extends CoreSitePluginsBaseHandler implements CoreBlockHandler { + + constructor(name: string, public blockName: string, protected handlerSchema: any, protected initResult: any) { + super(name); + } + + /** + * Gets display data for this block. The class and title can be provided either by data from + * the handler schema (mobile.php) or using default values. + * + * @param {Injector} injector Injector + * @param {any} block Block data + * @param {string} contextLevel Context level (not used) + * @param {number} instanceId Instance id (not used) + * @return {CoreBlockHandlerData|Promise} Data or promise resolved with the data + */ + getDisplayData(injector: Injector, block: any, contextLevel: string, instanceId: number): + CoreBlockHandlerData | Promise { + let title, + className; + if (this.handlerSchema.displaydata && this.handlerSchema.displaydata.title) { + title = this.handlerSchema.displaydata.title; + } else { + title = 'plugins.block_' + block.name + '.pluginname'; + } + if (this.handlerSchema.displaydata && this.handlerSchema.displaydata.class) { + className = this.handlerSchema.displaydata.class; + } else { + className = 'block_' + block.name; + } + + return { + title: title, + class: className, + component: CoreSitePluginsBlockComponent + }; + } +} diff --git a/src/core/siteplugins/components/assign-feedback/assign-feedback.ts b/src/core/siteplugins/components/assign-feedback/assign-feedback.ts index b8e1d8dcb..4fc0fe2eb 100644 --- a/src/core/siteplugins/components/assign-feedback/assign-feedback.ts +++ b/src/core/siteplugins/components/assign-feedback/assign-feedback.ts @@ -16,6 +16,7 @@ import { Component, OnInit, Input } from '@angular/core'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreSitePluginsProvider } from '../../providers/siteplugins'; import { CoreSitePluginsCompileInitComponent } from '../../classes/compile-init-component'; +import { AddonModAssignFeedbackDelegate } from '@addon/mod/assign/providers/feedback-delegate'; /** * Component that displays an assign feedback plugin created using a site plugin. @@ -33,7 +34,8 @@ export class CoreSitePluginsAssignFeedbackComponent extends CoreSitePluginsCompi @Input() canEdit: boolean; // Whether the user can edit. @Input() edit: boolean; // Whether the user is editing. - constructor(sitePluginsProvider: CoreSitePluginsProvider, utils: CoreUtilsProvider) { + constructor(sitePluginsProvider: CoreSitePluginsProvider, utils: CoreUtilsProvider, + protected assignFeedbackDelegate: AddonModAssignFeedbackDelegate) { super(sitePluginsProvider, utils); } @@ -51,7 +53,7 @@ export class CoreSitePluginsAssignFeedbackComponent extends CoreSitePluginsCompi this.jsData.canEdit = this.canEdit; if (this.plugin) { - this.getHandlerData('assignfeedback_' + this.plugin.type); + this.getHandlerData(this.assignFeedbackDelegate.getHandlerName(this.plugin.type)); } } diff --git a/src/core/siteplugins/components/assign-submission/assign-submission.ts b/src/core/siteplugins/components/assign-submission/assign-submission.ts index cd7af884e..f034caf41 100644 --- a/src/core/siteplugins/components/assign-submission/assign-submission.ts +++ b/src/core/siteplugins/components/assign-submission/assign-submission.ts @@ -16,6 +16,7 @@ import { Component, OnInit, Input } from '@angular/core'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreSitePluginsProvider } from '../../providers/siteplugins'; import { CoreSitePluginsCompileInitComponent } from '../../classes/compile-init-component'; +import { AddonModAssignSubmissionDelegate } from '@addon/mod/assign/providers/submission-delegate'; /** * Component that displays an assign submission plugin created using a site plugin. @@ -32,7 +33,8 @@ export class CoreSitePluginsAssignSubmissionComponent extends CoreSitePluginsCom @Input() edit: boolean; // Whether the user is editing. @Input() allowOffline: boolean; // Whether to allow offline. - constructor(sitePluginsProvider: CoreSitePluginsProvider, utils: CoreUtilsProvider) { + constructor(sitePluginsProvider: CoreSitePluginsProvider, utils: CoreUtilsProvider, + protected assignSubmissionDelegate: AddonModAssignSubmissionDelegate) { super(sitePluginsProvider, utils); } @@ -49,7 +51,7 @@ export class CoreSitePluginsAssignSubmissionComponent extends CoreSitePluginsCom this.jsData.allowOffline = this.allowOffline; if (this.plugin) { - this.getHandlerData('assignsubmission_' + this.plugin.type); + this.getHandlerData(this.assignSubmissionDelegate.getHandlerName(this.plugin.type)); } } diff --git a/src/core/siteplugins/components/block/block.ts b/src/core/siteplugins/components/block/block.ts new file mode 100644 index 000000000..d1e927add --- /dev/null +++ b/src/core/siteplugins/components/block/block.ts @@ -0,0 +1,68 @@ +// (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, OnChanges, Input, ViewChild, Injector } from '@angular/core'; +import { CoreSitePluginsProvider } from '../../providers/siteplugins'; +import { CoreSitePluginsPluginContentComponent } from '../plugin-content/plugin-content'; +import { CoreBlockBaseComponent } from '@core/block/classes/base-block-component'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; + +/** + * Component that displays the index of a course format site plugin. + */ +@Component({ + selector: 'core-site-plugins-block', + templateUrl: 'core-siteplugins-block.html', +}) +export class CoreSitePluginsBlockComponent extends CoreBlockBaseComponent implements OnChanges { + @Input() block: any; + @Input() contextLevel: number; + @Input() instanceId: number; + + @ViewChild(CoreSitePluginsPluginContentComponent) content: CoreSitePluginsPluginContentComponent; + + component: string; + method: string; + args: any; + initResult: any; + + constructor(protected injector: Injector, protected sitePluginsProvider: CoreSitePluginsProvider, + protected blockDelegate: CoreBlockDelegate) { + super(injector, 'CoreSitePluginsBlockComponent'); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(): void { + if (!this.component) { + // Initialize the data. + const handlerName = this.blockDelegate.getHandlerName(this.block.name); + const handler = this.sitePluginsProvider.getSitePluginHandler(handlerName); + if (handler) { + this.component = handler.plugin.component; + this.method = handler.handlerSchema.method; + this.args = { }; + this.initResult = handler.initResult; + } + } + } + + /** + * Pass on content invalidation by refreshing content in the plugin content component. + */ + protected invalidateContent(): Promise { + return Promise.resolve(this.content.refreshContent()); + } +} diff --git a/src/core/siteplugins/components/block/core-siteplugins-block.html b/src/core/siteplugins/components/block/core-siteplugins-block.html new file mode 100644 index 000000000..015e3dc49 --- /dev/null +++ b/src/core/siteplugins/components/block/core-siteplugins-block.html @@ -0,0 +1 @@ + diff --git a/src/core/siteplugins/components/components.module.ts b/src/core/siteplugins/components/components.module.ts index 9619ee02e..ebc7d03b9 100644 --- a/src/core/siteplugins/components/components.module.ts +++ b/src/core/siteplugins/components/components.module.ts @@ -29,11 +29,13 @@ import { CoreSitePluginsQuizAccessRuleComponent } from './quiz-access-rule/quiz- import { CoreSitePluginsAssignFeedbackComponent } from './assign-feedback/assign-feedback'; import { CoreSitePluginsAssignSubmissionComponent } from './assign-submission/assign-submission'; import { CoreSitePluginsWorkshopAssessmentStrategyComponent } from './workshop-assessment-strategy/workshop-assessment-strategy'; +import { CoreSitePluginsBlockComponent } from '@core/siteplugins/components/block/block'; @NgModule({ declarations: [ CoreSitePluginsPluginContentComponent, CoreSitePluginsModuleIndexComponent, + CoreSitePluginsBlockComponent, CoreSitePluginsCourseOptionComponent, CoreSitePluginsCourseFormatComponent, CoreSitePluginsUserProfileFieldComponent, @@ -56,6 +58,7 @@ import { CoreSitePluginsWorkshopAssessmentStrategyComponent } from './workshop-a exports: [ CoreSitePluginsPluginContentComponent, CoreSitePluginsModuleIndexComponent, + CoreSitePluginsBlockComponent, CoreSitePluginsCourseOptionComponent, CoreSitePluginsCourseFormatComponent, CoreSitePluginsUserProfileFieldComponent, @@ -68,6 +71,7 @@ import { CoreSitePluginsWorkshopAssessmentStrategyComponent } from './workshop-a ], entryComponents: [ CoreSitePluginsModuleIndexComponent, + CoreSitePluginsBlockComponent, CoreSitePluginsCourseOptionComponent, CoreSitePluginsCourseFormatComponent, CoreSitePluginsUserProfileFieldComponent, diff --git a/src/core/siteplugins/components/course-format/course-format.ts b/src/core/siteplugins/components/course-format/course-format.ts index 9673a7a26..3f4407c6e 100644 --- a/src/core/siteplugins/components/course-format/course-format.ts +++ b/src/core/siteplugins/components/course-format/course-format.ts @@ -16,6 +16,7 @@ import { Component, OnChanges, Input, ViewChild, Output, EventEmitter } from '@a import { CoreSitePluginsProvider } from '../../providers/siteplugins'; import { CoreSitePluginsPluginContentComponent } from '../plugin-content/plugin-content'; import { CoreCourseFormatComponent } from '@core/course/components/format/format'; +import { CoreCourseFormatDelegate } from '@core/course/providers/format-delegate'; /** * Component that displays the index of a course format site plugin. @@ -46,7 +47,8 @@ export class CoreSitePluginsCourseFormatComponent implements OnChanges { initResult: any; data: any; - constructor(protected sitePluginsProvider: CoreSitePluginsProvider) { } + constructor(protected sitePluginsProvider: CoreSitePluginsProvider, + protected courseFormatDelegate: CoreCourseFormatDelegate) { } /** * Detect changes on input properties. @@ -55,7 +57,9 @@ export class CoreSitePluginsCourseFormatComponent implements OnChanges { if (this.course && this.course.format) { if (!this.component) { // Initialize the data. - const handler = this.sitePluginsProvider.getSitePluginHandler(this.course.format); + const handlerName = this.courseFormatDelegate.getHandlerName(this.course.format), + handler = this.sitePluginsProvider.getSitePluginHandler(handlerName); + if (handler) { this.component = handler.plugin.component; this.method = handler.handlerSchema.method; diff --git a/src/core/siteplugins/components/module-index/module-index.ts b/src/core/siteplugins/components/module-index/module-index.ts index f24cc0685..3e231ecf4 100644 --- a/src/core/siteplugins/components/module-index/module-index.ts +++ b/src/core/siteplugins/components/module-index/module-index.ts @@ -17,7 +17,7 @@ import { TranslateService } from '@ngx-translate/core'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreSitePluginsProvider } from '../../providers/siteplugins'; -import { CoreCourseModuleMainComponent } from '@core/course/providers/module-delegate'; +import { CoreCourseModuleDelegate, CoreCourseModuleMainComponent } from '@core/course/providers/module-delegate'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { CoreCourseHelperProvider } from '@core/course/providers/helper'; import { CoreSitePluginsPluginContentComponent } from '../plugin-content/plugin-content'; @@ -60,7 +60,8 @@ export class CoreSitePluginsModuleIndexComponent implements OnInit, OnDestroy, C constructor(protected sitePluginsProvider: CoreSitePluginsProvider, protected courseHelper: CoreCourseHelperProvider, protected prefetchDelegate: CoreCourseModulePrefetchDelegate, protected textUtils: CoreTextUtilsProvider, - protected translate: TranslateService, protected utils: CoreUtilsProvider) { } + protected translate: TranslateService, protected utils: CoreUtilsProvider, + protected moduleDelegate: CoreCourseModuleDelegate) { } /** * Component being initialized. @@ -69,7 +70,9 @@ export class CoreSitePluginsModuleIndexComponent implements OnInit, OnDestroy, C this.refreshIcon = 'spinner'; if (this.module) { - const handler = this.sitePluginsProvider.getSitePluginHandler(this.module.modname); + const handlerName = this.moduleDelegate.getHandlerName(this.module.modname), + handler = this.sitePluginsProvider.getSitePluginHandler(handlerName); + if (handler) { this.component = handler.plugin.component; this.method = handler.handlerSchema.method; diff --git a/src/core/siteplugins/components/question-behaviour/question-behaviour.ts b/src/core/siteplugins/components/question-behaviour/question-behaviour.ts index 0f0bcee48..17970be85 100644 --- a/src/core/siteplugins/components/question-behaviour/question-behaviour.ts +++ b/src/core/siteplugins/components/question-behaviour/question-behaviour.ts @@ -16,6 +16,7 @@ import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreSitePluginsProvider } from '../../providers/siteplugins'; import { CoreSitePluginsCompileInitComponent } from '../../classes/compile-init-component'; +import { CoreQuestionBehaviourDelegate } from '@core/question/providers/behaviour-delegate'; /** * Component that displays a question behaviour created using a site plugin. @@ -33,7 +34,8 @@ export class CoreSitePluginsQuestionBehaviourComponent extends CoreSitePluginsCo @Output() buttonClicked: EventEmitter; // Should emit an event when a behaviour button is clicked. @Output() onAbort: EventEmitter; // Should emit an event if the question should be aborted. - constructor(sitePluginsProvider: CoreSitePluginsProvider, utils: CoreUtilsProvider) { + constructor(sitePluginsProvider: CoreSitePluginsProvider, utils: CoreUtilsProvider, + protected questionBehaviourDelegate: CoreQuestionBehaviourDelegate) { super(sitePluginsProvider, utils); } @@ -51,7 +53,7 @@ export class CoreSitePluginsQuestionBehaviourComponent extends CoreSitePluginsCo this.jsData.onAbort = this.onAbort; if (this.question) { - this.getHandlerData('qbehaviour_' + this.question.preferredBehaviour); + this.getHandlerData(this.questionBehaviourDelegate.getHandlerName(this.question.preferredBehaviour)); } } } diff --git a/src/core/siteplugins/components/question/question.ts b/src/core/siteplugins/components/question/question.ts index 012d23af9..2765ea707 100644 --- a/src/core/siteplugins/components/question/question.ts +++ b/src/core/siteplugins/components/question/question.ts @@ -16,6 +16,7 @@ import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreSitePluginsProvider } from '../../providers/siteplugins'; import { CoreSitePluginsCompileInitComponent } from '../../classes/compile-init-component'; +import { CoreQuestionDelegate } from '@core/question/providers/delegate'; /** * Component that displays a question created using a site plugin. @@ -33,7 +34,8 @@ export class CoreSitePluginsQuestionComponent extends CoreSitePluginsCompileInit @Output() buttonClicked: EventEmitter; // Should emit an event when a behaviour button is clicked. @Output() onAbort: EventEmitter; // Should emit an event if the question should be aborted. - constructor(sitePluginsProvider: CoreSitePluginsProvider, utils: CoreUtilsProvider) { + constructor(sitePluginsProvider: CoreSitePluginsProvider, utils: CoreUtilsProvider, + protected questionDelegate: CoreQuestionDelegate) { super(sitePluginsProvider, utils); } @@ -51,7 +53,7 @@ export class CoreSitePluginsQuestionComponent extends CoreSitePluginsCompileInit this.jsData.onAbort = this.onAbort; if (this.question) { - this.getHandlerData('qtype_' + this.question.type); + this.getHandlerData(this.questionDelegate.getHandlerName('qtype_' + this.question.type)); } } } diff --git a/src/core/siteplugins/components/quiz-access-rule/quiz-access-rule.ts b/src/core/siteplugins/components/quiz-access-rule/quiz-access-rule.ts index caa3f382c..b80f62c76 100644 --- a/src/core/siteplugins/components/quiz-access-rule/quiz-access-rule.ts +++ b/src/core/siteplugins/components/quiz-access-rule/quiz-access-rule.ts @@ -17,6 +17,7 @@ import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreSitePluginsProvider } from '../../providers/siteplugins'; import { CoreSitePluginsCompileInitComponent } from '../../classes/compile-init-component'; import { FormGroup } from '@angular/forms'; +import { AddonModQuizAccessRuleDelegate } from '@addon/mod/quiz/providers/access-rules-delegate'; /** * Component that displays a quiz access rule created using a site plugin. @@ -33,7 +34,8 @@ export class CoreSitePluginsQuizAccessRuleComponent extends CoreSitePluginsCompi @Input() siteId: string; // Site ID. @Input() form: FormGroup; // Form where to add the form control. - constructor(sitePluginsProvider: CoreSitePluginsProvider, utils: CoreUtilsProvider) { + constructor(sitePluginsProvider: CoreSitePluginsProvider, utils: CoreUtilsProvider, + protected accessRulesDelegate: AddonModQuizAccessRuleDelegate) { super(sitePluginsProvider, utils); } @@ -50,7 +52,7 @@ export class CoreSitePluginsQuizAccessRuleComponent extends CoreSitePluginsCompi this.jsData.form = this.form; if (this.rule) { - this.getHandlerData(this.rule); + this.getHandlerData(this.accessRulesDelegate.getHandlerName(this.rule)); } } } diff --git a/src/core/siteplugins/components/user-profile-field/user-profile-field.ts b/src/core/siteplugins/components/user-profile-field/user-profile-field.ts index 5e460eb5a..201b95930 100644 --- a/src/core/siteplugins/components/user-profile-field/user-profile-field.ts +++ b/src/core/siteplugins/components/user-profile-field/user-profile-field.ts @@ -17,6 +17,7 @@ import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreSitePluginsProvider } from '../../providers/siteplugins'; import { CoreSitePluginsCompileInitComponent } from '../../classes/compile-init-component'; import { FormGroup } from '@angular/forms'; +import { CoreUserProfileFieldDelegate } from '@core/user/providers/user-profile-field-delegate'; /** * Component that displays a user profile field created using a site plugin. @@ -33,7 +34,8 @@ export class CoreSitePluginsUserProfileFieldComponent extends CoreSitePluginsCom @Input() signup = false; // True if editing the field in signup. Defaults to false. @Input() registerAuth?: string; // Register auth method. E.g. 'email'. - constructor(sitePluginsProvider: CoreSitePluginsProvider, utils: CoreUtilsProvider) { + constructor(sitePluginsProvider: CoreSitePluginsProvider, utils: CoreUtilsProvider, + protected profileFieldDelegate: CoreUserProfileFieldDelegate) { super(sitePluginsProvider, utils); } @@ -51,7 +53,7 @@ export class CoreSitePluginsUserProfileFieldComponent extends CoreSitePluginsCom this.jsData.registerAuth = this.registerAuth; if (this.field) { - this.getHandlerData('profilefield_' + (this.field.type || this.field.datatype)); + this.getHandlerData(this.profileFieldDelegate.getHandlerName(this.field.type || this.field.datatype)); } } } diff --git a/src/core/siteplugins/components/workshop-assessment-strategy/workshop-assessment-strategy.ts b/src/core/siteplugins/components/workshop-assessment-strategy/workshop-assessment-strategy.ts index 3da761007..5c5b8c53f 100644 --- a/src/core/siteplugins/components/workshop-assessment-strategy/workshop-assessment-strategy.ts +++ b/src/core/siteplugins/components/workshop-assessment-strategy/workshop-assessment-strategy.ts @@ -16,6 +16,7 @@ import { Component, OnInit, Input } from '@angular/core'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreSitePluginsProvider } from '../../providers/siteplugins'; import { CoreSitePluginsCompileInitComponent } from '../../classes/compile-init-component'; +import { AddonWorkshopAssessmentStrategyDelegate } from '@addon/mod/workshop/providers/assessment-strategy-delegate'; /** * Component that displays a workshop assessment strategy plugin created using a site plugin. @@ -32,7 +33,8 @@ export class CoreSitePluginsWorkshopAssessmentStrategyComponent extends CoreSite @Input() fieldErrors: any; @Input() strategy: string; - constructor(sitePluginsProvider: CoreSitePluginsProvider, utils: CoreUtilsProvider) { + constructor(sitePluginsProvider: CoreSitePluginsProvider, utils: CoreUtilsProvider, + private workshopAssessmentStrategyDelegate: AddonWorkshopAssessmentStrategyDelegate) { super(sitePluginsProvider, utils); } @@ -48,6 +50,6 @@ export class CoreSitePluginsWorkshopAssessmentStrategyComponent extends CoreSite this.jsData.fieldErrors = this.fieldErrors; this.jsData.strategy = this.strategy; - this.getHandlerData('workshopform_' + this.strategy); + this.getHandlerData(this.workshopAssessmentStrategyDelegate.getHandlerName(this.strategy)); } } diff --git a/src/core/siteplugins/providers/helper.ts b/src/core/siteplugins/providers/helper.ts index cc78d9509..b55378728 100644 --- a/src/core/siteplugins/providers/helper.ts +++ b/src/core/siteplugins/providers/helper.ts @@ -64,6 +64,8 @@ import { CoreSitePluginsQuizAccessRuleHandler } from '../classes/handlers/quiz-a import { CoreSitePluginsAssignFeedbackHandler } from '../classes/handlers/assign-feedback-handler'; import { CoreSitePluginsAssignSubmissionHandler } from '../classes/handlers/assign-submission-handler'; import { CoreSitePluginsWorkshopAssessmentStrategyHandler } from '../classes/handlers/workshop-assessment-strategy-handler'; +import { CoreBlockDelegate } from '@core/block/providers/delegate'; +import { CoreSitePluginsBlockHandler } from '@core/siteplugins/classes/handlers/block-handler'; /** * Helper service to provide functionalities regarding site plugins. It basically has the features to load and register site @@ -92,7 +94,7 @@ export class CoreSitePluginsHelperProvider { private assignSubmissionDelegate: AddonModAssignSubmissionDelegate, private translate: TranslateService, private assignFeedbackDelegate: AddonModAssignFeedbackDelegate, private appProvider: CoreAppProvider, private workshopAssessmentStrategyDelegate: AddonWorkshopAssessmentStrategyDelegate, - private courseProvider: CoreCourseProvider) { + private courseProvider: CoreCourseProvider, private blockDelegate: CoreBlockDelegate) { this.logger = logger.getInstance('CoreSitePluginsHelperProvider'); @@ -107,8 +109,9 @@ export class CoreSitePluginsHelperProvider { }).finally(() => { eventsProvider.trigger(CoreEventsProvider.SITE_PLUGINS_LOADED, {}, data.siteId); }); - } + }).finally(() => { + this.sitePluginsProvider.setPluginsFetched(); }); }); @@ -477,6 +480,10 @@ export class CoreSitePluginsHelperProvider { promise = Promise.resolve(this.registerQuestionBehaviourHandler(plugin, handlerName, handlerSchema)); break; + case 'CoreBlockDelegate': + promise = Promise.resolve(this.registerBlockHandler(plugin, handlerName, handlerSchema, result)); + break; + case 'AddonMessageOutputDelegate': promise = Promise.resolve(this.registerMessageOutputHandler(plugin, handlerName, handlerSchema, result)); break; @@ -541,7 +548,7 @@ export class CoreSitePluginsHelperProvider { this.logger.debug('Register site plugin', plugin, handlerSchema); // Execute the main method and its JS. The template returned will be used in the right component. - return this.executeMethodAndJS(plugin, handlerSchema.method).then((result) => { + return this.executeMethodAndJS(plugin, handlerSchema.method).then((result): any => { // Create and register the handler. const uniqueName = this.sitePluginsProvider.getHandlerUniqueName(plugin, handlerName), @@ -564,7 +571,7 @@ export class CoreSitePluginsHelperProvider { delegate.registerHandler(handler); - return handlerSchema.moodlecomponent || plugin.component; + return uniqueName; }).catch((err) => { this.logger.error('Error executing main method', plugin.component, handlerSchema.method, err); }); @@ -610,6 +617,27 @@ export class CoreSitePluginsHelperProvider { }); } + /** + * Given a handler in a plugin, register it in the block delegate. + * + * @param {any} plugin Data of the plugin. + * @param {string} handlerName Name of the handler in the plugin. + * @param {any} handlerSchema Data about the handler. + * @param {any} initResult Result of init function. + * @return {string|Promise} A string (or a promise resolved with a string) to identify the handler. + */ + protected registerBlockHandler(plugin: any, handlerName: string, handlerSchema: any, initResult: any): + string | Promise { + + const uniqueName = this.sitePluginsProvider.getHandlerUniqueName(plugin, handlerName), + blockName = (handlerSchema.moodlecomponent || plugin.component).replace('block_', ''); + + this.blockDelegate.registerHandler( + new CoreSitePluginsBlockHandler(uniqueName, blockName, handlerSchema, initResult)); + + return uniqueName; + } + /** * Given a handler in a plugin, register it in the course format delegate. * @@ -626,7 +654,7 @@ export class CoreSitePluginsHelperProvider { formatName = (handlerSchema.moodlecomponent || plugin.component).replace('format_', ''); this.courseFormatDelegate.registerHandler(new CoreSitePluginsCourseFormatHandler(uniqueName, formatName, handlerSchema)); - return formatName; + return uniqueName; } /** @@ -749,7 +777,7 @@ export class CoreSitePluginsHelperProvider { this.sitePluginsProvider, plugin.component, uniqueName, modName, handlerSchema)); } - return modName; + return uniqueName; } /** diff --git a/src/core/siteplugins/providers/siteplugins.ts b/src/core/siteplugins/providers/siteplugins.ts index 43fd975c7..8739e4722 100644 --- a/src/core/siteplugins/providers/siteplugins.ts +++ b/src/core/siteplugins/providers/siteplugins.ts @@ -21,7 +21,7 @@ import { CoreLoggerProvider } from '@providers/logger'; import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreSitesProvider } from '@providers/sites'; import { CoreTextUtilsProvider } from '@providers/utils/text'; -import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreUtilsProvider, PromiseDefer } from '@providers/utils/utils'; import { CoreConfigConstants } from '../../../configconstants'; import { CoreCoursesProvider } from '@core/courses/providers/courses'; import { CoreEventsProvider } from '@providers/events'; @@ -67,6 +67,7 @@ export class CoreSitePluginsProvider { protected logger; protected sitePlugins: {[name: string]: CoreSitePluginsHandler} = {}; // Site plugins registered. protected sitePluginPromises: {[name: string]: Promise} = {}; // Promises of loading plugins. + protected fetchPluginsDeferred: PromiseDefer; hasSitePluginsLoaded = false; sitePluginsFinishedLoading = false; @@ -75,10 +76,17 @@ export class CoreSitePluginsProvider { private filepoolProvider: CoreFilepoolProvider, private coursesProvider: CoreCoursesProvider, private textUtils: CoreTextUtilsProvider, private eventsProvider: CoreEventsProvider) { this.logger = logger.getInstance('CoreUserProvider'); + const observer = this.eventsProvider.on(CoreEventsProvider.SITE_PLUGINS_LOADED, () => { this.sitePluginsFinishedLoading = true; observer && observer.off(); }); + + // Initialize deferred at start and on logout. + this.fetchPluginsDeferred = this.utils.promiseDefer(); + eventsProvider.on(CoreEventsProvider.LOGOUT, () => { + this.fetchPluginsDeferred = this.utils.promiseDefer(); + }); } /** @@ -226,6 +234,8 @@ export class CoreSitePluginsProvider { preSets = preSets || {}; preSets.cacheKey = this.getContentCacheKey(component, method, args); + preSets.updateFrequency = typeof preSets.updateFrequency != 'undefined' ? preSets.updateFrequency : + CoreSite.FREQUENCY_OFTEN; return this.sitesProvider.getCurrentSite().read('tool_mobile_get_content', data, preSets); }).then((result) => { @@ -531,6 +541,13 @@ export class CoreSitePluginsProvider { this.sitePluginPromises[component] = promise; } + /** + * Set plugins fetched. + */ + setPluginsFetched(): void { + this.fetchPluginsDeferred.resolve(); + } + /** * Is a plugin being initialised for the specified component? * @@ -550,4 +567,13 @@ export class CoreSitePluginsProvider { sitePluginLoaded(component: string): Promise { return this.sitePluginPromises[component]; } + + /** + * Wait for fetch plugins to be done. + * + * @return {Promise} Promise resolved when site plugins have been fetched. + */ + waitFetchPlugins(): Promise { + return this.fetchPluginsDeferred.promise; + } } diff --git a/src/core/user/components/participants/core-user-participants.html b/src/core/user/components/participants/core-user-participants.html index d1faf712d..61e8944b5 100644 --- a/src/core/user/components/participants/core-user-participants.html +++ b/src/core/user/components/participants/core-user-participants.html @@ -11,7 +11,8 @@

-

{{ 'core.lastaccess' | translate }}: {{ participant.lastaccess | coreTimeAgo }}

+

{{ 'core.lastaccess' | translate }}: {{ participant.lastcourseaccess | coreTimeAgo }}

+

{{ 'core.lastaccess' | translate }}: {{ participant.lastaccess | coreTimeAgo }}

diff --git a/src/core/user/pages/profile/profile.html b/src/core/user/pages/profile/profile.html index 57d96674e..fae5a9edd 100644 --- a/src/core/user/pages/profile/profile.html +++ b/src/core/user/pages/profile/profile.html @@ -8,7 +8,7 @@ - + @@ -58,8 +58,9 @@ - + + \ No newline at end of file diff --git a/src/core/user/pages/profile/profile.ts b/src/core/user/pages/profile/profile.ts index 957de53e7..fd3875f0d 100644 --- a/src/core/user/pages/profile/profile.ts +++ b/src/core/user/pages/profile/profile.ts @@ -46,6 +46,7 @@ export class CoreUserProfilePage { user: any; title: string; isDeleted = false; + isEnrolled = true; canChangeProfilePicture = false; actionHandlers: CoreUserProfileHandlerData[] = []; newPageHandlers: CoreUserProfileHandlerData[] = []; @@ -83,8 +84,9 @@ export class CoreUserProfilePage { */ ionViewDidLoad(): void { this.fetchUser().then(() => { - return this.userProvider.logView(this.userId, this.courseId).catch((error) => { + return this.userProvider.logView(this.userId, this.courseId, this.user.fullname).catch((error) => { this.isDeleted = error.errorcode === 'userdeleted'; + this.isEnrolled = error.errorcode !== 'notenrolledprofile'; }); }).finally(() => { this.userLoaded = true; diff --git a/src/core/user/providers/offline.ts b/src/core/user/providers/offline.ts new file mode 100644 index 000000000..36ccc4c05 --- /dev/null +++ b/src/core/user/providers/offline.ts @@ -0,0 +1,114 @@ +// (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 { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; + +/** + * Structure of offline user preferences. + */ +export interface CoreUserOfflinePreference { + name: string; + value: string; + onlinevalue: string; +} + +/** + * Service to handle offline user preferences. + */ +@Injectable() +export class CoreUserOfflineProvider { + + // Variables for database. + static PREFERENCES_TABLE = 'user_preferences'; + protected siteSchema: CoreSiteSchema = { + name: 'CoreUserOfflineProvider', + version: 1, + tables: [ + { + name: CoreUserOfflineProvider.PREFERENCES_TABLE, + columns: [ + { + name: 'name', + type: 'TEXT', + unique: true, + notNull: true + }, + { + name: 'value', + type: 'TEXT' + }, + { + name: 'onlinevalue', + type: 'TEXT' + }, + ] + } + ] + }; + + constructor(private sitesProvider: CoreSitesProvider) { + this.sitesProvider.registerSiteSchema(this.siteSchema); + } + + /** + * Get preferences that were changed offline. + * + * @return {Promise} Promise resolved with list of preferences. + */ + getChangedPreferences(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecordsSelect(CoreUserOfflineProvider.PREFERENCES_TABLE, 'value != onlineValue'); + }); + } + + /** + * Get an offline preference. + * + * @param {string} name Name of the preference. + * @return {Promise} Promise resolved with the preference, rejected if not found. + */ + getPreference(name: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const conditions = { name }; + + return site.getDb().getRecord(CoreUserOfflineProvider.PREFERENCES_TABLE, conditions); + }); + } + + /** + * Set an offline preference. + * + * @param {string} name Name of the preference. + * @param {string} value Value of the preference. + * @param {string} onlineValue Online value of the preference. If unedfined, preserve previously stored value. + * @return {Promise} Promise resolved when done. + */ + setPreference(name: string, value: string, onlineValue?: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + let promise: Promise; + if (typeof onlineValue == 'undefined') { + promise = this.getPreference(name, site.id).then((preference) => preference.onlinevalue); + } else { + promise = Promise.resolve(onlineValue); + } + + return promise.then((onlineValue) => { + const record = { name, value, onlinevalue: onlineValue }; + + return site.getDb().insertRecord(CoreUserOfflineProvider.PREFERENCES_TABLE, record); + }); + }); + } +} diff --git a/src/core/user/providers/participants-link-handler.ts b/src/core/user/providers/participants-link-handler.ts index 910b57eb6..b9bb3eddd 100644 --- a/src/core/user/providers/participants-link-handler.ts +++ b/src/core/user/providers/participants-link-handler.ts @@ -13,9 +13,12 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler'; import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate'; import { CoreLoginHelperProvider } from '@core/login/providers/helper'; +import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; import { CoreUserProvider } from './user'; /** @@ -27,7 +30,9 @@ export class CoreUserParticipantsLinkHandler extends CoreContentLinksHandlerBase featureName = 'CoreCourseOptionsDelegate_CoreUserParticipants'; pattern = /\/user\/index\.php/; - constructor(private userProvider: CoreUserProvider, private loginHelper: CoreLoginHelperProvider) { + constructor(private userProvider: CoreUserProvider, private loginHelper: CoreLoginHelperProvider, + private courseHelper: CoreCourseHelperProvider, private domUtils: CoreDomUtilsProvider, + private linkHelper: CoreContentLinksHelperProvider) { super(); } @@ -46,8 +51,22 @@ export class CoreUserParticipantsLinkHandler extends CoreContentLinksHandlerBase return [{ action: (siteId, navCtrl?): void => { - // Always use redirect to make it the new history root (to avoid "loops" in history). - this.loginHelper.redirect('CoreUserParticipantsPage', {courseId: courseId}, siteId); + const modal = this.domUtils.showModalLoading(); + + this.courseHelper.getCourse(courseId, siteId).then((result) => { + const params: any = { + course: result.course, + selectedTab: 'CoreUserParticipants' + }; + + // Always use redirect to make it the new history root (to avoid "loops" in history). + return this.loginHelper.redirect('CoreCourseSectionPage', params, siteId); + }).catch(() => { + // Cannot get course for some reason, just open the participants page. + return this.linkHelper.goInSite(navCtrl, 'CoreUserParticipantsPage', {courseId: courseId}, siteId); + }).finally(() => { + modal.dismiss(); + }); } }]; } diff --git a/src/core/user/providers/sync-cron-handler.ts b/src/core/user/providers/sync-cron-handler.ts new file mode 100644 index 000000000..a51b2da8b --- /dev/null +++ b/src/core/user/providers/sync-cron-handler.ts @@ -0,0 +1,48 @@ +// (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 { CoreCronHandler } from '@providers/cron'; +import { CoreUserSyncProvider } from './sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class CoreUserSyncCronHandler implements CoreCronHandler { + name = 'CoreUserSyncCronHandler'; + + constructor(private userSync: CoreUserSyncProvider) {} + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param {string} [siteId] ID of the site affected, undefined for all sites. + * @param {boolean} [force] Wether the execution is forced (manual sync). + * @return {Promise} Promise resolved when done, rejected if failure. + */ + execute(siteId?: string, force?: boolean): Promise { + return this.userSync.syncPreferences(siteId); + } + + /** + * Get the time between consecutive executions. + * + * @return {number} Time between consecutive executions (in ms). + */ + getInterval(): number { + return 300000; // 5 minutes. + } +} diff --git a/src/core/user/providers/sync.ts b/src/core/user/providers/sync.ts new file mode 100644 index 000000000..62de2f679 --- /dev/null +++ b/src/core/user/providers/sync.ts @@ -0,0 +1,100 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { CoreAppProvider } from '@providers/app'; +import { CoreUserOfflineProvider } from './offline'; +import { CoreUserProvider } from './user'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSyncProvider } from '@providers/sync'; + +/** + * Service to sync user preferences. + */ +@Injectable() +export class CoreUserSyncProvider extends CoreSyncBaseProvider { + + static AUTO_SYNCED = 'core_user_autom_synced'; + + constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider, + translate: TranslateService, syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, + private userOffline: CoreUserOfflineProvider, private userProvider: CoreUserProvider, + private utils: CoreUtilsProvider, timeUtils: CoreTimeUtilsProvider) { + super('CoreUserSync', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate, timeUtils); + } + + /** + * Try to synchronize user preferences in a certain site or in all sites. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + syncPreferences(siteId?: string): Promise { + const syncFunctionLog = 'all user preferences'; + + return this.syncOnSites(syncFunctionLog, this.syncPreferencesFunc.bind(this), [], siteId); + } + + /** + * Sync user preferences of a site. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + protected syncPreferencesFunc(siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const syncId = 'preferences'; + + if (this.isSyncing(syncId, siteId)) { + // There's already a sync ongoing, return the promise. + return this.getOngoingSync(syncId, siteId); + } + + const warnings = []; + + this.logger.debug(`Try to sync user preferences`); + + const syncPromise = this.userOffline.getChangedPreferences(siteId).then((preferences) => { + return this.utils.allPromises(preferences.map((preference) => { + return this.userProvider.getUserPreferenceOnline(preference.name, siteId).then((onlineValue) => { + if (preference.onlinevalue != onlineValue) { + // Prefernce was changed on web while the app was offline, do not sync. + return this.userOffline.setPreference(preference.name, onlineValue, onlineValue, siteId); + } + + return this.userProvider.setUserPreference(preference.name, preference.value, siteId).catch((error) => { + if (this.utils.isWebServiceError(error)) { + warnings.push(this.textUtils.getErrorMessageFromError(error)); + } else { + // Couldn't connect to server, reject. + return Promise.reject(error); + } + }); + }); + })); + }).then(() => { + // All done, return the warnings. + return warnings; + }); + + return this.addOngoingSync(syncId, syncPromise, siteId); + } +} diff --git a/src/core/user/providers/user-delegate.ts b/src/core/user/providers/user-delegate.ts index 7bab647d1..92796f273 100644 --- a/src/core/user/providers/user-delegate.ts +++ b/src/core/user/providers/user-delegate.ts @@ -16,6 +16,7 @@ import { Injectable } from '@angular/core'; import { NavController } from 'ionic-angular'; import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; import { CoreCoursesProvider } from '@core/courses/providers/courses'; +import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; import { CoreEventsProvider } from '@providers/events'; @@ -173,7 +174,8 @@ export class CoreUserDelegate extends CoreDelegate { }} = {}; constructor(protected loggerProvider: CoreLoggerProvider, protected sitesProvider: CoreSitesProvider, - private coursesProvider: CoreCoursesProvider, protected eventsProvider: CoreEventsProvider) { + private coursesProvider: CoreCoursesProvider, protected eventsProvider: CoreEventsProvider, + protected utils: CoreUtilsProvider) { super('CoreUserDelegate', loggerProvider, sitesProvider, eventsProvider); eventsProvider.on(CoreUserDelegate.UPDATE_HANDLER_EVENT, (data) => { @@ -266,25 +268,23 @@ export class CoreUserDelegate extends CoreDelegate { for (const name in this.enabledHandlers) { // Checks if the handler is enabled for the user. const handler = this.handlers[name], - isEnabledForUser = handler.isEnabledForUser(user, courseId, navOptions, admOptions), - promise = Promise.resolve(isEnabledForUser).then((enabled) => { - if (enabled) { - userData.handlers.push({ - name: name, - data: handler.getDisplayData(user, courseId), - priority: handler.priority, - type: handler.type || CoreUserDelegate.TYPE_NEW_PAGE - }); - } else { - return Promise.reject(null); - } - }).catch(() => { - // Nothing to do here, it is not enabled for this user. - }); - promises.push(promise); + isEnabledForUser = handler.isEnabledForUser(user, courseId, navOptions, admOptions); + + promises.push(Promise.resolve(isEnabledForUser).then((enabled) => { + if (enabled) { + userData.handlers.push({ + name: name, + data: handler.getDisplayData(user, courseId), + priority: handler.priority, + type: handler.type || CoreUserDelegate.TYPE_NEW_PAGE + }); + } + }).catch(() => { + // Nothing to do here, it is not enabled for this user. + })); } - return Promise.all(promises).then(() => { + return this.utils.allPromises(promises).then(() => { // Sort them by priority. userData.handlers.sort((a, b) => { return b.priority - a.priority; diff --git a/src/core/user/providers/user-handler.ts b/src/core/user/providers/user-handler.ts index 15956dd8e..699f1fac2 100644 --- a/src/core/user/providers/user-handler.ts +++ b/src/core/user/providers/user-handler.ts @@ -48,7 +48,7 @@ export class CoreUserProfileMailHandler implements CoreUserProfileHandler { */ isEnabledForUser(user: any, courseId: number, navOptions?: any, admOptions?: any): boolean | Promise { // Not current user required. - return user.id != this.sitesProvider.getCurrentSite().getUserId() && user.email; + return user.id != this.sitesProvider.getCurrentSite().getUserId() && !!user.email; } /** diff --git a/src/core/user/providers/user.ts b/src/core/user/providers/user.ts index 928fb7bff..5b6141006 100644 --- a/src/core/user/providers/user.ts +++ b/src/core/user/providers/user.ts @@ -15,9 +15,12 @@ import { Injectable } from '@angular/core'; import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreLoggerProvider } from '@providers/logger'; -import { CoreSite } from '@classes/site'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreAppProvider } from '@providers/app'; +import { CoreUserOfflineProvider } from './offline'; +import { CorePushNotificationsProvider } from '@core/pushnotifications/providers/pushnotifications'; /** * Service to provide user functionalities. @@ -34,6 +37,7 @@ export class CoreUserProvider { protected siteSchema: CoreSiteSchema = { name: 'CoreUserProvider', version: 1, + canBeCleared: [ this.USERS_TABLE ], tables: [ { name: this.USERS_TABLE, @@ -51,7 +55,7 @@ export class CoreUserProvider { name: 'profileimageurl', type: 'TEXT' } - ] + ], } ] }; @@ -59,7 +63,8 @@ export class CoreUserProvider { protected logger; constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider, - private filepoolProvider: CoreFilepoolProvider) { + private filepoolProvider: CoreFilepoolProvider, private appProvider: CoreAppProvider, + private userOffline: CoreUserOfflineProvider, private pushNotificationsProvider: CorePushNotificationsProvider) { this.logger = logger.getInstance('CoreUserProvider'); this.sitesProvider.registerSiteSchema(this.siteSchema); } @@ -146,7 +151,8 @@ export class CoreUserProvider { } ] }, preSets: any = { - cacheKey: this.getParticipantsListCacheKey(courseId) + cacheKey: this.getParticipantsListCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (ignoreCache) { @@ -230,7 +236,8 @@ export class CoreUserProvider { protected getUserFromWS(userId: number, courseId?: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { const presets = { - cacheKey: this.getUserCacheKey(userId) + cacheKey: this.getUserCacheKey(userId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; let wsName, data; @@ -250,8 +257,8 @@ export class CoreUserProvider { this.logger.debug(`Get user with ID '${userId}'`); wsName = 'core_user_get_users_by_field'; data = { - 'field': 'id', - 'values[0]': userId + field: 'id', + values: [userId] }; } @@ -272,6 +279,68 @@ export class CoreUserProvider { }); } + /** + * Get a user preference (online or offline). + * + * @param {string} name Name of the preference. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {string} Preference value or null if preference not set. + */ + getUserPreference(name: string, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.userOffline.getPreference(name, siteId).catch(() => { + return null; + }).then((preference) => { + if (preference && !this.appProvider.isOnline()) { + // Offline, return stored value. + return preference.value; + } + + return this.getUserPreferenceOnline(name, siteId).then((wsValue) => { + if (preference && preference.value != preference.onlinevalue && preference.onlinevalue == wsValue) { + // Sync is pending for this preference, return stored value. + return preference.value; + } + + return this.userOffline.setPreference(name, wsValue, wsValue).then(() => { + return wsValue; + }); + }); + }); + } + + /** + * Get cache key for a user preference WS call. + * + * @param {string} name Preference name. + * @return {string} Cache key. + */ + protected getUserPreferenceCacheKey(name: string): string { + return this.ROOT_CACHE_KEY + 'preference:' + name; + } + + /** + * Get a user preference online. + * + * @param {string} name Name of the preference. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {string} Preference value or null if preference not set. + */ + getUserPreferenceOnline(name: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const data = { name }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserPreferenceCacheKey(data.name), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES + }; + + return site.read('core_user_get_user_preferences', data, preSets).then((result) => { + return result.preferences[0] ? result.preferences[0].value : null; + }); + }); + } + /** * Invalidates user WS calls. * @@ -298,6 +367,19 @@ export class CoreUserProvider { }); } + /** + * Invalidate user preference. + * + * @param {string} name Name of the preference. + * @param {string} [siteId] Site Id. If not defined, use current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateUserPreference(name: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getUserPreferenceCacheKey(name)); + }); + } + /** * Check if course participants is disabled in a certain site. * @@ -354,18 +436,22 @@ export class CoreUserProvider { * Log User Profile View in Moodle. * @param {number} userId User ID. * @param {number} [courseId] Course ID. + * @param {string} [name] Name of the user. * @return {Promise} Promise resolved when done. */ - logView(userId: number, courseId?: number): Promise { + logView(userId: number, courseId?: number, name?: string): Promise { const params = { - userid: userId - }; + userid: userId + }, + wsName = 'core_user_view_user_profile'; if (courseId) { params['courseid'] = courseId; } - return this.sitesProvider.getCurrentSite().write('core_user_view_user_profile', params); + this.pushNotificationsProvider.logViewEvent(userId, name, 'user', wsName, {courseid: courseId}); + + return this.sitesProvider.getCurrentSite().write(wsName, params); } /** @@ -374,9 +460,13 @@ export class CoreUserProvider { * @return {Promise} Promise resolved when done. */ logParticipantsView(courseId?: number): Promise { - return this.sitesProvider.getCurrentSite().write('core_user_view_user_list', { + const params = { courseid: courseId - }); + }; + + this.pushNotificationsProvider.logViewListEvent('user', 'core_user_view_user_list', params); + + return this.sitesProvider.getCurrentSite().write('core_user_view_user_list', params); } /** @@ -455,6 +545,45 @@ export class CoreUserProvider { return Promise.all(promises); } + /** + * Set a user preference (online or offline). + * + * @param {string} name Name of the preference. + * @param {string} value Value of the preference. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved on success. + */ + setUserPreference(name: string, value: string, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + let isOnline = this.appProvider.isOnline(); + let promise: Promise; + + if (isOnline) { + const preferences = [{type: name, value}]; + promise = this.updateUserPreferences(preferences, undefined, undefined, siteId).catch((error) => { + // Preference not saved online. + isOnline = false; + + return Promise.reject(error); + }); + } else { + promise = Promise.resolve(); + } + + return promise.finally(() => { + // Update stored online value if saved online. + const onlineValue = isOnline ? value : undefined; + + return Promise.all([ + this.userOffline.setPreference(name, value, onlineValue), + this.invalidateUserPreference(name).catch(() => { + // Ignore error. + }) + ]); + }); + } + /** * Update a preference for a user. * @@ -478,13 +607,14 @@ export class CoreUserProvider { /** * Update some preferences for a user. * - * @param {any} preferences List of preferences. + * @param {{name: string, value: string}[]} preferences List of preferences. * @param {boolean} [disableNotifications] Whether to disable all notifications. Undefined to not update this value. * @param {number} [userId] User ID. If not defined, site's current user. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved if success. */ - updateUserPreferences(preferences: any, disableNotifications: boolean, userId?: number, siteId?: string): Promise { + updateUserPreferences(preferences: {type: string, value: string}[], disableNotifications?: boolean, userId?: number, + siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { userId = userId || site.getUserId(); diff --git a/src/core/user/user.module.ts b/src/core/user/user.module.ts index d2886ffe3..845f2179e 100644 --- a/src/core/user/user.module.ts +++ b/src/core/user/user.module.ts @@ -26,6 +26,10 @@ import { CoreUserParticipantsCourseOptionHandler } from './providers/course-opti import { CoreUserParticipantsLinkHandler } from './providers/participants-link-handler'; import { CoreCourseOptionsDelegate } from '@core/course/providers/options-delegate'; import { CoreUserComponentsModule } from './components/components.module'; +import { CoreCronDelegate } from '@providers/cron'; +import { CoreUserOfflineProvider } from './providers/offline'; +import { CoreUserSyncProvider } from './providers/sync'; +import { CoreUserSyncCronHandler } from './providers/sync-cron-handler'; // List of providers (without handlers). export const CORE_USER_PROVIDERS: any[] = [ @@ -33,6 +37,8 @@ export const CORE_USER_PROVIDERS: any[] = [ CoreUserProfileFieldDelegate, CoreUserProvider, CoreUserHelperProvider, + CoreUserOfflineProvider, + CoreUserSyncProvider ]; @NgModule({ @@ -46,10 +52,13 @@ export const CORE_USER_PROVIDERS: any[] = [ CoreUserProfileFieldDelegate, CoreUserProvider, CoreUserHelperProvider, + CoreUserOfflineProvider, + CoreUserSyncProvider, CoreUserProfileMailHandler, CoreUserProfileLinkHandler, CoreUserParticipantsCourseOptionHandler, - CoreUserParticipantsLinkHandler + CoreUserParticipantsLinkHandler, + CoreUserSyncCronHandler, ] }) export class CoreUserModule { @@ -57,12 +66,14 @@ export class CoreUserModule { eventsProvider: CoreEventsProvider, sitesProvider: CoreSitesProvider, userProvider: CoreUserProvider, contentLinksDelegate: CoreContentLinksDelegate, userLinkHandler: CoreUserProfileLinkHandler, courseOptionHandler: CoreUserParticipantsCourseOptionHandler, linkHandler: CoreUserParticipantsLinkHandler, - courseOptionsDelegate: CoreCourseOptionsDelegate) { + courseOptionsDelegate: CoreCourseOptionsDelegate, cronDelegate: CoreCronDelegate, + syncHandler: CoreUserSyncCronHandler) { userDelegate.registerHandler(userProfileMailHandler); courseOptionsDelegate.registerHandler(courseOptionHandler); contentLinksDelegate.registerHandler(userLinkHandler); contentLinksDelegate.registerHandler(linkHandler); + cronDelegate.register(syncHandler); eventsProvider.on(CoreEventsProvider.USER_DELETED, (data) => { // Search for userid in params. diff --git a/src/directives/format-text.ts b/src/directives/format-text.ts index d840c8a84..8cfbd3067 100644 --- a/src/directives/format-text.ts +++ b/src/directives/format-text.ts @@ -55,6 +55,7 @@ export class CoreFormatTextDirective implements OnChanges { // If you want to avoid this use class="inline" at the same time to use display: inline-block. @Input() fullOnClick?: boolean | string; // Whether it should open a new page with the full contents on click. @Input() fullTitle?: string; // Title to use in full view. Defaults to "Description". + @Input() highlight?: string; // Text to highlight. @Output() afterRender?: EventEmitter; // Called when the data is rendered. protected element: HTMLElement; @@ -348,7 +349,7 @@ export class CoreFormatTextDirective implements OnChanges { // Apply format text function. return this.textUtils.formatText(this.text, this.utils.isTrueOrOne(this.clean), - this.utils.isTrueOrOne(this.singleLine)); + this.utils.isTrueOrOne(this.singleLine), undefined, this.highlight); }).then((formatted) => { const div = document.createElement('div'), canTreatVimeo = site && site.isVersionGreaterEqualThan(['3.3.4', '3.4']); @@ -441,6 +442,8 @@ export class CoreFormatTextDirective implements OnChanges { this.iframeUtils.treatFrame(frame); }); + this.domUtils.handleBootstrapTooltips(div); + return div; }); } @@ -590,7 +593,7 @@ export class CoreFormatTextDirective implements OnChanges { // Check if it's a Vimeo video. If it is, use the wsplayer script instead to make restricted videos work. const matches = iframe.src.match(/https?:\/\/player\.vimeo\.com\/video\/([0-9]+)/); if (matches && matches[1]) { - const newUrl = this.textUtils.concatenatePaths(site.getURL(), '/media/player/vimeo/wsplayer.php?video=') + + let newUrl = this.textUtils.concatenatePaths(site.getURL(), '/media/player/vimeo/wsplayer.php?video=') + matches[1] + '&token=' + site.getToken(); // Width and height are mandatory, we need to calculate them. @@ -614,8 +617,12 @@ export class CoreFormatTextDirective implements OnChanges { } } - // Always include the width and height in the URL. - iframe.src = newUrl + '&width=' + width + '&height=' + height; + // Width and height parameters are required in 3.6 and older sites. + if (!site.isVersionGreaterEqualThan('3.7')) { + newUrl += '&width=' + width + '&height=' + height; + } + iframe.src = newUrl; + if (!iframe.width) { iframe.width = width; } diff --git a/src/directives/link.ts b/src/directives/link.ts index 5746fbf51..0540eb83f 100644 --- a/src/directives/link.ts +++ b/src/directives/link.ts @@ -68,7 +68,7 @@ export class CoreLinkDirective implements OnInit { event.stopPropagation(); if (this.utils.isTrueOrOne(this.capture)) { - this.contentLinksHelper.handleLink(href, undefined, navCtrl).then((treated) => { + this.contentLinksHelper.handleLink(href, undefined, navCtrl, true, true).then((treated) => { if (!treated) { this.navigate(href); } diff --git a/src/lang/en.json b/src/lang/en.json index 27e5e170f..bf2f305e5 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -4,6 +4,7 @@ "agelocationverification": "Age and location verification", "ago": "{{$a}} ago", "all": "All", + "allgroups": "All groups", "allparticipants": "All participants", "android": "Android", "answer": "Answer", @@ -72,6 +73,7 @@ "dismiss": "Dismiss", "done": "Done", "download": "Download", + "downloaded": "Downloaded", "downloading": "Downloading", "edit": "Edit", "emptysplit": "This page will appear blank if the left panel is empty or is loading.", @@ -171,6 +173,7 @@ "nopermissions": "Sorry, but you do not currently have permissions to do that ({{$a}}).", "noresults": "No results", "notapplicable": "n/a", + "notenrolledprofile": "This profile is not available because this user is not enrolled in this course.", "notice": "Notice", "notingroup": "Sorry, but you need to be part of a group to see this page.", "notsent": "Not sent", @@ -200,6 +203,7 @@ "resourcedisplayopen": "Open", "resources": "Resources", "restore": "Restore", + "restricted": "Restricted", "retry": "Retry", "save": "Save", "search": "Search", @@ -221,6 +225,7 @@ "sizemb": "MB", "sizetb": "TB", "sorry": "Sorry...", + "sort": "Sort", "sortby": "Sort by", "start": "Start", "strftimedate": "%d %B %Y", diff --git a/src/providers/app.ts b/src/providers/app.ts index 6fa8ad847..abbf4dd9f 100644 --- a/src/providers/app.ts +++ b/src/providers/app.ts @@ -16,11 +16,13 @@ import { Injectable, NgZone } from '@angular/core'; import { Platform, App, NavController, MenuController } from 'ionic-angular'; import { Keyboard } from '@ionic-native/keyboard'; import { Network } from '@ionic-native/network'; +import { StatusBar } from '@ionic-native/status-bar'; import { CoreDbProvider } from './db'; import { CoreLoggerProvider } from './logger'; import { CoreEventsProvider } from './events'; import { SQLiteDB } from '@classes/sqlitedb'; +import { CoreConfigConstants } from '../configconstants'; /** * Data stored for a redirect to another page/site. @@ -69,10 +71,13 @@ export class CoreAppProvider { protected ssoAuthenticationPromise: Promise; protected isKeyboardShown = false; protected backActions = []; + protected mainMenuId = 0; + protected mainMenuOpen: number; + protected forceOffline = false; constructor(dbProvider: CoreDbProvider, private platform: Platform, private keyboard: Keyboard, private appCtrl: App, - private network: Network, logger: CoreLoggerProvider, events: CoreEventsProvider, zone: NgZone, - private menuCtrl: MenuController) { + private network: Network, logger: CoreLoggerProvider, private events: CoreEventsProvider, zone: NgZone, + private menuCtrl: MenuController, private statusBar: StatusBar) { this.logger = logger.getInstance('CoreAppProvider'); this.db = dbProvider.getDB(this.DBNAME); @@ -98,6 +103,9 @@ export class CoreAppProvider { this.platform.registerBackButtonAction(() => { this.backButtonAction(); }, 100); + + // Export the app provider so Behat tests can change the forceOffline flag. + ( window).appProvider = this; } /** @@ -136,6 +144,15 @@ export class CoreAppProvider { return this.db; } + /** + * Get an ID for a main menu. + * + * @return {number} Main menu ID. + */ + getMainMenuId(): number { + return this.mainMenuId++; + } + /** * Get the app's root NavController. * @@ -211,6 +228,15 @@ export class CoreAppProvider { } } + /** + * Check if the main menu is open. + * + * @return {boolean} Whether the main menu is open. + */ + isMainMenuOpen(): boolean { + return typeof this.mainMenuOpen != 'undefined'; + } + /** * Checks if the app is running in a mobile or tablet device (Cordova). * @@ -235,6 +261,10 @@ export class CoreAppProvider { * @return {boolean} Whether the app is online. */ isOnline(): boolean { + if (this.forceOffline) { + return false; + } + let online = this.network.type !== null && this.network.type != Connection.NONE && this.network.type != Connection.UNKNOWN; // Double check we are not online because we cannot rely 100% in Cordova APIs. Also, check it in browser. if (!online && navigator.onLine) { @@ -297,6 +327,21 @@ export class CoreAppProvider { } } + /** + * Set a main menu as open or not. + * + * @param {number} id Main menu ID. + * @param {boolean} open Whether it's open or not. + */ + setMainMenuOpen(id: number, open: boolean): void { + if (open) { + this.mainMenuOpen = id; + this.events.trigger(CoreEventsProvider.MAIN_MENU_OPEN); + } else if (this.mainMenuOpen == id) { + delete this.mainMenuOpen; + } + } + /** * Start an SSO authentication process. * Please notice that this function should be called when the app receives the new token from the browser, @@ -490,4 +535,53 @@ export class CoreAppProvider { return index >= 0 && !!this.backActions.splice(index, 1); }; } + + /** + * Set StatusBar color depending on platform. + */ + setStatusBarColor(): void { + if (typeof CoreConfigConstants.statusbarbgios == 'string' && this.platform.is('ios')) { + // IOS Status bar properties. + this.statusBar.overlaysWebView(false); + this.statusBar.backgroundColorByHexString(CoreConfigConstants.statusbarbgios); + CoreConfigConstants.statusbarlighttextios ? this.statusBar.styleLightContent() : this.statusBar.styleDefault(); + } else if (typeof CoreConfigConstants.statusbarbgandroid == 'string' && this.platform.is('android')) { + // Android Status bar properties. + this.statusBar.backgroundColorByHexString(CoreConfigConstants.statusbarbgandroid); + CoreConfigConstants.statusbarlighttextandroid ? this.statusBar.styleLightContent() : this.statusBar.styleDefault(); + } else if (typeof CoreConfigConstants.statusbarbg == 'string') { + // Generic Status bar properties. + this.platform.is('ios') && this.statusBar.overlaysWebView(false); + this.statusBar.backgroundColorByHexString(CoreConfigConstants.statusbarbg); + CoreConfigConstants.statusbarlighttext ? this.statusBar.styleLightContent() : this.statusBar.styleDefault(); + } else { + // Default Status bar properties. + this.platform.is('android') ? this.statusBar.styleLightContent() : this.statusBar.styleDefault(); + } + } + + /** + * Reset StatusBar color if any was set. + */ + resetStatusBarColor(): void { + if (typeof CoreConfigConstants.statusbarbgremotetheme == 'string' && + ((typeof CoreConfigConstants.statusbarbgios == 'string' && this.platform.is('ios')) || + (typeof CoreConfigConstants.statusbarbgandroid == 'string' && this.platform.is('android')) || + typeof CoreConfigConstants.statusbarbg == 'string')) { + // If the status bar has been overriden and there's a fallback color for remote themes, use it now. + this.platform.is('ios') && this.statusBar.overlaysWebView(false); + this.statusBar.backgroundColorByHexString(CoreConfigConstants.statusbarbgremotetheme); + CoreConfigConstants.statusbarlighttextremotetheme ? + this.statusBar.styleLightContent() : this.statusBar.styleDefault(); + } + } + + /** + * Set value of forceOffline flag. If true, the app will think the device is offline. + * + * @param {boolean} value Value to set. + */ + setForceOffline(value: boolean): void { + this.forceOffline = !!value; + } } diff --git a/src/providers/cron.ts b/src/providers/cron.ts index 99d12b95d..b29bc654f 100644 --- a/src/providers/cron.ts +++ b/src/providers/cron.ts @@ -63,10 +63,11 @@ export interface CoreCronHandler { * Execute the process. * * @param {string} [siteId] ID of the site affected. If not defined, all sites. + * @param {boolean} [force] Determines if it's a forced execution. * @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; + execute?(siteId?: string, force?: boolean): Promise; /** * Whether the handler is running. Used internally by the provider, there's no need to set it. @@ -181,7 +182,7 @@ export class CoreCronDelegate { this.queuePromise = this.queuePromise.catch(() => { // Ignore errors in previous handlers. }).then(() => { - return this.executeHandler(name, siteId).then(() => { + return this.executeHandler(name, force, siteId).then(() => { this.logger.debug(`Execution of handler '${name}' was a success.`); return this.setHandlerLastExecutionTime(name, Date.now()).then(() => { @@ -204,16 +205,18 @@ export class CoreCronDelegate { * Run a handler, cancelling the execution if it takes more than MAX_TIME_PROCESS. * * @param {string} name Name of the handler. + * @param {boolean} [force] Wether the execution is forced (manual sync). * @param {string} [siteId] Site ID. If not defined, all sites. * @return {Promise} Promise resolved when the handler finishes or reaches max time, rejected if it fails. */ - protected executeHandler(name: string, siteId?: string): Promise { + protected executeHandler(name: string, force?: boolean, siteId?: string): Promise { return new Promise((resolve, reject): void => { let cancelTimeout; this.logger.debug('Executing handler: ' + name); + // Wrap the call in Promise.resolve to make sure it's a promise. - Promise.resolve(this.handlers[name].execute(siteId)).then(resolve).catch(reject).finally(() => { + Promise.resolve(this.handlers[name].execute(siteId, force)).then(resolve).catch(reject).finally(() => { clearTimeout(cancelTimeout); }); diff --git a/src/providers/events.ts b/src/providers/events.ts index fca9550ed..650cc55e8 100644 --- a/src/providers/events.ts +++ b/src/providers/events.ts @@ -59,6 +59,7 @@ export class CoreEventsProvider { static ORIENTATION_CHANGE = 'orientation_change'; static LOAD_PAGE_MAIN_MENU = 'load_page_main_menu'; static SEND_ON_ENTER_CHANGED = 'send_on_enter_changed'; + static MAIN_MENU_OPEN = 'main_menu_open'; protected logger; protected observables: { [s: string]: Subject } = {}; diff --git a/src/providers/filepool.ts b/src/providers/filepool.ts index d148c7f72..09d873f94 100644 --- a/src/providers/filepool.ts +++ b/src/providers/filepool.ts @@ -306,7 +306,7 @@ export class CoreFilepoolProvider { primaryKeys: ['siteId', 'fileId'] } ]; - protected siteSchma: CoreSiteSchema = { + protected siteSchema: CoreSiteSchema = { name: 'CoreFilepoolProvider', version: 1, tables: [ @@ -447,7 +447,7 @@ export class CoreFilepoolProvider { this.appDB = this.appProvider.getDB(); this.appDB.createTablesFromSchema(this.appTablesSchema); - this.sitesProvider.registerSiteSchema(this.siteSchma); + this.sitesProvider.registerSiteSchema(this.siteSchema); initDelegate.ready().then(() => { // Waiting for the app to be ready to start processing the queue. @@ -857,7 +857,10 @@ export class CoreFilepoolProvider { */ clearFilepool(siteId: string): Promise { return this.sitesProvider.getSiteDb(siteId).then((db) => { - return db.deleteRecords(this.FILES_TABLE); + return Promise.all([ + db.deleteRecords(this.FILES_TABLE), + db.deleteRecords(this.LINKS_TABLE) + ]); }); } @@ -1122,10 +1125,10 @@ export class CoreFilepoolProvider { return Promise.all(promises).then(() => { // Success prefetching, store package as downloaded. return this.storePackageStatus(siteId, CoreConstants.DOWNLOADED, component, componentId, extra); - }).catch(() => { + }).catch((error) => { // Error downloading, go back to previous status and reject the promise. return this.setPackagePreviousStatus(siteId, component, componentId).then(() => { - return Promise.reject(null); + return Promise.reject(error); }); }); @@ -2566,18 +2569,26 @@ export class CoreFilepoolProvider { dropFromQueue = true; } + let errorMessage = null; + // Some Android devices restrict the amount of usable storage using quotas. + // If this quota would be exceeded by the download, it throws an exception. + // We catch this exception here, and report a meaningful error message to the user. + if (errorObject instanceof FileTransferError && errorObject.exception && errorObject.exception.includes('EDQUOT')) { + errorMessage = 'core.course.insufficientavailablequota'; + } + if (dropFromQueue) { this.logger.debug('Item dropped from queue due to error: ' + fileUrl, errorObject); return this.removeFromQueue(siteId, fileId).catch(() => { // Consider this as a silent error, never reject the promise here. }).then(() => { - this.treatQueueDeferred(siteId, fileId, false); + this.treatQueueDeferred(siteId, fileId, false, errorMessage); this.notifyFileDownloadError(siteId, fileId); }); } else { // We considered the file as legit but did not get it, failure. - this.treatQueueDeferred(siteId, fileId, false); + this.treatQueueDeferred(siteId, fileId, false, errorMessage); this.notifyFileDownloadError(siteId, fileId); return Promise.reject(errorObject); @@ -2912,13 +2923,14 @@ export class CoreFilepoolProvider { * @param {string} siteId The site ID. * @param {string} fileId The file ID. * @param {boolean} resolve True if promise should be resolved, false if it should be rejected. + * @param {string} error String identifier for error message, if rejected. */ - protected treatQueueDeferred(siteId: string, fileId: string, resolve: boolean): void { + protected treatQueueDeferred(siteId: string, fileId: string, resolve: boolean, error?: string): void { if (this.queueDeferreds[siteId] && this.queueDeferreds[siteId][fileId]) { if (resolve) { this.queueDeferreds[siteId][fileId].resolve(); } else { - this.queueDeferreds[siteId][fileId].reject(); + this.queueDeferreds[siteId][fileId].reject(error); } delete this.queueDeferreds[siteId][fileId]; } diff --git a/src/providers/groups.ts b/src/providers/groups.ts index f2ce0b146..a6507bc1f 100644 --- a/src/providers/groups.ts +++ b/src/providers/groups.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { CoreSitesProvider } from './sites'; import { CoreCoursesProvider } from '@core/courses/providers/courses'; -import { CoreSiteWSPreSets } from '@classes/site'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; /** * Group info for an activity. @@ -89,7 +89,8 @@ export class CoreGroupsProvider { userid: userId }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getActivityAllowedGroupsCacheKey(cmId, userId) + cacheKey: this.getActivityAllowedGroupsCacheKey(cmId, userId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (ignoreCache) { @@ -196,7 +197,8 @@ export class CoreGroupsProvider { cmid: cmId }, preSets: CoreSiteWSPreSets = { - cacheKey: this.getActivityGroupModeCacheKey(cmId) + cacheKey: this.getActivityGroupModeCacheKey(cmId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; if (ignoreCache) { @@ -283,7 +285,8 @@ export class CoreGroupsProvider { courseid: courseId }, preSets = { - cacheKey: this.getUserGroupsInCourseCacheKey(courseId, userId) + cacheKey: this.getUserGroupsInCourseCacheKey(courseId, userId), + updateFrequency: CoreSite.FREQUENCY_RARELY }; return site.read('core_group_get_course_user_groups', data, preSets).then((response) => { diff --git a/src/providers/lang.ts b/src/providers/lang.ts index 266421412..04f8283ee 100644 --- a/src/providers/lang.ts +++ b/src/providers/lang.ts @@ -58,7 +58,7 @@ export class CoreLangProvider { * @param {string} [prefix] A prefix to add to all keys. */ addSitePluginsStrings(lang: string, strings: any, prefix?: string): void { - lang = lang.replace('_', '-'); // Use the app format instead of Moodle format. + lang = lang.replace(/_/g, '-'); // Use the app format instead of Moodle format. // Initialize structure if it doesn't exist. if (!this.sitePluginsStrings[lang]) { @@ -307,7 +307,7 @@ export class CoreLangProvider { return; } - lang = values[2].replace('_', '-'); // Use the app format instead of Moodle format. + lang = values[2].replace(/_/g, '-'); // Use the app format instead of Moodle format. if (!this.customStrings[lang]) { this.customStrings[lang] = {}; @@ -363,7 +363,7 @@ export class CoreLangProvider { * @param {string} value String value. */ loadString(langObject: any, lang: string, key: string, value: string): void { - lang = lang.replace('_', '-'); // Use the app format instead of Moodle format. + lang = lang.replace(/_/g, '-'); // Use the app format instead of Moodle format. if (this.translate.translations[lang]) { // The language is loaded. diff --git a/src/providers/local-notifications.ts b/src/providers/local-notifications.ts index 49ea21312..ae9de1652 100644 --- a/src/providers/local-notifications.ts +++ b/src/providers/local-notifications.ts @@ -25,6 +25,7 @@ import { CoreTextUtilsProvider } from './utils/text'; import { CoreUtilsProvider } from './utils/utils'; import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb'; import { CoreConstants } from '@core/constants'; +import { CoreConfigConstants } from '../configconstants'; import { Subject, Subscription } from 'rxjs'; /* @@ -101,6 +102,10 @@ export class CoreLocalNotificationsProvider { }; protected triggerSubscription: Subscription; protected clickSubscription: Subscription; + protected clearSubscription: Subscription; + protected cancelSubscription: Subscription; + protected addSubscription: Subscription; + protected updateSubscription: Subscription; constructor(logger: CoreLoggerProvider, private localNotifications: LocalNotifications, private platform: Platform, private appProvider: CoreAppProvider, private utils: CoreUtilsProvider, private configProvider: CoreConfigProvider, @@ -112,16 +117,31 @@ export class CoreLocalNotificationsProvider { this.appDB.createTablesFromSchema(this.tablesSchema); platform.ready().then(() => { + // Listen to events. this.triggerSubscription = localNotifications.on('trigger').subscribe((notification: ILocalNotification) => { this.trigger(notification); + + this.handleEvent('trigger', notification); }); this.clickSubscription = localNotifications.on('click').subscribe((notification: ILocalNotification) => { - if (notification && notification.data) { - this.logger.debug('Notification clicked: ', notification.data); + this.handleEvent('click', notification); + }); - this.notifyClick(notification.data); - } + this.clearSubscription = localNotifications.on('clear').subscribe((notification: ILocalNotification) => { + this.handleEvent('clear', notification); + }); + + this.cancelSubscription = localNotifications.on('cancel').subscribe((notification: ILocalNotification) => { + this.handleEvent('cancel', notification); + }); + + this.addSubscription = localNotifications.on('schedule').subscribe((notification: ILocalNotification) => { + this.handleEvent('schedule', notification); + }); + + this.updateSubscription = localNotifications.on('update').subscribe((notification: ILocalNotification) => { + this.handleEvent('update', notification); }); // Create the default channel for local notifications. @@ -185,6 +205,17 @@ export class CoreLocalNotificationsProvider { }); } + /** + * Check whether sound can be disabled for notifications. + * + * @return {boolean} Whether sound can be disabled for notifications. + */ + canDisableSound(): boolean { + // Only allow disabling sound in Android 7 or lower. In iOS and Android 8+ it can easily be done with system settings. + return this.isAvailable() && !this.appProvider.isDesktop() && this.platform.is('android') && + this.platform.version().major < 8; + } + /** * Create the default channel. It is used to change the name. * @@ -289,6 +320,20 @@ export class CoreLocalNotificationsProvider { }); } + /** + * Handle an event triggered by the local notifications plugin. + * + * @param {string} eventName Name of the event. + * @param {any} notification Notification. + */ + protected handleEvent(eventName: string, notification: any): void { + if (notification && notification.data) { + this.logger.debug('Notification event: ' + eventName + '. Data:', notification.data); + + this.notifyEvent(eventName, notification.data); + } + } + /** * Returns whether local notifications plugin is installed. * @@ -327,12 +372,22 @@ export class CoreLocalNotificationsProvider { * @param {any} data Data received by the notification. */ notifyClick(data: any): void { + this.notifyEvent('click', data); + } + + /** + * Notify a certain event to observers. Only the observers with the same component as the notification will be notified. + * + * @param {string} eventName Name of the event to notify. + * @param {any} data Data received by the notification. + */ + notifyEvent(eventName: string, data: any): void { // Execute the code in the Angular zone, so change detection doesn't stop working. this.zone.run(() => { const component = data.component; if (component) { - if (this.observables[component]) { - this.observables[component].next(data); + if (this.observables[eventName] && this.observables[eventName][component]) { + this.observables[eventName][component].next(data); } } }); @@ -384,18 +439,34 @@ export class CoreLocalNotificationsProvider { * @return {any} Object with an "off" property to stop listening for clicks. */ registerClick(component: string, callback: Function): any { - this.logger.debug(`Register observer '${component}' for notification click.`); + return this.registerObserver('click', component, callback); + } - if (typeof this.observables[component] == 'undefined') { - // No observable for this component, create a new one. - this.observables[component] = new Subject(); + /** + * Register an observer to be notified when a certain event is fired for a notification belonging to a certain component. + * + * @param {string} eventName Name of the event to listen to. + * @param {string} component Component to listen notifications for. + * @param {Function} callback Function to call with the data received by the notification. + * @return {any} Object with an "off" property to stop listening for events. + */ + registerObserver(eventName: string, component: string, callback: Function): any { + this.logger.debug(`Register observer '${component}' for event '${eventName}'.`); + + if (typeof this.observables[eventName] == 'undefined') { + this.observables[eventName] = {}; } - this.observables[component].subscribe(callback); + if (typeof this.observables[eventName][component] == 'undefined') { + // No observable for this component, create a new one. + this.observables[eventName][component] = new Subject(); + } + + this.observables[eventName][component].subscribe(callback); return { off: (): void => { - this.observables[component].unsubscribe(callback); + this.observables[eventName][component].unsubscribe(callback); } }; } @@ -469,10 +540,19 @@ export class CoreLocalNotificationsProvider { * be unique inside its component and site. * @param {string} component Component triggering the notification. It is used to generate unique IDs. * @param {string} siteId Site ID. + * @param {boolean} [alreadyUnique] Whether the ID is already unique. * @return {Promise} Promise resolved when the notification is scheduled. */ - schedule(notification: ILocalNotification, component: string, siteId: string): Promise { - return this.getUniqueNotificationId(notification.id, component, siteId).then((uniqueId) => { + schedule(notification: ILocalNotification, component: string, siteId: string, alreadyUnique?: boolean): Promise { + let promise; + + if (alreadyUnique) { + promise = Promise.resolve(notification.id); + } else { + promise = this.getUniqueNotificationId(notification.id, component, siteId); + } + + return promise.then((uniqueId) => { notification.id = uniqueId; notification.data = notification.data || {}; notification.data.component = component; @@ -481,6 +561,7 @@ export class CoreLocalNotificationsProvider { if (this.platform.is('android')) { notification.icon = notification.icon || 'res://icon'; notification.smallIcon = notification.smallIcon || 'res://smallicon'; + notification.color = notification.color || CoreConfigConstants.notificoncolor; const led: any = notification.led || {}; notification.led = { @@ -507,7 +588,15 @@ export class CoreLocalNotificationsProvider { return this.localNotifications.cancel(notification.id).finally(() => { if (!triggered) { // Check if sound is enabled for notifications. - return this.configProvider.get(CoreConstants.SETTINGS_NOTIFICATION_SOUND, true).then((soundEnabled) => { + let promise; + + if (this.canDisableSound()) { + promise = this.configProvider.get(CoreConstants.SETTINGS_NOTIFICATION_SOUND, true); + } else { + promise = Promise.resolve(true); + } + + return promise.then((soundEnabled) => { if (!soundEnabled) { notification.sound = null; } else { diff --git a/src/providers/sites.ts b/src/providers/sites.ts index 9ae995e8b..b64da98fd 100644 --- a/src/providers/sites.ts +++ b/src/providers/sites.ts @@ -27,6 +27,7 @@ import { CoreConfigConstants } from '../configconstants'; import { CoreSite } from '@classes/site'; import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb'; import { Md5 } from 'ts-md5/dist/md5'; +import { Location } from '@angular/common'; /** * Response of checking if a site exists and its configuration. @@ -145,6 +146,13 @@ export interface CoreSiteSchema { */ version: number; + /** + * Names of the tables of the site schema that can be cleared. + * + * @type {string[]} + */ + canBeCleared?: string[]; + /** * Tables to create when installing or upgrading the schema. */ @@ -270,6 +278,7 @@ export class CoreSitesProvider { protected siteSchema: CoreSiteSchema = { name: 'CoreSitesProvider', version: 1, + canBeCleared: [ CoreSite.WS_CACHE_TABLE ], tables: [ { name: CoreSite.WS_CACHE_TABLE, @@ -312,7 +321,7 @@ export class CoreSitesProvider { constructor(logger: CoreLoggerProvider, private http: HttpClient, private sitesFactory: CoreSitesFactoryProvider, private appProvider: CoreAppProvider, private translate: TranslateService, private urlUtils: CoreUrlUtilsProvider, - private eventsProvider: CoreEventsProvider, private textUtils: CoreTextUtilsProvider, + private eventsProvider: CoreEventsProvider, private textUtils: CoreTextUtilsProvider, private location: Location, private utils: CoreUtilsProvider) { this.logger = logger.getInstance('CoreSitesProvider'); @@ -565,33 +574,69 @@ export class CoreSitesProvider { * @param {string} siteUrl The site url. * @param {string} token User's token. * @param {string} [privateToken=''] User's private token. - * @return {Promise} A promise resolved when the site is added and the user is authenticated. + * @param {boolean} [login=true] Whether to login the user in the site. Defaults to true. + * @return {Promise} A promise resolved with siteId when the site is added and the user is authenticated. */ - newSite(siteUrl: string, token: string, privateToken: string = ''): Promise { + newSite(siteUrl: string, token: string, privateToken: string = '', login: boolean = true): Promise { + if (typeof login != 'boolean') { + login = true; + } + // Create a "candidate" site to fetch the site info. - const candidateSite = this.sitesFactory.makeSite(undefined, siteUrl, token, undefined, privateToken); + let candidateSite = this.sitesFactory.makeSite(undefined, siteUrl, token, undefined, privateToken), + isNewSite = true; return candidateSite.fetchSiteInfo().then((info) => { const result = this.isValidMoodleVersion(info); if (result == this.VALID_VERSION) { - // Set site ID and info. const siteId = this.createSiteID(info.siteurl, info.username); - candidateSite.setId(siteId); - candidateSite.setInfo(info); - // Create database tables before login and before any WS call. - return this.migrateSiteSchemas(candidateSite).then(() => { + // Check if the site already exists. + return this.getSite(siteId).catch(() => { + // Not exists. + }).then((site) => { + if (site) { + // Site already exists, update its data and use it. + isNewSite = false; + candidateSite = site; + candidateSite.setToken(token); + candidateSite.setPrivateToken(privateToken); + candidateSite.setInfo(info); + + } else { + // New site, set site ID and info. + isNewSite = true; + candidateSite.setId(siteId); + candidateSite.setInfo(info); + + // Create database tables before login and before any WS call. + return this.migrateSiteSchemas(candidateSite); + } + + }).then(() => { // Try to get the site config. - return this.getSiteConfig(candidateSite).then((config) => { - candidateSite.setConfig(config); + return this.getSiteConfig(candidateSite).catch((error) => { + // Ignore errors if it's not a new site, we'll use the config already stored. + if (isNewSite) { + return Promise.reject(error); + } + }).then((config) => { + if (typeof config != 'undefined') { + candidateSite.setConfig(config); + } + // Add site to sites list. this.addSite(siteId, siteUrl, token, info, privateToken, config); - // Turn candidate site into current site. - this.currentSite = candidateSite; this.sites[siteId] = candidateSite; - // Store session. - this.login(siteId); + + if (login) { + // Turn candidate site into current site. + this.currentSite = candidateSite; + // Store session. + this.login(siteId); + } + this.eventsProvider.trigger(CoreEventsProvider.SITE_ADDED, info, siteId); return siteId; @@ -1009,7 +1054,7 @@ export class CoreSitesProvider { id: site.id, siteUrl: site.siteUrl, fullName: siteInfo && siteInfo.fullname, - siteName: siteInfo && siteInfo.sitename, + siteName: CoreConfigConstants.sitename ? CoreConfigConstants.sitename : siteInfo && siteInfo.sitename, avatar: siteInfo && siteInfo.userpictureurl }; formattedSites.push(basicInfo); @@ -1117,6 +1162,9 @@ export class CoreSitesProvider { promises.push(this.appDB.deleteRecords(this.CURRENT_SITE_TABLE, { id: 1 })); return Promise.all(promises).finally(() => { + // Due to DeepLinker, we need to remove the path from the URL, otherwise some pages are re-created when they shouldn't. + this.location.replaceState(''); + this.eventsProvider.trigger(CoreEventsProvider.LOGOUT, {}, siteId); }); } @@ -1163,6 +1211,13 @@ export class CoreSitesProvider { }); } + /** + * Unset current site. + */ + unsetCurrentSite(): void { + this.currentSite = undefined; + } + /** * Updates a site's token. * @@ -1477,4 +1532,54 @@ export class CoreSitesProvider { delete this.siteSchemasMigration[site.id]; }); } + + /** + * Check if a URL is the root URL of any of the stored sites. + * + * @param {string} url URL to check. + * @param {string} [username] Username to check. + * @return {Promise<{site: CoreSite, siteIds: string[]}>} Promise resolved with site to use and the list of sites that have + * the URL. Site will be undefined if it isn't the root URL of any stored site. + */ + isStoredRootURL(url: string, username?: string): Promise<{site: CoreSite, siteIds: string[]}> { + // Check if the site is stored. + return this.getSiteIdsFromUrl(url, true, username).then((siteIds) => { + const result = { + siteIds: siteIds, + site: undefined + }; + + if (siteIds.length > 0) { + // If more than one site is returned it usually means there are different users stored. Use any of them. + return this.getSite(siteIds[0]).then((site) => { + const siteUrl = this.textUtils.removeEndingSlash(this.urlUtils.removeProtocolAndWWW(site.getURL())), + treatedUrl = this.textUtils.removeEndingSlash(this.urlUtils.removeProtocolAndWWW(url)); + + if (siteUrl == treatedUrl) { + result.site = site; + } + + return result; + }); + } + + return result; + }); + } + + /** + * Returns the Site Schema names that can be cleared on space storage. + * + * @return {string[]} Name of the site schemas. + */ + getSiteTableSchemasToClear(): string[] { + let reset = []; + for (const name in this.siteSchemas) { + if (this.siteSchemas[name].canBeCleared) { + reset = reset.concat(this.siteSchemas[name].canBeCleared); + } + } + + return reset; + } } diff --git a/src/providers/urlschemes.ts b/src/providers/urlschemes.ts new file mode 100644 index 000000000..83b189c3a --- /dev/null +++ b/src/providers/urlschemes.ts @@ -0,0 +1,520 @@ +// (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 { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from './app'; +import { CoreInitDelegate } from './init'; +import { CoreLoggerProvider } from './logger'; +import { CoreSitesProvider } from './sites'; +import { CoreDomUtilsProvider } from './utils/dom'; +import { CoreTextUtilsProvider } from './utils/text'; +import { CoreUrlUtilsProvider } from './utils/url'; +import { CoreUtilsProvider } from './utils/utils'; +import { CoreLoginHelperProvider } from '@core/login/providers/helper'; +import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper'; +import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; +import { CoreSitePluginsProvider } from '@core/siteplugins/providers/siteplugins'; +import { CoreConfigConstants } from '../configconstants'; +import { CoreConstants } from '@core/constants'; + +/** + * All params that can be in a custom URL scheme. + */ +export interface CoreCustomURLSchemesParams { + /** + * The site's URL. + * @type {string} + */ + siteUrl: string; + + /** + * User's token. If set, user will be authenticated. + * @type {string} + */ + token?: string; + + /** + * User's private token. + * @type {string} + */ + privateToken?: string; + + /** + * Username. + * @type {string} + */ + username?: string; + + /** + * URL to open once authenticated. + * @type {string} + */ + redirect?: any; + + /** + * Name of the page to go once authenticated. + * @type {string} + */ + pageName?: string; + + /** + * Params to pass to the page. + * @type {string} + */ + pageParams?: any; +} + +/* + * Provider to handle custom URL schemes. + */ +@Injectable() +export class CoreCustomURLSchemesProvider { + protected logger; + protected lastUrls = {}; + + constructor(logger: CoreLoggerProvider, private appProvider: CoreAppProvider, private utils: CoreUtilsProvider, + private loginHelper: CoreLoginHelperProvider, private linksHelper: CoreContentLinksHelperProvider, + private initDelegate: CoreInitDelegate, private domUtils: CoreDomUtilsProvider, private urlUtils: CoreUrlUtilsProvider, + private sitesProvider: CoreSitesProvider, private textUtils: CoreTextUtilsProvider, + private linksDelegate: CoreContentLinksDelegate, private translate: TranslateService, + private sitePluginsProvider: CoreSitePluginsProvider) { + this.logger = logger.getInstance('CoreCustomURLSchemesProvider'); + } + + /** + * Handle an URL received by custom URL scheme. + * + * @param {string} url URL to treat. + * @return {Promise} Promise resolved when done. + */ + handleCustomURL(url: string): Promise { + if (!this.isCustomURL(url)) { + return Promise.reject(null); + } + + let modal, + isSSOToken = false, + data: CoreCustomURLSchemesParams; + + /* First check that this URL hasn't been treated a few seconds ago. The function that handles custom URL schemes already + does this, but this function is called from other places so we need to handle it in here too. */ + if (this.lastUrls[url] && Date.now() - this.lastUrls[url] < 3000) { + // Function called more than once, stop. + return; + } + + this.lastUrls[url] = Date.now(); + + // Wait for app to be ready. + return this.initDelegate.ready().then(() => { + url = this.textUtils.decodeURIComponent(url); + + // Some platforms like Windows add a slash at the end. Remove it. + // Some sites add a # at the end of the URL. If it's there, remove it. + url = url.replace(/\/?#?\/?$/, ''); + + modal = this.domUtils.showModalLoading(); + + // Get the data from the URL. + if (this.isCustomURLToken(url)) { + isSSOToken = true; + + return this.getCustomURLTokenData(url); + } else if (this.isCustomURLLink(url)) { + // In iOS, the protocol after the scheme doesn't have ":". Add it. + url = url.replace(/\/\/link=(https?)\/\//, '//link=$1://'); + + return this.getCustomURLLinkData(url); + } else { + // In iOS, the protocol after the scheme doesn't have ":". Add it. + url = url.replace(/\/\/(https?)\/\//, '//$1://'); + + return this.getCustomURLData(url); + } + }).then((result) => { + data = result; + + if (data.redirect && data.redirect.match(/^https?:\/\//) && data.redirect.indexOf(data.siteUrl) == -1) { + // Redirect URL must belong to the same site. Reject. + return Promise.reject(this.translate.instant('core.contentlinks.errorredirectothersite')); + } + + // First of all, authenticate the user if needed. + const currentSite = this.sitesProvider.getCurrentSite(); + + if (data.token) { + if (!currentSite || currentSite.getToken() != data.token) { + // Token belongs to a different site, create it. It doesn't matter if it already exists. + let promise; + + if (!data.siteUrl.match(/^https?:\/\//)) { + // URL doesn't have a protocol and it's required to be able to create the site. Check which one to use. + promise = this.sitesProvider.checkSite(data.siteUrl).then((result) => { + data.siteUrl = result.siteUrl; + }); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + return this.sitesProvider.newSite(data.siteUrl, data.token, data.privateToken, isSSOToken); + }); + } else { + // Token belongs to current site, no need to create it. + return this.sitesProvider.getCurrentSiteId(); + } + } + }).then((siteId) => { + if (isSSOToken) { + // Site created and authenticated, open the page to go. + if (data.pageName) { + // State defined, go to that state instead of site initial page. + this.appProvider.getRootNavController().push(data.pageName, data.pageParams); + } else { + this.loginHelper.goToSiteInitialPage(); + } + + return; + } + + if (data.redirect && !data.redirect.match(/^https?:\/\//)) { + // Redirect is a relative URL. Append the site URL. + data.redirect = this.textUtils.concatenatePaths(data.siteUrl, data.redirect); + } + + let promise; + + if (siteId) { + // Site created, we know the site to use. + promise = Promise.resolve([siteId]); + } else { + // Check if the site is stored. + promise = this.sitesProvider.getSiteIdsFromUrl(data.siteUrl, true, data.username); + } + + return promise.then((siteIds) => { + if (siteIds.length > 1) { + // More than one site to treat the URL, let the user choose. + this.linksHelper.goToChooseSite(data.redirect || data.siteUrl); + + } else if (siteIds.length == 1) { + // Only one site, handle the link. + return this.sitesProvider.getSite(siteIds[0]).then((site) => { + if (!data.redirect) { + // No redirect, go to the root URL if needed. + + return this.linksHelper.handleRootURL(site, false, true); + } else { + // Handle the redirect link. + modal.dismiss(); // Dismiss modal so it doesn't collide with confirms. + + /* Always use the username from the site in this case. If the link has a username and a token, + this will make sure that the link is opened with the user the token belongs to. */ + const username = site.getInfo().username || data.username; + + return this.linksHelper.handleLink(data.redirect, username).then((treated) => { + if (!treated) { + this.domUtils.showErrorModal('core.contentlinks.errornoactions', true); + } + }); + } + }); + + } else { + // Site not stored. Try to add the site. + return this.sitesProvider.checkSite(data.siteUrl).then((result) => { + // Site exists. We'll allow to add it. + const ssoNeeded = this.loginHelper.isSSOLoginNeeded(result.code), + pageName = 'CoreLoginCredentialsPage', + pageParams = { + siteUrl: result.siteUrl, + username: data.username, + urlToOpen: data.redirect, + siteConfig: result.config + }; + let promise, + hasSitePluginsLoaded = false; + + modal.dismiss(); // Dismiss modal so it doesn't collide with confirms. + + if (!this.sitesProvider.isLoggedIn()) { + // Not logged in, no need to confirm. If SSO the confirm will be shown later. + promise = Promise.resolve(); + } else { + // Ask the user before changing site. + const confirmMsg = this.translate.instant('core.contentlinks.confirmurlothersite'); + promise = this.domUtils.showConfirm(confirmMsg).then(() => { + if (!ssoNeeded) { + hasSitePluginsLoaded = this.sitePluginsProvider.hasSitePluginsLoaded; + if (hasSitePluginsLoaded) { + // Store the redirect since logout will restart the app. + this.appProvider.storeRedirect(CoreConstants.NO_SITE_ID, pageName, pageParams); + } + + return this.sitesProvider.logout().catch(() => { + // Ignore errors (shouldn't happen). + }); + } + }); + } + + return promise.then(() => { + if (ssoNeeded) { + this.loginHelper.confirmAndOpenBrowserForSSOLogin( + result.siteUrl, result.code, result.service, result.config && result.config.launchurl); + } else if (!hasSitePluginsLoaded) { + return this.loginHelper.goToNoSitePage(undefined, pageName, pageParams); + } + }); + + }); + } + }); + + }).catch((error) => { + if (error == 'Duplicated') { + // Duplicated request + } else if (error && isSSOToken) { + // An error occurred, display the error and logout the user. + this.loginHelper.treatUserTokenError(data.siteUrl, error); + this.sitesProvider.logout(); + } else { + this.domUtils.showErrorModalDefault(error, this.translate.instant('core.login.invalidsite')); + } + }).finally(() => { + modal.dismiss(); + + if (isSSOToken) { + this.appProvider.finishSSOAuthentication(); + } + }); + + } + + /** + * Get the data from a custom URL scheme. The structure of the URL is: + * moodlemobile://username@domain.com?token=TOKEN&privatetoken=PRIVATETOKEN&redirect=http://domain.com/course/view.php?id=2 + * + * @param {string} url URL to treat. + * @return {Promise} Promise resolved with the data. + */ + protected getCustomURLData(url: string): Promise { + const urlScheme = CoreConfigConstants.customurlscheme + '://'; + if (url.indexOf(urlScheme) == -1) { + return Promise.reject(null); + } + + // App opened using custom URL scheme. + this.logger.debug('Treating custom URL scheme: ' + url); + + // Delete the sso scheme from the URL. + url = url.replace(urlScheme, ''); + + // Detect if there's a user specified. + const username = this.urlUtils.getUsernameFromUrl(url); + if (username) { + url = url.replace(username + '@', ''); // Remove the username from the URL. + } + + // Get the params of the URL. + const params = this.urlUtils.extractUrlParams(url); + + // Remove the params to get the site URL. + if (url.indexOf('?') != -1) { + url = url.substr(0, url.indexOf('?')); + } + + let promise; + + if (!url.match(/https?:\/\//)) { + // Url doesn't have a protocol. Check if the site is stored in the app to be able to determine the protocol. + promise = this.sitesProvider.getSiteIdsFromUrl(url, true, username).then((siteIds) => { + if (siteIds.length) { + // There is at least 1 site with this URL. Use it to know the full URL. + return this.sitesProvider.getSite(siteIds[0]).then((site) => { + return site.getURL(); + }); + } else { + // No site stored with this URL, just use the URL as it is. + return url; + } + }); + } else { + promise = Promise.resolve(url); + } + + return promise.then((url) => { + return { + siteUrl: url, + username: username, + token: params.token, + privateToken: params.privateToken, + redirect: params.redirect + }; + }); + } + + /** + * Get the data from a "link" custom URL scheme. This kind of URL is deprecated. + * + * @param {string} url URL to treat. + * @return {Promise} Promise resolved with the data. + */ + protected getCustomURLLinkData(url: string): Promise { + const contentLinksScheme = CoreConfigConstants.customurlscheme + '://link='; + if (url.indexOf(contentLinksScheme) == -1) { + return Promise.reject(null); + } + + // App opened using custom URL scheme. + this.logger.debug('Treating custom URL scheme with link param: ' + url); + + // Delete the sso scheme from the URL. + url = url.replace(contentLinksScheme, ''); + + // Detect if there's a user specified. + const username = this.urlUtils.getUsernameFromUrl(url); + if (username) { + url = url.replace(username + '@', ''); // Remove the username from the URL. + } + + // First of all, check if it's the root URL of a site. + return this.sitesProvider.isStoredRootURL(url, username).then((data): any => { + + if (data.site) { + // Root URL. + return { + siteUrl: data.site.getURL(), + username: username + }; + + } else if (data.siteIds.length > 0) { + // Not the root URL, but at least 1 site supports the URL. Get the site URL from the list of sites. + return this.sitesProvider.getSite(data.siteIds[0]).then((site) => { + return { + siteUrl: site.getURL(), + username: username, + redirect: url + }; + }); + + } else { + // Get the site URL. + let siteUrl = this.linksDelegate.getSiteUrl(url), + redirect = url; + + if (!siteUrl) { + // Site URL not found, use the original URL since it could be the root URL of the site. + siteUrl = url; + redirect = undefined; + } + + return { + siteUrl: siteUrl, + username: username, + redirect: redirect + }; + } + }); + } + + /** + * Get the data from a "token" custom URL scheme. This kind of URL is deprecated. + * + * @param {string} url URL to treat. + * @return {Promise} Promise resolved with the data. + */ + protected getCustomURLTokenData(url: string): Promise { + const ssoScheme = CoreConfigConstants.customurlscheme + '://token='; + if (url.indexOf(ssoScheme) == -1) { + return Promise.reject(null); + } + + if (this.appProvider.isSSOAuthenticationOngoing()) { + // Authentication ongoing, probably duplicated request. + return Promise.reject('Duplicated'); + } + + if (this.appProvider.isDesktop()) { + // In desktop, make sure InAppBrowser is closed. + this.utils.closeInAppBrowser(true); + } + + // App opened using custom URL scheme. Probably an SSO authentication. + this.appProvider.startSSOAuthentication(); + this.logger.debug('App launched by URL with an SSO'); + + // Delete the sso scheme from the URL. + url = url.replace(ssoScheme, ''); + + // Some platforms like Windows add a slash at the end. Remove it. + // Some sites add a # at the end of the URL. If it's there, remove it. + url = url.replace(/\/?#?\/?$/, ''); + + // Decode from base64. + try { + url = atob(url); + } catch (err) { + // Error decoding the parameter. + this.logger.error('Error decoding parameter received for login SSO'); + + return Promise.reject(null); + } + + return this.loginHelper.validateBrowserSSOLogin(url); + } + + /** + * Check whether a URL is a custom URL scheme. + * + * @param {string} url URL to check. + * @return {boolean} Whether it's a custom URL scheme. + */ + isCustomURL(url: string): boolean { + if (!url) { + return false; + } + + return url.indexOf(CoreConfigConstants.customurlscheme + '://') != -1; + } + + /** + * Check whether a URL is a custom URL scheme with the "link" param (deprecated). + * + * @param {string} url URL to check. + * @return {boolean} Whether it's a custom URL scheme. + */ + isCustomURLLink(url: string): boolean { + if (!url) { + return false; + } + + return url.indexOf(CoreConfigConstants.customurlscheme + '://link=') != -1; + } + + /** + * Check whether a URL is a custom URL scheme with a "token" param (deprecated). + * + * @param {string} url URL to check. + * @return {boolean} Whether it's a custom URL scheme. + */ + isCustomURLToken(url: string): boolean { + if (!url) { + return false; + } + + return url.indexOf(CoreConfigConstants.customurlscheme + '://token=') != -1; + } +} diff --git a/src/providers/utils/dom.ts b/src/providers/utils/dom.ts index bf303e859..4f4fdab17 100644 --- a/src/providers/utils/dom.ts +++ b/src/providers/utils/dom.ts @@ -14,8 +14,8 @@ import { Injectable, SimpleChange } from '@angular/core'; import { - LoadingController, Loading, ToastController, Toast, AlertController, Alert, Platform, Content, - ModalController + LoadingController, Loading, ToastController, Toast, AlertController, Alert, Platform, Content, PopoverController, + ModalController, } from 'ionic-angular'; import { DomSanitizer } from '@angular/platform-browser'; import { TranslateService } from '@ngx-translate/core'; @@ -23,7 +23,9 @@ import { CoreTextUtilsProvider } from './text'; import { CoreAppProvider } from '../app'; import { CoreConfigProvider } from '../config'; import { CoreUrlUtilsProvider } from './url'; +import { CoreFileProvider } from '@providers/file'; import { CoreConstants } from '@core/constants'; +import { CoreBSTooltipComponent } from '@components/bs-tooltip/bs-tooltip'; import { Md5 } from 'ts-md5/dist/md5'; import { Subject } from 'rxjs'; @@ -65,7 +67,8 @@ export class CoreDomUtilsProvider { constructor(private translate: TranslateService, private loadingCtrl: LoadingController, private toastCtrl: ToastController, private alertCtrl: AlertController, private textUtils: CoreTextUtilsProvider, private appProvider: CoreAppProvider, private platform: Platform, private configProvider: CoreConfigProvider, private urlUtils: CoreUrlUtilsProvider, - private modalCtrl: ModalController, private sanitizer: DomSanitizer) { + private modalCtrl: ModalController, private sanitizer: DomSanitizer, private popoverCtrl: PopoverController, + private fileProvider: CoreFileProvider) { // Check if debug messages should be displayed. configProvider.get(CoreConstants.SETTINGS_DEBUG_DISPLAY, false).then((debugDisplay) => { @@ -127,28 +130,73 @@ export class CoreDomUtilsProvider { */ confirmDownloadSize(size: any, message?: string, unknownMessage?: string, wifiThreshold?: number, limitedThreshold?: number, alwaysConfirm?: boolean): Promise { - wifiThreshold = typeof wifiThreshold == 'undefined' ? CoreConstants.WIFI_DOWNLOAD_THRESHOLD : wifiThreshold; - limitedThreshold = typeof limitedThreshold == 'undefined' ? CoreConstants.DOWNLOAD_THRESHOLD : limitedThreshold; + const readableSize = this.textUtils.bytesToSize(size.size, 2); - if (size.size < 0 || (size.size == 0 && !size.total)) { - // Seems size was unable to be calculated. Show a warning. - unknownMessage = unknownMessage || 'core.course.confirmdownloadunknownsize'; + const getAvailableBytes = new Promise((resolve): void => { + if (this.appProvider.isDesktop()) { + // Free space calculation is not supported on desktop. + resolve(null); + } else { + this.fileProvider.calculateFreeSpace().then((availableBytes) => { + if (this.platform.is('android')) { + return availableBytes; + } else { + // Space calculation is not accurate on iOS, but it gets more accurate when space is lower. + // We'll only use it when space is <500MB, or we're downloading more than twice the reported space. + if (availableBytes < CoreConstants.IOS_FREE_SPACE_THRESHOLD || size.size > availableBytes / 2) { + return availableBytes; + } else { + return null; + } + } + }).then((availableBytes) => { + resolve(availableBytes); + }); + } + }); - return this.showConfirm(this.translate.instant(unknownMessage)); - } else if (!size.total) { - // Filesize is only partial. - const readableSize = this.textUtils.bytesToSize(size.size, 2); + const getAvailableSpace = getAvailableBytes.then((availableBytes: number) => { + if (availableBytes === null) { + return ''; + } else { + const availableSize = this.textUtils.bytesToSize(availableBytes, 2); + if (this.platform.is('android') && size.size > availableBytes - CoreConstants.MINIMUM_FREE_SPACE) { + return Promise.reject(this.translate.instant('core.course.insufficientavailablespace', { size: readableSize })); + } - return this.showConfirm(this.translate.instant('core.course.confirmpartialdownloadsize', { size: readableSize })); - } else if (alwaysConfirm || size.size >= wifiThreshold || + return this.translate.instant('core.course.availablespace', {available: availableSize}); + } + }); + + return getAvailableSpace.then((availableSpace) => { + wifiThreshold = typeof wifiThreshold == 'undefined' ? CoreConstants.WIFI_DOWNLOAD_THRESHOLD : wifiThreshold; + limitedThreshold = typeof limitedThreshold == 'undefined' ? CoreConstants.DOWNLOAD_THRESHOLD : limitedThreshold; + + let wifiPrefix = ''; + if (this.appProvider.isNetworkAccessLimited()) { + wifiPrefix = this.translate.instant('core.course.confirmlimiteddownload'); + } + + if (size.size < 0 || (size.size == 0 && !size.total)) { + // Seems size was unable to be calculated. Show a warning. + unknownMessage = unknownMessage || 'core.course.confirmdownloadunknownsize'; + + return this.showConfirm(wifiPrefix + this.translate.instant(unknownMessage, {availableSpace: availableSpace})); + } else if (!size.total) { + // Filesize is only partial. + + return this.showConfirm(wifiPrefix + this.translate.instant('core.course.confirmpartialdownloadsize', + { size: readableSize, availableSpace: availableSpace })); + } else if (alwaysConfirm || size.size >= wifiThreshold || (this.appProvider.isNetworkAccessLimited() && size.size >= limitedThreshold)) { - message = message || 'core.course.confirmdownload'; - const readableSize = this.textUtils.bytesToSize(size.size, 2); + message = message || 'core.course.confirmdownload'; - return this.showConfirm(this.translate.instant(message, { size: readableSize })); - } + return this.showConfirm(wifiPrefix + this.translate.instant(message, + { size: readableSize, availableSpace: availableSpace })); + } - return Promise.resolve(); + return Promise.resolve(); + }); } /** @@ -564,6 +612,45 @@ export class CoreDomUtilsProvider { return this.instances[id]; } + /** + * Handle bootstrap tooltips in a certain element. + * + * @param {HTMLElement} element Element to check. + */ + handleBootstrapTooltips(element: HTMLElement): void { + const els = Array.from(element.querySelectorAll('[data-toggle="tooltip"]')); + + els.forEach((el) => { + const content = el.getAttribute('title') || el.getAttribute('data-original-title'), + trigger = el.getAttribute('data-trigger') || 'hover focus', + treated = el.getAttribute('data-bstooltip-treated'); + + if (!content || treated === 'true' || + (trigger.indexOf('hover') == -1 && trigger.indexOf('focus') == -1 && trigger.indexOf('click') == -1)) { + return; + } + + el.setAttribute('data-bstooltip-treated', 'true'); // Mark it as treated. + + // Store the title in data-original-title instead of title, like BS does. + el.setAttribute('data-original-title', content); + el.setAttribute('title', ''); + + el.addEventListener('click', (e) => { + const html = el.getAttribute('data-html'); + + const popover = this.popoverCtrl.create(CoreBSTooltipComponent, { + content: content, + html: html === 'true' + }); + + popover.present({ + ev: e + }); + }); + }); + } + /** * Check if an element is outside of screen (viewport). * @@ -990,10 +1077,10 @@ export class CoreDomUtilsProvider { * @param {string} [title] Title of the modal. * @param {string} [okText] Text of the OK button. * @param {string} [cancelText] Text of the Cancel button. - * @param {any} [options] More options. See https://ionicframework.com/docs/api/components/alert/AlertController/ - * @return {Promise} Promise resolved if the user confirms and rejected with a canceled error if he cancels. + * @param {any} [options] More options. See https://ionicframework.com/docs/v3/api/components/alert/AlertController/ + * @return {Promise} Promise resolved if the user confirms and rejected with a canceled error if he cancels. */ - showConfirm(message: string, title?: string, okText?: string, cancelText?: string, options?: any): Promise { + showConfirm(message: string, title?: string, okText?: string, cancelText?: string, options?: any): Promise { return new Promise((resolve, reject): void => { const hasHTMLTags = this.textUtils.hasHTMLTags(message); let promise; @@ -1023,8 +1110,8 @@ export class CoreDomUtilsProvider { }, { text: okText || this.translate.instant('core.ok'), - handler: (): void => { - resolve(); + handler: (data: any): void => { + resolve(data); } } ]; @@ -1163,11 +1250,14 @@ export class CoreDomUtilsProvider { content: text }), dismiss = loader.dismiss.bind(loader); - let isDismissed = false; + let isPresented = false, + isDismissed = false; // Override dismiss to prevent dismissing a modal twice (it can throw an error and cause problems). loader.dismiss = (data, role, navOptions): Promise => { - if (isDismissed) { + if (!isPresented || isDismissed) { + isDismissed = true; + return Promise.resolve(); } @@ -1176,7 +1266,13 @@ export class CoreDomUtilsProvider { return dismiss(data, role, navOptions); }; - loader.present(); + // Wait a bit before presenting the modal, to prevent it being displayed if dissmiss is called fast. + setTimeout(() => { + if (!isDismissed) { + isPresented = true; + loader.present(); + } + }, 40); return loader; } diff --git a/src/providers/utils/mimetype.ts b/src/providers/utils/mimetype.ts index 7ec3c895f..cac90bcb0 100644 --- a/src/providers/utils/mimetype.ts +++ b/src/providers/utils/mimetype.ts @@ -138,6 +138,37 @@ export class CoreMimetypeUtilsProvider { } } + /** + * Get the URL of the icon of an extension. + * + * @param {string} extension Extension. + * @return {string} Icon URL. + */ + getExtensionIcon(extension: string): string { + const icon = this.getExtensionIconName(extension) || 'unknown'; + + return this.getFileIconForType(icon); + } + + /** + * Get the name of the icon of an extension. + * + * @param {string} extension Extension. + * @return {string} Icon. Undefined if not found. + */ + getExtensionIconName(extension: string): string { + if (this.extToMime[extension]) { + if (this.extToMime[extension].icon) { + return this.extToMime[extension].icon; + } else { + const type = this.extToMime[extension].type.split('/')[0]; + if (type == 'video' || type == 'text' || type == 'image' || type == 'document' || type == 'audio') { + return type; + } + } + } + } + /** * Get the "type" (string) of an extension, something like "image", "video" or "audio". * @@ -172,19 +203,8 @@ export class CoreMimetypeUtilsProvider { * @return {string} The path to a file icon. */ getFileIcon(filename: string): string { - const ext = this.getFileExtension(filename); - let icon = 'unknown'; - - if (ext && this.extToMime[ext]) { - if (this.extToMime[ext].icon) { - icon = this.extToMime[ext].icon; - } else { - const type = this.extToMime[ext].type.split('/')[0]; - if (type == 'video' || type == 'text' || type == 'image' || type == 'document' || type == 'audio') { - icon = type; - } - } - } + const ext = this.getFileExtension(filename), + icon = this.getExtensionIconName(ext) || 'unknown'; return this.getFileIconForType(icon); } @@ -407,6 +427,30 @@ export class CoreMimetypeUtilsProvider { } } + /** + * Get the icon of a mimetype. + * + * @param {string} mimetype Mimetype. + * @return {string} Type of the mimetype. + */ + getMimetypeIcon(mimetype: string): string { + mimetype = mimetype.split(';')[0]; // Remove codecs from the mimetype if any. + + const extensions = this.mimeToExt[mimetype] || []; + let icon = 'unknown'; + + for (let i = 0; i < extensions.length; i++) { + const iconName = this.getExtensionIconName(extensions[i]); + + if (iconName) { + icon = iconName; + break; + } + } + + return this.getFileIconForType(icon); + } + /** * Given a group name, return the translated name. * diff --git a/src/providers/utils/text.ts b/src/providers/utils/text.ts index 9bef61b2f..a21ba8838 100644 --- a/src/providers/utils/text.ts +++ b/src/providers/utils/text.ts @@ -113,7 +113,7 @@ export class CoreTextUtilsProvider { */ bytesToSize(bytes: number, precision: number = 2): string { - if (typeof bytes == 'undefined' || bytes < 0) { + if (typeof bytes == 'undefined' || bytes === null || bytes < 0) { return this.translate.instant('core.notapplicable'); } @@ -396,9 +396,10 @@ export class CoreTextUtilsProvider { * @param {boolean} [clean] Whether HTML tags should be removed. * @param {boolean} [singleLine] Whether new lines should be removed. Only valid if clean is true. * @param {number} [shortenLength] Number of characters to shorten the text. + * @param {number} [highlight] Text to highlight. * @return {Promise} Promise resolved with the formatted text. */ - formatText(text: string, clean?: boolean, singleLine?: boolean, shortenLength?: number): Promise { + formatText(text: string, clean?: boolean, singleLine?: boolean, shortenLength?: number, highlight?: string): Promise { return this.treatMultilangTags(text).then((formatted) => { if (clean) { formatted = this.cleanTags(formatted, singleLine); @@ -406,6 +407,9 @@ export class CoreTextUtilsProvider { if (shortenLength > 0) { formatted = this.shortenText(formatted, shortenLength); } + if (highlight) { + formatted = this.highlightText(formatted, highlight); + } return formatted; }); @@ -452,6 +456,25 @@ export class CoreTextUtilsProvider { return /<[a-z][\s\S]*>/i.test(text); } + /** + * Highlight all occurrences of a certain text inside another text. It will add some HTML code to highlight it. + * + * @param {string} text Full text. + * @param {string} searchText Text to search and highlight. + * @return {string} Highlighted text. + */ + highlightText(text: string, searchText: string): string { + if (!text || typeof text != 'string') { + return ''; + } else if (!searchText) { + return text; + } + + const regex = new RegExp('(' + searchText + ')', 'gi'); + + return text.replace(regex, '$1'); + } + /** * Check if HTML content is blank. * @@ -528,6 +551,24 @@ export class CoreTextUtilsProvider { return typeof defaultValue != 'undefined' ? defaultValue : json; } + /** + * Remove ending slash from a path or URL. + * + * @param {string} text Text to treat. + * @return {string} Treated text. + */ + removeEndingSlash(text: string): string { + if (!text) { + return ''; + } + + if (text.slice(-1) == '/') { + return text.substr(0, text.length - 1); + } + + return text; + } + /** * Replace all characters that cause problems with files in Android and iOS. * diff --git a/src/providers/utils/url.ts b/src/providers/utils/url.ts index 35a1655ba..3035f53c8 100644 --- a/src/providers/utils/url.ts +++ b/src/providers/utils/url.ts @@ -52,12 +52,28 @@ export class CoreUrlUtilsProvider { */ extractUrlParams(url: string): any { const regex = /[?&]+([^=&]+)=?([^&]*)?/gi, + subParamsPlaceholder = '@@@SUBPARAMS@@@', params: any = {}, - urlAndHash = url.split('#'); + urlAndHash = url.split('#'), + questionMarkSplit = urlAndHash[0].split('?'); + let subParams; + + if (questionMarkSplit.length > 2) { + // There is more than one question mark in the URL. This can happen if any of the params is a URL with params. + // We only want to treat the first level of params, so we'll remove this second list of params and restore it later. + questionMarkSplit.splice(0, 2); + + subParams = '?' + questionMarkSplit.join('?'); + urlAndHash[0] = urlAndHash[0].replace(subParams, subParamsPlaceholder); + } urlAndHash[0].replace(regex, (match: string, key: string, value: string): string => { params[key] = typeof value != 'undefined' ? value : ''; + if (subParams) { + params[key] = params[key].replace(subParamsPlaceholder, subParams); + } + return match; }); @@ -229,7 +245,7 @@ export class CoreUrlUtilsProvider { getUsernameFromUrl(url: string): string { if (url.indexOf('@') > -1) { // Get URL without protocol. - const withoutProtocol = url.replace(/.*?:\/\//, ''), + const withoutProtocol = url.replace(/^[^?@\/]*:\/\//, ''), matches = withoutProtocol.match(/[^@]*/); // Make sure that @ is at the start of the URL, not in a param at the end. diff --git a/src/providers/utils/utils.ts b/src/providers/utils/utils.ts index 8a45fa2d2..e1595c75b 100644 --- a/src/providers/utils/utils.ts +++ b/src/providers/utils/utils.ts @@ -84,17 +84,19 @@ export class CoreUtilsProvider { return new Promise((resolve, reject): void => { const total = promises.length; let count = 0, + hasFailed = false, error; promises.forEach((promise) => { promise.catch((err) => { + hasFailed = true; error = err; }).finally(() => { count++; if (count === total) { // All promises have finished, reject/resolve. - if (error) { + if (hasFailed) { reject(error); } else { resolve(); @@ -740,11 +742,11 @@ export class CoreUtilsProvider { * @return {boolean} Whether the error was returned by the WebService. */ isWebServiceError(error: any): boolean { - return typeof error.warningcode != 'undefined' || (typeof error.errorcode != 'undefined' && + return error && (typeof error.warningcode != 'undefined' || (typeof error.errorcode != 'undefined' && error.errorcode != 'invalidtoken' && error.errorcode != 'userdeleted' && error.errorcode != 'upgraderunning' && error.errorcode != 'forcepasswordchangenotice' && error.errorcode != 'usernotfullysetup' && error.errorcode != 'sitepolicynotagreed' && error.errorcode != 'sitemaintenance' && - (error.errorcode != 'accessexception' || error.message.indexOf('Invalid token - token expired') == -1)); + (error.errorcode != 'accessexception' || error.message.indexOf('Invalid token - token expired') == -1))); } /** @@ -977,14 +979,21 @@ export class CoreUtilsProvider { objectToArrayOfObjects(obj: object, keyName: string, valueName: string, sortByKey?: boolean, sortByValue?: boolean): any[] { // Get the entries from an object or primitive value. const getEntries = (elKey, value): any[] | any => { - if (typeof value == 'object') { + if (typeof value == 'undefined' || value == null) { + // Filter undefined and null values. + return; + } else if (typeof value == 'object') { // It's an object, return at least an entry for each property. const keys = Object.keys(value); let entries = []; keys.forEach((key) => { - const newElKey = elKey ? elKey + '[' + key + ']' : key; - entries = entries.concat(getEntries(newElKey, value[key])); + const newElKey = elKey ? elKey + '[' + key + ']' : key, + subEntries = getEntries(newElKey, value[key]); + + if (subEntries) { + entries = entries.concat(subEntries); + } }); return entries; @@ -1042,6 +1051,24 @@ export class CoreUtilsProvider { return mapped; } + /** + * Add a prefix to all the keys in an object. + * + * @param {any} data Object. + * @param {string} prefix Prefix to add. + * @return {any} Prefixed object. + */ + prefixKeys(data: any, prefix: string): any { + const newObj = {}, + keys = Object.keys(data); + + keys.forEach((key) => { + newObj[prefix + key] = data[key]; + }); + + return newObj; + } + /** * Similar to AngularJS $q.defer(). * @@ -1251,18 +1278,16 @@ export class CoreUtilsProvider { */ uniqueArray(array: any[], key?: string): any[] { const filtered = [], - unique = [], - len = array.length; + unique = {}; // Use an object to make it faster to check if it's duplicate. - for (let i = 0; i < len; i++) { - const entry = array[i], - value = key ? entry[key] : entry; + array.forEach((entry) => { + const value = key ? entry[key] : entry; - if (unique.indexOf(value) == -1) { - unique.push(value); + if (!unique[value]) { + unique[value] = true; filtered.push(entry); } - } + }); return filtered; } diff --git a/src/providers/ws.ts b/src/providers/ws.ts index bc2cd80fc..0cc906f5a 100644 --- a/src/providers/ws.ts +++ b/src/providers/ws.ts @@ -234,7 +234,9 @@ export class CoreWSProvider { args: this.convertValuesToString(data) }]; - siteUrl = preSets.siteUrl + '/lib/ajax/service.php'; + // The info= parameter has no function. It is just to help with debugging. + // We call it info to match the parameter name use by Moodle's AMD ajax module. + siteUrl = preSets.siteUrl + '/lib/ajax/service.php?info=' + method; const promise = this.http.post(siteUrl, JSON.stringify(ajaxData)).timeout(CoreConstants.WS_TIMEOUT).toPromise(); @@ -284,32 +286,55 @@ export class CoreWSProvider { /** * Converts an objects values to strings where appropriate. - * Arrays (associative or otherwise) will be maintained. + * Arrays (associative or otherwise) will be maintained, null values will be removed. * * @param {object} data The data that needs all the non-object values set to strings. * @param {boolean} [stripUnicode] If Unicode long chars need to be stripped. - * @return {object} The cleaned object, with multilevel array and objects preserved. + * @return {object} The cleaned object or null if some strings becomes empty after stripping Unicode. */ - convertValuesToString(data: object, stripUnicode?: boolean): object { - let result; - if (!Array.isArray(data) && typeof data == 'object') { - result = {}; - } else { - result = []; - } + convertValuesToString(data: any, stripUnicode?: boolean): any { + const result: any = Array.isArray(data) ? [] : {}; - for (const el in data) { - if (typeof data[el] == 'object') { - result[el] = this.convertValuesToString(data[el], stripUnicode); - } else { - if (typeof data[el] == 'string') { - result[el] = stripUnicode ? this.textUtils.stripUnicode(data[el]) : data[el]; - if (stripUnicode && data[el] != result[el] && result[el].trim().length == 0) { - throw new Error(); - } - } else { - result[el] = data[el] + ''; + for (const key in data) { + let value = data[key]; + + if (value == null) { + // Skip null or undefined value. + continue; + } else if (typeof value == 'object') { + // Object or array. + value = this.convertValuesToString(value, stripUnicode); + if (value == null) { + return null; } + } else if (typeof value == 'string') { + if (stripUnicode) { + const stripped = this.textUtils.stripUnicode(value); + if (stripped != value && stripped.trim().length == 0) { + return null; + } + value = stripped; + } + } else if (typeof value == 'boolean') { + /* Moodle does not allow "true" or "false" in WS parameters, only in POST parameters. + We've been using "true" and "false" for WS settings "filter" and "fileurl", + we keep it this way to avoid changing cache keys. */ + if (key == 'moodlewssettingfilter' || key == 'moodlewssettingfileurl') { + value = value ? 'true' : 'false'; + } else { + value = value ? '1' : '0'; + } + } else if (typeof value == 'number') { + value = String(value); + } else { + // Unknown type. + continue; + } + + if (Array.isArray(result)) { + result.push(value); + } else { + result[key] = value; } } @@ -524,10 +549,15 @@ export class CoreWSProvider { options['responseType'] = 'text'; } - // Perform the post request. - let promise = this.http.post(siteUrl, ajaxData, options).timeout(CoreConstants.WS_TIMEOUT).toPromise(); + // We add the method name to the URL purely to help with debugging. + // This duplicates what is in the ajaxData, but that does no harm. + // POST variables take precedence over GET. + const requestUrl = siteUrl + '&wsfunction=' + method; - promise = promise.then((data: any) => { + // Perform the post request. + const promise = this.http.post(requestUrl, ajaxData, options).timeout(CoreConstants.WS_TIMEOUT).toPromise(); + + return promise.then((data: any) => { // Some moodle web services return null. // If the responseExpected value is set to false, we create a blank object if the response is null. if (!data && !preSets.responseExpected) { @@ -608,10 +638,6 @@ export class CoreWSProvider { return Promise.reject(this.createFakeWSError('core.serverconnection', true)); }); - - promise = this.setPromiseHttp(promise, 'post', preSets.siteUrl, ajaxData); - - return promise; } /** @@ -692,9 +718,8 @@ export class CoreWSProvider { preSets.responseExpected = true; } - try { - data = this.convertValuesToString(data, preSets.cleanUnicode); - } catch (e) { + data = this.convertValuesToString(data || {}, preSets.cleanUnicode); + if (data == null) { // Empty cleaned text found. errorResponse.message = this.translate.instant('core.unicodenotsupportedcleanerror'); diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 80a7b6d61..36a6110c3 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -126,14 +126,15 @@ $popover-width: 280px !default; $item-divider-background: $gray-lighter !default; $item-divider-color: $black !default; +$core-star-color: $core-color !default; // Moodle Mobile variables // -------------------------------------------------- // Init screen. -$core-color-init-screen: #ff5c00 !default; -$core-color-init-screen-alt: #ff7600 !default; -$core-init-screen-spinner-color: $white !default; +$core-color-init-screen: #ffffff !default; +$core-color-init-screen-alt: #ffffff !default; +$core-init-screen-spinner-color: $core-color !default; $core-init-screen-logo-width: 60% !default; $core-init-screen-logo-max-width: 300px !default; @@ -263,6 +264,8 @@ $core-rte-min-height: 80px; $core-toolbar-button-image-width: 32px; +$core-text-hightlight-background-color: lighten($blue, 40%) !default; + // Timer variables. $core-timer-warn-color: $red !default; $core-timer-iterations: 15 !default; diff --git a/upgrade.txt b/upgrade.txt index bf90adbac..56a1634dd 100644 --- a/upgrade.txt +++ b/upgrade.txt @@ -1,6 +1,11 @@ This files describes API changes in the Moodle Mobile app, information provided here is intended especially for developers. +=== 3.7.0 === + +- The pushnotifications addon has been moved to core. All imports of that addon need to be fixed to use the right path and name. +- Now the expirationTime of cache entries contains the time the entry was modified, not the expiration time. This is to allow calculating dynamic expiration times. We decided to reuse the same field to prevent modifying the database table. + === 3.6.1 === - The local notifications plugin was updated to its latest version. The new API has some breaking changes, so please check its documentation if you're using local notifications. Also, you need to run "npm install" to update the ionic-native library.