diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index d8ff19c9d0c..c23c2c1838f 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -26,7 +26,7 @@ jobs: env: MOODLE_DOCKER_DB: pgsql MOODLE_DOCKER_BROWSER: chrome - MOODLE_DOCKER_PHP_VERSION: 7.4 + MOODLE_DOCKER_PHP_VERSION: '8.0' MOODLE_BRANCH: ${{ github.event.inputs.moodle_branch || 'master' }} MOODLE_REPOSITORY: ${{ github.event.inputs.moodle_repository || 'https://github.com/moodle/moodle' }} BEHAT_TAGS: ${{ github.event.inputs.behat_tags || '~@performance' }} @@ -42,6 +42,12 @@ jobs: git clone --branch master --depth 1 https://github.com/moodlehq/moodle-docker $GITHUB_WORKSPACE/moodle-docker - name: Install npm packages run: npm ci --no-audit + - name: Create Behat faildumps folder + run: | + mkdir moodle/behatfaildumps + chmod 777 moodle/behatfaildumps + - name: Install Behat Snapshots plugin + run: git clone --branch main --depth 1 https://github.com/NoelDeMartin/moodle-local_behatsnapshots $GITHUB_WORKSPACE/moodle/local/behatsnapshots - name: Generate Behat tests plugin run: | export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle @@ -50,12 +56,21 @@ jobs: run: | export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle cp $GITHUB_WORKSPACE/moodle-docker/config.docker-template.php $GITHUB_WORKSPACE/moodle/config.php + sed -i "61c\$CFG->behat_faildump_path = '/var/www/html/behatfaildumps';" $GITHUB_WORKSPACE/moodle/config.php sed -i "61i\$CFG->behat_increasetimeout = 2;" $GITHUB_WORKSPACE/moodle/config.php sed -i "61i\$CFG->behat_ionic_wwwroot = 'http://moodleapp';" $GITHUB_WORKSPACE/moodle/config.php + sed -i "61i\$CFG->behat_snapshots_path = '/var/www/html/local/moodleappbehat/tests/behat/snapshots';" $GITHUB_WORKSPACE/moodle/config.php echo "define('TEST_MOD_BIGBLUEBUTTONBN_MOCK_SERVER', 'http://bbbmockserver/hash' . sha1(\$CFG->behat_wwwroot));" >> $GITHUB_WORKSPACE/moodle/config.php $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose pull $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose up -d $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-wait-for-db + - name: Install Imagick PHP extension + run: | + export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle + ./moodle-docker/bin/moodle-docker-compose exec webserver apt-get update + ./moodle-docker/bin/moodle-docker-compose exec webserver apt-get install -y libmagickwand-dev --no-install-recommends + ./moodle-docker/bin/moodle-docker-compose exec webserver pecl install imagick + ./moodle-docker/bin/moodle-docker-compose exec webserver docker-php-ext-enable imagick - name: Compile & launch app with Docker run: | docker build --build-arg build_command="npm run build:test" -t moodlehq/moodleapp:behat . @@ -65,8 +80,20 @@ jobs: - name: Init Behat run: | export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle - $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/init.php --parallel=8 --optimize-runs='@app&&$BEHAT_TAGS'" + $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/init.php --parallel=8 --optimize-runs='@app&&~@local&&$BEHAT_TAGS'" - name: Run Behat tests run: | export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle - $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/run.php --verbose --tags='@app&&$BEHAT_TAGS' --auto-rerun=3" + $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose exec -T webserver sh -c "php admin/tool/behat/cli/run.php --verbose --tags='@app&&~@local&&$BEHAT_TAGS' --auto-rerun=3" + - name: Upload Snapshot failures + uses: actions/upload-artifact@v3 + if: ${{ failure() }} + with: + name: snapshot_failures + path: moodle/local/moodleappbehat/tests/behat/snapshots/failures/* + - name: Upload Behat failures + uses: actions/upload-artifact@v3 + if: ${{ failure() }} + with: + name: behat_failures + path: moodle/behatfaildumps diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 746a6c38a96..47e3fbf7130 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -8,7 +8,7 @@ jobs: env: MOODLE_DOCKER_DB: pgsql MOODLE_DOCKER_BROWSER: chrome - MOODLE_DOCKER_PHP_VERSION: 7.4 + MOODLE_DOCKER_PHP_VERSION: '8.0' steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v3 diff --git a/Dockerfile b/Dockerfile index 82f3502795c..1ff23c922e2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,8 @@ WORKDIR /app # Prepare node dependencies RUN apt-get update && apt-get install libsecret-1-0 -y COPY package*.json ./ +COPY patches ./patches +RUN echo "unsafe-perm=true" > ./.npmrc RUN npm ci --no-audit # Build source diff --git a/README.md b/README.md index ae0e4be9f16..1a9f4aa4c24 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,12 @@ Moodle App This is the primary repository of source code for the official mobile app for Moodle. * [User documentation](https://docs.moodle.org/en/Moodle_app) -* [Developer documentation](http://docs.moodle.org/dev/Moodle_App) -* [Development environment setup](https://docs.moodle.org/dev/Setting_up_your_development_environment_for_the_Moodle_App) +* [Developer documentation](https://moodledev.io/general/app) +* [Development environment setup](https://moodledev.io/general/app/development/setup) * [Bug Tracker](https://tracker.moodle.org/browse/MOBILE) -* [Release Notes](https://docs.moodle.org/dev/Moodle_App_Release_Notes) +* [Release Notes](https://moodledev.io/general/app_releases) + +This project is tested with BrowserStack. License ------- diff --git a/angular.json b/angular.json index 376a770fd65..ec4ff0695c5 100644 --- a/angular.json +++ b/angular.json @@ -42,7 +42,8 @@ "input": "src/theme/theme.scss" } ], - "scripts": [] + "scripts": [], + "webWorkerTsConfig": "tsconfig.worker.json" }, "configurations": { "production": { @@ -50,6 +51,10 @@ { "replace": "src/testing/testing.module.ts", "with": "src/testing/testing.module.prod.ts" + }, + { + "replace": "src/core/features/emulator/emulator.module.ts", + "with": "src/core/features/emulator/emulator.module.prod.ts" } ], "optimization": { diff --git a/config.xml b/config.xml index a97ad98de26..cb9f500f479 100644 --- a/config.xml +++ b/config.xml @@ -1,5 +1,5 @@ - + Moodle Moodle official app Moodle Mobile team @@ -27,7 +27,7 @@ - + @@ -196,13 +196,9 @@ - - - - - + @@ -236,7 +232,7 @@ - 4.1.0 + 4.1.1 diff --git a/gulpfile.js b/gulpfile.js index 60451192ff1..d7098d9b715 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -71,5 +71,5 @@ gulp.task('watch', () => { }); gulp.task('watch-behat', () => { - gulp.watch(['./src/**/*.feature', './local_moodleappbehat'], { interval: 500 }, gulp.parallel('behat')); + gulp.watch(['./src/**/*.feature', './src/**/*.png', './local_moodleappbehat'], { interval: 500 }, gulp.parallel('behat')); }); diff --git a/local_moodleappbehat/tests/behat/behat_app.php b/local_moodleappbehat/tests/behat/behat_app.php index 168bfa9d55c..04395fbaf4b 100644 --- a/local_moodleappbehat/tests/behat/behat_app.php +++ b/local_moodleappbehat/tests/behat/behat_app.php @@ -19,6 +19,7 @@ require_once(__DIR__ . '/../../../../lib/behat/behat_base.php'); require_once(__DIR__ . '/behat_app_helper.php'); +use Behat\Behat\Hook\Scope\ScenarioScope; use Behat\Gherkin\Node\TableNode; use Behat\Mink\Exception\DriverException; use Behat\Mink\Exception\ExpectationException; @@ -45,6 +46,27 @@ class behat_app extends behat_app_helper { protected $windowsize = '360x720'; + /** + * @BeforeScenario + */ + public function before_scenario(ScenarioScope $scope) { + if (!$scope->getFeature()->hasTag('app')) { + return; + } + + global $CFG; + + $performanceLogs = $CFG->behat_profiles['default']['capabilities']['extra_capabilities']['goog:loggingPrefs']['performance'] ?? null; + + if ($performanceLogs !== 'ALL') { + return; + } + + // Enable DB Logging only for app tests with performance logs activated. + $this->getSession()->visit($this->get_app_url() . '/assets/env.json'); + $this->execute_script("document.cookie = 'MoodleAppDBLoggingEnabled=true;path=/';"); + } + /** * Opens the Moodle App in the browser and optionally logs in. * @@ -215,13 +237,21 @@ public function i_load_more_items_in_the_app(bool $not = false) { /** * Trigger swipe gesture. * - * @When /^I swipe to the (left|right) in the app$/ + * @When /^I swipe to the (left|right) (in (".+") )?in the app$/ * @param string $direction Swipe direction + * @param bool $hasLocator Whether a reference locator is used. + * @param string $locator Reference locator. */ - public function i_swipe_in_the_app(string $direction) { - $method = 'swipe' . ucwords($direction); + public function i_swipe_in_the_app(string $direction, bool $hasLocator = false, string $locator = '') { + if ($hasLocator) { + $locator = $this->parse_element_locator($locator); + } + + $result = $this->zone_js("swipe('$direction'" . ($hasLocator ? ", $locator" : '') . ')'); - $this->zone_js("getAngularInstance('ion-content', 'CoreSwipeNavigationDirective').$method()"); + if ($result !== 'OK') { + throw new DriverException('Error when swiping - ' . $result); + } $this->wait_for_pending_js(); diff --git a/local_moodleappbehat/tests/behat/snapshots/failures/.gitkeep b/local_moodleappbehat/tests/behat/snapshots/failures/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/moodle.config.json b/moodle.config.json index 103c210eac8..061b6a3ed31 100644 --- a/moodle.config.json +++ b/moodle.config.json @@ -1,8 +1,8 @@ { "app_id": "com.moodle.moodlemobile", "appname": "Moodle Mobile", - "versioncode": 41001, - "versionname": "4.1.0", + "versioncode": 41100, + "versionname": "4.1.1", "cache_update_frequency_usually": 420000, "cache_update_frequency_often": 1200000, "cache_update_frequency_sometimes": 3600000, diff --git a/package-lock.json b/package-lock.json index 9a020e161c9..71817b7fc56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "moodlemobile", - "version": "4.1.0", + "version": "4.1.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -3618,7 +3618,6 @@ "version": "7.9.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.6.tgz", "integrity": "sha512-64AF1xY3OAkFHqOb9s4jpgk1Mm5vDZ4L3acHvAml+53nO1XbXLuDodsVpO4OIUsmemlUHMxNdYMNJmsvOwLrvQ==", - "dev": true, "requires": { "regenerator-runtime": "^0.13.4" } @@ -4168,21 +4167,6 @@ } } }, - "@ionic-native/media": { - "version": "5.36.0", - "resolved": "https://registry.npmjs.org/@ionic-native/media/-/media-5.36.0.tgz", - "integrity": "sha512-WIDCeUlX7bCbse/x2Rr7mAIQJnLo18ZWcmsVgSTTBVS7ObU2DBl4ieqRx6y9PAAV+3tNZqMV4JAWDfMiFokpJg==", - "requires": { - "@types/cordova": "^0.0.34" - }, - "dependencies": { - "@types/cordova": { - "version": "0.0.34", - "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", - "integrity": "sha512-rkiiTuf/z2wTd4RxFOb+clE7PF4AEJU0hsczbUdkHHBtkUmpWQpEddynNfJYKYtZFJKbq4F+brfekt1kx85IZA==" - } - } - }, "@ionic-native/media-capture": { "version": "5.36.0", "resolved": "https://registry.npmjs.org/@ionic-native/media-capture/-/media-capture-5.36.0.tgz", @@ -4319,11 +4303,11 @@ } }, "@ionic/angular": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/@ionic/angular/-/angular-5.9.2.tgz", - "integrity": "sha512-5GzKg+l4au3xFECky2v/USlRsmTAXgvNO5Zalt7NUXc//VJIL2lQvswojE6FBWuM/xR5W0CWbJdFth19TaZWVQ==", + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/@ionic/angular/-/angular-5.9.4.tgz", + "integrity": "sha512-U/85FePF48VaZXTudTwpVXDqhGmYfarl/7vki7a4umnIORnWtHqD2/pXsqqZ/O1EcbALwULYIeVXAfkFpPd2wQ==", "requires": { - "@ionic/core": "5.9.2", + "@ionic/core": "5.9.4", "tslib": "^1.9.3" }, "dependencies": { @@ -4666,9 +4650,9 @@ } }, "@ionic/core": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-5.9.2.tgz", - "integrity": "sha512-1ZqSBS8R6tGQsc+LsLxIRv0q3Ww6jwgJXLvdn6FmVWfpPbBvT+CjCuU9hqJ5qwM+atErblUMYSexvvpws8lGAA==", + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-5.9.4.tgz", + "integrity": "sha512-Ngz9yVT6fIiGdSxxBer8uJxP4w6PasvohYpLxhtMgYiWnyIu0vZra2ui3HrYukCzUo5/SbNPiUr1l7cj1E+7qw==", "requires": { "@stencil/core": "^2.4.0", "ionicons": "^5.5.3", @@ -5852,9 +5836,9 @@ } }, "@stencil/core": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.11.0.tgz", - "integrity": "sha512-/IubCWhVXCguyMUp/3zGrg3c882+RJNg/zpiKfyfJL3kRCOwe+/MD8OoAXVGdd+xAohZKIi1Ik+EHFlsptsjLg==" + "version": "2.22.2", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.22.2.tgz", + "integrity": "sha512-r+vbxsGNcBaV1VDOYW25lv4QfXTlNoIb5GpUX7rZ+cr59yqYCZC5tlV+IzX6YgHKW62ulCc9M3RYtTfHtNbNNw==" }, "@storybook/addon-controls": { "version": "6.1.21", @@ -9255,6 +9239,71 @@ "eslint-visitor-keys": "^2.0.0" } }, + "@videojs/http-streaming": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-2.15.1.tgz", + "integrity": "sha512-/uuN3bVkEeJAdrhu5Hyb19JoUo3CMys7yf2C1vUjeL1wQaZ4Oe8JrZzRrnWZ0rjvPgKfNLPXQomsRtgrMoRMJQ==", + "requires": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "3.0.5", + "aes-decrypter": "3.1.3", + "global": "^4.4.0", + "m3u8-parser": "4.8.0", + "mpd-parser": "^0.22.1", + "mux.js": "6.0.1", + "video.js": "^6 || ^7" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", + "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + } + } + }, + "@videojs/vhs-utils": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-3.0.5.tgz", + "integrity": "sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==", + "requires": { + "@babel/runtime": "^7.12.5", + "global": "^4.4.0", + "url-toolkit": "^2.2.1" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", + "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + } + } + }, + "@videojs/xhr": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.6.0.tgz", + "integrity": "sha512-7J361GiN1tXpm+gd0xz2QWr3xNWBE+rytvo8J3KuggFaLg+U37gZQ2BuPLcnkfGffy2e+ozY70RHC8jt7zjA6Q==", + "requires": { + "@babel/runtime": "^7.5.5", + "global": "~4.4.0", + "is-function": "^1.0.1" + } + }, "@webassemblyjs/ast": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz", @@ -9430,6 +9479,11 @@ "@xtuc/long": "4.2.2" } }, + "@xmldom/xmldom": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.6.tgz", + "integrity": "sha512-uRjjusqpoqfmRkTaNuLJ2VohVr67Q5YwDATW3VU7PfzTj6IRaihGrYI7zckGZjxQPBIp63nfvJbM+Yu5ICh0Bg==" + }, "@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -9567,6 +9621,32 @@ } } }, + "aes-decrypter": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-3.1.3.tgz", + "integrity": "sha512-VkG9g4BbhMBy+N5/XodDeV6F02chEk9IpgRTq/0bS80y4dzy79VH2Gtms02VXomf3HmyRe3yyJYkJ990ns+d6A==", + "requires": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^3.0.5", + "global": "^4.4.0", + "pkcs7": "^1.0.4" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", + "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + } + } + }, "agent-base": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", @@ -14245,11 +14325,6 @@ "resolved": "https://registry.npmjs.org/cordova-plugin-ionic-keyboard/-/cordova-plugin-ionic-keyboard-2.2.0.tgz", "integrity": "sha512-yDUG+9ieKVRitq5mGlNxjaZh/MgEhFFIgTIPhqSbUaQ8UuZbawy5mhJAVClqY97q8/rcQtL6dCDa7x2sEtCLcA==" }, - "cordova-plugin-media": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cordova-plugin-media/-/cordova-plugin-media-5.0.4.tgz", - "integrity": "sha512-mAqincYqOT5gu5LWyfgJu3qmOq+lhLAKhnOZULpG622FvYiHjjfsoJ/fkI55WwI3FIcHeeyhToGvHXBCNJePZg==" - }, "cordova-plugin-media-capture": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/cordova-plugin-media-capture/-/cordova-plugin-media-capture-3.0.3.tgz", @@ -15674,8 +15749,7 @@ "dom-walk": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", - "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==", - "dev": true + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" }, "domain-browser": { "version": "1.2.0", @@ -16760,6 +16834,11 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" }, + "event-target-shim": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-6.0.2.tgz", + "integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==" + }, "eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -18627,7 +18706,6 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", - "dev": true, "requires": { "min-document": "^2.19.0", "process": "^0.11.10" @@ -20030,6 +20108,11 @@ "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=", "dev": true }, + "individual": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/individual/-/individual-2.0.0.tgz", + "integrity": "sha512-pWt8hBCqJsUWI/HtcfWod7+N9SgAqyPEaF7JQjwzjn5vGrpg6aQ5qeAFQ7dx//UH4J1O+7xqew+gCeeFt6xN/g==" + }, "infer-owner": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", @@ -20545,8 +20628,7 @@ "is-function": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", - "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==", - "dev": true + "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==" }, "is-generator-fn": { "version": "2.1.0", @@ -22371,6 +22453,11 @@ "source-map-support": "^0.5.5" } }, + "keycode": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.1.tgz", + "integrity": "sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==" + }, "keytar": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.2.0.tgz", @@ -22940,6 +23027,31 @@ "yallist": "^4.0.0" } }, + "m3u8-parser": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-4.8.0.tgz", + "integrity": "sha512-UqA2a/Pw3liR6Df3gwxrqghCP17OpPlQj6RBPLYygf/ZSQ4MoSgvdvhvt35qV+3NaaA0FSZx93Ix+2brT1U7cA==", + "requires": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^3.0.5", + "global": "^4.4.0" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", + "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + } + } + }, "macos-release": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.0.tgz", @@ -23425,7 +23537,6 @@ "version": "2.19.0", "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==", - "dev": true, "requires": { "dom-walk": "^0.1.0" } @@ -23708,6 +23819,41 @@ } } }, + "mp3-mediarecorder": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/mp3-mediarecorder/-/mp3-mediarecorder-4.0.5.tgz", + "integrity": "sha512-tu8XvKGMrdwNmEQTzBbaJRLBAuVNEzbzmCOnYzUyYuEb48Kwl97qA6f5nBEaZXveNmHgvvi0i85TjROPC49qFA==", + "requires": { + "event-target-shim": "6.0.2", + "vmsg": "0.4.0" + } + }, + "mpd-parser": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-0.22.1.tgz", + "integrity": "sha512-fwBebvpyPUU8bOzvhX0VQZgSohncbgYwUyJJoTSNpmy7ccD2ryiCvM7oRkn/xQH5cv73/xU7rJSNCLjdGFor0Q==", + "requires": { + "@babel/runtime": "^7.12.5", + "@videojs/vhs-utils": "^3.0.5", + "@xmldom/xmldom": "^0.8.3", + "global": "^4.4.0" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", + "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + } + } + }, "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -23740,6 +23886,30 @@ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" }, + "mux.js": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-6.0.1.tgz", + "integrity": "sha512-22CHb59rH8pWGcPGW5Og7JngJ9s+z4XuSlYvnxhLuc58cA1WqGDQPzuG8I+sPm1/p0CdgpzVTaKW408k5DNn8w==", + "requires": { + "@babel/runtime": "^7.11.2", + "global": "^4.4.0" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", + "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + } + } + }, "nan": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz", @@ -24875,6 +25045,29 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "dev": true }, + "ogv": { + "version": "1.8.9", + "resolved": "https://registry.npmjs.org/ogv/-/ogv-1.8.9.tgz", + "integrity": "sha512-tQA2E3E2PzdWqxIaI5X8q8Vxvj1Ap3JSZmD1MfnA+cTY3o0t+06zY4RKXckQ9pxeqGy/UH4l4QensssmbPLwAQ==", + "requires": { + "@babel/runtime": "^7.16.7" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", + "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + } + } + }, "on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -25905,6 +26098,14 @@ "node-modules-regexp": "^1.0.0" } }, + "pkcs7": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz", + "integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==", + "requires": { + "@babel/runtime": "^7.5.5" + } + }, "pkg-dir": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", @@ -26858,8 +27059,7 @@ "process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", - "dev": true + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=" }, "process-nextick-args": { "version": "2.0.1", @@ -28270,8 +28470,7 @@ "regenerator-runtime": { "version": "0.13.5", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", - "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==", - "dev": true + "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==" }, "regenerator-transform": { "version": "0.14.5", @@ -28944,6 +29143,14 @@ "aproba": "^1.1.1" } }, + "rust-result": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rust-result/-/rust-result-1.0.0.tgz", + "integrity": "sha512-6cJzSBU+J/RJCF063onnQf0cDUOHs9uZI1oroSGnHOph+CQTIJ5Pp2hK5kEQq1+7yE/EEWfulSNXAQ2jikPthA==", + "requires": { + "individual": "^2.0.0" + } + }, "rxjs": { "version": "6.5.5", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.5.tgz", @@ -28964,6 +29171,14 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "safe-json-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/safe-json-parse/-/safe-json-parse-4.0.0.tgz", + "integrity": "sha512-RjZPPHugjK0TOzFrLZ8inw44s9bKox99/0AZW9o/BEQVrJfhI+fIHMErnPyRa89/yRXUUr93q+tiN6zhoVV4wQ==", + "requires": { + "rust-result": "^1.0.0" + } + }, "safe-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", @@ -32936,6 +33151,11 @@ "prepend-http": "^2.0.0" } }, + "url-toolkit": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.2.5.tgz", + "integrity": "sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg==" + }, "use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", @@ -33118,6 +33338,54 @@ "extsprintf": "^1.2.0" } }, + "video.js": { + "version": "7.21.1", + "resolved": "https://registry.npmjs.org/video.js/-/video.js-7.21.1.tgz", + "integrity": "sha512-AvHfr14ePDHCfW5Lx35BvXk7oIonxF6VGhSxocmTyqotkQpxwYdmt4tnQSV7MYzNrYHb0GI8tJMt20NDkCQrxg==", + "requires": { + "@babel/runtime": "^7.12.5", + "@videojs/http-streaming": "2.15.1", + "@videojs/vhs-utils": "^3.0.4", + "@videojs/xhr": "2.6.0", + "aes-decrypter": "3.1.3", + "global": "^4.4.0", + "keycode": "^2.2.0", + "m3u8-parser": "4.8.0", + "mpd-parser": "0.22.1", + "mux.js": "6.0.1", + "safe-json-parse": "4.0.0", + "videojs-font": "3.2.0", + "videojs-vtt.js": "^0.15.4" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.7.tgz", + "integrity": "sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + } + } + }, + "videojs-font": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-3.2.0.tgz", + "integrity": "sha512-g8vHMKK2/JGorSfqAZQUmYYNnXmfec4MLhwtEFS+mMs2IDY398GLysy6BH6K+aS1KMNu/xWZ8Sue/X/mdQPliA==" + }, + "videojs-vtt.js": { + "version": "0.15.4", + "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.4.tgz", + "integrity": "sha512-r6IhM325fcLb1D6pgsMkTQT1PpFdUdYZa1iqk7wJEu+QlibBwATPfPc9Bg8Jiym0GE5yP1AG2rMLu+QMVWkYtA==", + "requires": { + "global": "^4.3.1" + } + }, "vinyl": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.2.1.tgz", @@ -33213,6 +33481,11 @@ } } }, + "vmsg": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/vmsg/-/vmsg-0.4.0.tgz", + "integrity": "sha512-46BBqRSfqdFGUpO2j+Hpz8T9YE5uWG0/PWal1PT+R1o8NEthtjG/XWl4HzbB8hIHpg/UtmKvsxL2OKQBrIYcHQ==" + }, "w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", diff --git a/package.json b/package.json index e359872dabf..815c1605bde 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moodlemobile", - "version": "4.1.0", + "version": "4.1.1", "description": "The official app for Moodle.", "author": { "name": "Moodle Pty Ltd.", @@ -24,7 +24,7 @@ "build": "ionic build", "build:prod": "NODE_ENV=production ionic build --prod", "build:test": "NODE_ENV=testing ionic build --configuration=testing", - "dev:android": "ionic cordova run android --livereload", + "dev:android": "ionic cordova run android --livereload --external --ssl", "dev:ios": "ionic cordova run ios", "prod:android": "NODE_ENV=production ionic cordova run android --prod", "prod:ios": "NODE_ENV=production ionic cordova run ios --prod", @@ -63,7 +63,6 @@ "@ionic-native/ionic-webview": "5.36.0", "@ionic-native/keyboard": "5.36.0", "@ionic-native/local-notifications": "5.36.0", - "@ionic-native/media": "5.36.0", "@ionic-native/media-capture": "5.36.0", "@ionic-native/network": "5.36.0", "@ionic-native/push": "5.36.0", @@ -73,7 +72,7 @@ "@ionic-native/status-bar": "5.36.0", "@ionic-native/web-intent": "5.36.0", "@ionic-native/zip": "5.36.0", - "@ionic/angular": "5.9.2", + "@ionic/angular": "5.9.4", "@moodlehq/cordova-plugin-file-opener": "3.0.5-moodle.1", "@moodlehq/cordova-plugin-file-transfer": "1.7.1-moodle.5", "@moodlehq/cordova-plugin-inappbrowser": "5.0.0-moodle.3", @@ -104,7 +103,6 @@ "cordova-plugin-file": "6.0.2", "cordova-plugin-geolocation": "4.1.0", "cordova-plugin-ionic-keyboard": "2.2.0", - "cordova-plugin-media": "5.0.4", "cordova-plugin-media-capture": "3.0.3", "cordova-plugin-network-information": "3.0.0", "cordova-plugin-prevent-override": "1.0.1", @@ -122,10 +120,13 @@ "mathjax": "2.7.9", "moment": "2.29.4", "moment-timezone": "0.5.38", + "mp3-mediarecorder": "^4.0.5", "nl.kingsquare.cordova.background-audio": "1.0.1", + "ogv": "1.8.9", "rxjs": "6.5.5", "ts-md5": "1.2.7", "tslib": "2.3.1", + "video.js": "7.21.1", "zone.js": "0.10.3" }, "devDependencies": { @@ -222,9 +223,6 @@ "ANDROID_SUPPORT_V4_VERSION": "26.+" }, "cordova-plugin-media-capture": {}, - "cordova-plugin-media": { - "KEEP_AVAUDIOSESSION_ALWAYS_ACTIVE": "NO" - }, "cordova-plugin-network-information": {}, "@moodlehq/cordova-plugin-qrscanner": {}, "cordova-plugin-splashscreen": {}, diff --git a/patches/event-target-shim+6.0.2.patch b/patches/event-target-shim+6.0.2.patch new file mode 100644 index 00000000000..8d8947270d8 --- /dev/null +++ b/patches/event-target-shim+6.0.2.patch @@ -0,0 +1,30 @@ +diff --git a/node_modules/event-target-shim/index.d.ts b/node_modules/event-target-shim/index.d.ts +index 7a5bfc7..ba5e7d8 100644 +--- a/node_modules/event-target-shim/index.d.ts ++++ b/node_modules/event-target-shim/index.d.ts +@@ -359,7 +359,7 @@ export declare namespace defineCustomEventTarget { + /** + * The interface of CustomEventTarget. + */ +- type CustomEventTarget, TMode extends "standard" | "strict"> = EventTarget & defineEventAttribute.EventAttributes; ++ type CustomEventTarget, TMode extends "standard" | "strict"> = EventTarget & defineEventAttribute.EventAttributes; + } + /** + * Define an event attribute. +@@ -368,14 +368,12 @@ export declare namespace defineCustomEventTarget { + * @param _eventClass Unused, but to infer `Event` class type. + * @deprecated Use `getEventAttributeValue`/`setEventAttributeValue` pair on your derived class instead because of static analysis friendly. + */ +-export declare function defineEventAttribute(target: TEventTarget, type: TEventType, _eventClass?: TEventConstrucor): asserts target is TEventTarget & defineEventAttribute.EventAttributes>>; ++export declare function defineEventAttribute(target: TEventTarget, type: TEventType, _eventClass?: TEventConstrucor): asserts target is TEventTarget & defineEventAttribute.EventAttributes; + export declare namespace defineEventAttribute { + /** + * Definition of event attributes. + */ +- type EventAttributes, TEventMap extends Record> = { +- [P in string & keyof TEventMap as `on${P}`]: EventTarget.CallbackFunction | null; +- }; ++ type EventAttributes> = Record | null>; + } + /** + * Set the warning handler. diff --git a/patches/mp3-mediarecorder+4.0.5.patch b/patches/mp3-mediarecorder+4.0.5.patch new file mode 100644 index 00000000000..230e1e04f9c --- /dev/null +++ b/patches/mp3-mediarecorder+4.0.5.patch @@ -0,0 +1,91 @@ +diff --git a/node_modules/mp3-mediarecorder/dist/index.es.js b/node_modules/mp3-mediarecorder/dist/index.es.js +index 7a96961..82ec4e8 100644 +--- a/node_modules/mp3-mediarecorder/dist/index.es.js ++++ b/node_modules/mp3-mediarecorder/dist/index.es.js +@@ -357,8 +357,7 @@ class Event$1 { + InitEventWasCalledWhileDispatching.warn(); + return; + } +- internalDataMap.set(this, { +- ...data, ++ internalDataMap.set(this, Object.assign({}, data, { + type: String(type), + bubbles: Boolean(bubbles), + cancelable: Boolean(cancelable), +@@ -366,8 +365,8 @@ class Event$1 { + currentTarget: null, + stopPropagationFlag: false, + stopImmediatePropagationFlag: false, +- canceledFlag: false, +- }); ++ canceledFlag: false ++ })); + } + } + //------------------------------------------------------------------------------ +diff --git a/node_modules/mp3-mediarecorder/dist/index.es5.js b/node_modules/mp3-mediarecorder/dist/index.es5.js +index 0caa82d..aa46cc2 100644 +--- a/node_modules/mp3-mediarecorder/dist/index.es5.js ++++ b/node_modules/mp3-mediarecorder/dist/index.es5.js +@@ -418,7 +418,7 @@ class Event$1 { + return; + } + +- internalDataMap.set(this, { ...data, ++ internalDataMap.set(this, Object.assign({}, data, { + type: String(type), + bubbles: Boolean(bubbles), + cancelable: Boolean(cancelable), +@@ -427,7 +427,7 @@ class Event$1 { + stopPropagationFlag: false, + stopImmediatePropagationFlag: false, + canceledFlag: false +- }); ++ })); + } + + } //------------------------------------------------------------------------------ +diff --git a/node_modules/mp3-mediarecorder/dist/index.js b/node_modules/mp3-mediarecorder/dist/index.js +index f7a517e..5f7f415 100644 +--- a/node_modules/mp3-mediarecorder/dist/index.js ++++ b/node_modules/mp3-mediarecorder/dist/index.js +@@ -418,7 +418,7 @@ class Event$1 { + return; + } + +- internalDataMap.set(this, { ...data, ++ internalDataMap.set(this, Object.assign({}, data, { + type: String(type), + bubbles: Boolean(bubbles), + cancelable: Boolean(cancelable), +@@ -427,7 +427,7 @@ class Event$1 { + stopPropagationFlag: false, + stopImmediatePropagationFlag: false, + canceledFlag: false +- }); ++ })); + } + + } //------------------------------------------------------------------------------ +diff --git a/node_modules/mp3-mediarecorder/dist/index.umd.js b/node_modules/mp3-mediarecorder/dist/index.umd.js +index 3f5f2a2..dd7783d 100644 +--- a/node_modules/mp3-mediarecorder/dist/index.umd.js ++++ b/node_modules/mp3-mediarecorder/dist/index.umd.js +@@ -418,7 +418,7 @@ class Event$1 { + return; + } + +- internalDataMap.set(this, { ...data, ++ internalDataMap.set(this, Object.assign({}, data, { + type: String(type), + bubbles: Boolean(bubbles), + cancelable: Boolean(cancelable), +@@ -427,7 +427,7 @@ class Event$1 { + stopPropagationFlag: false, + stopImmediatePropagationFlag: false, + canceledFlag: false +- }); ++ })); + } + + } //------------------------------------------------------------------------------ diff --git a/resources/splash.png b/resources/splash.png index e7889ccf91e..d44505493ca 100644 Binary files a/resources/splash.png and b/resources/splash.png differ diff --git a/scripts/build-behat-plugin.js b/scripts/build-behat-plugin.js index 9dc94bcc871..621ad007fca 100755 --- a/scripts/build-behat-plugin.js +++ b/scripts/build-behat-plugin.js @@ -76,37 +76,46 @@ async function main() { }; writeFileSync(pluginFilePath, replaceArguments(fileContents, replacements)); - // Copy feature files. + // Copy feature and snapshot files. if (!excludeFeatures) { const behatTempFeaturesPath = `${pluginPath}/behat-tmp`; - copySync(projectPath('src'), behatTempFeaturesPath, { filter: isFeatureFileOrDirectory }); + copySync(projectPath('src'), behatTempFeaturesPath, { filter: shouldCopyFileOrDirectory }); const behatFeaturesPath = `${pluginPath}/tests/behat`; if (!existsSync(behatFeaturesPath)) { mkdirSync(behatFeaturesPath, {recursive: true}); } - for await (const featureFile of getDirectoryFiles(behatTempFeaturesPath)) { - const featurePath = dirname(featureFile); - if (!featurePath.endsWith('/tests/behat')) { + for await (const file of getDirectoryFiles(behatTempFeaturesPath)) { + const filePath = dirname(file); + + if (filePath.endsWith('/tests/behat/snapshots')) { + renameSync(file, behatFeaturesPath + '/snapshots/' + basename(file)); + + continue; + } + + if (!filePath.endsWith('/tests/behat')) { continue; } - const newPath = featurePath.substring(0, featurePath.length - ('/tests/behat'.length)); + const newPath = filePath.substring(0, filePath.length - ('/tests/behat'.length)); const searchRegExp = /\//g; const prefix = relative(behatTempFeaturesPath, newPath).replace(searchRegExp,'-') || 'core'; - const featureFilename = prefix + '-' + basename(featureFile); - renameSync(featureFile, behatFeaturesPath + '/' + featureFilename); + const featureFilename = prefix + '-' + basename(file); + renameSync(file, behatFeaturesPath + '/' + featureFilename); } rmSync(behatTempFeaturesPath, {recursive: true}); } } -function isFeatureFileOrDirectory(src) { - const stats = statSync(src); +function shouldCopyFileOrDirectory(path) { + const stats = statSync(path); - return stats.isDirectory() || extname(src) === '.feature'; + return stats.isDirectory() + || extname(path) === '.feature' + || extname(path) === '.png'; } function isExcluded(file, exclusions) { diff --git a/scripts/copy-assets.js b/scripts/copy-assets.js index 3b3f03ea418..72274160b9d 100644 --- a/scripts/copy-assets.js +++ b/scripts/copy-assets.js @@ -27,7 +27,10 @@ const ASSETS = { '/node_modules/mathjax/jax/output/SVG': '/lib/mathjax/jax/output/SVG', '/node_modules/mathjax/jax/output/PreviewHTML': '/lib/mathjax/jax/output/PreviewHTML', '/node_modules/mathjax/localization': '/lib/mathjax/localization', + '/node_modules/mp3-mediarecorder/dist/vmsg.wasm': '/lib/vmsg/vmsg.wasm', '/src/core/features/h5p/assets': '/lib/h5p', + '/node_modules/ogv/dist': '/lib/ogv', + '/node_modules/video.js/dist/video-js.min.css': '/lib/video.js/video-js.min.css', }; module.exports = function(ctx) { diff --git a/scripts/langindex.json b/scripts/langindex.json index 7d24079128b..95e7f3887a0 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1652,6 +1652,13 @@ "core.courses.totalcoursesearchresults": "local_moodlemobileapp", "core.currentdevice": "local_moodlemobileapp", "core.custom": "form", + "core.reportbuilder.modifiedby": "tool_reportbuilder", + "core.reportbuilder.reports": "moodle", + "core.reportbuilder.reportsource": "moodle", + "core.reportbuilder.timecreated": "moodle", + "core.reportbuilder.filtersapplied": "local_moodlemobileapp", + "core.reportbuilder.showcolumns": "local_moodlemobileapp", + "core.reportbuilder.hidecolumns": "local_moodlemobileapp", "core.datastoredoffline": "local_moodlemobileapp", "core.date": "moodle", "core.datecreated": "repository", @@ -1735,9 +1742,11 @@ "core.filenotfound": "resource", "core.fileuploader.addfiletext": "repository", "core.fileuploader.audio": "local_moodlemobileapp", + "core.fileuploader.audiotitle": "tiny_recordrtc", "core.fileuploader.camera": "local_moodlemobileapp", "core.fileuploader.confirmuploadfile": "local_moodlemobileapp", "core.fileuploader.confirmuploadunknownsize": "local_moodlemobileapp", + "core.fileuploader.discardrecording": "local_moodlemobileapp", "core.fileuploader.errorcapturingaudio": "local_moodlemobileapp", "core.fileuploader.errorcapturingimage": "local_moodlemobileapp", "core.fileuploader.errorcapturingvideo": "local_moodlemobileapp", @@ -1751,11 +1760,18 @@ "core.fileuploader.fileuploaded": "local_moodlemobileapp", "core.fileuploader.invalidfiletype": "repository", "core.fileuploader.maxbytesfile": "local_moodlemobileapp", + "core.fileuploader.microphonepermissiondenied": "local_moodlemobileapp", + "core.fileuploader.microphonepermissionrestricted": "local_moodlemobileapp", "core.fileuploader.more": "data", + "core.fileuploader.pauserecording": "local_moodlemobileapp", "core.fileuploader.photoalbums": "local_moodlemobileapp", "core.fileuploader.readingfile": "local_moodlemobileapp", "core.fileuploader.readingfileperc": "local_moodlemobileapp", + "core.fileuploader.resumerecording": "local_moodlemobileapp", "core.fileuploader.selectafile": "local_moodlemobileapp", + "core.fileuploader.startrecording": "tiny_recordrtc", + "core.fileuploader.startrecordinginstructions": "local_moodlemobileapp", + "core.fileuploader.stoprecording": "tiny_recordrtc", "core.fileuploader.uploadafile": "local_moodlemobileapp", "core.fileuploader.uploading": "local_moodlemobileapp", "core.fileuploader.uploadingperc": "local_moodlemobileapp", @@ -2098,6 +2114,7 @@ "core.nopasswordchangeforced": "local_moodlemobileapp", "core.nopermissionerror": "local_moodlemobileapp", "core.nopermissions": "error", + "core.nopermissiontoaccesspage": "error", "core.noresults": "moodle", "core.noselection": "form", "core.notapplicable": "local_moodlemobileapp", diff --git a/src/addons/badges/pages/issued-badge/issued-badge.html b/src/addons/badges/pages/issued-badge/issued-badge.html index 5f233087f2a..abb339c92b7 100644 --- a/src/addons/badges/pages/issued-badge/issued-badge.html +++ b/src/addons/badges/pages/issued-badge/issued-badge.html @@ -33,7 +33,7 @@

{{ 'addon.badges.recipientdetails' | translate}}

-

{{ 'core.name' | translate}}

+

{{ 'core.name' | translate}}

{{ user.fullname }}

@@ -48,13 +48,13 @@

{{ 'addon.badges.issuerdetails' | translate}}

-

{{ 'addon.badges.issuername' | translate}}

+

{{ 'addon.badges.issuername' | translate}}

{{ badge.issuername }}

-

{{ 'addon.badges.contact' | translate}}

+

{{ 'addon.badges.contact' | translate}}

{{ badge.issuercontact }}

@@ -70,37 +70,37 @@

{{ 'addon.badges.badgedetails' | translate}}

-

{{ 'core.name' | translate}}

+

{{ 'core.name' | translate}}

{{ badge.name }}

-

{{ 'addon.badges.version' | translate}}

+

{{ 'addon.badges.version' | translate}}

{{ badge.version }}

-

{{ 'addon.badges.language' | translate}}

+

{{ 'addon.badges.language' | translate}}

{{ badge.language }}

-

{{ 'core.description' | translate}}

+

{{ 'core.description' | translate}}

{{ badge.description }}

-

{{ 'addon.badges.imageauthorname' | translate}}

+

{{ 'addon.badges.imageauthorname' | translate}}

{{ badge.imageauthorname }}

-

{{ 'addon.badges.imageauthoremail' | translate}}

+

{{ 'addon.badges.imageauthoremail' | translate}}

{{ badge.imageauthoremail }}

@@ -108,19 +108,19 @@

{{ 'addon.badges.imageauthoremail' | translate}}

-

{{ 'addon.badges.imageauthorurl' | translate}}

+

{{ 'addon.badges.imageauthorurl' | translate}}

{{ badge.imageauthorurl }}

-

{{ 'addon.badges.imagecaption' | translate}}

+

{{ 'addon.badges.imagecaption' | translate}}

{{ badge.imagecaption }}

-

{{ 'core.course' | translate}}

+

{{ 'core.course' | translate}}

@@ -138,13 +138,13 @@

{{ 'addon.badges.issuancedetails' | translate}}

-

{{ 'addon.badges.dateawarded' | translate}}

+

{{ 'addon.badges.dateawarded' | translate}}

{{badge.dateissued * 1000 | coreFormatDate }}

-

{{ 'addon.badges.expirydate' | translate}}

+

{{ 'addon.badges.expirydate' | translate}}

{{ badge.dateexpire * 1000 | coreFormatDate }} @@ -165,13 +165,13 @@

{{ 'addon.badges.bendorsement' | translate}}

-

{{ 'addon.badges.issuername' | translate}}

+

{{ 'addon.badges.issuername' | translate}}

{{ badge.endorsement.issuername }}

-

{{ 'addon.badges.issueremail' | translate}}

+

{{ 'addon.badges.issueremail' | translate}}

{{ badge.endorsement.issueremail }} @@ -181,25 +181,25 @@

{{ 'addon.badges.issueremail' | translate}}

-

{{ 'addon.badges.issuerurl' | translate}}

+

{{ 'addon.badges.issuerurl' | translate}}

{{ badge.endorsement.issuerurl }}

-

{{ 'addon.badges.dateawarded' | translate}}

+

{{ 'addon.badges.dateawarded' | translate}}

{{ badge.endorsement.dateissued * 1000 | coreFormatDate }}

-

{{ 'addon.badges.claimid' | translate}}

+

{{ 'addon.badges.claimid' | translate}}

{{ badge.endorsement.claimid }}

-

{{ 'addon.badges.claimcomment' | translate}}

+

{{ 'addon.badges.claimcomment' | translate}}

{{ badge.endorsement.claimcomment }}

@@ -214,12 +214,12 @@

{{ 'addon.badges.relatedbages' | translate}}

-

{{ relatedBadge.name }}

+

{{ relatedBadge.name }}

-

{{ 'addon.badges.norelated' | translate}}

+

{{ 'addon.badges.norelated' | translate}}

@@ -234,12 +234,12 @@

{{ 'addon.badges.alignment' | translate}}

-

{{ alignment.targetname }}

+

{{ alignment.targetname }}

-

{{ 'addon.badges.noalignment' | translate}}

+

{{ 'addon.badges.noalignment' | translate}}

diff --git a/src/addons/block/timeline/tests/behat/basic_usage-311.feature b/src/addons/block/timeline/tests/behat/basic_usage-311.feature index e7501b5f989..d22c111bf2e 100644 --- a/src/addons/block/timeline/tests/behat/basic_usage-311.feature +++ b/src/addons/block/timeline/tests/behat/basic_usage-311.feature @@ -25,11 +25,11 @@ Feature: Timeline block. | assign | C1 | assign03 | Assignment 03 | ##tomorrow## | | assign | C2 | assign04 | Assignment 04 | ##+2 days## | | assign | C1 | assign05 | Assignment 05 | ##+5 days## | - | assign | C2 | assign06 | Assignment 06 | ##+1 month## | - | assign | C2 | assign07 | Assignment 07 | ##+1 month## | - | assign | C3 | assign08 | Assignment 08 | ##+1 month## | - | assign | C2 | assign09 | Assignment 09 | ##+1 month## | - | assign | C1 | assign10 | Assignment 10 | ##+1 month## | + | assign | C2 | assign06 | Assignment 06 | ##+31 days## | + | assign | C2 | assign07 | Assignment 07 | ##+31 days## | + | assign | C3 | assign08 | Assignment 08 | ##+31 days## | + | assign | C2 | assign09 | Assignment 09 | ##+31 days## | + | assign | C1 | assign10 | Assignment 10 | ##+31 days## | | assign | C1 | assign11 | Assignment 11 | ##+6 months## | | assign | C1 | assign12 | Assignment 12 | ##+6 months## | | assign | C1 | assign13 | Assignment 13 | ##+6 months## | diff --git a/src/addons/block/timeline/tests/behat/basic_usage.feature b/src/addons/block/timeline/tests/behat/basic_usage.feature index f69fc3f3a9c..9042ad3078f 100644 --- a/src/addons/block/timeline/tests/behat/basic_usage.feature +++ b/src/addons/block/timeline/tests/behat/basic_usage.feature @@ -25,11 +25,11 @@ Feature: Timeline block. | assign | C1 | assign03 | Assignment 03 | ##tomorrow## | | assign | C2 | assign04 | Assignment 04 | ##+2 days## | | assign | C1 | assign05 | Assignment 05 | ##+5 days## | - | assign | C2 | assign06 | Assignment 06 | ##+1 month## | - | assign | C2 | assign07 | Assignment 07 | ##+1 month## | - | assign | C3 | assign08 | Assignment 08 | ##+1 month## | - | assign | C2 | assign09 | Assignment 09 | ##+1 month## | - | assign | C1 | assign10 | Assignment 10 | ##+1 month## | + | assign | C2 | assign06 | Assignment 06 | ##+31 days## | + | assign | C2 | assign07 | Assignment 07 | ##+31 days## | + | assign | C3 | assign08 | Assignment 08 | ##+31 days## | + | assign | C2 | assign09 | Assignment 09 | ##+31 days## | + | assign | C1 | assign10 | Assignment 10 | ##+31 days## | | assign | C1 | assign11 | Assignment 11 | ##+6 months## | | assign | C1 | assign12 | Assignment 12 | ##+6 months## | | assign | C1 | assign13 | Assignment 13 | ##+6 months## | diff --git a/src/addons/blog/pages/entries/entries.html b/src/addons/blog/pages/entries/entries.html index 06d7039325c..44d416a2a46 100644 --- a/src/addons/blog/pages/entries/entries.html +++ b/src/addons/blog/pages/entries/entries.html @@ -27,19 +27,22 @@

{{ title | translate }}

-

- - - +

+

+ + +

+ {{ 'addon.blog.' + entry.publishTranslated! | translate}} -

-

- +

+
+ {{entry.user && entry.user.fullname}} + {{entry.created | coreDateDayOrTime}} - {{entry.user && entry.user!.fullname}} -

+
diff --git a/src/addons/calendar/components/calendar/addon-calendar-calendar.html b/src/addons/calendar/components/calendar/addon-calendar-calendar.html index b355dd1e510..3578f740c3d 100644 --- a/src/addons/calendar/components/calendar/addon-calendar-calendar.html +++ b/src/addons/calendar/components/calendar/addon-calendar-calendar.html @@ -33,7 +33,7 @@

- +
@@ -57,9 +57,9 @@

"today": month.isCurrentMonth && day.istoday, "weekend": day.isweekend, "duration_finish": day.haslastdayofevent - }' [class.addon-calendar-event-past-day]="month.isPastMonth || day.ispast" role="cell" tabindex="0" - (ariaButtonClick)="dayClicked(day.mday)"> -

+ }' [class.addon-calendar-event-past-day]="month.isPastMonth || day.ispast" role="cell" + (ariaButtonClick)="dayClicked(day.mday)" [tabindex]="activeView ? 0 : -1"> +

{{ day.periodName | translate }}

@@ -72,8 +72,8 @@

+ [class.addon-calendar-event-past]="event.ispast" (ariaButtonClick)="eventClicked(event, $event)" + [tabindex]="activeView ? 0 : -1"> diff --git a/src/addons/calendar/components/calendar/calendar.scss b/src/addons/calendar/components/calendar/calendar.scss index 7f6833c23c3..0df183e400e 100644 --- a/src/addons/calendar/components/calendar/calendar.scss +++ b/src/addons/calendar/components/calendar/calendar.scss @@ -25,7 +25,6 @@ @include border-end(1px, solid var(--addon-calendar-border-color)); overflow: hidden; min-height: 60px; - cursor: pointer; &:first-child { @include padding-horizontal(10px, null); @@ -99,7 +98,7 @@ .addon-calendar-period { flex-grow: 3; - h3 { + h2 { margin-top: 10px; font-size: 1.2rem; } diff --git a/src/addons/calendar/pages/day/day.html b/src/addons/calendar/pages/day/day.html index 1abcf2c1678..8607da1549d 100644 --- a/src/addons/calendar/pages/day/day.html +++ b/src/addons/calendar/pages/day/day.html @@ -38,7 +38,7 @@

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

-

{{ periodName }}

+

{{ periodName }}

diff --git a/src/addons/calendar/pages/day/day.scss b/src/addons/calendar/pages/day/day.scss index 145eccfb8aa..111ba695370 100644 --- a/src/addons/calendar/pages/day/day.scss +++ b/src/addons/calendar/pages/day/day.scss @@ -6,7 +6,7 @@ .addon-calendar-period { flex-grow: 3; - h3 { + h2 { margin-top: 10px; font-size: 1.2rem; } diff --git a/src/addons/calendar/pages/event/event.html b/src/addons/calendar/pages/event/event.html index e41c029cc2c..40adfcf4c9b 100644 --- a/src/addons/calendar/pages/event/event.html +++ b/src/addons/calendar/pages/event/event.html @@ -60,7 +60,7 @@

-

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

+

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

@@ -70,13 +70,13 @@

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

-

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

+

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

{{ 'addon.calendar.type' + event.formattedType | translate }}

-

{{ 'core.course' | translate}}

+

{{ 'core.course' | translate}}

@@ -85,13 +85,13 @@

{{ 'core.course' | translate}}

-

{{ 'core.group' | translate}}

+

{{ 'core.group' | translate}}

{{ groupName }}

-

{{ 'core.category' | translate}}

+

{{ 'core.category' | translate}}

@@ -100,7 +100,7 @@

{{ 'core.category' | translate}}

-

{{ 'core.description' | translate}}

+

{{ 'core.description' | translate}}

@@ -109,7 +109,7 @@

{{ 'core.description' | translate}}

-

{{ 'core.location' | translate}}

+

{{ 'core.location' | translate}}

-

{{ user.fullname }}

+

{{ user.fullname }}

@@ -115,7 +115,7 @@

{{ user.fullname }}

-

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

+

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

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

diff --git a/src/addons/competency/pages/coursecompetencies/coursecompetencies.html b/src/addons/competency/pages/coursecompetencies/coursecompetencies.html index daea9587959..3d36e23219e 100644 --- a/src/addons/competency/pages/coursecompetencies/coursecompetencies.html +++ b/src/addons/competency/pages/coursecompetencies/coursecompetencies.html @@ -53,7 +53,7 @@

-

{{ user.fullname }}

+

{{ user.fullname }}

diff --git a/src/addons/competency/pages/plan/plan.html b/src/addons/competency/pages/plan/plan.html index 0559725274a..ec42cb599c9 100644 --- a/src/addons/competency/pages/plan/plan.html +++ b/src/addons/competency/pages/plan/plan.html @@ -17,7 +17,7 @@

{{plan.plan.name}}

-

{{ user.fullname }}

+

{{ user.fullname }}

diff --git a/src/addons/coursecompletion/pages/report/report.html b/src/addons/coursecompletion/pages/report/report.html index e9c51650003..22420d5f91c 100644 --- a/src/addons/coursecompletion/pages/report/report.html +++ b/src/addons/coursecompletion/pages/report/report.html @@ -16,20 +16,20 @@

{{ 'addon.coursecompletion.coursecompletion' | translate }}

-

{{user!.fullname}}

+

{{user.fullname}}

-

{{ 'addon.coursecompletion.status' | translate }}

+

{{ 'addon.coursecompletion.status' | translate }}

{{ statusText! | translate }}

-

{{ 'addon.coursecompletion.required' | translate }}

+

{{ 'addon.coursecompletion.required' | translate }}

{{ 'addon.coursecompletion.criteriarequiredall' | translate }}

{{ 'addon.coursecompletion.criteriarequiredany' | translate }}

diff --git a/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts b/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts new file mode 100644 index 00000000000..3301aecb6a6 --- /dev/null +++ b/src/addons/filter/mediaplugin/classes/videojs-ogvjs.ts @@ -0,0 +1,741 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CorePlatform } from '@services/platform'; +import { OGVPlayer, OGVCompat, OGVLoader } from 'ogv'; +import videojs, { PreloadOption, TechSourceObject, VideoJSOptions } from 'video.js'; + +const Tech = videojs.getComponent('Tech'); + +/** + * Object.defineProperty but "lazy", which means that the value is only set after + * it retrieved the first time, rather than being set right away. + * + * @param obj The object to set the property on. + * @param key The key for the property to set. + * @param getValue The function used to get the value when it is needed. + * @param setter Whether a setter should be allowed or not. + * @returns Object. + */ +const defineLazyProperty = (obj: T, key: string, getValue: () => unknown, setter = true): T => { + const set = (value: unknown): void => { + Object.defineProperty(obj, key, { value, enumerable: true, writable: true }); + }; + + const options: PropertyDescriptor = { + configurable: true, + enumerable: true, + get() { + const value = getValue(); + + set(value); + + return value; + }, + }; + + if (setter) { + options.set = set; + } + + return Object.defineProperty(obj, key, options); +}; + +/** + * OgvJS Media Controller for VideoJS - Wrapper for ogv.js Media API. + * + * Code adapted from https://github.com/HuongNV13/videojs-ogvjs/blob/f9b12bd53018d967bb305f02725834a98f20f61f/src/plugin.js + * Modified in the following ways: + * - Adapted to Typescript. + * - Use our own functions to detect the platform instead of using getDeviceOS. + * - Add an initialize static function. + * - In the play function, reset the media if it already ended to fix problems with replaying media. + * - Allow full screen in iOS devices, and implement enterFullScreen and exitFullScreen to use a fake full screen. + */ +export class VideoJSOgvJS extends Tech { + + /** + * List of available events of the media player. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + static readonly Events = [ + 'loadstart', + 'suspend', + 'abort', + 'error', + 'emptied', + 'stalled', + 'loadedmetadata', + 'loadeddata', + 'canplay', + 'canplaythrough', + 'playing', + 'waiting', + 'seeking', + 'seeked', + 'ended', + 'durationchange', + 'timeupdate', + 'progress', + 'play', + 'pause', + 'ratechange', + 'resize', + 'volumechange', + ]; + + protected playerId?: string; + protected parentElement: HTMLElement | null = null; + protected placeholderElement = document.createElement('div'); + + // Variables/functions defined in parent classes. + protected el_!: OGVPlayerEl; // eslint-disable-line @typescript-eslint/naming-convention + protected options_!: VideoJSOptions; // eslint-disable-line @typescript-eslint/naming-convention + protected currentSource_?: TechSourceObject; // eslint-disable-line @typescript-eslint/naming-convention + protected triggerReady!: () => void; + protected on!: (name: string, callback: (e?: Event) => void) => void; + + /** + * Create an instance of this Tech. + * + * @param options The key/value store of player options. + * @param ready Callback function to call when the `OgvJS` Tech is ready. + */ + constructor(options: VideoJSTechOptions, ready: () => void) { + super(options, ready); + + this.el_.src = options.src || options.source?.src || options.sources?.[0]?.src || this.el_.src; + VideoJSOgvJS.setIfAvailable(this.el_, 'autoplay', options.autoplay); + VideoJSOgvJS.setIfAvailable(this.el_, 'loop', options.loop); + VideoJSOgvJS.setIfAvailable(this.el_, 'poster', options.poster); + VideoJSOgvJS.setIfAvailable(this.el_, 'preload', options.preload); + this.playerId = options.playerId; + + this.on('loadedmetadata', () => { + if (CorePlatform.isIPhone()) { + // iPhoneOS add some inline styles to the canvas, we need to remove it. + const canvas = this.el_.getElementsByTagName('canvas')[0]; + + canvas.style.removeProperty('width'); + canvas.style.removeProperty('margin'); + } + + this.triggerReady(); + }); + } + + /** + * Set the value for the player is it has that property. + * + * @param el HTML player. + * @param name Name of the property. + * @param value Value to set. + */ + static setIfAvailable(el: HTMLElement, name: string, value: unknown): void { + // eslint-disable-next-line no-prototype-builtins + if (el.hasOwnProperty(name)) { + el[name] = value; + } + }; + + /** + * Check if browser/device is supported by Ogv.JS. + * + * @returns Whether it's supported. + */ + static isSupported(): boolean { + return OGVCompat.supported('OGVPlayer'); + }; + + /** + * Check if the tech can support the given type. + * + * @param type The mimetype to check. + * @returns 'probably', 'maybe', or '' (empty string). + */ + static canPlayType(type: string): string { + return (type.indexOf('/ogg') !== -1 || type.indexOf('/webm')) ? 'maybe' : ''; + }; + + /** + * Check if the tech can support the given source. + * + * @param srcObj The source object. + * @returns The options passed to the tech. + */ + static canPlaySource(srcObj: TechSourceObject): string { + return VideoJSOgvJS.canPlayType(srcObj.type); + }; + + /** + * Check if the volume can be changed in this browser/device. + * Volume cannot be changed in a lot of mobile devices. + * Specifically, it can't be changed from 1 on iOS. + * + * @returns True if volume can be controlled. + */ + static canControlVolume(): boolean { + if (CorePlatform.isIPhone()) { + return false; + } + + const player = new OGVPlayer(); + + // eslint-disable-next-line no-prototype-builtins + return player.hasOwnProperty('volume'); + }; + + /** + * Check if the volume can be muted in this browser/device. + * + * @returns True if volume can be muted. + */ + static canMuteVolume(): boolean { + return true; + }; + + /** + * Check if the playback rate can be changed in this browser/device. + * + * @returns True if playback rate can be controlled. + */ + static canControlPlaybackRate(): boolean { + return true; + }; + + /** + * Check to see if native 'TextTracks' are supported by this browser/device. + * + * @returns True if native 'TextTracks' are supported. + */ + static supportsNativeTextTracks(): boolean { + return false; + }; + + /** + * Check if the fullscreen resize is supported by this browser/device. + * + * @returns True if the fullscreen resize is supported. + */ + static supportsFullscreenResize(): boolean { + return true; + }; + + /** + * Check if the progress events is supported by this browser/device. + * + * @returns True if the progress events is supported. + */ + static supportsProgressEvents(): boolean { + return true; + }; + + /** + * Check if the time update events is supported by this browser/device. + * + * @returns True if the time update events is supported. + */ + static supportsTimeupdateEvents(): boolean { + return true; + }; + + /** + * Create the 'OgvJS' Tech's DOM element. + * + * @returns The element that gets created. + */ + createEl(): OGVPlayerEl { + const options = this.options_; + + if (options.base) { + OGVLoader.base = options.base; + } else if (!OGVLoader.base) { + throw new Error('Please specify the base for the ogv.js library'); + } + + const el = new OGVPlayer(options); + + el.className += ' vjs-tech'; + options.tag = el; + + return el; + } + + /** + * Start playback. + */ + play(): void { + if (this.ended()) { + // Reset the player, otherwise the Replay button doesn't work. + this.el_.stop(); + } + + this.el_.play(); + } + + /** + * Get the current playback speed. + * + * @returns Playback speed. + */ + playbackRate(): number { + return this.el_.playbackRate || 1; + } + + /** + * Set the playback speed. + * + * @param val Speed for the player to play. + */ + setPlaybackRate(val: number): void { + // eslint-disable-next-line no-prototype-builtins + if (this.el_.hasOwnProperty('playbackRate')) { + this.el_.playbackRate = val; + } + } + + /** + * Returns a TimeRanges object that represents the ranges of the media resource that the user agent has played. + * + * @returns The range of points on the media timeline that has been reached through normal playback. + */ + played(): TimeRanges { + return this.el_.played; + } + + /** + * Pause playback. + */ + pause(): void { + this.el_.pause(); + } + + /** + * Is the player paused or not. + * + * @returns Whether is paused. + */ + paused(): boolean { + return this.el_.paused; + } + + /** + * Get current playing time. + * + * @returns Current time. + */ + currentTime(): number { + return this.el_.currentTime; + } + + /** + * Set current playing time. + * + * @param seconds Current time of audio/video. + */ + setCurrentTime(seconds: number): void { + try { + this.el_.currentTime = seconds; + } catch (e) { + videojs.log(e, 'Media is not ready. (Video.JS)'); + } + } + + /** + * Get media's duration. + * + * @returns Duration. + */ + duration(): number { + if (this.el_.duration && this.el_.duration !== Infinity) { + return this.el_.duration; + } + + return 0; + } + + /** + * Get a TimeRange object that represents the intersection + * of the time ranges for which the user agent has all + * relevant media. + * + * @returns Time ranges. + */ + buffered(): TimeRanges { + return this.el_.buffered; + } + + /** + * Get current volume level. + * + * @returns Volume. + */ + volume(): number { + // eslint-disable-next-line no-prototype-builtins + return this.el_.hasOwnProperty('volume') ? this.el_.volume : 1; + } + + /** + * Set current playing volume level. + * + * @param percentAsDecimal Volume percent as a decimal. + */ + setVolume(percentAsDecimal: number): void { + // eslint-disable-next-line no-prototype-builtins + if (!CorePlatform.isIPhone() && this.el_.hasOwnProperty('volume')) { + this.el_.volume = percentAsDecimal; + } + } + + /** + * Is the player muted or not. + * + * @returns Whether it's muted. + */ + muted(): boolean { + return this.el_.muted; + } + + /** + * Mute the player. + * + * @param muted True to mute the player. + */ + setMuted(muted: boolean): void { + this.el_.muted = !!muted; + } + + /** + * Is the player muted by default or not. + * + * @returns Whether it's muted by default. + */ + defaultMuted(): boolean { + return this.el_.defaultMuted || false; + } + + /** + * Get the player width. + * + * @returns Width. + */ + width(): number { + return this.el_.offsetWidth; + } + + /** + * Get the player height. + * + * @returns Height. + */ + height(): number { + return this.el_.offsetHeight; + } + + /** + * Get the video width. + * + * @returns Video width. + */ + videoWidth(): number { + return ( this.el_).videoWidth ?? 0; + } + + /** + * Get the video height. + * + * @returns Video heigth. + */ + videoHeight(): number { + return ( this.el_).videoHeight ?? 0; + } + + /** + * Get/set media source. + * + * @param src Source. + * @returns Source when getting it, undefined when setting it. + */ + src(src?: string): string | undefined { + if (typeof src === 'undefined') { + return this.el_.src; + } + + this.el_.src = src; + } + + /** + * Load the media into the player. + */ + load(): void { + this.el_.load(); + } + + /** + * Get current media source. + * + * @returns Current source. + */ + currentSrc(): string { + if (this.currentSource_) { + return this.currentSource_.src; + } + + return this.el_.currentSrc; + } + + /** + * Get media poster URL. + * + * @returns Poster. + */ + poster(): string { + return 'poster' in this.el_ ? this.el_.poster : ''; + } + + /** + * Set media poster URL. + * + * @param url The poster image's url. + */ + setPoster(url: string): void { + ( this.el_).poster = url; + } + + /** + * Is the media preloaded or not. + * + * @returns Whether it's preloaded. + */ + preload(): PreloadOption { + return this.el_.preload || 'none'; + } + + /** + * Set the media preload method. + * + * @param val Value for preload attribute. + */ + setPreload(val: PreloadOption): void { + // eslint-disable-next-line no-prototype-builtins + if (this.el_.hasOwnProperty('preload')) { + this.el_.preload = val; + } + } + + /** + * Is the media auto-played or not. + * + * @returns Whether it's auto-played. + */ + autoplay(): boolean { + return this.el_.autoplay || false; + } + + /** + * Set media autoplay method. + * + * @param val Value for autoplay attribute. + */ + setAutoplay(val: boolean): void { + // eslint-disable-next-line no-prototype-builtins + if (this.el_.hasOwnProperty('autoplay')) { + this.el_.autoplay = !!val; + } + } + + /** + * Does the media has controls or not. + * + * @returns Whether it has controls. + */ + controls(): boolean { + return this.el_.controls || false; + } + + /** + * Set the media controls method. + * + * @param val Value for controls attribute. + */ + setControls(val: boolean): void { + // eslint-disable-next-line no-prototype-builtins + if (this.el_.hasOwnProperty('controls')) { + this.el_.controls = !!val; + } + } + + /** + * Is the media looped or not. + * + * @returns Whether it's looped. + */ + loop(): boolean { + return this.el_.loop || false; + } + + /** + * Set the media loop method. + * + * @param val Value for loop attribute. + */ + setLoop(val: boolean): void { + // eslint-disable-next-line no-prototype-builtins + if (this.el_.hasOwnProperty('loop')) { + this.el_.loop = !!val; + } + } + + /** + * Get a TimeRanges object that represents the + * ranges of the media resource to which it is possible + * for the user agent to seek. + * + * @returns Time ranges. + */ + seekable(): TimeRanges { + return this.el_.seekable; + } + + /** + * Is player in the "seeking" state or not. + * + * @returns Whether is in the seeking state. + */ + seeking(): boolean { + return this.el_.seeking; + } + + /** + * Is the media ended or not. + * + * @returns Whether it's ended. + */ + ended(): boolean { + return this.el_.ended; + } + + /** + * Get the current state of network activity + * NETWORK_EMPTY (numeric value 0) + * NETWORK_IDLE (numeric value 1) + * NETWORK_LOADING (numeric value 2) + * NETWORK_NO_SOURCE (numeric value 3) + * + * @returns Network state. + */ + networkState(): number { + return this.el_.networkState; + } + + /** + * Get the current state of the player. + * HAVE_NOTHING (numeric value 0) + * HAVE_METADATA (numeric value 1) + * HAVE_CURRENT_DATA (numeric value 2) + * HAVE_FUTURE_DATA (numeric value 3) + * HAVE_ENOUGH_DATA (numeric value 4) + * + * @returns Ready state. + */ + readyState(): number { + return this.el_.readyState; + } + + /** + * Does the player support native fullscreen mode or not. (Mobile devices) + * + * @returns Whether it supports full screen. + */ + supportsFullScreen(): boolean { + return !!this.playerId; + } + + /** + * Get media player error. + * + * @returns Error. + */ + error(): MediaError | null { + return this.el_.error; + } + + /** + * Enter full screen mode. + */ + enterFullScreen(): void { + // Use a "fake" full screen mode, moving the player to a different place in DOM to be able to use full screen size. + const player = videojs.getPlayer(this.playerId ?? ''); + if (!player) { + return; + } + + const container = player.el(); + this.parentElement = container.parentElement; + if (!this.parentElement) { + // Shouldn't happen, it means the element is not in DOM. Do not support full screen in this case. + return; + } + + this.parentElement.replaceChild(this.placeholderElement, container); + document.body.appendChild(container); + container.classList.add('vjs-ios-moodleapp-fs'); + + player.isFullscreen(true); + } + + /** + * Exit full screen mode. + */ + exitFullScreen(): void { + if (!this.parentElement) { + return; + } + + const player = videojs.getPlayer(this.playerId ?? ''); + if (!player) { + return; + } + + const container = player.el(); + this.parentElement.replaceChild(container, this.placeholderElement); + container.classList.remove('vjs-ios-moodleapp-fs'); + + player.isFullscreen(false); + } + +} + +[ + ['featuresVolumeControl', 'canControlVolume'], + ['featuresMuteControl', 'canMuteVolume'], + ['featuresPlaybackRate', 'canControlPlaybackRate'], + ['featuresNativeTextTracks', 'supportsNativeTextTracks'], + ['featuresFullscreenResize', 'supportsFullscreenResize'], + ['featuresProgressEvents', 'supportsProgressEvents'], + ['featuresTimeupdateEvents', 'supportsTimeupdateEvents'], +].forEach(([key, fn]) => { + defineLazyProperty(VideoJSOgvJS.prototype, key, () => VideoJSOgvJS[fn](), true); +}); + +type OGVPlayerEl = (HTMLAudioElement | HTMLVideoElement) & { + stop: () => void; +}; + +/** + * VideoJS Tech options. It includes some options added by VideoJS internally. + */ +type VideoJSTechOptions = VideoJSOptions & { + playerId?: string; +}; diff --git a/src/addons/filter/mediaplugin/mediaplugin.module.ts b/src/addons/filter/mediaplugin/mediaplugin.module.ts index 1977bd08da1..69451421c19 100644 --- a/src/addons/filter/mediaplugin/mediaplugin.module.ts +++ b/src/addons/filter/mediaplugin/mediaplugin.module.ts @@ -26,7 +26,9 @@ import { AddonFilterMediaPluginHandler } from './services/handlers/mediaplugin'; { provide: APP_INITIALIZER, multi: true, - useValue: () => CoreFilterDelegate.registerHandler(AddonFilterMediaPluginHandler.instance), + useValue: () => { + CoreFilterDelegate.registerHandler(AddonFilterMediaPluginHandler.instance); + }, }, ], }) diff --git a/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts b/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts index 50945192719..5c5cea5765c 100644 --- a/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts +++ b/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts @@ -12,12 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { AddonFilterMediaPluginVideoJS } from '@addons/filter/mediaplugin/services/videojs'; import { Injectable } from '@angular/core'; import { CoreFilterDefaultHandler } from '@features/filter/services/handlers/default-filter'; -import { CoreTextUtils } from '@services/utils/text'; -import { CoreUrlUtils } from '@services/utils/url'; import { makeSingleton } from '@singletons'; +import { CoreMedia } from '@singletons/media'; /** * Handler to support the Multimedia filter. @@ -33,58 +33,38 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl /** * @inheritdoc */ - filter( - text: string, - ): string | Promise { + filter(text: string): string | Promise { this.template.innerHTML = text; const videos = Array.from(this.template.content.querySelectorAll('video')); videos.forEach((video) => { - this.treatVideoFilters(video); + AddonFilterMediaPluginVideoJS.treatYoutubeVideos(video); }); return this.template.innerHTML; } /** - * Treat video filters. Currently only treating youtube video using video JS. - * - * @param video Video element. + * @inheritdoc */ - protected treatVideoFilters(video: HTMLElement): void { - // Treat Video JS Youtube video links and translate them to iframes. - if (!video.classList.contains('video-js')) { - return; - } - - const dataSetupString = video.getAttribute('data-setup') || video.getAttribute('data-setup-lazy') || '{}'; - const data = CoreTextUtils.parseJSON(dataSetupString, {}); - const youtubeUrl = data.techOrder?.[0] == 'youtube' && CoreUrlUtils.getYoutubeEmbedUrl(data.sources?.[0]?.src); + handleHtml(container: HTMLElement): void { + const mediaElements = Array.from(container.querySelectorAll('video, audio')); - if (!youtubeUrl) { - return; - } + mediaElements.forEach((mediaElement) => { + if (CoreMedia.mediaUsesJavascriptPlayer(mediaElement)) { + AddonFilterMediaPluginVideoJS.createPlayer(mediaElement); - const iframe = document.createElement('iframe'); - iframe.id = video.id; - iframe.src = youtubeUrl; - iframe.setAttribute('frameborder', '0'); - iframe.setAttribute('allowfullscreen', '1'); - iframe.width = '100%'; - iframe.height = '300'; + return; + } - // Replace video tag by the iframe. - video.parentNode?.replaceChild(iframe, video); + // Remove the VideoJS classes and data if present. + mediaElement.classList.remove('video-js'); + mediaElement.removeAttribute('data-setup'); + mediaElement.removeAttribute('data-setup-lazy'); + }); } } export const AddonFilterMediaPluginHandler = makeSingleton(AddonFilterMediaPluginHandlerService); - -type VideoDataSetup = { - techOrder?: string[]; - sources?: { - src?: string; - }[]; -}; diff --git a/src/addons/filter/mediaplugin/services/videojs.ts b/src/addons/filter/mediaplugin/services/videojs.ts new file mode 100644 index 00000000000..2bfb87f8fb1 --- /dev/null +++ b/src/addons/filter/mediaplugin/services/videojs.ts @@ -0,0 +1,188 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CorePromisedValue } from '@classes/promised-value'; +import { CoreExternalContentDirective } from '@directives/external-content'; +import { CoreLang } from '@services/lang'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUrlUtils } from '@services/utils/url'; +import { makeSingleton } from '@singletons'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; +import { CoreEvents } from '@singletons/events'; +import type videojs from 'video.js'; + +// eslint-disable-next-line no-duplicate-imports +import type { VideoJSOptions, VideoJSPlayer } from 'video.js'; + +declare module '@singletons/events' { + + /** + * Augment CoreEventsData interface with events specific to this service. + * + * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + */ + export interface CoreEventsData { + [VIDEO_JS_PLAYER_CREATED]: CoreEventJSVideoPlayerCreated; + } + +} + +export const VIDEO_JS_PLAYER_CREATED = 'video_js_player_created'; + +/** + * Wrapper encapsulating videojs functionality. + */ +@Injectable({ providedIn: 'root' }) +export class AddonFilterMediaPluginVideoJSService { + + protected videojs?: CorePromisedValue; + + /** + * Create a VideoJS player. + * + * @param element Media element. + */ + async createPlayer(element: HTMLVideoElement | HTMLAudioElement): Promise { + // Wait for external-content to finish in the element and its sources. + await Promise.all([ + CoreDirectivesRegistry.waitDirectivesReady(element, undefined, CoreExternalContentDirective), + CoreDirectivesRegistry.waitDirectivesReady(element, 'source', CoreExternalContentDirective), + ]); + + // Create player. + const videojs = await this.getVideoJS(); + const dataSetupString = element.getAttribute('data-setup') || element.getAttribute('data-setup-lazy') || '{}'; + const data = CoreTextUtils.parseJSON(dataSetupString, {}); + const player = videojs( + element, + { + controls: true, + techOrder: ['OgvJS'], + language: await CoreLang.getCurrentLanguage(), + controlBar: { pictureInPictureToggle: false }, + aspectRatio: data.aspectRatio, + }, + () => element.tagName === 'VIDEO' && this.fixVideoJSPlayerSize(player), + ); + + CoreEvents.trigger(VIDEO_JS_PLAYER_CREATED, { + element, + player, + }); + } + + /** + * Find a VideoJS player by id. + * + * @param id Element id. + * @returns VideoJS player. + */ + async findPlayer(id: string): Promise { + const videojs = await this.getVideoJS(); + + return videojs.getPlayer(id); + } + + /** + * Treat Video JS Youtube video links and translate them to iframes. + * + * @param video Video element. + */ + treatYoutubeVideos(video: HTMLElement): void { + if (!video.classList.contains('video-js')) { + return; + } + + const dataSetupString = video.getAttribute('data-setup') || video.getAttribute('data-setup-lazy') || '{}'; + const data = CoreTextUtils.parseJSON(dataSetupString, {}); + const youtubeUrl = data.techOrder?.[0] == 'youtube' && CoreUrlUtils.getYoutubeEmbedUrl(data.sources?.[0]?.src); + + if (!youtubeUrl) { + return; + } + + const iframe = document.createElement('iframe'); + iframe.id = video.id; + iframe.src = youtubeUrl; + iframe.setAttribute('frameborder', '0'); + iframe.setAttribute('allowfullscreen', '1'); + iframe.width = '100%'; + iframe.height = '300'; + + // Replace video tag by the iframe. + video.parentNode?.replaceChild(iframe, video); + } + + /** + * Gets videojs instance. + * + * @returns VideoJS. + */ + protected async getVideoJS(): Promise { + if (!this.videojs) { + this.videojs = new CorePromisedValue(); + + // Inject CSS. + const link = document.createElement('link'); + + link.rel = 'stylesheet'; + link.href = 'assets/lib/video.js/video-js.min.css'; + + document.head.appendChild(link); + + // Load library. + return import('@addons/filter/mediaplugin/utils/videojs').then(({ initializeVideoJSOgvJS, videojs }) => { + initializeVideoJSOgvJS(); + + this.videojs?.resolve(videojs); + + return videojs; + }); + } + + return this.videojs; + } + + /** + * Fix VideoJS player size. + * If video width is wider than available width, video is cut off. Fix the dimensions in this case. + * + * @param player Player instance. + */ + protected fixVideoJSPlayerSize(player: VideoJSPlayer): void { + const videoWidth = player.videoWidth(); + const videoHeight = player.videoHeight(); + const playerDimensions = player.currentDimensions(); + if (!videoWidth || !videoHeight || !playerDimensions.width || videoWidth === playerDimensions.width) { + return; + } + + const candidateHeight = playerDimensions.width * videoHeight / videoWidth; + if (!playerDimensions.height || Math.abs(candidateHeight - playerDimensions.height) > 1) { + player.dimension('height', candidateHeight); + } + } + +} + +export const AddonFilterMediaPluginVideoJS = makeSingleton(AddonFilterMediaPluginVideoJSService); + +/** + * Data passed to VIDEO_JS_PLAYER_CREATED event. + */ +export type CoreEventJSVideoPlayerCreated = { + element: HTMLAudioElement | HTMLVideoElement; + player: VideoJSPlayer; +}; diff --git a/src/addons/filter/mediaplugin/utils/videojs.ts b/src/addons/filter/mediaplugin/utils/videojs.ts new file mode 100644 index 00000000000..6d074d473ed --- /dev/null +++ b/src/addons/filter/mediaplugin/utils/videojs.ts @@ -0,0 +1,28 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { VideoJSOgvJS } from '@addons/filter/mediaplugin/classes/videojs-ogvjs'; +import { OGVLoader } from 'ogv'; +import videojs from 'video.js'; + +export { videojs }; + +/** + * Initialize the controller. + */ +export function initializeVideoJSOgvJS(): void { + OGVLoader.base = 'assets/lib/ogv'; + + videojs.getComponent('Tech').registerTech('OgvJS', VideoJSOgvJS); +} diff --git a/src/addons/messages/components/conversation-info/conversation-info.html b/src/addons/messages/components/conversation-info/conversation-info.html index c72e7b34413..38f7c081dd5 100644 --- a/src/addons/messages/components/conversation-info/conversation-info.html +++ b/src/addons/messages/components/conversation-info/conversation-info.html @@ -1,7 +1,7 @@ -

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

+

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

@@ -19,18 +19,18 @@

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

-

- +

-

-

{{ 'addon.messages.numparticipants' | translate:{$a: conversation!.membercount} }}

+

{{ 'addon.messages.numparticipants' | translate:{$a: conversation.membercount} }}

diff --git a/src/addons/messages/pages/contacts-35/contacts.html b/src/addons/messages/pages/contacts-35/contacts.html index 285f0723124..1e52521dfda 100644 --- a/src/addons/messages/pages/contacts-35/contacts.html +++ b/src/addons/messages/pages/contacts-35/contacts.html @@ -7,6 +7,8 @@

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

+ +
diff --git a/src/addons/messages/pages/contacts-35/contacts.page.ts b/src/addons/messages/pages/contacts-35/contacts.page.ts index b552e49c073..a5dd98e8a42 100644 --- a/src/addons/messages/pages/contacts-35/contacts.page.ts +++ b/src/addons/messages/pages/contacts-35/contacts.page.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { IonRefresher } from '@ionic/angular'; import { CoreSites } from '@services/sites'; import { @@ -29,6 +29,7 @@ import { ActivatedRoute } from '@angular/router'; import { Translate } from '@singletons'; import { CoreScreen } from '@services/screen'; import { CoreNavigator } from '@services/navigator'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; /** * Page that displays the list of contacts. @@ -40,6 +41,8 @@ import { CoreNavigator } from '@services/navigator'; }) export class AddonMessagesContacts35Page implements OnInit, OnDestroy { + @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; + protected searchingMessages: string; protected loadingMessages: string; protected siteId: string; @@ -244,7 +247,9 @@ export class AddonMessagesContacts35Page implements OnInit, OnDestroy { const path = CoreNavigator.getRelativePathToParent('/messages/contacts-35') + `discussion/user/${discussionUserId}`; // @todo Check why this is failing on ngInit. - CoreNavigator.navigate(path); + CoreNavigator.navigate(path, { + reset: CoreScreen.isTablet && !!this.splitView && !this.splitView.isNested, + }); } /** diff --git a/src/addons/messages/pages/contacts/contacts.html b/src/addons/messages/pages/contacts/contacts.html index 0715dea693d..00e28e4d0d9 100644 --- a/src/addons/messages/pages/contacts/contacts.html +++ b/src/addons/messages/pages/contacts/contacts.html @@ -10,6 +10,8 @@

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

+ + diff --git a/src/addons/messages/pages/contacts/contacts.page.ts b/src/addons/messages/pages/contacts/contacts.page.ts index 97535862e8a..187a392938c 100644 --- a/src/addons/messages/pages/contacts/contacts.page.ts +++ b/src/addons/messages/pages/contacts/contacts.page.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreSites } from '@services/sites'; import { @@ -24,6 +24,7 @@ import { CoreNavigator } from '@services/navigator'; import { CoreScreen } from '@services/screen'; import { CoreDomUtils } from '@services/utils/dom'; import { IonRefresher } from '@ionic/angular'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; /** * Page that displays contacts and contact requests. @@ -37,6 +38,8 @@ import { IonRefresher } from '@ionic/angular'; }) export class AddonMessagesContactsPage implements OnInit, OnDestroy { + @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; + selected: 'confirmed' | 'requests' = 'confirmed'; requestsBadge = ''; selectedUserId?: number; // User id of the conversation opened in the split view. @@ -292,7 +295,9 @@ export class AddonMessagesContactsPage implements OnInit, OnDestroy { this.selectedUserId = userId; const path = CoreNavigator.getRelativePathToParent('/messages/contacts') + `discussion/user/${userId}`; - CoreNavigator.navigate(path); + CoreNavigator.navigate(path, { + reset: CoreScreen.isTablet && !!this.splitView && !this.splitView.isNested, + }); } /** diff --git a/src/addons/messages/pages/discussions-35/discussions.html b/src/addons/messages/pages/discussions-35/discussions.html index 63fa5426d87..83053d987fe 100644 --- a/src/addons/messages/pages/discussions-35/discussions.html +++ b/src/addons/messages/pages/discussions-35/discussions.html @@ -7,6 +7,8 @@

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

+ + @@ -29,7 +31,7 @@

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

[attr.aria-label]="'addon.messages.contacts' | translate" detail="true" button> -

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

+

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

diff --git a/src/addons/messages/pages/discussions-35/discussions.page.ts b/src/addons/messages/pages/discussions-35/discussions.page.ts index ef4df17013a..0706b9b9d30 100644 --- a/src/addons/messages/pages/discussions-35/discussions.page.ts +++ b/src/addons/messages/pages/discussions-35/discussions.page.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreSites } from '@services/sites'; import { @@ -34,6 +34,7 @@ import { CoreNavigator } from '@services/navigator'; import { CoreScreen } from '@services/screen'; import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager'; import { CorePlatform } from '@services/platform'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; /** * Page that displays the list of discussions. @@ -45,6 +46,8 @@ import { CorePlatform } from '@services/platform'; }) export class AddonMessagesDiscussions35Page implements OnInit, OnDestroy { + @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; + protected newMessagesObserver: CoreEventObserver; protected readChangedObserver: CoreEventObserver; protected appResumeSubscription: Subscription; @@ -264,7 +267,10 @@ export class AddonMessagesDiscussions35Page implements OnInit, OnDestroy { const path = CoreNavigator.getRelativePathToParent('/messages/index') + `discussion/user/${discussionUserId}`; - await CoreNavigator.navigate(path, { params }); + await CoreNavigator.navigate(path, { + params, + reset: CoreScreen.isTablet && !!this.splitView && !this.splitView.isNested, + }); } /** diff --git a/src/addons/messages/pages/group-conversations/group-conversations.html b/src/addons/messages/pages/group-conversations/group-conversations.html index 71f57c29d49..223cc96d7aa 100644 --- a/src/addons/messages/pages/group-conversations/group-conversations.html +++ b/src/addons/messages/pages/group-conversations/group-conversations.html @@ -13,6 +13,8 @@

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

+ + diff --git a/src/addons/messages/pages/group-conversations/group-conversations.page.ts b/src/addons/messages/pages/group-conversations/group-conversations.page.ts index d54e0b79396..d250f26ade9 100644 --- a/src/addons/messages/pages/group-conversations/group-conversations.page.ts +++ b/src/addons/messages/pages/group-conversations/group-conversations.page.ts @@ -38,6 +38,7 @@ import { CoreNavigator } from '@services/navigator'; import { CoreScreen } from '@services/screen'; import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager'; import { CorePlatform } from '@services/platform'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; /** * Page that displays the list of conversations, including group conversations. @@ -49,6 +50,8 @@ import { CorePlatform } from '@services/platform'; }) export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { + @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; + @ViewChild(IonContent) content?: IonContent; @ViewChild('favlist') favListEl?: ElementRef; @ViewChild('grouplist') groupListEl?: ElementRef; @@ -526,7 +529,10 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy { const path = CoreNavigator.getRelativePathToParent('/messages/group-conversations') + 'discussion/' + (conversationId ? conversationId : `user/${userId}`); - await CoreNavigator.navigate(path, { params }); + await CoreNavigator.navigate(path, { + params, + reset: CoreScreen.isTablet && !!this.splitView && !this.splitView.isNested, + }); } /** diff --git a/src/addons/messages/pages/search/search.html b/src/addons/messages/pages/search/search.html index 133b5c5aea7..9830db473db 100644 --- a/src/addons/messages/pages/search/search.html +++ b/src/addons/messages/pages/search/search.html @@ -7,6 +7,8 @@

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

+ + diff --git a/src/addons/messages/pages/search/search.page.ts b/src/addons/messages/pages/search/search.page.ts index 160b34f7e23..8d309a17ab7 100644 --- a/src/addons/messages/pages/search/search.page.ts +++ b/src/addons/messages/pages/search/search.page.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnDestroy } from '@angular/core'; +import { Component, OnDestroy, ViewChild } from '@angular/core'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreSites } from '@services/sites'; import { @@ -25,6 +25,7 @@ import { CoreDomUtils } from '@services/utils/dom'; import { CoreApp } from '@services/app'; import { CoreNavigator } from '@services/navigator'; import { CoreScreen } from '@services/screen'; +import { CoreSplitViewComponent } from '@components/split-view/split-view'; /** * Page for searching users. @@ -35,6 +36,8 @@ import { CoreScreen } from '@services/screen'; }) export class AddonMessagesSearchPage implements OnDestroy { + @ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent; + disableSearch = false; displaySearching = false; displayResults = false; @@ -260,7 +263,9 @@ export class AddonMessagesSearchPage implements OnDestroy { const path = CoreNavigator.getRelativePathToParent('/messages/search') + 'discussion/' + (conversationId ? conversationId : `user/${userId}`); - CoreNavigator.navigate(path); + CoreNavigator.navigate(path, { + reset: CoreScreen.isTablet && !!this.splitView && !this.splitView.isNested, + }); } } diff --git a/src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.html b/src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.html index f24b5d9b36d..79f6fb07090 100644 --- a/src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.html +++ b/src/addons/mod/assign/components/edit-feedback-modal/edit-feedback-modal.html @@ -1,7 +1,7 @@ -

{{ plugin.name }}

+

{{ plugin.name }}

diff --git a/src/addons/mod/assign/components/index/addon-mod-assign-index.html b/src/addons/mod/assign/components/index/addon-mod-assign-index.html index 170d86ab370..117b0c76df0 100644 --- a/src/addons/mod/assign/components/index/addon-mod-assign-index.html +++ b/src/addons/mod/assign/components/index/addon-mod-assign-index.html @@ -25,7 +25,7 @@ -

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

+

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

{{ 'core.no' | translate }}

{{ 'core.yes' | translate }}

@@ -33,13 +33,13 @@

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

-

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

+

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

{{ timeRemaining }}

-

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

+

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

{{ lateSubmissions }}

@@ -47,8 +47,8 @@

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

-

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

-

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

+

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

+

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

@@ -66,7 +66,7 @@

{{ 'addon.mod_assign.numberofparticipants' | [class.hide-detail]="!summary.submissiondraftscount" [detail]="true" [button]="summary.submissiondraftscount" (click)="goToSubmissionList(submissionStatusDraft, !!summary.submissiondraftscount)"> -

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

+

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

@@ -82,7 +82,7 @@

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

[class.hide-detail]="!summary.submissionssubmittedcount" [detail]="true" [button]="summary.submissionssubmittedcount" (click)="goToSubmissionList(submissionStatusSubmitted, !!summary.submissionssubmittedcount)"> -

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

+

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

@@ -98,7 +98,7 @@

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

[class.hide-detail]="!needsGradingAvailable" [detail]="true" [button]="needsGradingAvailable" (click)="goToSubmissionList(needGrading, needsGradingAvailable)"> -

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

+

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

diff --git a/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html b/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html index 2d8ec7e0796..122f5c79b74 100644 --- a/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html +++ b/src/addons/mod/assign/components/submission/addon-mod-assign-submission.html @@ -15,7 +15,7 @@ [attr.aria-label]="user!.fullname"> -

{{ user!.fullname }}

+

{{ user!.fullname }}

@@ -23,7 +23,7 @@

{{ user!.fullname }}

-

{{ 'addon.mod_assign.hiddenuser' | translate }} {{blindId}}

+

{{ 'addon.mod_assign.hiddenuser' | translate }} {{blindId}}

@@ -31,7 +31,7 @@

{{ 'addon.mod_assign.hiddenuser' | translate }} {{blindId}}

-

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

+

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

@@ -44,7 +44,7 @@

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

-

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

+

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

{{ 'addon.mod_assign.outof' | translate : {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }} @@ -59,7 +59,7 @@

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

-

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

+

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

@@ -77,7 +77,7 @@

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

-

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

+

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

{{ assign!.duedate * 1000 | coreFormatDate }}

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

@@ -85,14 +85,14 @@

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

-

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

+

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

{{ assign!.cutoffdate * 1000 | coreFormatDate }}

-

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

+

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

{{ lastAttempt!.extensionduedate * 1000 | coreFormatDate }}

@@ -100,7 +100,7 @@

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

-

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

+

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

@@ -111,7 +111,7 @@

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

-

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

+

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

{{ assign.timelimit | coreDuration }}

@@ -120,7 +120,7 @@

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

-

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

+

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

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

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

@@ -130,7 +130,7 @@

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

-

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

+

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

{{ userSubmission!.timemodified * 1000 | coreFormatDate }}

@@ -151,7 +151,7 @@

{{ 'addon.mod_assign.userswhoneedtosubmit' | translate: {$a: ''} }}

[attr.aria-label]="user.fullname"> -

{{ user.fullname }}

+

{{ user.fullname }}

@@ -257,7 +257,7 @@

{{ user.fullname }}

-

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

+

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

@@ -273,7 +273,7 @@

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

Use a text input because otherwise we cannot readthe value if it has an invalid character. --> -

{{ 'addon.mod_assign.gradeoutof' | translate: {$a: gradeInfo!.grade} }}

+

{{ 'addon.mod_assign.gradeoutof' | translate: {$a: gradeInfo!.grade} }}

@@ -284,7 +284,7 @@

{{ 'addon.mod_assign.gradeoutof' | translate: {$a: gradeInfo!.grade} }}

-

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

+

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

@@ -297,7 +297,7 @@

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

-

{{ outcome.name }}

+

{{ outcome.name }}

@@ -311,7 +311,7 @@

{{ outcome.name }}

-

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

+

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

{{ grade.gradebookGrade }}

@@ -332,7 +332,7 @@

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

-

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

+

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

{{ workflowStatusTranslationId | translate }}

@@ -340,7 +340,7 @@

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

-

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

+

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

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

@@ -350,7 +350,7 @@

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

-

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

+

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

{{ 'addon.mod_assign.outof' | translate : {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }} @@ -376,8 +376,8 @@

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

[attr.aria-label]="grader!.fullname" detail="true"> -

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

-

{{ grader!.fullname }}

+

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

+

{{ grader!.fullname }}

{{ feedback!.gradeddate * 1000 | coreFormatDate }}

@@ -385,7 +385,7 @@

{{ grader!.fullname }}

-

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

+

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

{{ feedback!.gradeddate * 1000 | coreFormatDate }}

diff --git a/src/addons/mod/assign/submission/onlinetext/component/addon-mod-assign-submission-onlinetext.html b/src/addons/mod/assign/submission/onlinetext/component/addon-mod-assign-submission-onlinetext.html index 549976d19cf..43f6b2009d1 100644 --- a/src/addons/mod/assign/submission/onlinetext/component/addon-mod-assign-submission-onlinetext.html +++ b/src/addons/mod/assign/submission/onlinetext/component/addon-mod-assign-submission-onlinetext.html @@ -20,7 +20,7 @@

{{ plugin.name }}

-

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

+

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

{{ 'core.numwords' | translate: {'$a': words + ' / ' + wordLimit} }}

diff --git a/src/addons/mod/bigbluebuttonbn/components/index/index.html b/src/addons/mod/bigbluebuttonbn/components/index/index.html index 2fceda71527..782717ab024 100644 --- a/src/addons/mod/bigbluebuttonbn/components/index/index.html +++ b/src/addons/mod/bigbluebuttonbn/components/index/index.html @@ -19,13 +19,13 @@ -

{{ 'addon.mod_bigbluebuttonbn.mod_form_field_openingtime' | translate }}

+

{{ 'addon.mod_bigbluebuttonbn.mod_form_field_openingtime' | translate }}

{{ meetingInfo.openingtime * 1000 | coreFormatDate }}

-

{{ 'addon.mod_bigbluebuttonbn.mod_form_field_closingtime' | translate }}

+

{{ 'addon.mod_bigbluebuttonbn.mod_form_field_closingtime' | translate }}

{{ meetingInfo.closingtime * 1000 | coreFormatDate }}

@@ -45,31 +45,31 @@

{{ 'addon.mod_bigbluebuttonbn.mod_form_field_closingtime' | translate }}

-

{{ 'addon.mod_bigbluebuttonbn.view_message_session_started_at' | translate }}

+

{{ 'addon.mod_bigbluebuttonbn.view_message_session_started_at' | translate }}

{{ meetingInfo.startedat * 1000 | coreFormatDate: "strftimetime" }}

-

+

{{ 'addon.mod_bigbluebuttonbn.view_message_moderators' | translate }} -

-

+

+

{{ 'addon.mod_bigbluebuttonbn.view_message_moderator' | translate }} -

+

{{ meetingInfo.moderatorcount }}

-

+

{{ 'addon.mod_bigbluebuttonbn.view_message_viewers' | translate }} -

-

+

+

{{ 'addon.mod_bigbluebuttonbn.view_message_viewer' | translate }} -

+

{{ meetingInfo.participantcount }}

@@ -108,7 +108,7 @@

{{ 'addon.mod_bigbluebuttonbn.view_section_title_recordings' | translate }}<
-

{{ data.label }}

+

{{ data.label }}

diff --git a/src/addons/mod/book/components/index/addon-mod-book-index.html b/src/addons/mod/book/components/index/addon-mod-book-index.html index 380582d583a..b186dfb3c12 100644 --- a/src/addons/mod/book/components/index/addon-mod-book-index.html +++ b/src/addons/mod/book/components/index/addon-mod-book-index.html @@ -24,8 +24,8 @@

{{ 'addon.mod_book.toc' | translate }}

(click)="openBook(chapter.id)">

- {{chapter.indexNumber}}  - •  + {{chapter.indexNumber}} +

diff --git a/src/addons/mod/book/components/index/index.ts b/src/addons/mod/book/components/index/index.ts index 838f154e85c..93c64972f41 100644 --- a/src/addons/mod/book/components/index/index.ts +++ b/src/addons/mod/book/components/index/index.ts @@ -18,6 +18,7 @@ import { AddonModBook, AddonModBookBookWSData, AddonModBookNumbering, AddonModBo import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; import { CoreCourse } from '@features/course/services/course'; import { CoreNavigator } from '@services/navigator'; +import { AddonModBookModuleHandlerService } from '../../services/handlers/module'; /** * Component that displays a book entry page. @@ -60,6 +61,13 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp ]); } + /** + * @inheritdoc + */ + protected async invalidateContent(): Promise { + await AddonModBook.invalidateContent(this.module.id, this.courseId); + } + /** * Load book data. * @@ -102,14 +110,11 @@ export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComp * * @param chapterId Chapter to open, undefined for last chapter viewed. */ - openBook(chapterId?: number): void { - CoreNavigator.navigate('contents', { - params: { - cmId: this.module.id, - courseId: this.courseId, - chapterId, - }, - }); + async openBook(chapterId?: number): Promise { + await CoreNavigator.navigateToSitePath( + `${AddonModBookModuleHandlerService.PAGE_NAME}/${this.courseId}/${this.module.id}/contents`, + { params: { chapterId } }, + ); this.hasStartedBook = true; } diff --git a/src/addons/mod/book/components/toc/toc.html b/src/addons/mod/book/components/toc/toc.html index 42165ea288e..a8a12c97842 100644 --- a/src/addons/mod/book/components/toc/toc.html +++ b/src/addons/mod/book/components/toc/toc.html @@ -1,7 +1,7 @@ -

{{ 'addon.mod_book.toc' | translate }}

+

{{ 'addon.mod_book.toc' | translate }}

@@ -17,8 +17,8 @@

{{ 'addon.mod_book.toc' | translate }}

[attr.aria-current]="selected == chapter.id ? 'page' : 'false'" button [class.item-dimmed]="chapter.hidden" detail="false">

- {{chapter.indexNumber}}  - •  + {{chapter.indexNumber}} +

diff --git a/src/addons/mod/book/services/book.ts b/src/addons/mod/book/services/book.ts index 8681ef369ec..2365c52919b 100644 --- a/src/addons/mod/book/services/book.ts +++ b/src/addons/mod/book/services/book.ts @@ -291,7 +291,9 @@ export class AddonModBookProvider { }); } - chapterNumber++; + if (!parseInt(chapter.hidden, 10)) { + chapterNumber++; + } }); return chapters; diff --git a/src/addons/mod/book/tests/behat/basic_usage.feature b/src/addons/mod/book/tests/behat/basic_usage.feature new file mode 100755 index 00000000000..65a09b54e7d --- /dev/null +++ b/src/addons/mod/book/tests/behat/basic_usage.feature @@ -0,0 +1,308 @@ +@mod @mod_book @app @javascript +Feature: Test basic usage of book activity in app + In order to view a book while using the mobile app + As a student + I need basic book functionality to work + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | teacher | teacher1@example.com | + | student1 | Student | student | student1@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And the following "activities" exist: + | activity | name | intro | course | idnumber | numbering | + | book | Basic book | Test book description | C1 | book | 1 | + And the following "mod_book > chapter" exist: + | book | title | content | subchapter | hidden | pagenum | + | Basic book | Chapt 1 | This is the first chapter | 0 | 0 | 1 | + | Basic book | Chapt 1.1 | This is a subchapter | 1 | 0 | 2 | + | Basic book | Chapt 2 | This is the second chapter | 0 | 0 | 3 | + | Basic book | Hidden chapter | This is a hidden chapter | 0 | 1 | 4 | + | Basic book | Hidden subchapter | This is a hidden subchapter | 1 | 1 | 5 | + | Basic book | Chapt 3 | This is the third chapter | 0 | 0 | 6 | + | Basic book | Last hidden | Another hidden subchapter | 1 | 1 | 7 | + + Scenario: View book table of contents (student) + Given I entered the course "Course 1" as "student1" in the app + And I press "Basic book" in the app + Then I should find "Test book description" in the app + And I should find "Chapt 1" in the app + And I should find "Chapt 1.1" in the app + And I should find "Chapt 2" in the app + And I should find "Chapt 3" in the app + And I should find "Start" in the app + But I should not find "Hidden chapter" in the app + And I should not find "Hidden subchapter" in the app + And I should not find "Last hidden" in the app + And I should not find "This is the first chapter" in the app + + When I press "Start" in the app + And I press "Table of contents" in the app + Then I should find "Chapt 1" in the app + And I should find "Chapt 1.1" in the app + And I should find "Chapt 2" in the app + And I should find "Chapt 3" in the app + But I should not find "Hidden chapter" in the app + And I should not find "Hidden subchapter" in the app + And I should not find "Last hidden" in the app + + Scenario: View book table of contents (teacher) + Given I entered the course "Course 1" as "teacher1" in the app + And I press "Basic book" in the app + Then I should find "Test book description" in the app + And I should find "Chapt 1" in the app + And I should find "Chapt 1.1" in the app + And I should find "Chapt 2" in the app + And I should find "Hidden chapter" in the app + And I should find "Hidden subchapter" in the app + And I should find "Chapt 3" in the app + And I should find "Last hidden" in the app + And I should find "Start" in the app + And I should not find "This is the first chapter" in the app + + When I press "Start" in the app + And I press "Table of contents" in the app + Then I should find "Chapt 1" in the app + And I should find "Chapt 1.1" in the app + And I should find "Chapt 2" in the app + And I should find "Hidden chapter" in the app + And I should find "Hidden subchapter" in the app + And I should find "Chapt 3" in the app + And I should find "Last hidden" in the app + + Scenario: Open chapters from table of contents + Given I entered the course "Course 1" as "student1" in the app + And I press "Basic book" in the app + When I press "Chapt 1" in the app + Then I should find "Chapt 1" in the app + And I should find "This is the first chapter" in the app + But I should not find "This is the second chapter" in the app + + When I press the back button in the app + And I press "Chapt 2" in the app + Then I should find "Chapt 2" in the app + And I should find "This is the second chapter" in the app + But I should not find "This is the first chapter" in the app + + Scenario: View and navigate book contents (student) + Given I entered the course "Course 1" as "student1" in the app + And I press "Basic book" in the app + And I press "Start" in the app + Then I should find "Chapt 1" in the app + And I should find "This is the first chapter" in the app + And I should find "1 / 4" in the app + + When I press "Next" in the app + Then I should find "Chapt 1.1" in the app + And I should find "This is a subchapter" in the app + And I should find "2 / 4" in the app + But I should not find "This is the first chapter" in the app + + When I press "Next" in the app + Then I should find "Chapt 2" in the app + And I should find "This is the second chapter" in the app + And I should find "3 / 4" in the app + But I should not find "This is a subchapter" in the app + + When I press "Previous" in the app + Then I should find "Chapt 1.1" in the app + And I should find "This is a subchapter" in the app + And I should find "2 / 4" in the app + But I should not find "This is the second chapter" in the app + + # Navigate using TOC. + When I press "Table of contents" in the app + And I press "Chapt 1" in the app + Then I should find "Chapt 1" in the app + And I should find "This is the first chapter" in the app + And I should find "1 / 4" in the app + But I should not find "This is a subchapter" in the app + + When I press "Table of contents" in the app + And I press "Chapt 3" in the app + Then I should find "Chapt 3" in the app + And I should find "This is the third chapter" in the app + And I should find "4 / 4" in the app + But I should not find "This is the first chapter" in the app + + # Navigate using swipe. + When I swipe to the left in "Chapt 3" "ion-slides" in the app + Then I should find "Chapt 3" in the app + And I should find "This is the third chapter" in the app + And I should find "4 / 4" in the app + + When I swipe to the right in "Chapt 3" "ion-slides" in the app + Then I should find "Chapt 2" in the app + And I should find "This is the second chapter" in the app + And I should find "3 / 4" in the app + + When I swipe to the right in "Chapt 2" "ion-slides" in the app + Then I should find "Chapt 1.1" in the app + And I should find "This is a subchapter" in the app + And I should find "2 / 4" in the app + + When I swipe to the left in "Chapt 1.1" "ion-slides" in the app + Then I should find "Chapt 2" in the app + And I should find "This is the second chapter" in the app + And I should find "3 / 4" in the app + +Scenario: View and navigate book contents (teacher) + Given I entered the course "Course 1" as "teacher1" in the app + And I press "Basic book" in the app + And I press "Start" in the app + Then I should find "Chapt 1" in the app + And I should find "This is the first chapter" in the app + And I should find "1 / 7" in the app + + When I press "Next" in the app + Then I should find "Chapt 1.1" in the app + And I should find "This is a subchapter" in the app + And I should find "2 / 7" in the app + But I should not find "This is the first chapter" in the app + + When I press "Next" in the app + Then I should find "Chapt 2" in the app + And I should find "This is the second chapter" in the app + And I should find "3 / 7" in the app + But I should not find "This is a subchapter" in the app + + When I press "Next" in the app + Then I should find "Hidden chapter" in the app + And I should find "This is a hidden chapter" in the app + And I should find "4 / 7" in the app + But I should not find "This is the second chapter" in the app + + When I press "Next" in the app + Then I should find "Hidden subchapter" in the app + And I should find "This is a hidden subchapter" in the app + And I should find "5 / 7" in the app + But I should not find "This is a hidden chapter" in the app + + When I press "Previous" in the app + Then I should find "Hidden chapter" in the app + And I should find "This is a hidden chapter" in the app + And I should find "4 / 7" in the app + But I should not find "This is a hidden subchapter" in the app + + # Navigate using TOC. + When I press "Table of contents" in the app + And I press "Chapt 1" in the app + Then I should find "Chapt 1" in the app + And I should find "This is the first chapter" in the app + And I should find "1 / 7" in the app + But I should not find "This is a hidden chapter" in the app + + When I press "Table of contents" in the app + And I press "Hidden subchapter" in the app + Then I should find "Hidden subchapter" in the app + And I should find "This is a hidden subchapter" in the app + And I should find "5 / 7" in the app + But I should not find "This is the first chapter" in the app + + # Navigate using swipe. + When I swipe to the left in "Hidden subchapter" "ion-slides" in the app + Then I should find "Chapt 3" in the app + And I should find "This is the third chapter" in the app + And I should find "6 / 7" in the app + + When I swipe to the left in "Chapt 3" "ion-slides" in the app + Then I should find "Last hidden" in the app + And I should find "Another hidden subchapter" in the app + And I should find "7 / 7" in the app + + When I swipe to the left in "Last hidden" "ion-slides" in the app + Then I should find "Last hidden" in the app + And I should find "Another hidden subchapter" in the app + And I should find "7 / 7" in the app + + When I swipe to the right in "Last hidden" "ion-slides" in the app + Then I should find "Chapt 3" in the app + And I should find "This is the third chapter" in the app + And I should find "6 / 7" in the app + + Scenario: Link to book opens chapter content + Given I entered the book activity "Basic book" on course "Course 1" as "student1" in the app + Then I should find "This is the first chapter" in the app + + Scenario: Test numbering (student) + Given the following "activities" exist: + | activity | name | intro | course | idnumber | numbering | + | book | Bull book | Test book description | C1 | book2 | 2 | + | book | Ind book | Test book description | C1 | book2 | 3 | + | book | None book | Test book description | C1 | book2 | 0 | + And the following "mod_book > chapter" exist: + | book | title | content | subchapter | hidden | pagenum | + | Bull book | Chapt 1 | This is the first chapter | 0 | 0 | 1 | + | Ind book | Chapt 1 | This is the first chapter | 0 | 0 | 1 | + | None book | Chapt 1 | This is the first chapter | 0 | 0 | 1 | + And I entered the course "Course 1" as "student1" in the app + And I press "Basic book" in the app + Then I should find "1. Chapt 1" in the app + And I should find "1.1. Chapt 1.1" in the app + And I should find "2. Chapt 2" in the app + And I should find "3. Chapt 3" in the app + + When I press "Start" in the app + And I press "Table of contents" in the app + Then I should find "1. Chapt 1" in the app + And I should find "1.1. Chapt 1.1" in the app + And I should find "2. Chapt 2" in the app + And I should find "3. Chapt 3" in the app + + When I press "Close" in the app + And I press the back button in the app + And I press the back button in the app + And I press "Bull book" in the app + Then I should find "• Chapt 1" in the app + But I should not find "1. Chapt 1" in the app + + When I press "Start" in the app + And I press "Table of contents" in the app + Then I should find "• Chapt 1" in the app + But I should not find "1. Chapt 1" in the app + + When I press "Close" in the app + And I press the back button in the app + And I press the back button in the app + And I press "Ind book" in the app + Then I should find "Chapt 1" in the app + But I should not find "• Chapt 1" in the app + And I should not find "1. Chapt 1" in the app + + When I press "Start" in the app + And I press "Table of contents" in the app + Then I should find "Chapt 1" in the app + But I should not find "• Chapt 1" in the app + And I should not find "1. Chapt 1" in the app + + When I press "Close" in the app + And I press the back button in the app + And I press the back button in the app + And I press "None book" in the app + Then I should find "Chapt 1" in the app + But I should not find "• Chapt 1" in the app + And I should not find "1. Chapt 1" in the app + + When I press "Start" in the app + And I press "Table of contents" in the app + Then I should find "Chapt 1" in the app + But I should not find "• Chapt 1" in the app + And I should not find "1. Chapt 1" in the app + + Scenario: Test numbering (teacher) + Given I entered the course "Course 1" as "teacher1" in the app + And I press "Basic book" in the app + Then I should find "1. Chapt 1" in the app + And I should find "1.1. Chapt 1.1" in the app + And I should find "2. Chapt 2" in the app + And I should find "x. Hidden chapter" in the app + And I should find "x.x. Hidden subchapter" in the app + And I should find "3. Chapt 3" in the app + And I should find "3.x. Last hidden" in the app diff --git a/src/addons/mod/book/tests/behat/single_activity.feature b/src/addons/mod/book/tests/behat/single_activity.feature new file mode 100644 index 00000000000..f247c203477 --- /dev/null +++ b/src/addons/mod/book/tests/behat/single_activity.feature @@ -0,0 +1,33 @@ +@app @javascript @mod @mod_book +Feature: Test single activity of book type in app + In order to view a book while using the mobile app + As a student + I need single activity of book type functionality to work + + Background: + Given the following "users" exist: + | username | firstname | lastname | + | student1 | First | Student | + And the following "courses" exist: + | fullname | shortname | category | format | activitytype | + | Course 1 | C1 | 0 | singleactivity | book | + And the following "course enrolments" exist: + | user | course | role | + | student1 | C1 | student | + And the following "activity" exist: + | activity | name | intro | course | idnumber | numbering | section | + | book | Single activity book | Test book description | C1 | 1 | 1 | 0 | + And the following "mod_book > chapter" exist: + | book | title | content | subchapter | hidden | pagenum | + | Single activity book | Chapt 1 | This is the first chapter | 0 | 0 | 1 | + | Single activity book | Chapt 2 | This is the second chapter | 0 | 0 | 1 | + | Single activity book | Chapt 3 | This is the third chapter | 0 | 0 | 1 | + + Scenario: Single activity book + Given I entered the course "Course 1" as "student1" in the app + Then I should find "Chapt 1" in the app + And I should find "Chapt 2" in the app + And I press "Chapt 1" in the app + Then I should find "Chapt 1" in the app + And I should find "This is the first chapter" in the app + But I should not find "This is the second chapter" in the app diff --git a/src/addons/mod/chat/components/users-modal/users-modal.html b/src/addons/mod/chat/components/users-modal/users-modal.html index b0b09c1407c..0f06b26d292 100644 --- a/src/addons/mod/chat/components/users-modal/users-modal.html +++ b/src/addons/mod/chat/components/users-modal/users-modal.html @@ -1,7 +1,7 @@ -

{{ 'addon.mod_chat.currentusers' | translate }}

+

{{ 'addon.mod_chat.currentusers' | translate }}

diff --git a/src/addons/mod/data/components/search/search.html b/src/addons/mod/data/components/search/search.html index e7dee1e2e5c..1c7d2d852eb 100644 --- a/src/addons/mod/data/components/search/search.html +++ b/src/addons/mod/data/components/search/search.html @@ -1,7 +1,7 @@ -

{{ 'addon.mod_data.search' | translate }}

+

{{ 'addon.mod_data.search' | translate }}

diff --git a/src/addons/mod/data/fields/checkbox/services/handler.ts b/src/addons/mod/data/fields/checkbox/services/handler.ts index 2bc249e5e02..569fb55f58d 100644 --- a/src/addons/mod/data/fields/checkbox/services/handler.ts +++ b/src/addons/mod/data/fields/checkbox/services/handler.ts @@ -11,6 +11,7 @@ // 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 { AddonModDataEntryField, AddonModDataField, diff --git a/src/addons/mod/data/fields/file/component/file.ts b/src/addons/mod/data/fields/file/component/file.ts index 11abea5bb7f..21059692972 100644 --- a/src/addons/mod/data/fields/file/component/file.ts +++ b/src/addons/mod/data/fields/file/component/file.ts @@ -11,6 +11,7 @@ // 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 { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data'; import { AddonModDataFieldPluginBaseComponent } from '@addons/mod/data/classes/base-field-plugin-component'; diff --git a/src/addons/mod/data/fields/latlong/component/latlong.ts b/src/addons/mod/data/fields/latlong/component/latlong.ts index 38398beae73..17c7a4f18fd 100644 --- a/src/addons/mod/data/fields/latlong/component/latlong.ts +++ b/src/addons/mod/data/fields/latlong/component/latlong.ts @@ -18,8 +18,8 @@ import { Component } from '@angular/core'; import { FormBuilder } from '@angular/forms'; import { SafeUrl } from '@angular/platform-browser'; import { CoreAnyError } from '@classes/errors/error'; -import { CoreApp } from '@services/app'; import { CoreGeolocation, CoreGeolocationError, CoreGeolocationErrorReason } from '@services/geolocation'; +import { CorePlatform } from '@services/platform'; import { CoreDomUtils } from '@services/utils/dom'; import { DomSanitizer } from '@singletons'; @@ -73,7 +73,7 @@ export class AddonModDataFieldLatlongComponent extends AddonModDataFieldPluginBa const northFixed = north ? north.toFixed(4) : '0.0000'; const eastFixed = east ? east.toFixed(4) : '0.0000'; - if (CoreApp.isIOS()) { + if (CorePlatform.isIOS()) { url = 'http://maps.apple.com/?ll=' + northFixed + ',' + eastFixed + '&near=' + northFixed + ',' + eastFixed; } else { url = 'geo:' + northFixed + ',' + eastFixed; diff --git a/src/addons/mod/data/fields/number/services/handler.ts b/src/addons/mod/data/fields/number/services/handler.ts index 05ea5dd74a4..5846662c448 100644 --- a/src/addons/mod/data/fields/number/services/handler.ts +++ b/src/addons/mod/data/fields/number/services/handler.ts @@ -45,17 +45,17 @@ export class AddonModDataFieldNumberHandlerService extends AddonModDataFieldText originalFieldData: AddonModDataEntryField, ): boolean { const fieldName = 'f_' + field.id; - const input = inputData[fieldName] || ''; - const content = originalFieldData?.content || ''; + const input = inputData[fieldName] ?? ''; + const content = originalFieldData?.content ?? ''; - return input != content; + return input !== content; } /** * @inheritdoc */ getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined { - if (field.required && (!inputData || !inputData.length || inputData[0].value == '')) { + if (field.required && (!inputData || !inputData.length || inputData[0].value === '')) { return Translate.instant('addon.mod_data.errormustsupplyvalue'); } } diff --git a/src/addons/mod/data/fields/picture/component/picture.ts b/src/addons/mod/data/fields/picture/component/picture.ts index 6b99f0c1074..adf032eb148 100644 --- a/src/addons/mod/data/fields/picture/component/picture.ts +++ b/src/addons/mod/data/fields/picture/component/picture.ts @@ -11,6 +11,7 @@ // 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 { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data'; import { Component } from '@angular/core'; import { CoreFileEntry, CoreFileHelper } from '@services/file-helper'; diff --git a/src/addons/mod/data/fields/radiobutton/component/radiobutton.ts b/src/addons/mod/data/fields/radiobutton/component/radiobutton.ts index 5653e88306e..cfc0064fba2 100644 --- a/src/addons/mod/data/fields/radiobutton/component/radiobutton.ts +++ b/src/addons/mod/data/fields/radiobutton/component/radiobutton.ts @@ -11,6 +11,7 @@ // 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 { AddonModDataFieldPluginBaseComponent } from '../../../classes/base-field-plugin-component'; diff --git a/src/addons/mod/data/fields/text/services/handler.ts b/src/addons/mod/data/fields/text/services/handler.ts index 8083b72b8bd..40088d2304e 100644 --- a/src/addons/mod/data/fields/text/services/handler.ts +++ b/src/addons/mod/data/fields/text/services/handler.ts @@ -70,7 +70,7 @@ export class AddonModDataFieldTextHandlerService implements AddonModDataFieldHan return [{ fieldid: field.id, - value: inputData[fieldName] || '', + value: inputData[fieldName] ?? '', }]; } @@ -83,10 +83,10 @@ export class AddonModDataFieldTextHandlerService implements AddonModDataFieldHan originalFieldData: AddonModDataEntryField, ): boolean { const fieldName = 'f_' + field.id; - const input = inputData[fieldName] || ''; - const content = originalFieldData?.content || ''; + const input = inputData[fieldName] ?? ''; + const content = originalFieldData?.content ?? ''; - return input != content; + return input !== content; } /** @@ -102,7 +102,7 @@ export class AddonModDataFieldTextHandlerService implements AddonModDataFieldHan * @inheritdoc */ overrideData(originalContent: AddonModDataEntryField, offlineContent: CoreFormFields): AddonModDataEntryField { - originalContent.content = offlineContent[''] || ''; + originalContent.content = offlineContent[''] ?? ''; return originalContent; } diff --git a/src/addons/mod/data/fields/url/component/url.ts b/src/addons/mod/data/fields/url/component/url.ts index ca90c661f72..8784b07483d 100644 --- a/src/addons/mod/data/fields/url/component/url.ts +++ b/src/addons/mod/data/fields/url/component/url.ts @@ -11,6 +11,7 @@ // 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 { AddonModDataEntryField } from '@addons/mod/data/services/data'; import { Component } from '@angular/core'; import { AddonModDataFieldPluginBaseComponent } from '../../../classes/base-field-plugin-component'; diff --git a/src/addons/mod/data/pages/edit/edit.ts b/src/addons/mod/data/pages/edit/edit.ts index e95ec4a5e12..6509bc4a4e4 100644 --- a/src/addons/mod/data/pages/edit/edit.ts +++ b/src/addons/mod/data/pages/edit/edit.ts @@ -42,6 +42,7 @@ import { import { AddonModDataHelper } from '../../services/data-helper'; import { CoreDom } from '@singletons/dom'; import { AddonModDataEntryFieldInitialized } from '../../classes/base-field-plugin-component'; +import { CoreTextUtils } from '@services/utils/text'; /** * Page that displays the view edit page. @@ -368,9 +369,18 @@ export class AddonModDataEditPage implements OnInit { } }); } + this.jsData!.errors = this.errors; this.scrollToFirstError(); + + if (updateEntryResult.generalnotifications?.length) { + CoreDomUtils.showAlertWithOptions({ + header: Translate.instant('core.notice'), + message: CoreTextUtils.buildMessage(updateEntryResult.generalnotifications), + buttons: [Translate.instant('core.ok')], + }); + } } } finally { modal.dismiss(); diff --git a/src/addons/mod/data/services/data-helper.ts b/src/addons/mod/data/services/data-helper.ts index c83abf47aa2..52b822c955e 100644 --- a/src/addons/mod/data/services/data-helper.ts +++ b/src/addons/mod/data/services/data-helper.ts @@ -590,8 +590,8 @@ export class AddonModDataHelperProvider { // WS wants values in JSON format. entryFieldDataToSend.push({ fieldid: fieldSubdata.fieldid, - subfield: fieldSubdata.subfield || '', - value: value ? JSON.stringify(value) : '', + subfield: fieldSubdata.subfield ?? '', + value: (value || value === 0) ? JSON.stringify(value) : '', }); return; diff --git a/src/addons/mod/data/tests/behat/entries.feature b/src/addons/mod/data/tests/behat/entries.feature index 59320cca381..bd6707cdfed 100644 --- a/src/addons/mod/data/tests/behat/entries.feature +++ b/src/addons/mod/data/tests/behat/entries.feature @@ -206,3 +206,22 @@ Feature: Users can manage entries in database activities Then I should find "Are you sure you want to delete this entry?" in the app And I press "Delete" in the app And I should not find "Moodle Cloud" in the app + + Scenario: Handle number 0 correctly when creating entries + Given the following "activities" exist: + | activity | name | intro | course | idnumber | + | data | Number DB | Number DB | C1 | data2 | + And the following "mod_data > fields" exist: + | database | type | name | description | + | data2 | number | Number | Number value | + And I entered the data activity "Number DB" on course "Course 1" as "student1" in the app + When I press "Add entries" in the app + And I press "Save" near "Number DB" in the app + Then I should find "You did not fill out any fields!" in the app + + When I press "OK" in the app + And I set the following fields to these values in the app: + | Number | 0 | + And I press "Save" near "Number DB" in the app + Then I should find "0" near "Number:" in the app + But I should not find "Save" in the app diff --git a/src/addons/mod/feedback/components/index/addon-mod-feedback-index.html b/src/addons/mod/feedback/components/index/addon-mod-feedback-index.html index d416650cab2..64187214e52 100644 --- a/src/addons/mod/feedback/components/index/addon-mod-feedback-index.html +++ b/src/addons/mod/feedback/components/index/addon-mod-feedback-index.html @@ -37,7 +37,8 @@
- + {{ 'addon.mod_feedback.preview' | translate }} @@ -64,7 +65,7 @@ -

{{ 'addon.mod_feedback.completed_feedbacks' | translate }}

+

{{ 'addon.mod_feedback.completed_feedbacks' | translate }}

@@ -76,12 +77,12 @@

{{ 'addon.mod_feedback.completed_feedbacks' | translate }}

-

{{ 'addon.mod_feedback.show_nonrespondents' | translate }}

+

{{ 'addon.mod_feedback.show_nonrespondents' | translate }}

-

{{ 'addon.mod_feedback.questions' | translate }}

+

{{ 'addon.mod_feedback.questions' | translate }}

@@ -115,19 +116,19 @@

{{ 'addon.mod_feedback.questions' | translate }}

-

{{ 'addon.mod_feedback.feedbackopen' | translate }}

+

{{ 'addon.mod_feedback.feedbackopen' | translate }}

{{overview.openTimeReadable}}

-

{{ 'addon.mod_feedback.feedbackclose' | translate }}

+

{{ 'addon.mod_feedback.feedbackclose' | translate }}

{{overview.closeTimeReadable}}

-

{{ 'addon.mod_feedback.page_after_submit' | translate }}

+

{{ 'addon.mod_feedback.page_after_submit' | translate }}

@@ -136,7 +137,7 @@

{{ 'addon.mod_feedback.page_after_submit' | translate }}

-

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

+

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

{{ 'addon.mod_feedback.anonymous' | translate }}

{{ 'addon.mod_feedback.non_anonymous' | translate }}

diff --git a/src/addons/mod/feedback/pages/attempt/attempt.html b/src/addons/mod/feedback/pages/attempt/attempt.html index 41005c39334..0a110f49bc7 100644 --- a/src/addons/mod/feedback/pages/attempt/attempt.html +++ b/src/addons/mod/feedback/pages/attempt/attempt.html @@ -33,12 +33,12 @@

{{ 'addon.mod_feedback.anonymous_user' |translate }}

-

+

{{item.itemnumber}}. -

+

diff --git a/src/addons/mod/feedback/pages/form/form.html b/src/addons/mod/feedback/pages/form/form.html index e16c65d3967..9f07a02553e 100644 --- a/src/addons/mod/feedback/pages/form/form.html +++ b/src/addons/mod/feedback/pages/form/form.html @@ -17,7 +17,7 @@

-

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

+

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

{{ 'addon.mod_feedback.anonymous' | translate }}

{{ 'addon.mod_feedback.non_anonymous' | translate }}

diff --git a/src/addons/mod/feedback/pages/form/form.ts b/src/addons/mod/feedback/pages/form/form.ts index 3ff6d0e444d..ad96737b0d8 100644 --- a/src/addons/mod/feedback/pages/form/form.ts +++ b/src/addons/mod/feedback/pages/form/form.ts @@ -109,6 +109,14 @@ export class AddonModFeedbackFormPage implements OnInit, OnDestroy, CanLeave { await this.fetchData(); + if (!this.access || this.access.isempty && (!this.access.canedititems && !this.access.canviewreports)) { + CoreDomUtils.showErrorModal(Translate.instant('core.nopermissiontoaccesspage')); + + CoreNavigator.back(); + + return; + } + if (!this.feedback) { return; } diff --git a/src/addons/mod/forum/components/index/index.html b/src/addons/mod/forum/components/index/index.html index e4d953a31d4..90e871645a2 100644 --- a/src/addons/mod/forum/components/index/index.html +++ b/src/addons/mod/forum/components/index/index.html @@ -3,6 +3,8 @@ + + @@ -74,26 +76,20 @@ [lines]="discussion.groupname && 'none'" [attr.aria-current]="discussions?.getItemAriaCurrent(discussion)" (click)="discussions?.select(discussion)" button> -
-

- - - - - -

- - - -
+

+ + + + + +

- +
{{discussion.userfullname}} @@ -136,6 +132,11 @@ + + + -

{{ 'addon.mod_forum.advanced' | translate }}

+

{{ 'addon.mod_forum.advanced' | translate }}

diff --git a/src/addons/mod/forum/components/sort-order-selector/sort-order-selector.html b/src/addons/mod/forum/components/sort-order-selector/sort-order-selector.html index 45ff2157789..54d488ebaea 100644 --- a/src/addons/mod/forum/components/sort-order-selector/sort-order-selector.html +++ b/src/addons/mod/forum/components/sort-order-selector/sort-order-selector.html @@ -1,7 +1,7 @@ -

{{ 'core.sort' | translate }}

+

{{ 'core.sort' | translate }}

@@ -17,7 +17,7 @@

{{ 'core.sort' | translate }}

[attr.aria-current]="selected == sortOrder.value ? 'page' : 'false'" [attr.aria-label]="sortOrder.label | translate" (click)="selectSortOrder(sortOrder)" button aria-haspopup="dialog"> -

{{ sortOrder.label | translate }}

+

{{ sortOrder.label | translate }}

diff --git a/src/addons/mod/forum/pages/new-discussion/new-discussion.html b/src/addons/mod/forum/pages/new-discussion/new-discussion.html index f403daa714c..205501ce769 100644 --- a/src/addons/mod/forum/pages/new-discussion/new-discussion.html +++ b/src/addons/mod/forum/pages/new-discussion/new-discussion.html @@ -46,10 +46,10 @@

{{ 'addon.mod_forum.advanced' | translate }}

- {{ 'addon.mod_forum.group' | translate }} - + {{ 'addon.mod_forum.group' | translate }} + {{ group.name }} diff --git a/src/addons/mod/glossary/pages/edit/edit.html b/src/addons/mod/glossary/pages/edit/edit.html index f943c19e3f5..8850c93d39d 100644 --- a/src/addons/mod/glossary/pages/edit/edit.html +++ b/src/addons/mod/glossary/pages/edit/edit.html @@ -28,11 +28,11 @@

- + {{ 'addon.mod_glossary.categories' | translate }} - {{ category.name }} @@ -40,11 +40,10 @@

- + {{ 'addon.mod_glossary.aliases' | translate }} - + diff --git a/src/addons/mod/glossary/tests/behat/navigation.feature b/src/addons/mod/glossary/tests/behat/navigation.feature index 309c0a1f0a0..659d286ff9e 100644 --- a/src/addons/mod/glossary/tests/behat/navigation.feature +++ b/src/addons/mod/glossary/tests/behat/navigation.feature @@ -200,6 +200,7 @@ Feature: Test glossary navigation When I swipe to the left in the app Then I should find "Acerola is a fruit" in the app + @ci_jenkins_skip Scenario: Tablet navigation on glossary Given I entered the course "Course 1" as "student1" in the app And I change viewport size to "1200x640" diff --git a/src/addons/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html b/src/addons/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html index 41245df1ad1..b4bff993233 100644 --- a/src/addons/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html +++ b/src/addons/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html @@ -57,7 +57,7 @@ -

{{ progressMessage | translate }}

+

{{ progressMessage | translate }}

diff --git a/src/addons/mod/h5pactivity/pages/attempt-results/attempt-results.html b/src/addons/mod/h5pactivity/pages/attempt-results/attempt-results.html index d40c535162e..efe7dd8c062 100644 --- a/src/addons/mod/h5pactivity/pages/attempt-results/attempt-results.html +++ b/src/addons/mod/h5pactivity/pages/attempt-results/attempt-results.html @@ -23,13 +23,13 @@

[attr.aria-label]="user.fullname"> -

{{ 'addon.mod_h5pactivity.attempt' | translate }} #{{attempt.attempt}}: {{user.fullname}}

+

{{ 'addon.mod_h5pactivity.attempt' | translate }} #{{attempt.attempt}}: {{user.fullname}}

-

{{ 'addon.mod_h5pactivity.attempt' | translate }} #{{attempt.attempt}}

+

{{ 'addon.mod_h5pactivity.attempt' | translate }} #{{attempt.attempt}}

@@ -38,13 +38,13 @@

{{ 'addon.mod_h5pactivity.attempt' | translate }} #{{attempt.attempt}}

-

{{ 'addon.mod_h5pactivity.startdate' | translate }}

+

{{ 'addon.mod_h5pactivity.startdate' | translate }}

{{ attempt.timecreated | coreFormatDate:'strftimedatetime' }}

-

{{ 'addon.mod_h5pactivity.completion' | translate }}

+

{{ 'addon.mod_h5pactivity.completion' | translate }}

{{ 'addon.mod_h5pactivity.attempt_completion_yes' | translate }} @@ -57,13 +57,13 @@

{{ 'addon.mod_h5pactivity.completion' | translate }}

-

{{ 'addon.mod_h5pactivity.duration' | translate }}

+

{{ 'addon.mod_h5pactivity.duration' | translate }}

{{ attempt.durationReadable }}

-

{{ 'addon.mod_h5pactivity.outcome' | translate }}

+

{{ 'addon.mod_h5pactivity.outcome' | translate }}

{{ 'addon.mod_h5pactivity.attempt_success_pass' | translate }} @@ -79,7 +79,7 @@

{{ 'addon.mod_h5pactivity.outcome' | translate }}

-

{{ 'addon.mod_h5pactivity.totalscore' | translate }}

+

{{ 'addon.mod_h5pactivity.totalscore' | translate }}

{{ 'addon.mod_h5pactivity.score_out_of' | translate:{$a: attempt} }}

diff --git a/src/addons/mod/h5pactivity/pages/user-attempts/user-attempts.html b/src/addons/mod/h5pactivity/pages/user-attempts/user-attempts.html index 30b181aca84..c9030e75deb 100644 --- a/src/addons/mod/h5pactivity/pages/user-attempts/user-attempts.html +++ b/src/addons/mod/h5pactivity/pages/user-attempts/user-attempts.html @@ -37,7 +37,7 @@

{{ 'addon.mod_h5pactivity.myattempts' | translate }}

-

{{ attemptsData.scored.title }}

+

{{ attemptsData.scored.title }}

@@ -48,7 +48,7 @@

{{ attemptsData.scored.title }}

-

{{ 'addon.mod_h5pactivity.all_attempts' | translate }}

+

{{ 'addon.mod_h5pactivity.all_attempts' | translate }}

diff --git a/src/addons/mod/imscp/components/toc/toc.html b/src/addons/mod/imscp/components/toc/toc.html index e9aee76c64f..6a0565fc0cf 100644 --- a/src/addons/mod/imscp/components/toc/toc.html +++ b/src/addons/mod/imscp/components/toc/toc.html @@ -1,7 +1,7 @@ -

{{ 'addon.mod_imscp.toc' | translate }}

+

{{ 'addon.mod_imscp.toc' | translate }}

diff --git a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html index 2ed4ab3a183..104ab736417 100644 --- a/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html +++ b/src/addons/mod/lesson/components/index/addon-mod-lesson-index.html @@ -77,7 +77,7 @@ -

{{ 'addon.mod_lesson.averagescore' | translate }}

+

{{ 'addon.mod_lesson.averagescore' | translate }}

{{ 'core.percentagenumber' | translate:{$a: overview.avescore} }}

@@ -85,7 +85,7 @@

{{ 'addon.mod_lesson.averagescore' | translate }}

-

{{ 'addon.mod_lesson.highscore' | translate }}

+

{{ 'addon.mod_lesson.highscore' | translate }}

{{ 'core.percentagenumber' | translate:{$a: overview.highscore} }}

@@ -93,7 +93,7 @@

{{ 'addon.mod_lesson.highscore' | translate }}

-

{{ 'addon.mod_lesson.lowscore' | translate }}

+

{{ 'addon.mod_lesson.lowscore' | translate }}

{{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }}

@@ -102,7 +102,7 @@

{{ 'addon.mod_lesson.lowscore' | translate }}

-

{{ 'addon.mod_lesson.averagetime' | translate }}

+

{{ 'addon.mod_lesson.averagetime' | translate }}

{{ avetimeReadable }}

{{ 'addon.mod_lesson.notcompleted' | translate }} @@ -110,13 +110,13 @@

{{ 'addon.mod_lesson.averagetime' | translate }}

-

{{ 'addon.mod_lesson.hightime' | translate }}

+

{{ 'addon.mod_lesson.hightime' | translate }}

{{ hightimeReadable }}

{{ 'addon.mod_lesson.notcompleted' | translate }}

-

{{ 'addon.mod_lesson.lowtime' | translate }}

+

{{ 'addon.mod_lesson.lowtime' | translate }}

{{ lowtimeReadable }}

{{ 'addon.mod_lesson.notcompleted' | translate }}

@@ -127,7 +127,7 @@

{{ 'addon.mod_lesson.lowtime' | translate }}

-

{{ 'addon.mod_lesson.averagescore' | translate }}

+

{{ 'addon.mod_lesson.averagescore' | translate }}

{{ 'core.percentagenumber' | translate:{$a: overview.avescore} }}

@@ -135,7 +135,7 @@

{{ 'addon.mod_lesson.averagescore' | translate }}

-

{{ 'addon.mod_lesson.averagetime' | translate }}

+

{{ 'addon.mod_lesson.averagetime' | translate }}

{{ avetimeReadable }}

{{ 'addon.mod_lesson.notcompleted' | translate }} @@ -144,7 +144,7 @@

{{ 'addon.mod_lesson.averagetime' | translate }}

-

{{ 'addon.mod_lesson.highscore' | translate }}

+

{{ 'addon.mod_lesson.highscore' | translate }}

{{ 'core.percentagenumber' | translate:{$a: overview.highscore} }}

@@ -152,14 +152,14 @@

{{ 'addon.mod_lesson.highscore' | translate }}

-

{{ 'addon.mod_lesson.hightime' | translate }}

+

{{ 'addon.mod_lesson.hightime' | translate }}

{{ hightimeReadable }}

{{ 'addon.mod_lesson.notcompleted' | translate }}

-

{{ 'addon.mod_lesson.lowscore' | translate }}

+

{{ 'addon.mod_lesson.lowscore' | translate }}

{{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }}

@@ -167,7 +167,7 @@

{{ 'addon.mod_lesson.lowscore' | translate }}

-

{{ 'addon.mod_lesson.lowtime' | translate }}

+

{{ 'addon.mod_lesson.lowtime' | translate }}

{{ lowtimeReadable }}

{{ 'addon.mod_lesson.notcompleted' | translate }}

diff --git a/src/addons/mod/lesson/components/menu-modal/menu-modal.html b/src/addons/mod/lesson/components/menu-modal/menu-modal.html index 80e242f0b05..a0a1c2cb769 100644 --- a/src/addons/mod/lesson/components/menu-modal/menu-modal.html +++ b/src/addons/mod/lesson/components/menu-modal/menu-modal.html @@ -1,7 +1,7 @@ -

{{ pageInstance?.lesson?.name }}

+

{{ pageInstance?.lesson?.name }}

diff --git a/src/addons/mod/lesson/components/password-modal/password-modal.html b/src/addons/mod/lesson/components/password-modal/password-modal.html index 1f033719b35..5bcc9b3751a 100644 --- a/src/addons/mod/lesson/components/password-modal/password-modal.html +++ b/src/addons/mod/lesson/components/password-modal/password-modal.html @@ -1,7 +1,7 @@ -

{{ 'core.login.password' | translate }}

+

{{ 'core.login.password' | translate }}

diff --git a/src/addons/mod/lesson/pages/player/player.html b/src/addons/mod/lesson/pages/player/player.html index da29653c950..6d8e6e8dcb1 100644 --- a/src/addons/mod/lesson/pages/player/player.html +++ b/src/addons/mod/lesson/pages/player/player.html @@ -94,7 +94,7 @@

-

{{ 'addon.mod_lesson.youranswer' | translate }}

+

{{ 'addon.mod_lesson.youranswer' | translate }}

{{ 'addon.mod_lesson.youranswer' | translate }}

- +

- + {{option.label}} diff --git a/src/addons/mod/lesson/pages/user-retake/user-retake.html b/src/addons/mod/lesson/pages/user-retake/user-retake.html index 41e59c571ff..f55593aaea7 100644 --- a/src/addons/mod/lesson/pages/user-retake/user-retake.html +++ b/src/addons/mod/lesson/pages/user-retake/user-retake.html @@ -27,9 +27,8 @@

{{student.fullname}}

- {{ 'addon.mod_lesson.attemptheader' | translate }} - {{ 'addon.mod_lesson.attemptheader' | translate }} + {{retake.label}} @@ -44,12 +43,12 @@

{{student.fullname}}

-

{{ 'addon.mod_lesson.grade' | translate }}

+

{{ 'addon.mod_lesson.grade' | translate }}

{{ 'core.percentagenumber' | translate:{$a: retake.userstats.grade} }}

-

{{ 'addon.mod_lesson.rawgrade' | translate }}

+

{{ 'addon.mod_lesson.rawgrade' | translate }}

{{ retake.userstats.gradeinfo.earned }} / {{ retake.userstats.gradeinfo.total }}

@@ -58,13 +57,13 @@

{{ 'addon.mod_lesson.rawgrade' | translate }}

-

{{ 'addon.mod_lesson.timetaken' | translate }}

+

{{ 'addon.mod_lesson.timetaken' | translate }}

{{ timeTakenReadable }}

-

{{ 'addon.mod_lesson.completed' | translate }}

+

{{ 'addon.mod_lesson.completed' | translate }}

{{ retake.userstats.completed * 1000 | coreFormatDate }}

@@ -85,7 +84,7 @@

{{ 'addon.mod_lesson.completed' | translate }}

-

{{ 'addon.mod_lesson.question' | translate }}

+

{{ 'addon.mod_lesson.question' | translate }}

{{ 'addon.mod_lesson.question' | translate }}

-

{{ 'addon.mod_lesson.answer' | translate }}

+

{{ 'addon.mod_lesson.answer' | translate }}

@@ -227,7 +226,7 @@

{{ 'addon.mod_lesson.answer' | translate }}

-

{{ 'addon.mod_lesson.response' | translate }}

+

{{ 'addon.mod_lesson.response' | translate }}

-

{{ 'core.settings.synchronization' | translate }}

+

{{ 'core.settings.synchronization' | translate }}

{{ 'addon.mod_quiz.confirmcontinueoffline' | translate:{$a: syncTimeReadable} }}

diff --git a/src/addons/mod/quiz/accessrules/password/component/addon-mod-quiz-access-password.html b/src/addons/mod/quiz/accessrules/password/component/addon-mod-quiz-access-password.html index 9b713053ea4..ed53f0e8e76 100644 --- a/src/addons/mod/quiz/accessrules/password/component/addon-mod-quiz-access-password.html +++ b/src/addons/mod/quiz/accessrules/password/component/addon-mod-quiz-access-password.html @@ -1,6 +1,6 @@ -

{{ 'addon.mod_quiz.quizpassword' | translate }}

+

{{ 'addon.mod_quiz.quizpassword' | translate }}

{{ 'addon.mod_quiz.requirepasswordmessage' | translate}}

diff --git a/src/addons/mod/quiz/accessrules/timelimit/component/addon-mod-quiz-access-time-limit.html b/src/addons/mod/quiz/accessrules/timelimit/component/addon-mod-quiz-access-time-limit.html index b48075dbfb3..6cd54c1e86c 100644 --- a/src/addons/mod/quiz/accessrules/timelimit/component/addon-mod-quiz-access-time-limit.html +++ b/src/addons/mod/quiz/accessrules/timelimit/component/addon-mod-quiz-access-time-limit.html @@ -1,6 +1,6 @@ -

{{ 'addon.mod_quiz.confirmstartheader' | translate }}

+

{{ 'addon.mod_quiz.confirmstartheader' | translate }}

{{ 'addon.mod_quiz.confirmstart' | translate:{$a: readableTimeLimit} }}

diff --git a/src/addons/mod/quiz/components/navigation-modal/navigation-modal.html b/src/addons/mod/quiz/components/navigation-modal/navigation-modal.html index 239b919fa2e..0f4b70459c0 100644 --- a/src/addons/mod/quiz/components/navigation-modal/navigation-modal.html +++ b/src/addons/mod/quiz/components/navigation-modal/navigation-modal.html @@ -1,7 +1,7 @@ -

{{ 'addon.mod_quiz.quiznavigation' | translate }}

+

{{ 'addon.mod_quiz.quiznavigation' | translate }}

diff --git a/src/addons/mod/quiz/components/preflight-modal/preflight-modal.html b/src/addons/mod/quiz/components/preflight-modal/preflight-modal.html index c642e6cc914..effcffa7644 100644 --- a/src/addons/mod/quiz/components/preflight-modal/preflight-modal.html +++ b/src/addons/mod/quiz/components/preflight-modal/preflight-modal.html @@ -1,7 +1,7 @@ -

{{ title | translate }}

+

{{ title | translate }}

diff --git a/src/addons/mod/quiz/pages/attempt/attempt.html b/src/addons/mod/quiz/pages/attempt/attempt.html index e7dfbdeadee..9227f2e1f6a 100644 --- a/src/addons/mod/quiz/pages/attempt/attempt.html +++ b/src/addons/mod/quiz/pages/attempt/attempt.html @@ -20,32 +20,32 @@

-

{{ 'addon.mod_quiz.attemptnumber' | translate }}

+

{{ 'addon.mod_quiz.attemptnumber' | translate }}

{{ 'addon.mod_quiz.preview' | translate }}

{{ attempt.attempt }}

-

{{ 'addon.mod_quiz.attemptstate' | translate }}

+

{{ 'addon.mod_quiz.attemptstate' | translate }}

{{ sentence }}

-

{{ 'addon.mod_quiz.marks' | translate }} / {{ quiz!.sumGradesFormatted }}

+

{{ 'addon.mod_quiz.marks' | translate }} / {{ quiz!.sumGradesFormatted }}

{{ attempt.readableMark }}

-

{{ 'addon.mod_quiz.grade' | translate }} / {{ quiz!.gradeFormatted }}

+

{{ 'addon.mod_quiz.grade' | translate }} / {{ quiz!.gradeFormatted }}

{{ attempt.readableGrade }}

-

{{ 'addon.mod_quiz.feedback' | translate }}

+

{{ 'addon.mod_quiz.feedback' | translate }}

diff --git a/src/addons/mod/quiz/pages/player/player.html b/src/addons/mod/quiz/pages/player/player.html index dd42c0bc522..2e4b6f12031 100644 --- a/src/addons/mod/quiz/pages/player/player.html +++ b/src/addons/mod/quiz/pages/player/player.html @@ -132,7 +132,7 @@

{{ 'core.question.information' | tra -

{{ 'addon.mod_quiz.cannotsubmitquizdueto' | translate }}

+

{{ 'addon.mod_quiz.cannotsubmitquizdueto' | translate }}

{{message}}

diff --git a/src/addons/mod/quiz/pages/player/player.page.ts b/src/addons/mod/quiz/pages/player/player.page.ts index 4eb7dd7eabf..42b91c4622b 100644 --- a/src/addons/mod/quiz/pages/player/player.page.ts +++ b/src/addons/mod/quiz/pages/player/player.page.ts @@ -47,7 +47,7 @@ import { CanLeave } from '@guards/can-leave'; import { CoreForms } from '@singletons/form'; import { CoreDom } from '@singletons/dom'; import { CoreTime } from '@singletons/time'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; /** * Page that allows attempting a quiz. @@ -690,7 +690,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave { */ protected async scrollToQuestion(slot: number): Promise { await CoreUtils.nextTick(); - await CoreComponentsRegistry.waitComponentsReady(this.elementRef.nativeElement, 'core-question'); + await CoreDirectivesRegistry.waitDirectivesReady(this.elementRef.nativeElement, 'core-question'); await CoreDom.scrollToElement( this.elementRef.nativeElement, '#addon-mod_quiz-question-' + slot, diff --git a/src/addons/mod/quiz/pages/review/review.html b/src/addons/mod/quiz/pages/review/review.html index fc90cf8553a..12e0feb232f 100644 --- a/src/addons/mod/quiz/pages/review/review.html +++ b/src/addons/mod/quiz/pages/review/review.html @@ -26,43 +26,43 @@

{{ 'addon.mod_quiz.review' | translate }}

-

{{ 'addon.mod_quiz.startedon' | translate }}

+

{{ 'addon.mod_quiz.startedon' | translate }}

{{ attempt.timestart! * 1000 | coreFormatDate }}

-

{{ 'addon.mod_quiz.attemptstate' | translate }}

+

{{ 'addon.mod_quiz.attemptstate' | translate }}

{{ readableState }}

-

{{ 'addon.mod_quiz.completedon' | translate }}

+

{{ 'addon.mod_quiz.completedon' | translate }}

{{ attempt.timefinish! * 1000 | coreFormatDate }}

-

{{ 'addon.mod_quiz.timetaken' | translate }}

+

{{ 'addon.mod_quiz.timetaken' | translate }}

{{ timeTaken }}

-

{{ 'addon.mod_quiz.overdue' | translate }}

+

{{ 'addon.mod_quiz.overdue' | translate }}

{{ overTime }}

-

{{ 'addon.mod_quiz.marks' | translate }}

+

{{ 'addon.mod_quiz.marks' | translate }}

{{ readableMark }}

-

{{ 'addon.mod_quiz.grade' | translate }}

+

{{ 'addon.mod_quiz.grade' | translate }}

{{ readableGrade }}

diff --git a/src/addons/mod/quiz/services/quiz-sync.ts b/src/addons/mod/quiz/services/quiz-sync.ts index 68568c798d8..e6f799dc81e 100644 --- a/src/addons/mod/quiz/services/quiz-sync.ts +++ b/src/addons/mod/quiz/services/quiz-sync.ts @@ -15,7 +15,6 @@ import { Injectable } from '@angular/core'; import { CoreError } from '@classes/errors/error'; -import { CoreSite } from '@classes/site'; import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync'; import { CoreCourse, CoreCourseModuleBasicInfo } from '@features/course/services/course'; import { CoreCourseLogHelper } from '@features/course/services/log-helper'; @@ -314,7 +313,7 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider if (!CoreNetwork.isOnline()) { // Cannot sync in offline. - throw new CoreError(Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION })); + throw new CoreError(Translate.instant('core.cannotconnect')); } const offlineAttempt = offlineAttempts.pop()!; diff --git a/src/addons/mod/resource/components/index/addon-mod-resource-index.html b/src/addons/mod/resource/components/index/addon-mod-resource-index.html index 020ba6eea6a..3a7823f5453 100644 --- a/src/addons/mod/resource/components/index/addon-mod-resource-index.html +++ b/src/addons/mod/resource/components/index/addon-mod-resource-index.html @@ -32,7 +32,7 @@ -

{{ 'core.type' | translate }}

+

{{ 'core.type' | translate }}

{{ type }}

@@ -40,28 +40,28 @@

{{ 'core.type' | translate }}

-

{{ 'core.size' | translate }}

+

{{ 'core.size' | translate }}

{{ readableSize }}

-

{{ 'core.datecreated' | translate }}

+

{{ 'core.datecreated' | translate }}

{{ timecreated | coreFormatDate }}

-

{{ 'core.lastmodified' | translate }}

+

{{ 'core.lastmodified' | translate }}

{{ timemodified | coreFormatDate }}

-

{{ 'core.lastdownloaded' | translate }}

+

{{ 'core.lastdownloaded' | translate }}

{{ downloadTimeReadable }}

diff --git a/src/addons/mod/resource/components/index/index.ts b/src/addons/mod/resource/components/index/index.ts index 545082efc01..1fb0ecfebef 100644 --- a/src/addons/mod/resource/components/index/index.ts +++ b/src/addons/mod/resource/components/index/index.ts @@ -19,7 +19,6 @@ import { CoreCourseModuleMainResourceComponent } from '@features/course/classes/ import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; import { CoreCourse } from '@features/course/services/course'; import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; -import { CoreApp } from '@services/app'; import { CoreNetwork } from '@services/network'; import { CoreFileHelper } from '@services/file-helper'; import { CoreSites } from '@services/sites'; @@ -35,6 +34,7 @@ import { AddonModResourceProvider, } from '../../services/resource'; import { AddonModResourceHelper } from '../../services/resource-helper'; +import { CorePlatform } from '@services/platform'; /** * Component that displays a resource. @@ -79,7 +79,7 @@ export class AddonModResourceIndexComponent extends CoreCourseModuleMainResource async ngOnInit(): Promise { super.ngOnInit(); - this.isIOS = CoreApp.isIOS(); + this.isIOS = CorePlatform.isIOS(); this.isOnline = CoreNetwork.isOnline(); // Refresh online status when changes. diff --git a/src/addons/mod/scorm/components/index/addon-mod-scorm-index.html b/src/addons/mod/scorm/components/index/addon-mod-scorm-index.html index d2b0c257f74..de077cdb957 100644 --- a/src/addons/mod/scorm/components/index/addon-mod-scorm-index.html +++ b/src/addons/mod/scorm/components/index/addon-mod-scorm-index.html @@ -214,7 +214,7 @@

{{'core.grades.grades' | translate}}

-

{{ progressMessage | translate }}

+

{{ progressMessage | translate }}

diff --git a/src/addons/mod/scorm/components/toc/toc.html b/src/addons/mod/scorm/components/toc/toc.html index 01163c6fcda..224f8270cac 100644 --- a/src/addons/mod/scorm/components/toc/toc.html +++ b/src/addons/mod/scorm/components/toc/toc.html @@ -1,7 +1,7 @@ -

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

+

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

diff --git a/src/addons/mod/survey/components/index/addon-mod-survey-index.html b/src/addons/mod/survey/components/index/addon-mod-survey-index.html index a8aa106d489..94418bb86f4 100644 --- a/src/addons/mod/survey/components/index/addon-mod-survey-index.html +++ b/src/addons/mod/survey/components/index/addon-mod-survey-index.html @@ -102,13 +102,12 @@

- + {{question.num}}. {{ question.text }} - + diff --git a/src/addons/mod/url/components/index/addon-mod-url-index.html b/src/addons/mod/url/components/index/addon-mod-url-index.html index 0e7fa1baa05..85519119458 100644 --- a/src/addons/mod/url/components/index/addon-mod-url-index.html +++ b/src/addons/mod/url/components/index/addon-mod-url-index.html @@ -31,7 +31,7 @@ -

{{ 'addon.mod_url.pointingtourl' | translate }}

+

{{ 'addon.mod_url.pointingtourl' | translate }}

{{ url }}

diff --git a/src/addons/mod/wiki/components/map/map.html b/src/addons/mod/wiki/components/map/map.html index c8c1a44cb77..f0cb4d57705 100644 --- a/src/addons/mod/wiki/components/map/map.html +++ b/src/addons/mod/wiki/components/map/map.html @@ -1,7 +1,7 @@ -

{{ 'addon.mod_wiki.map' | translate }}

+

{{ 'addon.mod_wiki.map' | translate }}

diff --git a/src/addons/mod/wiki/components/subwiki-picker/addon-mod-wiki-subwiki-picker.html b/src/addons/mod/wiki/components/subwiki-picker/addon-mod-wiki-subwiki-picker.html index aa7de86e636..866a50f80fe 100644 --- a/src/addons/mod/wiki/components/subwiki-picker/addon-mod-wiki-subwiki-picker.html +++ b/src/addons/mod/wiki/components/subwiki-picker/addon-mod-wiki-subwiki-picker.html @@ -2,7 +2,7 @@ -

{{ group.label }}

+

{{ group.label }}

+
@@ -22,7 +22,8 @@

{{ field.dimtitle }}

-

{{ 'addon.mod_workshop_assessment_accumulative.dimensiongradefor' | translate : {'$a': field.dimtitle } }}

+

{{ 'addon.mod_workshop_assessment_accumulative.dimensiongradefor' | translate : {'$a': + field.dimtitle } }}

{{grade.label}}

@@ -37,9 +38,9 @@

{{ 'addon.mod_workshop_assessment_accumulative.dimensiongradefor' | translat -

+

{{ 'addon.mod_workshop_assessment_accumulative.dimensioncommentfor' | translate : {'$a': field.dimtitle } }} -

+

@@ -48,4 +49,4 @@

- +

diff --git a/src/addons/mod/workshop/assessment/comments/component/addon-mod-workshop-assessment-strategy-comments.html b/src/addons/mod/workshop/assessment/comments/component/addon-mod-workshop-assessment-strategy-comments.html index 4badf862d2c..974b0deb634 100644 --- a/src/addons/mod/workshop/assessment/comments/component/addon-mod-workshop-assessment-strategy-comments.html +++ b/src/addons/mod/workshop/assessment/comments/component/addon-mod-workshop-assessment-strategy-comments.html @@ -1,4 +1,4 @@ - +
@@ -20,7 +20,8 @@

{{ field.dimtitle }}

-

{{ 'addon.mod_workshop_assessment_comments.dimensioncommentfor' | translate : {'$a': field.dimtitle } }}

+

{{ 'addon.mod_workshop_assessment_comments.dimensioncommentfor' | translate : {'$a': field.dimtitle + } }}

@@ -29,4 +30,4 @@

{{ 'addon.mod_workshop_assessment_comments.dimensioncommentfor' | translate - +

diff --git a/src/addons/mod/workshop/assessment/numerrors/component/addon-mod-workshop-assessment-strategy-numerrors.html b/src/addons/mod/workshop/assessment/numerrors/component/addon-mod-workshop-assessment-strategy-numerrors.html index b742587b42a..33a93fc1442 100644 --- a/src/addons/mod/workshop/assessment/numerrors/component/addon-mod-workshop-assessment-strategy-numerrors.html +++ b/src/addons/mod/workshop/assessment/numerrors/component/addon-mod-workshop-assessment-strategy-numerrors.html @@ -1,4 +1,4 @@ - +
@@ -42,7 +42,8 @@

{{ field.dimtitle }}

-

{{ 'addon.mod_workshop_assessment_numerrors.dimensioncommentfor' | translate : {'$a': field.dimtitle } }}

+

{{ 'addon.mod_workshop_assessment_numerrors.dimensioncommentfor' | translate : {'$a': field.dimtitle + } }}

@@ -51,4 +52,4 @@

{{ 'addon.mod_workshop_assessment_numerrors.dimensioncommentfor' | translate - +

diff --git a/src/addons/mod/workshop/assessment/rubric/component/addon-mod-workshop-assessment-strategy-rubric.html b/src/addons/mod/workshop/assessment/rubric/component/addon-mod-workshop-assessment-strategy-rubric.html index 1920e85c8be..58fa080bad8 100644 --- a/src/addons/mod/workshop/assessment/rubric/component/addon-mod-workshop-assessment-strategy-rubric.html +++ b/src/addons/mod/workshop/assessment/rubric/component/addon-mod-workshop-assessment-strategy-rubric.html @@ -1,4 +1,4 @@ - +
@@ -24,4 +24,4 @@

{{ field.dimtitle }}

- +
diff --git a/src/addons/mod/workshop/components/assessment-strategy/addon-mod-workshop-assessment-strategy.html b/src/addons/mod/workshop/components/assessment-strategy/addon-mod-workshop-assessment-strategy.html index 1f3111ee3c7..160f9e4f041 100644 --- a/src/addons/mod/workshop/components/assessment-strategy/addon-mod-workshop-assessment-strategy.html +++ b/src/addons/mod/workshop/components/assessment-strategy/addon-mod-workshop-assessment-strategy.html @@ -1,4 +1,4 @@ -

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

+

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

@@ -18,7 +18,7 @@

{{ 'addon.mod_workshop.assessmentform' | translate }} -

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

+

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

diff --git a/src/addons/mod/workshop/components/index/addon-mod-workshop-index.html b/src/addons/mod/workshop/components/index/addon-mod-workshop-index.html index 434e6f5a2a0..7487a611876 100644 --- a/src/addons/mod/workshop/components/index/addon-mod-workshop-index.html +++ b/src/addons/mod/workshop/components/index/addon-mod-workshop-index.html @@ -31,7 +31,7 @@

{{ phases[workshop!.phase].title }}

-

{{task.title}}

+

{{task.title}}

@@ -125,7 +125,7 @@

-

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

+

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

@@ -141,7 +141,7 @@

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

-

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

+

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

@@ -153,7 +153,7 @@

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

-

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

+

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

@@ -175,8 +175,10 @@

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

((grades && grades.length) || (groupInfo && (groupInfo.separateGroups || groupInfo.visibleGroups)))"> -

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

-

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

+

{{ 'addon.mod_workshop.submissionsreport' | + translate }}

+

{{ 'addon.mod_workshop.gradesreport' | translate }} +

diff --git a/src/addons/mod/workshop/components/phase/phase.html b/src/addons/mod/workshop/components/phase/phase.html index 407551e370b..c9720ceb79d 100644 --- a/src/addons/mod/workshop/components/phase/phase.html +++ b/src/addons/mod/workshop/components/phase/phase.html @@ -1,7 +1,7 @@ -

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

+

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

@@ -40,7 +40,7 @@

{{ phase.title }}

-

{{task.title}}

+

{{task.title}}

-

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

+

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

@@ -112,7 +112,7 @@

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

-

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

+

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

{{ submission.grade }}

@@ -142,9 +142,9 @@

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

-

+

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

+

diff --git a/src/addons/notes/components/add/add-modal.html b/src/addons/notes/components/add/add-modal.html index e3cb5c8580a..55a6feeedc1 100644 --- a/src/addons/notes/components/add/add-modal.html +++ b/src/addons/notes/components/add/add-modal.html @@ -1,7 +1,7 @@ -

{{ 'addon.notes.addnewnote' | translate }}

+

{{ 'addon.notes.addnewnote' | translate }}

diff --git a/src/addons/notes/pages/list/list.html b/src/addons/notes/pages/list/list.html index 6c58feff3bb..af931bd96c5 100644 --- a/src/addons/notes/pages/list/list.html +++ b/src/addons/notes/pages/list/list.html @@ -34,7 +34,7 @@

{{ 'addon.notes.notes' | translate }}

-

{{user!.fullname}}

+

{{user!.fullname}}

diff --git a/src/addons/qbehaviour/qbehaviour.module.ts b/src/addons/qbehaviour/qbehaviour.module.ts index 93f38bd98ab..ab148e0e04e 100644 --- a/src/addons/qbehaviour/qbehaviour.module.ts +++ b/src/addons/qbehaviour/qbehaviour.module.ts @@ -11,6 +11,7 @@ // 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 { AddonQbehaviourAdaptiveModule } from './adaptive/adaptive.module'; diff --git a/src/addons/qtype/ddwtos/classes/ddwtos.ts b/src/addons/qtype/ddwtos/classes/ddwtos.ts index 152b38766e7..a06ac29280f 100644 --- a/src/addons/qtype/ddwtos/classes/ddwtos.ts +++ b/src/addons/qtype/ddwtos/classes/ddwtos.ts @@ -15,7 +15,7 @@ import { CoreFormatTextDirective } from '@directives/format-text'; import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils } from '@services/utils/utils'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreCoordinates, CoreDom } from '@singletons/dom'; import { CoreEventObserver } from '@singletons/events'; import { CoreLogger } from '@singletons/logger'; @@ -427,7 +427,7 @@ export class AddonQtypeDdwtosQuestion { protected async waitForReady(): Promise { await CoreDom.waitToBeInDOM(this.container); - await CoreComponentsRegistry.waitComponentsReady(this.container, 'core-format-text', CoreFormatTextDirective); + await CoreDirectivesRegistry.waitDirectivesReady(this.container, 'core-format-text', CoreFormatTextDirective); const drag = Array.from(this.container.querySelectorAll(this.selectors.dragHomes()))[0]; diff --git a/src/addons/storagemanager/pages/course-storage/course-storage.html b/src/addons/storagemanager/pages/course-storage/course-storage.html index 8ec983aabd7..c8a243275ca 100644 --- a/src/addons/storagemanager/pages/course-storage/course-storage.html +++ b/src/addons/storagemanager/pages/course-storage/course-storage.html @@ -108,11 +108,11 @@

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

[modname]="module.modname" [componentId]="module.instance"> -

+

-

+

-

{{ field.name }}

+

{{ field.name }}

{{ 'core.yes' | translate }}

@@ -19,4 +19,4 @@

{{ field.name }}

-
\ No newline at end of file +
diff --git a/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html b/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html index bf48afb57a5..19b3d16de91 100644 --- a/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html +++ b/src/addons/userprofilefield/datetime/component/addon-user-profile-field-datetime.html @@ -1,7 +1,7 @@ -

{{ field.name }}

+

{{ field.name }}

{{ valueNumber * 1000 | coreFormatDate }}

diff --git a/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html b/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html index 3816c2090b0..a657ef6c1e3 100644 --- a/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html +++ b/src/addons/userprofilefield/menu/component/addon-user-profile-field-menu.html @@ -1,7 +1,7 @@ -

{{ field.name }}

+

{{ field.name }}

diff --git a/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html b/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html index 4ee87abe64c..2426c2dd378 100644 --- a/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html +++ b/src/addons/userprofilefield/text/component/addon-user-profile-field-text.html @@ -1,7 +1,7 @@ -

{{ field.name }}

+

{{ field.name }}

diff --git a/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html b/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html index bd1a4189133..ff938e96e0f 100644 --- a/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html +++ b/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html @@ -1,7 +1,7 @@ -

{{ field.name }}

+

{{ field.name }}

diff --git a/src/addons/userprofilefield/userprofilefield.module.ts b/src/addons/userprofilefield/userprofilefield.module.ts index 2b6096c2ad2..3ddecabfc8d 100644 --- a/src/addons/userprofilefield/userprofilefield.module.ts +++ b/src/addons/userprofilefield/userprofilefield.module.ts @@ -11,6 +11,7 @@ // 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 { AddonUserProfileFieldCheckboxModule } from './checkbox/checkbox.module'; diff --git a/src/assets/img/login_logo.png b/src/assets/img/login_logo.png index 0cbb69d0e49..265b478f78d 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/top_logo.png b/src/assets/img/top_logo.png index 0cbb69d0e49..265b478f78d 100644 Binary files a/src/assets/img/top_logo.png and b/src/assets/img/top_logo.png differ diff --git a/src/core/classes/aria-role-tab.ts b/src/core/classes/aria-role-tab.ts index 3c7ea6938f0..2965eb3dce9 100644 --- a/src/core/classes/aria-role-tab.ts +++ b/src/core/classes/aria-role-tab.ts @@ -29,19 +29,19 @@ export class CoreAriaRoleTab { * @param e Event. */ keyDown(tabFindIndex: string, e: KeyboardEvent): void { - if (e.key == ' ' || - e.key == 'Enter' || - e.key == 'Home' || - e.key == 'End' || - (this.isHorizontal() && (e.key == 'ArrowRight' || e.key == 'ArrowLeft')) || - (!this.isHorizontal() && (e.key == 'ArrowUp' ||e.key == 'ArrowDown')) + if (e.key === ' ' || + e.key === 'Enter' || + e.key === 'Home' || + e.key === 'End' || + (this.isHorizontal() && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) || + (!this.isHorizontal() && (e.key === 'ArrowUp' ||e.key === 'ArrowDown')) ) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } - if (e.key == ' ' || e.key == 'Enter') { + if (e.key === ' ' || e.key === 'Enter') { this.selectTabCandidate = tabFindIndex; } } @@ -64,7 +64,7 @@ export class CoreAriaRoleTab { e.stopPropagation(); e.stopImmediatePropagation(); - if (e.key == ' ' || e.key == 'Enter') { + if (e.key === ' ' || e.key === 'Enter') { if (this.selectTabCandidate === tabFindIndex) { this.selectTab(tabFindIndex, e); } diff --git a/src/core/classes/async-component.ts b/src/core/classes/async-directive.ts similarity index 80% rename from src/core/classes/async-component.ts rename to src/core/classes/async-directive.ts index 77635a70fd6..b659dc253c1 100644 --- a/src/core/classes/async-component.ts +++ b/src/core/classes/async-directive.ts @@ -13,12 +13,12 @@ // limitations under the License. /** - * Component that is not rendered immediately after being mounted. + * Directive that is not rendered immediately after being mounted. */ -export interface AsyncComponent { +export interface AsyncDirective { /** - * Wait until the component is fully rendered and ready. + * Wait until the directive is fully rendered and ready. */ ready(): Promise; } diff --git a/src/core/classes/element-controllers/ElementController.ts b/src/core/classes/element-controllers/ElementController.ts index e4ef1b0004c..d7e889e589e 100644 --- a/src/core/classes/element-controllers/ElementController.ts +++ b/src/core/classes/element-controllers/ElementController.ts @@ -18,6 +18,7 @@ export abstract class ElementController { protected enabled: boolean; + protected destroyed = false; constructor(enabled: boolean) { this.enabled = enabled; @@ -49,6 +50,19 @@ export abstract class ElementController { this.onDisabled(); } + /** + * Destroy the element. + */ + destroy(): void { + if (this.destroyed) { + return; + } + + this.destroyed = true; + + this.onDestroy(); + } + /** * Update underlying element to enable interactivity. */ @@ -59,4 +73,11 @@ export abstract class ElementController { */ abstract onDisabled(): void; + /** + * Destroy/dispose pertinent data. + */ + onDestroy(): void { + // By default, nothing to destroy. + } + } diff --git a/src/core/classes/element-controllers/MediaElementController.ts b/src/core/classes/element-controllers/MediaElementController.ts index 0ae911fc039..3a67a6cebf3 100644 --- a/src/core/classes/element-controllers/MediaElementController.ts +++ b/src/core/classes/element-controllers/MediaElementController.ts @@ -14,6 +14,11 @@ import { CoreUtils } from '@services/utils/utils'; import { ElementController } from './ElementController'; +import { CorePromisedValue } from '@classes/promised-value'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreMedia } from '@singletons/media'; +import { AddonFilterMediaPluginVideoJS, VIDEO_JS_PLAYER_CREATED } from '@addons/filter/mediaplugin/services/videojs'; +import type { VideoJSPlayer } from 'video.js'; /** * Wrapper class to control the interactivity of a media element. @@ -25,6 +30,10 @@ export class MediaElementController extends ElementController { private playing?: boolean; private playListener?: () => void; private pauseListener?: () => void; + private jsPlayer = new CorePromisedValue(); + private jsPlayerListener?: CoreEventObserver; + private shouldEnable = false; + private shouldDisable = false; constructor(media: HTMLMediaElement, enabled: boolean) { super(enabled); @@ -34,48 +43,136 @@ export class MediaElementController extends ElementController { media.autoplay = false; + this.initJSPlayer(media); + enabled && this.onEnabled(); } /** * @inheritdoc */ - onEnabled(): void { + async onEnabled(): Promise { + this.shouldEnable = true; + this.shouldDisable = false; + + const jsPlayer = await this.jsPlayer; + + if (!this.shouldEnable || this.destroyed) { + return; + } + const ready = this.playing ?? this.autoplay - ? this.media.play() + ? (jsPlayer ?? this.media).play() : Promise.resolve(); - ready - .then(() => this.addPlaybackEventListeners()) - .catch(error => CoreUtils.logUnhandledError('Error enabling media element', error)); + try { + await ready; + + this.addPlaybackEventListeners(jsPlayer); + } catch (error) { + CoreUtils.logUnhandledError('Error enabling media element', error); + } } /** * @inheritdoc */ async onDisabled(): Promise { - this.removePlaybackEventListeners(); + this.shouldDisable = true; + this.shouldEnable = false; + + const jsPlayer = await this.jsPlayer; + + if (!this.shouldDisable || this.destroyed) { + return; + } + + this.removePlaybackEventListeners(jsPlayer); + + (jsPlayer ?? this.media).pause(); + } + + /** + * @inheritdoc + */ + async onDestroy(): Promise { + const jsPlayer = await this.jsPlayer; - this.media.pause(); + this.removePlaybackEventListeners(jsPlayer); + jsPlayer?.dispose(); + } + + /** + * Init JS Player instance. + * + * @param media Media element. + */ + private async initJSPlayer(media: HTMLMediaElement): Promise { + if (!CoreMedia.mediaUsesJavascriptPlayer(media)) { + this.jsPlayer.resolve(null); + + return; + } + + const player = await this.searchJSPlayer(); + + if (!player) { + this.jsPlayerListener = CoreEvents.on(VIDEO_JS_PLAYER_CREATED, ({ element, player }) => { + if (element !== media) { + return; + } + + this.jsPlayerListener?.off(); + this.jsPlayer.resolve(player); + }); + + return; + } + + this.jsPlayer.resolve(player); } /** * Start listening playback events. + * + * @param jsPlayer Javascript player instance (if any). */ - private addPlaybackEventListeners(): void { - this.media.addEventListener('play', this.playListener = () => this.playing = true); - this.media.addEventListener('pause', this.pauseListener = () => this.playing = false); + private addPlaybackEventListeners(jsPlayer: VideoJSPlayer | null): void { + if (jsPlayer) { + jsPlayer.on('play', this.playListener = () => this.playing = true); + jsPlayer.on('pause', this.pauseListener = () => this.playing = false); + } else { + this.media.addEventListener('play', this.playListener = () => this.playing = true); + this.media.addEventListener('pause', this.pauseListener = () => this.playing = false); + } } /** * Stop listening playback events. + * + * @param jsPlayer Javascript player instance (if any). */ - private removePlaybackEventListeners(): void { - this.playListener && this.media.removeEventListener('play', this.playListener); - this.pauseListener && this.media.removeEventListener('pause', this.pauseListener); + private removePlaybackEventListeners(jsPlayer: VideoJSPlayer | null): void { + if (jsPlayer) { + this.playListener && jsPlayer.off('play', this.playListener); + this.pauseListener && jsPlayer.off('pause', this.pauseListener); + } else { + this.playListener && this.media.removeEventListener('play', this.playListener); + this.pauseListener && this.media.removeEventListener('pause', this.pauseListener); + } delete this.playListener; delete this.pauseListener; } + /** + * Search JS player instance. + * + * @returns Player instance if found. + */ + private async searchJSPlayer(): Promise { + return AddonFilterMediaPluginVideoJS.findPlayer(this.media.id) + ?? AddonFilterMediaPluginVideoJS.findPlayer(this.media.id.replace('_html5_api', '')); + } + } diff --git a/src/core/classes/errors/captureerror.ts b/src/core/classes/errors/captureerror.ts index 302ffb5a859..08424029095 100644 --- a/src/core/classes/errors/captureerror.ts +++ b/src/core/classes/errors/captureerror.ts @@ -14,6 +14,8 @@ import { CoreError } from './error'; +export const CAPTURE_ERROR_NO_MEDIA_FILES = 3; + /** * Capture error. */ diff --git a/src/core/classes/modal-component.ts b/src/core/classes/modal-component.ts new file mode 100644 index 00000000000..207c8a9bb36 --- /dev/null +++ b/src/core/classes/modal-component.ts @@ -0,0 +1,45 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { ElementRef } from '@angular/core'; +import { CorePromisedValue } from '@classes/promised-value'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; + +/** + * Helper class to build modals. + */ +export class CoreModalComponent { + + result: CorePromisedValue = new CorePromisedValue(); + + constructor({ nativeElement: element }: ElementRef) { + CoreDirectivesRegistry.register(element, this); + } + + /** + * Close the modal. + * + * @param result Result data, or error instance if the modal was closed with a failure. + */ + async close(result: T | Error): Promise { + if (result instanceof Error) { + this.result.reject(result); + + return; + } + + this.result.resolve(result); + } + +} diff --git a/src/core/classes/page-load-watcher.ts b/src/core/classes/page-load-watcher.ts index 52bba8de700..e062e3b9810 100644 --- a/src/core/classes/page-load-watcher.ts +++ b/src/core/classes/page-load-watcher.ts @@ -15,7 +15,7 @@ import { CoreSitesReadingStrategy } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; import { Subscription } from 'rxjs'; -import { AsyncComponent } from './async-component'; +import { AsyncDirective } from './async-directive'; import { PageLoadsManager } from './page-loads-manager'; import { CorePromisedValue } from './promised-value'; import { WSObservable } from './site'; @@ -27,7 +27,7 @@ export class PageLoadWatcher { protected hasChanges = false; protected ongoingRequests = 0; - protected components = new Set(); + protected components = new Set(); protected loadedTimeout?: number; protected hasChangesPromises: Promise[] = []; @@ -66,7 +66,7 @@ export class PageLoadWatcher { * * @param component Component instance. */ - async watchComponent(component: AsyncComponent): Promise { + async watchComponent(component: AsyncDirective): Promise { this.components.add(component); clearTimeout(this.loadedTimeout); diff --git a/src/core/classes/page-loads-manager.ts b/src/core/classes/page-loads-manager.ts index 24a28c4a1bd..c3c179ec046 100644 --- a/src/core/classes/page-loads-manager.ts +++ b/src/core/classes/page-loads-manager.ts @@ -16,7 +16,7 @@ import { CoreRefreshButtonModalComponent } from '@components/refresh-button-moda import { CoreNavigator } from '@services/navigator'; import { CoreDomUtils } from '@services/utils/dom'; import { Subject } from 'rxjs'; -import { AsyncComponent } from './async-component'; +import { AsyncDirective } from './async-directive'; import { PageLoadWatcher } from './page-load-watcher'; /** @@ -37,7 +37,7 @@ export class PageLoadsManager { * @param staleWhileRevalidate Whether to use stale while revalidate strategy. * @returns Load watcher to use. */ - startPageLoad(page: AsyncComponent, staleWhileRevalidate: boolean): PageLoadWatcher { + startPageLoad(page: AsyncDirective, staleWhileRevalidate: boolean): PageLoadWatcher { this.initialPath = this.initialPath ?? CoreNavigator.getCurrentPath(); this.currentLoadWatcher = new PageLoadWatcher(this, staleWhileRevalidate); this.ongoingLoadWatchers.add(this.currentLoadWatcher); @@ -53,7 +53,7 @@ export class PageLoadsManager { * @param component Component instance. * @returns Load watcher to use. */ - startComponentLoad(component: AsyncComponent): PageLoadWatcher { + startComponentLoad(component: AsyncDirective): PageLoadWatcher { // If a component is loading data without the page loading data, probably the component is reloading/refreshing. // In that case, create a load watcher instance but don't store it in currentLoadWatcher because it's not a page load. const loadWatcher = this.currentLoadWatcher ?? new PageLoadWatcher(this, false); diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index 6b7fa753887..6fffa7b91d9 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -106,7 +106,8 @@ export class CoreSite { '3.10': 2020110900, '3.11': 2021051700, '4.0': 2022041900, - '4.1': 2022111100, // @todo [4.1] replace with right value when released. Using a tmp value to be able to test new things. + '4.1': 2022112800, + '4.2': 2023011300, // @todo [4.2] replace with right value when released. Using a tmp value to be able to test new things. }; // Possible cache update frequencies. @@ -806,7 +807,7 @@ export class CoreSite { ): Promise { if (preSets.forceOffline) { // Don't call the WS, just fail. - throw new CoreError(Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION })); + throw new CoreError(Translate.instant('core.cannotconnect')); } try { @@ -1694,7 +1695,7 @@ export class CoreSite { .catch(async () => { if (cachePreSets.forceOffline) { // Don't call the WS, just fail. - throw new CoreError(Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION })); + throw new CoreError(Translate.instant('core.cannotconnect')); } // Call the WS. diff --git a/src/core/classes/tabs.ts b/src/core/classes/tabs.ts index 5fe244c3a0f..f74babe6822 100644 --- a/src/core/classes/tabs.ts +++ b/src/core/classes/tabs.ts @@ -37,8 +37,8 @@ import { CoreDom } from '@singletons/dom'; import { CoreUtils } from '@services/utils/utils'; import { CoreError } from './errors/error'; import { CorePromisedValue } from './promised-value'; -import { AsyncComponent } from './async-component'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { AsyncDirective } from './async-directive'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CorePlatform } from '@services/platform'; /** @@ -47,7 +47,7 @@ import { CorePlatform } from '@services/platform'; @Component({ template: '', }) -export class CoreTabsBaseComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy, AsyncComponent { +export class CoreTabsBaseComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy, AsyncDirective { // Minimum tab's width. protected static readonly MIN_TAB_WIDTH = 107; @@ -99,7 +99,7 @@ export class CoreTabsBaseComponent implements OnInit, Aft this.tabAction = new CoreTabsRoleTab(this); - CoreComponentsRegistry.register(element.nativeElement, this); + CoreDirectivesRegistry.register(element.nativeElement, this); } /** diff --git a/src/core/components/chrono/chrono.ts b/src/core/components/chrono/chrono.ts index 8f30406a1d7..0604cc65e66 100644 --- a/src/core/components/chrono/chrono.ts +++ b/src/core/components/chrono/chrono.ts @@ -46,6 +46,7 @@ export class CoreChronoComponent implements OnInit, OnChanges, OnDestroy { @Input() startTime = 0; // Number of milliseconds to put in the chrono before starting. @Input() endTime?: number; // Number of milliseconds to stop the chrono. @Input() reset?: boolean; // Set it to true to reset the chrono. + @Input() hours = true; @Output() onEnd: EventEmitter; // Will emit an event when the endTime is reached. time = 0; diff --git a/src/core/components/chrono/core-chrono.html b/src/core/components/chrono/core-chrono.html index 5f0f28ade6e..fccdbca9395 100644 --- a/src/core/components/chrono/core-chrono.html +++ b/src/core/components/chrono/core-chrono.html @@ -1 +1 @@ -{{ time / 1000 | coreSecondsToHMS }} \ No newline at end of file +{{ time / 1000 | coreSecondsToHMS:hours }} diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index 846aee4e6ca..f0cfb2cef2f 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -64,6 +64,7 @@ import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe- import { CoreMessageComponent } from './message/message'; import { CoreGroupSelectorComponent } from './group-selector/group-selector'; import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh-button-modal'; +import { CoreSheetModalComponent } from '@components/sheet-modal/sheet-modal'; @NgModule({ declarations: [ @@ -110,6 +111,7 @@ import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh- CoreHorizontalScrollControlsComponent, CoreSwipeNavigationTourComponent, CoreRefreshButtonModalComponent, + CoreSheetModalComponent, ], imports: [ CommonModule, @@ -163,6 +165,7 @@ import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh- CoreHorizontalScrollControlsComponent, CoreSwipeNavigationTourComponent, CoreRefreshButtonModalComponent, + CoreSheetModalComponent, ], }) export class CoreComponentsModule {} diff --git a/src/core/components/context-menu/context-menu.ts b/src/core/components/context-menu/context-menu.ts index 1574075629e..33710b71250 100644 --- a/src/core/components/context-menu/context-menu.ts +++ b/src/core/components/context-menu/context-menu.ts @@ -20,7 +20,7 @@ import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons'; import { CoreContextMenuItemComponent } from './context-menu-item'; import { CoreContextMenuPopoverComponent } from './context-menu-popover'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; /** * This component adds a button (usually in the navigation bar) that displays a context menu popover. @@ -61,7 +61,7 @@ export class CoreContextMenuComponent implements OnInit, OnDestroy { // Calculate the unique ID. this.uniqueId = 'core-context-menu-' + CoreUtils.getUniqueId('CoreContextMenuComponent'); - CoreComponentsRegistry.register(elementRef.nativeElement, this); + CoreDirectivesRegistry.register(elementRef.nativeElement, this); } /** diff --git a/src/core/components/file/file.ts b/src/core/components/file/file.ts index 12f2d2fb2cb..d15e5e9e410 100644 --- a/src/core/components/file/file.ts +++ b/src/core/components/file/file.ts @@ -13,7 +13,6 @@ // limitations under the License. import { Component, Input, Output, OnInit, OnDestroy, EventEmitter } from '@angular/core'; -import { CoreApp } from '@services/app'; import { CoreNetwork } from '@services/network'; import { CoreFilepool } from '@services/filepool'; import { CoreFileHelper } from '@services/file-helper'; @@ -27,6 +26,7 @@ import { CoreTextUtils } from '@services/utils/text'; import { CoreConstants } from '@/core/constants'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreWSFile } from '@services/ws'; +import { CorePlatform } from '@services/platform'; /** * Component to handle a remote file. Shows the file name, icon (depending on mimetype) and a button @@ -87,7 +87,7 @@ export class CoreFileComponent implements OnInit, OnDestroy { this.fileSize = this.file.filesize; this.fileName = this.file.filename || ''; - this.isIOS = CoreApp.isIOS(); + this.isIOS = CorePlatform.isIOS(); this.defaultIsOpenWithPicker = CoreFileHelper.defaultIsOpenWithPicker(); this.openButtonIcon = this.defaultIsOpenWithPicker ? 'fas-file' : 'fas-share-square'; this.openButtonLabel = this.defaultIsOpenWithPicker ? 'core.openfile' : 'core.openwith'; diff --git a/src/core/components/group-selector/group-selector.html b/src/core/components/group-selector/group-selector.html index 3c667d8ea30..8febfda048d 100644 --- a/src/core/components/group-selector/group-selector.html +++ b/src/core/components/group-selector/group-selector.html @@ -7,12 +7,11 @@ - + {{'core.groupsseparate' | translate }} {{'core.groupsvisible' | translate }} - {{groupOpt.name}} diff --git a/src/core/components/group-selector/group-selector.ts b/src/core/components/group-selector/group-selector.ts index b98b5ec5e72..2365e3ffe18 100644 --- a/src/core/components/group-selector/group-selector.ts +++ b/src/core/components/group-selector/group-selector.ts @@ -12,9 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, Output, EventEmitter, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; import { CoreGroupInfo } from '@services/groups'; -import { CoreUtils } from '@services/utils/utils'; /** * Component to display a group selector. @@ -24,20 +23,11 @@ import { CoreUtils } from '@services/utils/utils'; templateUrl: 'group-selector.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CoreGroupSelectorComponent implements OnInit { +export class CoreGroupSelectorComponent { @Input() groupInfo?: CoreGroupInfo; @Input() multipleGroupsMessage?: string; @Input() selected!: number; @Output() selectedChange = new EventEmitter(); - id!: number; - - /** - * @inheritdoc - */ - ngOnInit(): void { - this.id = CoreUtils.getUniqueId('CoreGroupSelectorComponent'); - } - } diff --git a/src/core/components/loading/loading.ts b/src/core/components/loading/loading.ts index 6db45e3c8b1..df08396dcf7 100644 --- a/src/core/components/loading/loading.ts +++ b/src/core/components/loading/loading.ts @@ -18,10 +18,10 @@ import { CoreEventLoadingChangedData, CoreEvents } from '@singletons/events'; import { CoreUtils } from '@services/utils/utils'; import { CoreAnimations } from '@components/animations'; import { Translate } from '@singletons'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CorePromisedValue } from '@classes/promised-value'; -import { AsyncComponent } from '@classes/async-component'; -import { CoreApp } from '@services/app'; +import { AsyncDirective } from '@classes/async-directive'; +import { CorePlatform } from '@services/platform'; /** * Component to show a loading spinner and message while data is being loaded. @@ -49,7 +49,7 @@ import { CoreApp } from '@services/app'; styleUrls: ['loading.scss'], animations: [CoreAnimations.SHOW_HIDE], }) -export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit, AsyncComponent, OnDestroy { +export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit, AsyncDirective, OnDestroy { @Input() hideUntil: unknown = false; // Determine when should the contents be shown. @Input() message?: string; // Message to show while loading. @@ -65,7 +65,7 @@ export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit, A constructor(element: ElementRef) { this.element = element.nativeElement; - CoreComponentsRegistry.register(this.element, this); + CoreDirectivesRegistry.register(this.element, this); // Calculate the unique ID. this.uniqueId = 'core-loading-content-' + CoreUtils.getUniqueId('CoreLoadingComponent'); @@ -146,7 +146,7 @@ export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit, A if (loaded) { this.onReadyPromise.resolve(); this.restoreScrollPosition(); - if (CoreApp.isIOS()) { + if (CorePlatform.isIOS()) { this.mutationObserver.observe(this.element, { childList: true }); } } else { diff --git a/src/core/components/local-file/local-file.ts b/src/core/components/local-file/local-file.ts index 8301fbad399..62de411c772 100644 --- a/src/core/components/local-file/local-file.ts +++ b/src/core/components/local-file/local-file.ts @@ -25,8 +25,8 @@ import { CoreTextUtils } from '@services/utils/text'; import { CoreTimeUtils } from '@services/utils/time'; import { CoreUtils, CoreUtilsOpenFileOptions, OpenFileAction } from '@services/utils/utils'; import { CoreForms } from '@singletons/form'; -import { CoreApp } from '@services/app'; import { CorePath } from '@singletons/path'; +import { CorePlatform } from '@services/platform'; /** * Component to handle a local file. Only files inside the app folder can be managed. @@ -83,7 +83,7 @@ export class CoreLocalFileComponent implements OnInit { this.timemodified = CoreTimeUtils.userDate(metadata.modificationTime.getTime(), 'core.strftimedatetimeshort'); - this.isIOS = CoreApp.isIOS(); + this.isIOS = CorePlatform.isIOS(); this.defaultIsOpenWithPicker = CoreFileHelper.defaultIsOpenWithPicker(); this.openButtonIcon = this.defaultIsOpenWithPicker ? 'fas-file' : 'fas-share-square'; this.openButtonLabel = this.defaultIsOpenWithPicker ? 'core.openfile' : 'core.openwith'; diff --git a/src/core/components/navbar-buttons/navbar-buttons.ts b/src/core/components/navbar-buttons/navbar-buttons.ts index 83e18fb4765..662ce0cf892 100644 --- a/src/core/components/navbar-buttons/navbar-buttons.ts +++ b/src/core/components/navbar-buttons/navbar-buttons.ts @@ -25,7 +25,7 @@ import { import { CoreLogger } from '@singletons/logger'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreContextMenuComponent } from '../context-menu/context-menu'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreDom } from '@singletons/dom'; const BUTTON_HIDDEN_CLASS = 'core-navbar-button-hidden'; @@ -82,7 +82,7 @@ export class CoreNavBarButtonsComponent implements OnInit, OnDestroy { this.element = element.nativeElement; this.logger = CoreLogger.getInstance('CoreNavBarButtonsComponent'); - CoreComponentsRegistry.register(this.element, this); + CoreDirectivesRegistry.register(this.element, this); } /** @@ -156,14 +156,16 @@ export class CoreNavBarButtonsComponent implements OnInit, OnDestroy { } const mainContextMenu = buttonsContainer.querySelector('core-context-menu'); - const secondaryContextMenuInstance = CoreComponentsRegistry.resolve(secondaryContextMenu, CoreContextMenuComponent); + const secondaryContextMenuInstance = CoreDirectivesRegistry.resolve(secondaryContextMenu, CoreContextMenuComponent); let mainContextMenuInstance: CoreContextMenuComponent | null; if (mainContextMenu) { // Both containers have a context menu. Merge them to prevent having 2 menus at the same time. - mainContextMenuInstance = CoreComponentsRegistry.resolve(mainContextMenu, CoreContextMenuComponent); + mainContextMenuInstance = CoreDirectivesRegistry.resolve(mainContextMenu, CoreContextMenuComponent); } else { // There is a context-menu in these buttons, but there is no main context menu in the header. // Create one main context menu dynamically. + // @todo: Find a better way to handle header buttons. This isn't working as expected in some cases because the menu + // is destroyed when the page is destroyed, so click listeners stop working. mainContextMenuInstance = this.createMainContextMenu(); } diff --git a/src/core/components/sheet-modal/sheet-modal.html b/src/core/components/sheet-modal/sheet-modal.html new file mode 100644 index 00000000000..e5a79c2c86d --- /dev/null +++ b/src/core/components/sheet-modal/sheet-modal.html @@ -0,0 +1,2 @@ + +

diff --git a/src/core/components/sheet-modal/sheet-modal.scss b/src/core/components/sheet-modal/sheet-modal.scss new file mode 100644 index 00000000000..d80185bab40 --- /dev/null +++ b/src/core/components/sheet-modal/sheet-modal.scss @@ -0,0 +1,43 @@ +@import "~theme/globals"; + +:host { + --backdrop-opacity: var(--ion-backdrop-opacity, 0.4); + + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + isolation: isolate; + + ion-backdrop { + opacity: 0; + transition: opacity 300ms ease-in; + } + + .sheet-modal--wrapper { + border-radius: var(--big-radius) var(--big-radius) 0 0; + @include padding(24px, 16px, 24px, 16px); + + background-color: var(--ion-overlay-background-color, var(--ion-background-color, #fff)); + z-index: 3; // ion-backdrop has z-index 2 + transform: translateY(100%); + transition: transform 300ms ease-in; + width: 100%; + max-width: 500px; + } + + &.active { + + ion-backdrop { + opacity: var(--backdrop-opacity); + } + + .sheet-modal--wrapper { + transform: translateY(0%); + } + + } + +} diff --git a/src/core/components/sheet-modal/sheet-modal.ts b/src/core/components/sheet-modal/sheet-modal.ts new file mode 100644 index 00000000000..b3c409775e0 --- /dev/null +++ b/src/core/components/sheet-modal/sheet-modal.ts @@ -0,0 +1,97 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { Constructor } from '@/core/utils/types'; +import { AfterViewInit, Component, ElementRef, Input, ViewChild } from '@angular/core'; +import { CoreModalComponent } from '@classes/modal-component'; +import { CorePromisedValue } from '@classes/promised-value'; +import { CoreModals } from '@services/modals'; +import { CoreUtils } from '@services/utils/utils'; +import { AngularFrameworkDelegate } from '@singletons'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; + +@Component({ + selector: 'core-sheet-modal', + templateUrl: 'sheet-modal.html', + styleUrls: ['sheet-modal.scss'], +}) +export class CoreSheetModalComponent implements AfterViewInit { + + @Input() component!: Constructor; + @Input() componentProps?: Record; + @ViewChild('wrapper') wrapper?: ElementRef; + + private element: HTMLElement; + private wrapperElement = new CorePromisedValue(); + private content?: HTMLElement; + + constructor({ nativeElement: element }: ElementRef) { + this.element = element; + + CoreDirectivesRegistry.register(element, this); + } + + /** + * @inheritdoc + */ + ngAfterViewInit(): void { + if (!this.wrapper) { + this.wrapperElement.reject(new Error('CoreSheetModalComponent wasn\'t mounted properly')); + + return; + } + + this.wrapperElement.resolve(this.wrapper.nativeElement); + } + + /** + * Show modal. + * + * @returns Component instance. + */ + async show(): Promise { + const wrapper = await this.wrapperElement; + this.content = await AngularFrameworkDelegate.attachViewToDom(wrapper, this.component, this.componentProps ?? {}); + + await CoreUtils.nextTick(); + + this.element.classList.add('active'); + this.element.style.zIndex = `${20000 + CoreModals.getTopOverlayIndex()}`; + + await CoreUtils.nextTick(); + await CoreUtils.wait(300); + + const instance = CoreDirectivesRegistry.resolve(this.content, this.component); + + if (!instance) { + throw new Error('Modal not mounted properly'); + } + + return instance; + } + + /** + * Hide modal. + */ + async hide(): Promise { + const wrapper = await this.wrapperElement; + + this.element.classList.remove('active'); + + await CoreUtils.nextTick(); + await CoreUtils.wait(300); + await AngularFrameworkDelegate.removeViewFromDom(wrapper, this.content); + } + +} diff --git a/src/core/components/show-password/show-password.ts b/src/core/components/show-password/show-password.ts index 2ab247d287d..3b61044bf1a 100644 --- a/src/core/components/show-password/show-password.ts +++ b/src/core/components/show-password/show-password.ts @@ -15,7 +15,7 @@ import { Component, OnInit, AfterViewInit, Input, ElementRef, ContentChild } from '@angular/core'; import { IonInput } from '@ionic/angular'; -import { CoreApp } from '@services/app'; +import { CorePlatform } from '@services/platform'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; @@ -121,7 +121,7 @@ export class CoreShowPasswordComponent implements OnInit, AfterViewInit { this.setData(this.input); // In Android, the keyboard is closed when the input type changes. Focus it again. - if (isFocused && CoreApp.isAndroid()) { + if (isFocused && CorePlatform.isAndroid()) { CoreDomUtils.focusElement(this.input); } } @@ -132,7 +132,7 @@ export class CoreShowPasswordComponent implements OnInit, AfterViewInit { * @param event The mouse event. */ doNotBlur(event: Event): void { - if (event.type == 'keydown' && !this.isValidKeyboardKey(event)) { + if (event.type === 'keydown' && !this.isValidKeyboardKey(event)) { return; } @@ -147,7 +147,7 @@ export class CoreShowPasswordComponent implements OnInit, AfterViewInit { * @returns Wether space or enter have been pressed. */ protected isValidKeyboardKey(event: KeyboardEvent): boolean { - return event.key == ' ' || event.key == 'Enter'; + return event.key === ' ' || event.key === 'Enter'; } } diff --git a/src/core/components/swipe-slides/swipe-slides.html b/src/core/components/swipe-slides/swipe-slides.html index 8b086075063..65ab4c92a3f 100644 --- a/src/core/components/swipe-slides/swipe-slides.html +++ b/src/core/components/swipe-slides/swipe-slides.html @@ -1,5 +1,5 @@ - + diff --git a/src/core/components/tabs-outlet/core-tabs-outlet.html b/src/core/components/tabs-outlet/core-tabs-outlet.html index b820e9c7235..8933706b0bf 100644 --- a/src/core/components/tabs-outlet/core-tabs-outlet.html +++ b/src/core/components/tabs-outlet/core-tabs-outlet.html @@ -16,7 +16,7 @@ [tabindex]="selected == tab.id ? 0 : -1"> - {{ tab.title | translate}} +

{{ tab.title | translate}}

{{ tab.badge }} diff --git a/src/core/components/tabs-outlet/tabs-outlet.ts b/src/core/components/tabs-outlet/tabs-outlet.ts index 93d72b195e3..81f56cb0819 100644 --- a/src/core/components/tabs-outlet/tabs-outlet.ts +++ b/src/core/components/tabs-outlet/tabs-outlet.ts @@ -31,7 +31,7 @@ import { CoreNavBarButtonsComponent } from '../navbar-buttons/navbar-buttons'; import { StackEvent } from '@ionic/angular/directives/navigation/stack-utils'; import { CoreNavigator } from '@services/navigator'; import { CoreTabBase, CoreTabsBaseComponent } from '@classes/tabs'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; /** * This component displays some top scrollable tabs that will autohide on vertical scroll. @@ -207,7 +207,7 @@ export class CoreTabsOutletComponent extends CoreTabsBaseComponent { - const instance = CoreComponentsRegistry.resolve(element, CoreNavBarButtonsComponent); + const instance = CoreDirectivesRegistry.resolve(element, CoreNavBarButtonsComponent); if (instance) { const pagetagName = element.closest('.ion-page')?.tagName; diff --git a/src/core/components/tabs/core-tabs.html b/src/core/components/tabs/core-tabs.html index 26f990e080a..9ea65bdedb1 100644 --- a/src/core/components/tabs/core-tabs.html +++ b/src/core/components/tabs/core-tabs.html @@ -14,7 +14,7 @@ [attr.aria-selected]="selected == tab.id" [tabindex]="selected == tab.id ? 0 : -1"> - {{ tab.title | translate}} +

{{ tab.title | translate}}

{{ tab.badge }} diff --git a/src/core/components/tabs/tab.ts b/src/core/components/tabs/tab.ts index 3d9e40561b2..a93de6abc9a 100644 --- a/src/core/components/tabs/tab.ts +++ b/src/core/components/tabs/tab.ts @@ -16,7 +16,7 @@ import { Component, Input, Output, OnInit, OnDestroy, ElementRef, EventEmitter, import { CoreTabBase } from '@classes/tabs'; import { CoreUtils } from '@services/utils/utils'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreNavBarButtonsComponent } from '../navbar-buttons/navbar-buttons'; import { CoreTabsComponent } from './tabs'; @@ -140,7 +140,7 @@ export class CoreTabComponent implements OnInit, OnDestroy, CoreTabBase { protected showHideNavBarButtons(show: boolean): void { const elements = this.element.querySelectorAll('core-navbar-buttons'); elements.forEach((element) => { - const instance = CoreComponentsRegistry.resolve(element, CoreNavBarButtonsComponent); + const instance = CoreDirectivesRegistry.resolve(element, CoreNavBarButtonsComponent); if (instance) { instance.forceHide(!show); diff --git a/src/core/components/tabs/tabs.scss b/src/core/components/tabs/tabs.scss index 69d09150f04..26eebb6a935 100644 --- a/src/core/components/tabs/tabs.scss +++ b/src/core/components/tabs/tabs.scss @@ -59,14 +59,16 @@ ion-tab-button { max-width: 100%; ion-label { - font-size: 14px; - font-weight: 400; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; word-wrap: break-word; max-width: 100%; line-height: 1.2em; + h2 { + font-size: 14px; + font-weight: 400; + } } } diff --git a/src/core/components/user-avatar/core-user-avatar.html b/src/core/components/user-avatar/core-user-avatar.html index 1e90d6bf319..67eae965c7e 100644 --- a/src/core/components/user-avatar/core-user-avatar.html +++ b/src/core/components/user-avatar/core-user-avatar.html @@ -1,10 +1,14 @@ - + - + + + + + diff --git a/src/core/components/user-avatar/user-avatar.scss b/src/core/components/user-avatar/user-avatar.scss index 38eea550074..f2028ece88a 100644 --- a/src/core/components/user-avatar/user-avatar.scss +++ b/src/core/components/user-avatar/user-avatar.scss @@ -5,9 +5,6 @@ width: var(--core-avatar-size); height: var(--core-avatar-size); - .clickable { - cursor: pointer; - } img { border-radius: 50%; width: var(--core-avatar-size); diff --git a/src/core/directives/aria-button.ts b/src/core/directives/aria-button.ts index 3d0d2959312..ec05e4cabf4 100644 --- a/src/core/directives/aria-button.ts +++ b/src/core/directives/aria-button.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Directive, ElementRef, OnInit, Output, EventEmitter } from '@angular/core'; +import { Directive, ElementRef, OnInit, Output, EventEmitter, OnChanges, SimpleChanges, Input } from '@angular/core'; import { CoreDom } from '@singletons/dom'; /** @@ -21,10 +21,11 @@ import { CoreDom } from '@singletons/dom'; @Directive({ selector: '[ariaButtonClick]', }) -export class CoreAriaButtonClickDirective implements OnInit { +export class CoreAriaButtonClickDirective implements OnInit, OnChanges { protected element: HTMLElement; + @Input() disabled = false; @Output() ariaButtonClick = new EventEmitter(); constructor( @@ -34,10 +35,27 @@ export class CoreAriaButtonClickDirective implements OnInit { } /** - * Initialize actions. + * @inheritdoc */ ngOnInit(): void { - CoreDom.onActivate(this.element, (event) => this.ariaButtonClick.emit(event)); + CoreDom.initializeClickableElementA11y(this.element, (event) => this.ariaButtonClick.emit(event)); + } + + /** + * @inheritdoc + */ + ngOnChanges(changes: SimpleChanges): void { + if (!changes.disabled) { + return; + } + + if (this.element.getAttribute('tabindex') === '0' && this.disabled) { + this.element.setAttribute('tabindex', '-1'); + } + + if (this.element.getAttribute('tabindex') === '-1' && !this.disabled) { + this.element.setAttribute('tabindex', '0'); + } } } diff --git a/src/core/directives/collapsible-footer.ts b/src/core/directives/collapsible-footer.ts index e93fab94809..de58fcedb04 100644 --- a/src/core/directives/collapsible-footer.ts +++ b/src/core/directives/collapsible-footer.ts @@ -17,7 +17,7 @@ import { ScrollDetail } from '@ionic/core'; import { IonContent } from '@ionic/angular'; import { CoreUtils } from '@services/utils/utils'; import { CoreMath } from '@singletons/math'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreFormatTextDirective } from './format-text'; import { CoreEventObserver } from '@singletons/events'; import { CoreLoadingComponent } from '@components/loading/loading'; @@ -203,7 +203,7 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { * Wait until all children inside the element are done rendering. */ protected async waitFormatTextsRendered(): Promise { - await CoreComponentsRegistry.waitComponentsReady(this.element, 'core-format-text', CoreFormatTextDirective); + await CoreDirectivesRegistry.waitDirectivesReady(this.element, 'core-format-text', CoreFormatTextDirective); } /** @@ -249,8 +249,8 @@ export class CoreCollapsibleFooterDirective implements OnInit, OnDestroy { const scrollElement = await this.ionContent.getScrollElement(); await Promise.all([ - await CoreComponentsRegistry.waitComponentsReady(scrollElement, 'core-loading', CoreLoadingComponent), - await CoreComponentsRegistry.waitComponentsReady(this.element, 'core-loading', CoreLoadingComponent), + await CoreDirectivesRegistry.waitDirectivesReady(scrollElement, 'core-loading', CoreLoadingComponent), + await CoreDirectivesRegistry.waitDirectivesReady(this.element, 'core-loading', CoreLoadingComponent), ]); } diff --git a/src/core/directives/collapsible-header.ts b/src/core/directives/collapsible-header.ts index fbd6ff8458d..28d1c7f0971 100644 --- a/src/core/directives/collapsible-header.ts +++ b/src/core/directives/collapsible-header.ts @@ -21,7 +21,7 @@ import { CoreTabsComponent } from '@components/tabs/tabs'; import { CoreSettingsHelper } from '@features/settings/services/settings-helper'; import { ScrollDetail } from '@ionic/core'; import { CoreUtils } from '@services/utils/utils'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreDom } from '@singletons/dom'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreMath } from '@singletons/math'; @@ -294,7 +294,7 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest this.listenEvents(); // Initialize from tabs. - const tabs = CoreComponentsRegistry.resolve(this.page.querySelector('core-tabs-outlet'), CoreTabsOutletComponent); + const tabs = CoreDirectivesRegistry.resolve(this.page.querySelector('core-tabs-outlet'), CoreTabsOutletComponent); if (tabs) { const outlet = tabs.getOutlet(); @@ -424,14 +424,14 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest } // Wait loadings to finish. - await CoreComponentsRegistry.waitComponentsReady(this.page, 'core-loading', CoreLoadingComponent); + await CoreDirectivesRegistry.waitDirectivesReady(this.page, 'core-loading', CoreLoadingComponent); // Wait tabs to be ready. - await CoreComponentsRegistry.waitComponentsReady(this.page, 'core-tabs', CoreTabsComponent); - await CoreComponentsRegistry.waitComponentsReady(this.page, 'core-tabs-outlet', CoreTabsOutletComponent); + await CoreDirectivesRegistry.waitDirectivesReady(this.page, 'core-tabs', CoreTabsComponent); + await CoreDirectivesRegistry.waitDirectivesReady(this.page, 'core-tabs-outlet', CoreTabsOutletComponent); // Wait loadings to finish, inside tabs (if any). - await CoreComponentsRegistry.waitComponentsReady( + await CoreDirectivesRegistry.waitDirectivesReady( this.page, 'core-tab core-loading, ion-router-outlet core-loading', CoreLoadingComponent, @@ -445,7 +445,7 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest * @returns Promise resolved when texts are rendered. */ protected async waitFormatTextsRendered(element: Element): Promise { - await CoreComponentsRegistry.waitComponentsReady(element, 'core-format-text', CoreFormatTextDirective); + await CoreDirectivesRegistry.waitDirectivesReady(element, 'core-format-text', CoreFormatTextDirective); } /** diff --git a/src/core/directives/collapsible-item.ts b/src/core/directives/collapsible-item.ts index 635da33e60c..a845e0a1428 100644 --- a/src/core/directives/collapsible-item.ts +++ b/src/core/directives/collapsible-item.ts @@ -19,7 +19,7 @@ import { CoreSettingsHelper } from '@features/settings/services/settings-helper' import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons'; import { CoreColors } from '@singletons/colors'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreDom } from '@singletons/dom'; import { CoreEventObserver } from '@singletons/events'; import { Subscription } from 'rxjs'; @@ -128,14 +128,14 @@ export class CoreCollapsibleItemDirective implements OnInit, OnDestroy { return; } - await CoreComponentsRegistry.waitComponentsReady(this.page, 'core-loading', CoreLoadingComponent); + await CoreDirectivesRegistry.waitDirectivesReady(this.page, 'core-loading', CoreLoadingComponent); } /** * Wait until all children inside the element are done rendering. */ protected async waitFormatTextsRendered(): Promise { - await CoreComponentsRegistry.waitComponentsReady(this.element, 'core-format-text', CoreFormatTextDirective); + await CoreDirectivesRegistry.waitDirectivesReady(this.element, 'core-format-text', CoreFormatTextDirective); } /** diff --git a/src/core/directives/external-content.ts b/src/core/directives/external-content.ts index 68f0323e443..a4cd4afa73f 100644 --- a/src/core/directives/external-content.ts +++ b/src/core/directives/external-content.ts @@ -23,7 +23,6 @@ import { EventEmitter, OnDestroy, } from '@angular/core'; -import { CoreApp } from '@services/app'; import { CoreFile } from '@services/file'; import { CoreFilepool, CoreFilepoolFileActions, CoreFilepoolFileEventData } from '@services/filepool'; import { CoreSites } from '@services/sites'; @@ -36,6 +35,10 @@ import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreConstants } from '../constants'; import { CoreNetwork } from '@services/network'; import { Translate } from '@singletons'; +import { AsyncDirective } from '@classes/async-directive'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; +import { CorePromisedValue } from '@classes/promised-value'; +import { CorePlatform } from '@services/platform'; /** * Directive to handle external content. @@ -50,7 +53,7 @@ import { Translate } from '@singletons'; @Directive({ selector: '[core-external-content]', }) -export class CoreExternalContentDirective implements AfterViewInit, OnChanges, OnDestroy { +export class CoreExternalContentDirective implements AfterViewInit, OnChanges, OnDestroy, AsyncDirective { @Input() siteId?: string; // Site ID to use. @Input() component?: string; // Component to link the file to. @@ -67,11 +70,14 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O protected logger: CoreLogger; protected initialized = false; protected fileEventObserver?: CoreEventObserver; + protected onReadyPromise = new CorePromisedValue(); constructor(element: ElementRef) { this.element = element.nativeElement; this.logger = CoreLogger.getInstance('CoreExternalContentDirective'); + + CoreDirectivesRegistry.register(this.element, this); } /** @@ -111,7 +117,7 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O newSource.setAttribute('src', url); if (type) { - if (CoreApp.isAndroid() && type == 'video/quicktime') { + if (CorePlatform.isAndroid() && type == 'video/quicktime') { // Fix for VideoJS/Chrome bug https://github.com/videojs/video.js/issues/423 . newSource.setAttribute('type', 'video/mp4'); } else { @@ -157,15 +163,21 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O } else { this.invalid = true; + this.onReadyPromise.resolve(); return; } // Avoid handling data url's. if (url && url.indexOf('data:') === 0) { - this.invalid = true; + if (tagName === 'SOURCE') { + // Restoring original src. + this.addSource(url); + } + this.onLoad.emit(); this.loaded = true; + this.onReadyPromise.resolve(); return; } @@ -182,6 +194,8 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O this.loaded = true; } } + } finally { + this.onReadyPromise.resolve(); } } @@ -266,13 +280,11 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O return; } - let urls = inlineStyles.match(/https?:\/\/[^"') ;]*/g); - if (!urls || !urls.length) { + const urls = CoreUtils.uniqueArray(Array.from(inlineStyles.match(/https?:\/\/[^"') ;]*/g) ?? [])); + if (!urls.length) { return; } - urls = CoreUtils.uniqueArray(urls); // Remove duplicates. - const promises = urls.map(async (url) => { const finalUrl = await CoreFilepool.getSrcByUrl(siteId, url, this.component, this.componentId, 0, true, true); @@ -462,4 +474,11 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O this.fileEventObserver?.off(); } + /** + * @inheritdoc + */ + async ready(): Promise { + return this.onReadyPromise; + } + } diff --git a/src/core/directives/format-text.ts b/src/core/directives/format-text.ts index 7fdd4e165b2..b747497e64a 100644 --- a/src/core/directives/format-text.ts +++ b/src/core/directives/format-text.ts @@ -42,10 +42,10 @@ import { CoreFilter, CoreFilterFilter, CoreFilterFormatTextOptions } from '@feat import { CoreFilterDelegate } from '@features/filter/services/filter-delegate'; import { CoreFilterHelper } from '@features/filter/services/filter-helper'; import { CoreSubscriptions } from '@singletons/subscriptions'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreCollapsibleItemDirective } from './collapsible-item'; import { CoreCancellablePromise } from '@classes/cancellable-promise'; -import { AsyncComponent } from '@classes/async-component'; +import { AsyncDirective } from '@classes/async-directive'; import { CorePath } from '@singletons/path'; import { CoreDom } from '@singletons/dom'; import { CoreEvents } from '@singletons/events'; @@ -67,7 +67,7 @@ import { FrameElementController } from '@classes/element-controllers/FrameElemen @Directive({ selector: 'core-format-text', }) -export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncComponent { +export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncDirective { @ViewChild(CoreCollapsibleItemDirective) collapsible?: CoreCollapsibleItemDirective; @@ -111,7 +111,7 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncCompo protected viewContainerRef: ViewContainerRef, @Optional() @Inject(CORE_REFRESH_CONTEXT) protected refreshContext?: CoreRefreshContext, ) { - CoreComponentsRegistry.register(element.nativeElement, this); + CoreDirectivesRegistry.register(element.nativeElement, this); this.element = element.nativeElement; this.element.classList.add('core-loading'); // Hide contents until they're treated. @@ -149,6 +149,7 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncCompo ngOnDestroy(): void { this.domElementPromise?.cancel(); this.domPromises.forEach((promise) => { promise.cancel();}); + this.elementControllers.forEach(controller => controller.destroy()); } /** @@ -365,6 +366,7 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncCompo // Move the children to the current element to be able to calculate the height. CoreDomUtils.moveChildren(result.div, this.element); + this.elementControllers.forEach(controller => controller.destroy()); this.elementControllers = result.elementControllers; await CoreUtils.nextTick(); @@ -610,12 +612,7 @@ export class CoreFormatTextDirective implements OnChanges, OnDestroy, AsyncCompo return; } - if (element.tagName !== 'BUTTON' && element.tagName !== 'A') { - element.setAttribute('tabindex', '0'); - element.setAttribute('role', 'button'); - } - - CoreDom.onActivate(element, async (event) => { + CoreDom.initializeClickableElementA11y(element, async (event) => { event.preventDefault(); event.stopPropagation(); diff --git a/src/core/directives/link.ts b/src/core/directives/link.ts index 86d2c18efa6..7511193d1fc 100644 --- a/src/core/directives/link.ts +++ b/src/core/directives/link.ts @@ -57,12 +57,7 @@ export class CoreLinkDirective implements OnInit { * Function executed when the component is initialized. */ ngOnInit(): void { - if (this.element.tagName != 'BUTTON' && this.element.tagName != 'A') { - this.element.setAttribute('tabindex', '0'); - this.element.setAttribute('role', 'button'); - } - - CoreDom.onActivate(this.element, (event) => this.performAction(event)); + CoreDom.initializeClickableElementA11y(this.element, (event) => this.performAction(event)); } /** diff --git a/src/core/features/block/classes/base-block-component.ts b/src/core/features/block/classes/base-block-component.ts index 36851922318..c6073cac60f 100644 --- a/src/core/features/block/classes/base-block-component.ts +++ b/src/core/features/block/classes/base-block-component.ts @@ -21,7 +21,7 @@ import { CoreCourseBlock } from '../../course/services/course'; import { Params } from '@angular/router'; import { ContextLevel } from '@/core/constants'; import { CoreNavigationOptions } from '@services/navigator'; -import { AsyncComponent } from '@classes/async-component'; +import { AsyncDirective } from '@classes/async-directive'; import { CorePromisedValue } from '@classes/promised-value'; /** @@ -30,7 +30,7 @@ import { CorePromisedValue } from '@classes/promised-value'; @Component({ template: '', }) -export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockComponent, AsyncComponent { +export abstract class CoreBlockBaseComponent implements OnInit, ICoreBlockComponent, AsyncDirective { @Input() title!: string; // The block title. @Input() block!: CoreCourseBlock; // The block to render. diff --git a/src/core/features/block/components/block/block.scss b/src/core/features/block/components/block/block.scss index 72e49c32fc8..721e6f354e3 100644 --- a/src/core/features/block/components/block/block.scss +++ b/src/core/features/block/components/block/block.scss @@ -4,7 +4,7 @@ flex-direction: column; background: var(--background); - ion-item-divider { + ::ng-deep ion-item-divider { min-height: var(--item-divider-min-height); } diff --git a/src/core/features/block/components/block/block.ts b/src/core/features/block/components/block/block.ts index ae37afab250..b505f69373a 100644 --- a/src/core/features/block/components/block/block.ts +++ b/src/core/features/block/components/block/block.ts @@ -35,6 +35,7 @@ export class CoreBlockComponent implements OnChanges, OnDestroy { @Input() contextLevel!: string; // The context where the block will be used. @Input() instanceId!: number; // The instance ID associated with the context level. @Input() extraData!: Record; // Any extra data to be passed to the block. + @Input() labelledBy?: string; componentClass?: Type; // The class of the component to render. data: Record = {}; // Data to pass to the component. diff --git a/src/core/features/block/components/block/core-block.html b/src/core/features/block/components/block/core-block.html index 663799e01f4..100fe873ad3 100644 --- a/src/core/features/block/components/block/core-block.html +++ b/src/core/features/block/components/block/core-block.html @@ -1,4 +1,5 @@ - + diff --git a/src/core/features/block/components/side-blocks-tour/side-blocks-tour.html b/src/core/features/block/components/side-blocks-tour/side-blocks-tour.html index 00793c3ff85..49c98e1168e 100644 --- a/src/core/features/block/components/side-blocks-tour/side-blocks-tour.html +++ b/src/core/features/block/components/side-blocks-tour/side-blocks-tour.html @@ -1,4 +1,4 @@ -

{{ 'core.block.tour_navigation_dashboard_title' | translate }}

+

{{ 'core.block.tour_navigation_dashboard_title' | translate }}

{{ 'core.block.tour_navigation_dashboard_content' | translate }}

{{ 'core.endonesteptour' | translate }} diff --git a/src/core/features/compile/services/compile.ts b/src/core/features/compile/services/compile.ts index 51fdcea13bd..0ba9a739c20 100644 --- a/src/core/features/compile/services/compile.ts +++ b/src/core/features/compile/services/compile.ts @@ -79,6 +79,7 @@ import { Md5 } from 'ts-md5/dist/md5'; import { CoreSyncBaseProvider } from '@classes/base-sync'; import { CoreArray } from '@singletons/array'; import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreDom } from '@singletons/dom'; import { CoreForms } from '@singletons/form'; import { CoreText } from '@singletons/text'; @@ -350,6 +351,7 @@ export class CoreCompileProvider { instance['CoreSyncBaseProvider'] = CoreSyncBaseProvider; instance['CoreArray'] = CoreArray; instance['CoreComponentsRegistry'] = CoreComponentsRegistry; + instance['CoreDirectivesRegistry'] = CoreDirectivesRegistry; instance['CoreNetwork'] = CoreNetwork.instance; instance['CorePlatform'] = CorePlatform.instance; instance['CoreDom'] = CoreDom; diff --git a/src/core/features/contentlinks/components/choose-site-modal/choose-site-modal.html b/src/core/features/contentlinks/components/choose-site-modal/choose-site-modal.html index f166b4111a5..efb48a855ad 100644 --- a/src/core/features/contentlinks/components/choose-site-modal/choose-site-modal.html +++ b/src/core/features/contentlinks/components/choose-site-modal/choose-site-modal.html @@ -1,7 +1,7 @@ -

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

+

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

@@ -15,7 +15,7 @@

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

-

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

+

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

{{ url }}

diff --git a/src/core/features/course/components/course-format/course-format.html b/src/core/features/course/components/course-format/course-format.html index 7db7e5d2d28..16e6aca9e11 100644 --- a/src/core/features/course/components/course-format/course-format.html +++ b/src/core/features/course/components/course-format/course-format.html @@ -65,10 +65,11 @@
+ class="core-course-module-list-wrapper" [id]="section.id" + [attr.aria-labelledby]="section.name ? 'core-section-name-' + section.id : null"> -

+

diff --git a/src/core/features/course/components/course-index-tour/course-index-tour.html b/src/core/features/course/components/course-index-tour/course-index-tour.html index dbf65101be3..3b70e424497 100644 --- a/src/core/features/course/components/course-index-tour/course-index-tour.html +++ b/src/core/features/course/components/course-index-tour/course-index-tour.html @@ -1,4 +1,4 @@ -

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

+

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

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

{{ 'core.endonesteptour' | translate }} diff --git a/src/core/features/course/components/course-index/course-index.html b/src/core/features/course/components/course-index/course-index.html index dd97d4628fc..b833477fd9a 100644 --- a/src/core/features/course/components/course-index/course-index.html +++ b/src/core/features/course/components/course-index/course-index.html @@ -1,7 +1,7 @@ -

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

+

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

@@ -32,7 +32,7 @@

class="expandable-status-icon" (ariaButtonClick)="toggleExpand($event, section)" [attr.aria-label]="(section.expanded ? 'core.collapse' : 'core.expand') | translate" [attr.aria-expanded]="section.expanded" [attr.aria-controls]="'core-course-index-section-' + section.id" - [class.expandable-status-icon-expanded]="section.expanded" tabindex="0"> + [class.expandable-status-icon-expanded]="section.expanded"> diff --git a/src/core/features/course/components/module/core-course-module.html b/src/core/features/course/components/module/core-course-module.html index 5206b6397dd..3d8d08d2da1 100644 --- a/src/core/features/course/components/module/core-course-module.html +++ b/src/core/features/course/components/module/core-course-module.html @@ -1,10 +1,11 @@ - + - + }"> diff --git a/src/core/features/course/pages/index/index.html b/src/core/features/course/pages/index/index.html index 35099223102..6edf868b40b 100644 --- a/src/core/features/course/pages/index/index.html +++ b/src/core/features/course/pages/index/index.html @@ -13,6 +13,8 @@

+ + diff --git a/src/core/features/course/tests/behat/basic_usage.feature b/src/core/features/course/tests/behat/basic_usage.feature index a569847a975..392feb76fe4 100755 --- a/src/core/features/course/tests/behat/basic_usage.feature +++ b/src/core/features/course/tests/behat/basic_usage.feature @@ -89,6 +89,10 @@ Feature: Test basic usage of one course in app And I should find "Test scorm name" in the app And I should find "Test workshop name" in the app + When I set "page-core-course-index .core-course-thumb" styles to "--course-color" "lightblue" + And I set "page-core-course-index .core-course-thumb" styles to "--course-color-tint" "white" + # Then the UI should match the snapshot + When I press "Choice course 1" in the app Then the header should be "Choice course 1" in the app diff --git a/src/core/features/course/tests/behat/snapshots/test-basic-usage-of-one-course-in-app-view-course-contents_39.png b/src/core/features/course/tests/behat/snapshots/test-basic-usage-of-one-course-in-app-view-course-contents_39.png new file mode 100644 index 00000000000..e4060a65d7e Binary files /dev/null and b/src/core/features/course/tests/behat/snapshots/test-basic-usage-of-one-course-in-app-view-course-contents_39.png differ diff --git a/src/core/features/courses/components/course-list-item/core-courses-course-list-item.html b/src/core/features/courses/components/course-list-item/core-courses-course-list-item.html index eef29500ebe..94045bb3b22 100644 --- a/src/core/features/courses/components/course-list-item/core-courses-course-list-item.html +++ b/src/core/features/courses/components/course-list-item/core-courses-course-list-item.html @@ -1,8 +1,8 @@ + [class.core-course-list-card]="layout == 'card' || layout == 'summarycard'" [class.item-dimmed]="course.hidden" (click)="openCourse()" + button [attr.aria-label]="course.displayname || course.fullname"> -
+
@@ -27,7 +27,7 @@
- + - - - -

{{ prefetch.statusTranslatable | translate }}

-
-
- - - -

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

-
-
- - - -

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

-
-
- - - -

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

-
-
- - - -

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

-
-
- - - -

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

-
-
+ + + + + +

{{ prefetch.statusTranslatable | translate }}

+
+
+ + + +

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

+
+
+ + + +

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

+
+
+ + + +

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

+
+
+ + + +

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

+
+
+ + + +

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

+
+
+
diff --git a/src/core/features/courses/components/self-enrol-password/self-enrol-password.html b/src/core/features/courses/components/self-enrol-password/self-enrol-password.html index 45b60f5e3c6..58a856f2496 100644 --- a/src/core/features/courses/components/self-enrol-password/self-enrol-password.html +++ b/src/core/features/courses/components/self-enrol-password/self-enrol-password.html @@ -1,7 +1,7 @@ -

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

+

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

diff --git a/src/core/features/courses/pages/categories/categories.html b/src/core/features/courses/pages/categories/categories.html index da1c214e4f0..c4c600bc10a 100644 --- a/src/core/features/courses/pages/categories/categories.html +++ b/src/core/features/courses/pages/categories/categories.html @@ -51,10 +51,10 @@

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

detail="true"> -

+

-

+

diff --git a/src/core/features/courses/pages/my/my.html b/src/core/features/courses/pages/my/my.html index 1f307e4b7f0..83cc1fd54d4 100644 --- a/src/core/features/courses/pages/my/my.html +++ b/src/core/features/courses/pages/my/my.html @@ -22,7 +22,7 @@

-

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

+

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

@@ -46,7 +46,8 @@

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

- +
- @@ -25,69 +25,70 @@ @@ -105,20 +106,20 @@ -
diff --git a/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss b/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss index 606f54c1e22..a335fddcb5e 100644 --- a/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss +++ b/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss @@ -149,8 +149,9 @@ background-color: var(--toobar-background); } - &.toolbar-arrow-hidden { - opacity: 0; + &[disabled], + &:disabled { + opacity: .5; } } } diff --git a/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts index e2d28827ec3..5b32415bbbc 100644 --- a/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts +++ b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts @@ -36,7 +36,7 @@ import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons'; import { CoreEventFormActionData, CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreEditorOffline } from '../../services/editor-offline'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreLoadingComponent } from '@components/loading/loading'; import { CoreScreen } from '@services/screen'; import { CoreCancellablePromise } from '@classes/cancellable-promise'; @@ -304,7 +304,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit, return; } - await CoreComponentsRegistry.waitComponentsReady(page, 'core-loading', CoreLoadingComponent); + await CoreDirectivesRegistry.waitDirectivesReady(page, 'core-loading', CoreLoadingComponent); } /** @@ -391,7 +391,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit, this.stopBubble(event); - const move = event.key == 'ArrowLeft' ? -1 : +1; + const move = event.key === 'ArrowLeft' ? -1 : +1; const cursor = this.getCurrentCursorPosition(this.editorElement); this.setCurrentCursorPosition(this.editorElement, cursor + move); @@ -754,7 +754,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit, * @returns Wether space or enter have been pressed. */ protected isValidKeyboardKey(event: KeyboardEvent): boolean { - return event.key == ' ' || event.key == 'Enter'; + return event.key === ' ' || event.key === 'Enter'; } /** diff --git a/src/core/features/emulator/components/capture-media/capture-media.html b/src/core/features/emulator/components/capture-media/capture-media.html index f85e62a51a6..a33ca161c25 100644 --- a/src/core/features/emulator/components/capture-media/capture-media.html +++ b/src/core/features/emulator/components/capture-media/capture-media.html @@ -14,7 +14,7 @@

{{ title | translate }}

- +

{{ 'core.capturedimage' | translate }} - - -
- - - - - - - - - - - -
@@ -48,8 +33,7 @@

{{ title | translate }}

- - + diff --git a/src/core/features/emulator/components/capture-media/capture-media.scss b/src/core/features/emulator/components/capture-media/capture-media.scss index 57d6b03518b..f128e962979 100644 --- a/src/core/features/emulator/components/capture-media/capture-media.scss +++ b/src/core/features/emulator/components/capture-media/capture-media.scss @@ -21,30 +21,6 @@ width: 100%; } - .button { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - margin: auto; - height: 120px; - width: 120px; - - .icon { - font-size: 120px; - width: auto; - } - } - - audio { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - margin: auto; - } } video, img { diff --git a/src/core/features/emulator/components/capture-media/capture-media.ts b/src/core/features/emulator/components/capture-media/capture-media.ts index 520d280998a..34f9390e9c5 100644 --- a/src/core/features/emulator/components/capture-media/capture-media.ts +++ b/src/core/features/emulator/components/capture-media/capture-media.ts @@ -13,23 +13,20 @@ // limitations under the License. import { Component, OnInit, OnDestroy, ViewChild, ElementRef, ChangeDetectorRef, Input } from '@angular/core'; -import { MediaObject } from '@ionic-native/media/ngx'; -import { FileEntry } from '@ionic-native/file/ngx'; import { MediaFile } from '@ionic-native/media-capture/ngx'; import { CoreFile, CoreFileProvider } from '@services/file'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreMimetypeUtils } from '@services/utils/mimetype'; import { CoreTimeUtils } from '@services/utils/time'; -import { ModalController, Media, Translate } from '@singletons'; +import { ModalController, Translate } from '@singletons'; import { CoreError } from '@classes/errors/error'; import { CoreCaptureError } from '@classes/errors/captureerror'; import { CoreCanceledError } from '@classes/errors/cancelederror'; import { CorePath } from '@singletons/path'; -import { CorePlatform } from '@services/platform'; /** - * Page to capture media in browser, or to capture audio in mobile devices. + * Page to capture media in browser. */ @Component({ selector: 'core-emulator-capture-media', @@ -38,7 +35,7 @@ import { CorePlatform } from '@services/platform'; }) export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { - @Input() type?: 'audio' | 'video' | 'image' | 'captureimage'; + @Input() type?: 'video' | 'image' | 'captureimage'; @Input() maxTime?: number; // Max time to capture. @Input() facingMode?: string; // Camera facing mode. @Input() mimetype?: string; @@ -50,30 +47,20 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { @ViewChild('previewVideo') previewVideo?: ElementRef; @ViewChild('imgCanvas') imgCanvas?: ElementRef; @ViewChild('previewImage') previewImage?: ElementRef; - @ViewChild('streamAudio') streamAudio?: ElementRef; - @ViewChild('previewAudio') previewAudio?: ElementRef; title?: string; // The title of the page. - isAudio?: boolean; // Whether it should capture audio. isVideo?: boolean; // Whether it should capture video. isImage?: boolean; // Whether it should capture image. readyToCapture?: boolean; // Whether it's ready to capture. hasCaptured?: boolean; // Whether it has captured something. isCapturing?: boolean; // Whether it's capturing. resetChrono?: boolean; // Boolean to reset the chrono. - isCordovaAudioCapture?: boolean; // Whether it's capturing audio using Cordova plugin. protected isCaptureImage?: boolean; // To identify if it's capturing an image using media capture plugin (instead of camera). - protected mediaRecorder?: MediaRecorder; // To record video/audio. - protected previewMedia?: HTMLAudioElement | HTMLVideoElement; // The element to preview the audio/video captured. + protected mediaRecorder?: MediaRecorder; // To record video. + protected previewMedia?: HTMLVideoElement; // The element to preview the video captured. protected mediaBlob?: Blob; // A Blob where the captured data is stored. protected localMediaStream?: MediaStream; - protected audioDrawer?: {start: () => void; stop: () => void }; // To start/stop the display of audio sound. - - // Variables for Cordova Media capture. - protected mediaFile?: MediaObject; - protected filePath?: string; - protected fileEntry?: FileEntry; constructor( protected changeDetectorRef: ChangeDetectorRef, @@ -84,12 +71,7 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { */ ngOnInit(): void { this.initVariables(); - - if (this.isCordovaAudioCapture) { - this.initCordovaMediaPlugin(); - } else { - this.initHtmlCapture(); - } + this.initHtmlCapture(); } /** @@ -108,71 +90,10 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { if (this.type == 'video') { this.isVideo = true; this.title = 'core.capturevideo'; - } else if (this.type == 'audio') { - this.isAudio = true; - this.title = 'core.captureaudio'; } else if (this.type == 'image') { this.isImage = true; this.title = 'core.captureimage'; } - - this.isCordovaAudioCapture = CorePlatform.isMobile() && this.isAudio; - - if (this.isCordovaAudioCapture) { - this.extension = CorePlatform.is('ios') ? 'wav' : 'aac'; - this.returnDataUrl = false; - } - } - - /** - * Init recording with Cordova media plugin. - * - * @returns Promise resolved when ready. - */ - protected async initCordovaMediaPlugin(): Promise { - - try { - await this.createFileAndMediaInstance(); - - this.readyToCapture = true; - this.previewMedia = this.previewAudio?.nativeElement; - } catch (error) { - this.dismissWithError(-1, error.message || error); - } - } - - /** - * Create a file entry and the cordova media instance. - */ - protected async createFileAndMediaInstance(): Promise { - this.filePath = this.getFilePath(); - - // First create the file. - this.fileEntry = await CoreFile.createFile(this.filePath); - - // Now create the media instance. - let absolutePath = CorePath.concatenatePaths(CoreFile.getBasePathInstant(), this.filePath); - - if (CorePlatform.is('ios')) { - // In iOS we need to remove the file:// part. - absolutePath = absolutePath.replace(/^file:\/\//, ''); - } - - this.mediaFile = Media.create(absolutePath); - } - - /** - * Reset the file and the cordova media instance. - */ - protected async resetCordovaMediaCapture(): Promise { - if (this.filePath) { - // Remove old file, don't block the user for this. - CoreFile.removeFile(this.filePath); - } - - this.mediaFile?.release(); - - await this.createFileAndMediaInstance(); } /** @@ -183,8 +104,7 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { */ protected async initHtmlCapture(): Promise { const constraints = { - video: this.isAudio ? false : { facingMode: this.facingMode }, - audio: !this.isImage, + video: { facingMode: this.facingMode }, }; try { @@ -196,22 +116,18 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { if (!this.isImage) { if (this.isVideo) { this.previewMedia = this.previewVideo?.nativeElement; - } else { - this.previewMedia = this.previewAudio?.nativeElement; - this.initAudioDrawer(this.localMediaStream); - this.audioDrawer?.start(); } this.mediaRecorder = new MediaRecorder(this.localMediaStream, { mimeType: this.mimetype }); - // When video or audio is recorded, add it to the list of chunks. + // When video is recorded, add it to the list of chunks. this.mediaRecorder.ondataavailable = (e): void => { if (e.data.size > 0) { chunks.push(e.data); } }; - // When recording stops, create a Blob element with the recording and set it to the video or audio. + // When recording stops, create a Blob element with the recording and set it to the video. this.mediaRecorder.onstop = (): void => { this.mediaBlob = new Blob(chunks); chunks = []; @@ -267,91 +183,6 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { } } - /** - * Initialize the audio drawer. This code has been extracted from MDN's example on MediaStream Recording: - * https://github.com/mdn/web-dictaphone - * - * @param stream Stream returned by getUserMedia. - */ - protected initAudioDrawer(stream: MediaStream): void { - if (!this.streamAudio) { - return; - } - - let skip = true; - let running = false; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const audioCtx = new (window.AudioContext || ( window).webkitAudioContext)(); - const canvasCtx = this.streamAudio.nativeElement.getContext('2d'); - const source = audioCtx.createMediaStreamSource(stream); - const analyser = audioCtx.createAnalyser(); - const bufferLength = analyser.frequencyBinCount; - const dataArray = new Uint8Array(bufferLength); - const width = this.streamAudio.nativeElement.width; - const height = this.streamAudio.nativeElement.height; - const drawAudio = (): void => { - if (!running) { - return; - } - - // Update the draw every animation frame. - requestAnimationFrame(drawAudio); - - // Skip half of the frames to improve performance, shouldn't affect the smoothness. - skip = !skip; - if (skip) { - return; - } - - const sliceWidth = width / bufferLength; - let x = 0; - - analyser.getByteTimeDomainData(dataArray); - - canvasCtx.fillStyle = 'rgb(200, 200, 200)'; - canvasCtx.fillRect(0, 0, width, height); - - canvasCtx.lineWidth = 1; - canvasCtx.strokeStyle = 'rgb(0, 0, 0)'; - - canvasCtx.beginPath(); - - for (let i = 0; i < bufferLength; i++) { - const v = dataArray[i] / 128.0; - const y = v * height / 2; - - if (i === 0) { - canvasCtx.moveTo(x, y); - } else { - canvasCtx.lineTo(x, y); - } - - x += sliceWidth; - } - - canvasCtx.lineTo(width, height / 2); - canvasCtx.stroke(); - }; - - analyser.fftSize = 2048; - source.connect(analyser); - - this.audioDrawer = { - start: (): void => { - if (running) { - return; - } - - running = true; - drawAudio(); - }, - stop: (): void => { - running = false; - }, - }; - } - /** * Main action clicked: record or stop recording. */ @@ -369,15 +200,7 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { this.isCapturing = true; this.resetChrono = false; - if (this.isCordovaAudioCapture) { - this.mediaFile?.startRecord(); - if (this.previewMedia) { - this.previewMedia.src = ''; - } - } else { - this.mediaRecorder?.start(); - } - + this.mediaRecorder?.start(); this.changeDetectorRef.detectChanges(); } else { if (!this.imgCanvas) { @@ -419,11 +242,6 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { // Send a "cancelled" error like the Cordova plugin does. this.dismissWithCanceledError('Canceled.', 'Camera cancelled'); - - if (this.isCordovaAudioCapture && this.filePath) { - // Delete the tmp file. - CoreFile.removeFile(this.filePath); - } } /** @@ -432,11 +250,6 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { async discard(): Promise { this.previewMedia?.pause(); this.streamVideo?.nativeElement.play(); - this.audioDrawer?.start(); - - if (this.isCordovaAudioCapture) { - await this.resetCordovaMediaCapture(); - } this.hasCaptured = false; this.isCapturing = false; @@ -492,30 +305,23 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { return; } - if (!this.mediaBlob && !this.isCordovaAudioCapture) { + if (!this.mediaBlob) { // Shouldn't happen. CoreDomUtils.showErrorModal('Please capture the media first.'); return; } - let fileEntry = this.fileEntry; const loadingModal = await CoreDomUtils.showModalLoading(); try { - if (!this.isCordovaAudioCapture) { - // Capturing in browser. Write the blob in a file. - if (!this.mediaBlob) { - // Shouldn't happen. - throw new Error('Please capture the media first.'); - } - - fileEntry = await CoreFile.writeFile(this.getFilePath(), this.mediaBlob); + // Capturing in browser. Write the blob in a file. + if (!this.mediaBlob) { + // Shouldn't happen. + throw new Error('Please capture the media first.'); } - if (!fileEntry) { - throw new CoreError('File not found.'); - } + const fileEntry = await CoreFile.writeFile(this.getFilePath(), this.mediaBlob); if (this.isImage && !this.isCaptureImage) { this.dismissWithData(fileEntry.toURL()); @@ -560,30 +366,20 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { } /** - * Stop capturing. Only for video and audio. + * Stop capturing. Only for video. */ stopCapturing(): void { this.isCapturing = false; this.hasCaptured = true; - if (this.isCordovaAudioCapture) { - this.mediaFile?.stopRecord(); - if (this.previewMedia && this.fileEntry) { - this.previewMedia.src = CoreFile.convertFileSrc(this.fileEntry.toURL()); - } - } else { - this.streamVideo && this.streamVideo.nativeElement.pause(); - this.audioDrawer && this.audioDrawer.stop(); - this.mediaRecorder && this.mediaRecorder.stop(); - } + this.streamVideo && this.streamVideo.nativeElement.pause(); + this.mediaRecorder && this.mediaRecorder.stop(); } /** * Page destroyed. */ ngOnDestroy(): void { - this.mediaFile?.release(); - if (this.localMediaStream) { const tracks = this.localMediaStream.getTracks(); tracks.forEach((track) => { @@ -592,14 +388,13 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { } this.streamVideo?.nativeElement.pause(); this.previewMedia?.pause(); - this.audioDrawer?.stop(); delete this.mediaBlob; } } export type CaptureMediaComponentInputs = { - type: 'audio' | 'video' | 'image' | 'captureimage'; + type: 'video' | 'image' | 'captureimage'; maxTime?: number; // Max time to capture. facingMode?: string; // Camera facing mode. mimetype?: string; diff --git a/src/core/features/emulator/emulator.module.prod.ts b/src/core/features/emulator/emulator.module.prod.ts new file mode 100644 index 00000000000..82336ab3409 --- /dev/null +++ b/src/core/features/emulator/emulator.module.prod.ts @@ -0,0 +1,21 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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'; + +/** + * Stub used in production to avoid including emulator code in production bundles. + */ +@NgModule({}) +export class CoreEmulatorModule {} diff --git a/src/core/features/emulator/services/capture-helper.ts b/src/core/features/emulator/services/capture-helper.ts index 17432f9129b..54885b62208 100644 --- a/src/core/features/emulator/services/capture-helper.ts +++ b/src/core/features/emulator/services/capture-helper.ts @@ -14,23 +14,18 @@ import { Injectable } from '@angular/core'; import { CameraOptions } from '@ionic-native/camera/ngx'; -import { CaptureAudioOptions, CaptureImageOptions, CaptureVideoOptions, MediaFile } from '@ionic-native/media-capture/ngx'; +import { CaptureImageOptions, CaptureVideoOptions, MediaFile } from '@ionic-native/media-capture/ngx'; import { CoreMimetypeUtils } from '@services/utils/mimetype'; import { makeSingleton, ModalController } from '@singletons'; import { CaptureMediaComponentInputs, CoreEmulatorCaptureMediaComponent } from '../components/capture-media/capture-media'; /** - * Helper service with some features to capture media (image, audio, video). + * Helper service with some features to capture media (image, video). */ @Injectable({ providedIn: 'root' }) export class CoreEmulatorCaptureHelperProvider { - protected possibleAudioMimeTypes = { - 'audio/webm': 'weba', - 'audio/ogg': 'ogg', - }; - protected possibleVideoMimeTypes = { 'video/webm;codecs=vp9': 'webm', 'video/webm;codecs=vp8': 'webm', @@ -38,22 +33,20 @@ export class CoreEmulatorCaptureHelperProvider { }; videoMimeType?: string; - audioMimeType?: string; /** - * Capture media (image, audio, video). + * Capture media (image, video). * - * @param type Type of media: image, audio, video. + * @param type Type of media: image, video. * @param options Optional options. * @returns Promise resolved when captured, rejected if error. */ captureMedia(type: 'image', options?: MockCameraOptions): Promise; captureMedia(type: 'captureimage', options?: MockCaptureImageOptions): Promise; - captureMedia(type: 'audio', options?: MockCaptureAudioOptions): Promise; captureMedia(type: 'video', options?: MockCaptureVideoOptions): Promise; async captureMedia( - type: 'image' | 'captureimage' | 'audio' | 'video', - options?: MockCameraOptions | MockCaptureImageOptions | MockCaptureAudioOptions | MockCaptureVideoOptions, + type: 'image' | 'captureimage' | 'video', + options?: MockCameraOptions | MockCaptureImageOptions | MockCaptureVideoOptions, ): Promise { options = options || {}; @@ -67,10 +60,6 @@ export class CoreEmulatorCaptureHelperProvider { const mimeAndExt = this.getMimeTypeAndExtension(type, options.mimetypes); params.mimetype = mimeAndExt.mimetype; params.extension = mimeAndExt.extension; - } else if (type == 'audio') { - const mimeAndExt = this.getMimeTypeAndExtension(type, options.mimetypes); - params.mimetype = mimeAndExt.mimetype; - params.extension = mimeAndExt.extension; } else if (type == 'image') { if ('sourceType' in options && options.sourceType !== undefined && options.sourceType != 1) { return Promise.reject('This source type is not supported in browser.'); @@ -121,7 +110,7 @@ export class CoreEmulatorCaptureHelperProvider { /** * Get the mimetype and extension to capture media. * - * @param type Type of media: image, audio, video. + * @param type Type of media: image, video. * @param mimetypes List of supported mimetypes. If undefined, all mimetypes supported. * @returns An object with mimetype and extension to use. */ @@ -148,10 +137,6 @@ export class CoreEmulatorCaptureHelperProvider { // No mimetype found, use default extension. result.mimetype = this.videoMimeType; result.extension = this.possibleVideoMimeTypes[result.mimetype!]; - } else if (type == 'audio') { - // No mimetype found, use default extension. - result.mimetype = this.audioMimeType; - result.extension = this.possibleAudioMimeTypes[result.mimetype!]; } return result; @@ -170,20 +155,12 @@ export class CoreEmulatorCaptureHelperProvider { * Initialize the mimetypes to use when capturing. */ protected initMimeTypes(): void { - // Determine video and audio mimetype to use. for (const mimeType in this.possibleVideoMimeTypes) { if (window.MediaRecorder.isTypeSupported(mimeType)) { this.videoMimeType = mimeType; break; } } - - for (const mimeType in this.possibleAudioMimeTypes) { - if (window.MediaRecorder.isTypeSupported(mimeType)) { - this.audioMimeType = mimeType; - break; - } - } } /** @@ -209,9 +186,6 @@ export interface MockCameraOptions extends CameraOptions { export interface MockCaptureImageOptions extends CaptureImageOptions { mimetypes?: string[]; // Allowed mimetypes. } -export interface MockCaptureAudioOptions extends CaptureAudioOptions { - mimetypes?: string[]; // Allowed mimetypes. -} export interface MockCaptureVideoOptions extends CaptureVideoOptions { mimetypes?: string[]; // Allowed mimetypes. } diff --git a/src/core/features/emulator/services/media-capture.ts b/src/core/features/emulator/services/media-capture.ts index c9f3e02a056..8c8985fbd87 100644 --- a/src/core/features/emulator/services/media-capture.ts +++ b/src/core/features/emulator/services/media-capture.ts @@ -15,7 +15,6 @@ import { Injectable } from '@angular/core'; import { MediaCapture, - CaptureAudioOptions, CaptureImageOptions, CaptureVideoOptions, MediaFile, @@ -29,16 +28,6 @@ import { CoreEmulatorCaptureHelper } from './capture-helper'; @Injectable() export class MediaCaptureMock extends MediaCapture { - /** - * Start the audio recorder application and return information about captured audio clip files. - * - * @param options Options. - * @returns Promise resolved when captured. - */ - captureAudio(options: CaptureAudioOptions): Promise { - return CoreEmulatorCaptureHelper.captureMedia('audio', options); - } - /** * Start the camera application and return information about captured image files. * diff --git a/src/core/features/features.module.ts b/src/core/features/features.module.ts index 7b5d7e56908..6c4e3240e58 100644 --- a/src/core/features/features.module.ts +++ b/src/core/features/features.module.ts @@ -43,6 +43,7 @@ import { CoreUserModule } from './user/user.module'; import { CoreUserToursModule } from './usertours/user-tours.module'; import { CoreViewerModule } from './viewer/viewer.module'; import { CoreXAPIModule } from './xapi/xapi.module'; +import { CoreReportBuilderModule } from './reportbuilder/reportbuilder.module'; @NgModule({ imports: [ @@ -74,6 +75,7 @@ import { CoreXAPIModule } from './xapi/xapi.module'; CoreUserToursModule, CoreViewerModule, CoreXAPIModule, + CoreReportBuilderModule, // Import last to allow overrides. CoreEmulatorModule, diff --git a/src/core/features/fileuploader/components/audio-histogram/audio-histogram.html b/src/core/features/fileuploader/components/audio-histogram/audio-histogram.html new file mode 100644 index 00000000000..c2e2ad0b69e --- /dev/null +++ b/src/core/features/fileuploader/components/audio-histogram/audio-histogram.html @@ -0,0 +1 @@ + diff --git a/src/core/features/fileuploader/components/audio-histogram/audio-histogram.scss b/src/core/features/fileuploader/components/audio-histogram/audio-histogram.scss new file mode 100644 index 00000000000..d484435f910 --- /dev/null +++ b/src/core/features/fileuploader/components/audio-histogram/audio-histogram.scss @@ -0,0 +1,15 @@ +:host { + --background-color: var(--ion-background-color, #fff); + --bars-color: var(--ion-text-color, #000); + + position: relative; + + canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + +} diff --git a/src/core/features/fileuploader/components/audio-histogram/audio-histogram.ts b/src/core/features/fileuploader/components/audio-histogram/audio-histogram.ts new file mode 100644 index 00000000000..671680473a5 --- /dev/null +++ b/src/core/features/fileuploader/components/audio-histogram/audio-histogram.ts @@ -0,0 +1,175 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, Input, OnDestroy, ViewChild } from '@angular/core'; + +@Component({ + selector: 'core-audio-histogram', + templateUrl: 'audio-histogram.html', + styleUrls: ['audio-histogram.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CoreFileUploaderAudioHistogramComponent implements AfterViewInit, OnDestroy { + + private static readonly BARS_WIDTH = 2; + private static readonly BARS_MIN_HEIGHT = 4; + private static readonly BARS_GUTTER = 4; + + @Input() analyser!: AnalyserNode; + @Input() paused?: boolean; + @ViewChild('canvas') canvasRef?: ElementRef; + + private element: HTMLElement; + private canvas?: HTMLCanvasElement; + private context?: CanvasRenderingContext2D | null; + private buffer?: Uint8Array; + private destroyed = false; + + constructor({ nativeElement }: ElementRef) { + this.element = nativeElement; + } + + /** + * @inheritdoc + */ + ngAfterViewInit(): void { + this.canvas = this.canvasRef?.nativeElement; + this.context = this.canvas?.getContext('2d'); + this.buffer = new Uint8Array(this.analyser.fftSize); + + this.updateCanvas(this.element.clientWidth, this.element.clientHeight); + this.draw(); + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.destroyed = true; + } + + /** + * Draw histogram. + */ + private draw(): void { + if (this.destroyed || !this.canvas || !this.context || !this.buffer) { + return; + } + + if (this.canvas.width !== this.element.clientWidth || this.canvas.height !== this.element.clientHeight) { + this.updateCanvas(this.element.clientWidth, this.element.clientHeight); + } + + const width = this.canvas.width; + const height = this.canvas.height; + const barsWidth = CoreFileUploaderAudioHistogramComponent.BARS_WIDTH; + const barsGutter = CoreFileUploaderAudioHistogramComponent.BARS_GUTTER; + const chunkLength = Math.floor(this.buffer.length / ((width - barsWidth - 1) / (barsWidth + barsGutter))); + const barsCount = Math.floor(this.buffer.length / chunkLength); + + // Reset canvas. + this.context.fillRect(0, 0, width, height); + + // Draw bars. + const startX = Math.floor((width - (barsWidth + barsGutter)*barsCount - barsWidth - 1)/2); + + this.context.beginPath(); + this.paused ? this.drawPausedBars(startX) : this.drawActiveBars(startX); + this.context.stroke(); + + // Schedule next frame. + requestAnimationFrame(() => this.draw()); + } + + /** + * Draws bars on the histogram when it is active. + * + * @param x Starting x position. + */ + private drawActiveBars(x: number): void { + if (!this.canvas || !this.context || !this.buffer) { + return; + } + + let bufferX = 0; + const width = this.canvas.width; + const halfHeight = this.canvas.height / 2; + const halfMinHeight = CoreFileUploaderAudioHistogramComponent.BARS_MIN_HEIGHT / 2; + const barsWidth = CoreFileUploaderAudioHistogramComponent.BARS_WIDTH; + const barsGutter = CoreFileUploaderAudioHistogramComponent.BARS_GUTTER; + const bufferLength = this.buffer.length; + const barsBufferWidth = Math.floor(bufferLength / ((width - barsWidth - 1) / (barsWidth + barsGutter))); + + this.analyser.getByteTimeDomainData(this.buffer); + + while (bufferX < bufferLength) { + let maxLevel = halfMinHeight; + + do { + maxLevel = Math.max(maxLevel, halfHeight * (1 - (this.buffer[bufferX] / 128))); + bufferX++; + } while (bufferX % barsBufferWidth !== 0 && bufferX < bufferLength); + + this.context.moveTo(x, halfHeight - maxLevel); + this.context.lineTo(x, halfHeight + maxLevel); + + x += barsWidth + barsGutter; + } + } + + /** + * Draws bars on the histogram when it is paused. + * + * @param x Starting x position. + */ + private drawPausedBars(x: number): void { + if (!this.canvas || !this.context) { + return; + } + + const width = this.canvas.width; + const halfHeight = this.canvas.height / 2; + const halfMinHeight = CoreFileUploaderAudioHistogramComponent.BARS_MIN_HEIGHT / 2; + const xStep = CoreFileUploaderAudioHistogramComponent.BARS_WIDTH + CoreFileUploaderAudioHistogramComponent.BARS_GUTTER; + + while (x < width) { + this.context.moveTo(x, halfHeight - halfMinHeight); + this.context.lineTo(x, halfHeight + halfMinHeight); + + x += xStep; + } + } + + /** + * Set canvas element dimensions and configure styles. + * + * @param width Canvas width. + * @param height Canvas height. + */ + private updateCanvas(width: number, height: number): void { + if (!this.canvas || !this.context) { + return; + } + + const styles = getComputedStyle(this.element); + + this.canvas.width = width; + this.canvas.height = height; + this.context.fillStyle = styles.getPropertyValue('--background-color'); + this.context.lineCap = 'round'; + this.context.lineWidth = CoreFileUploaderAudioHistogramComponent.BARS_WIDTH; + this.context.strokeStyle = styles.getPropertyValue('--bars-color'); + } + +} diff --git a/src/core/features/fileuploader/components/audio-recorder/audio-recorder.component.ts b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.component.ts new file mode 100644 index 00000000000..85d01340ce6 --- /dev/null +++ b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.component.ts @@ -0,0 +1,340 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { ChangeDetectionStrategy, Component, ElementRef, OnDestroy } from '@angular/core'; +import { CoreModalComponent } from '@classes/modal-component'; +import { CorePlatform } from '@services/platform'; +import { Diagnostic, DomSanitizer, Translate } from '@singletons'; +import { BehaviorSubject, combineLatest, Observable, OperatorFunction } from 'rxjs'; +import { Mp3MediaRecorder } from 'mp3-mediarecorder'; +import { map, shareReplay, tap } from 'rxjs/operators'; +import { initAudioEncoderMessage } from '@features/fileuploader/utils/worker-messages'; +import { SafeUrl } from '@angular/platform-browser'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CAPTURE_ERROR_NO_MEDIA_FILES, CoreCaptureError } from '@classes/errors/captureerror'; +import { CoreFileUploaderAudioRecording } from '@features/fileuploader/services/fileuploader'; +import { CoreFile, CoreFileProvider } from '@services/file'; +import { CorePath } from '@singletons/path'; + +@Component({ + selector: 'core-fileuploader-audio-recorder', + styleUrls: ['./audio-recorder.scss'], + templateUrl: 'audio-recorder.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CoreFileUploaderAudioRecorderComponent extends CoreModalComponent + implements OnDestroy { + + recordingUrl$: Observable; + histogramAnalyzer$: Observable; + status$: Observable<'recording-ongoing' | 'recording-paused' | 'done' | 'empty'>; + + protected recording: AudioRecording | null; + protected media$: BehaviorSubject; + protected recording$: Observable; + + constructor(elementRef: ElementRef) { + super(elementRef); + + this.recording = null; + this.media$ = new BehaviorSubject(null); + this.recording$ = this.media$.pipe( + recorderAudioRecording(), + shareReplay(), + tap(recording => this.recording = recording), + ); + this.recordingUrl$ = this.recording$.pipe( + map(recording => recording && DomSanitizer.bypassSecurityTrustUrl(recording.url)), + ); + this.histogramAnalyzer$ = this.media$.pipe(map(media => { + if (!media?.analyser || CorePlatform.prefersReducedMotion()) { + return null; + } + + return media.analyser; + })); + this.status$ = combineLatest([this.media$.pipe(recorderStatus(), shareReplay()), this.recording$]) + .pipe(map(([recordingStatus, recording]) => { + if (recordingStatus === 'recording') { + return 'recording-ongoing'; + } + + if (recordingStatus === 'paused') { + return 'recording-paused'; + } + + if (recording) { + return 'done'; + } + + return 'empty'; + })); + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + const recorder = this.media$.value?.recorder; + + if (recorder && recorder.state !== 'inactive') { + recorder.stop(); + } + } + + /** + * Start recording. + */ + async startRecording(): Promise { + try { + const media = await this.createMedia(); + + this.media$.next(media); + + media.recorder.start(); + } catch (error) { + CoreDomUtils.showErrorModal(error); + } + } + + /** + * Stop recording. + */ + stopRecording(): void { + try { + this.media$.value?.recorder.stop(); + } catch (error) { + CoreDomUtils.showErrorModal(error); + } + } + + /** + * Stop recording. + */ + pauseRecording(): void { + try { + this.media$.value?.recorder.pause(); + } catch (error) { + CoreDomUtils.showErrorModal(error); + } + } + + /** + * Stop recording. + */ + resumeRecording(): void { + try { + this.media$.value?.recorder.resume(); + } catch (error) { + CoreDomUtils.showErrorModal(error); + } + } + + /** + * Discard recording. + */ + discardRecording(): void { + this.media$.next(null); + } + + /** + * Dismiss modal without a result. + */ + async cancel(): Promise { + this.close(new CoreCaptureError(CAPTURE_ERROR_NO_MEDIA_FILES)); + } + + /** + * Dismiss the modal with the current recording as a result. + */ + async submit(): Promise { + if (!this.recording) { + return; + } + + try { + const fileName = await CoreFile.getUniqueNameInFolder(CoreFileProvider.TMPFOLDER, 'recording.mp3'); + const filePath = CorePath.concatenatePaths(CoreFileProvider.TMPFOLDER, fileName); + const fileEntry = await CoreFile.writeFile(filePath, this.recording.blob); + + this.close({ + name: fileEntry.name, + fullPath: fileEntry.toURL(), + type: 'audio/mpeg', + }); + } catch (error) { + CoreDomUtils.showErrorModal(error); + } + } + + /** + * Create media instances. + * + * @returns Media instances. + */ + protected async createMedia(): Promise { + await this.prepareMicrophoneAuthorization(); + + const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const audioContext = new window.AudioContext(); + const source = audioContext.createMediaStreamSource(mediaStream); + const analyser = audioContext.createAnalyser(); + + analyser.fftSize = 2048; + source.connect(analyser); + + return { + analyser, + recorder: new Mp3MediaRecorder(mediaStream, { worker: this.startWorker(), audioContext }), + }; + } + + /** + * Make sure that microphone usage has been authorized. + */ + protected async prepareMicrophoneAuthorization(): Promise { + if (!CorePlatform.isMobile()) { + return; + } + + const status = await Diagnostic.requestMicrophoneAuthorization(); + + switch (status) { + case Diagnostic.permissionStatus.DENIED_ONCE: + case Diagnostic.permissionStatus.DENIED_ALWAYS: + throw new Error(Translate.instant('core.fileuploader.microphonepermissiondenied')); + case Diagnostic.permissionStatus.RESTRICTED: + throw new Error(Translate.instant('core.fileuploader.microphonepermissionrestricted')); + } + } + + /** + * Start worker script. + * + * @returns Worker. + */ + protected startWorker(): Worker { + const worker = new Worker('./audio-recorder.worker', { type: 'module' }); + + worker.postMessage( + initAudioEncoderMessage({ vmsgWasmUrl: `${document.head.baseURI}assets/lib/vmsg/vmsg.wasm` }), + ); + + return worker; + } + +} + +/** + * Audio recording data. + */ +interface AudioRecording { + url: string; + blob: Blob; +} + +/** + * Media instances. + */ +interface AudioRecorderMedia { + recorder: Mp3MediaRecorder; + analyser: AnalyserNode; +} + +/** + * Observable operator that listens to a recorder and emits a recording file. + * + * @returns Operator. + */ +function recorderAudioRecording(): OperatorFunction { + return source => new Observable(subscriber => { + let audioChunks: Blob[] = []; + let previousRecorder: Mp3MediaRecorder | undefined; + const onDataAvailable = event => audioChunks.push(event.data); + const onError = event => CoreDomUtils.showErrorModal(event.error); + const onStop = () => { + const blob = new Blob(audioChunks, { type: 'audio/mpeg' }); + + subscriber.next({ + url: URL.createObjectURL(blob), + blob, + }); + }; + const subscription = source.subscribe(media => { + previousRecorder?.removeEventListener('dataavailable', onDataAvailable); + previousRecorder?.removeEventListener('error', onError); + previousRecorder?.removeEventListener('stop', onStop); + + media?.recorder.addEventListener('dataavailable', onDataAvailable); + media?.recorder.addEventListener('error', onError); + media?.recorder.addEventListener('stop', onStop); + + audioChunks = []; + previousRecorder = media?.recorder; + + subscriber.next(null); + }); + + subscriber.next(null); + + return () => { + subscription.unsubscribe(); + + previousRecorder?.removeEventListener('dataavailable', onDataAvailable); + previousRecorder?.removeEventListener('error', onError); + previousRecorder?.removeEventListener('stop', onStop); + }; + }); +} + +/** + * Observable operator that listens to a recorder and emits its recording status. + * + * @returns Operator. + */ +function recorderStatus(): OperatorFunction { + return source => new Observable(subscriber => { + let previousRecorder: Mp3MediaRecorder | undefined; + const onStart = () => subscriber.next('recording'); + const onPause = () => subscriber.next('paused'); + const onResume = () => subscriber.next('recording'); + const onStop = () => subscriber.next('inactive'); + const subscription = source.subscribe(media => { + previousRecorder?.removeEventListener('start', onStart); + previousRecorder?.removeEventListener('pause', onPause); + previousRecorder?.removeEventListener('resume', onResume); + previousRecorder?.removeEventListener('stop', onStop); + + media?.recorder.addEventListener('start', onStart); + media?.recorder.addEventListener('pause', onPause); + media?.recorder.addEventListener('resume', onResume); + media?.recorder.addEventListener('stop', onStop); + + previousRecorder = media?.recorder; + + subscriber.next(media?.recorder.state ?? 'inactive'); + }); + + subscriber.next('inactive'); + + return () => { + subscription.unsubscribe(); + + previousRecorder?.removeEventListener('start', onStart); + previousRecorder?.removeEventListener('pause', onPause); + previousRecorder?.removeEventListener('resume', onResume); + previousRecorder?.removeEventListener('stop', onStop); + }; + }); +} diff --git a/src/core/features/fileuploader/components/audio-recorder/audio-recorder.html b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.html new file mode 100644 index 00000000000..60858f79f6c --- /dev/null +++ b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.html @@ -0,0 +1,74 @@ +
+

{{ 'core.fileuploader.audiotitle' | translate }}

+ + + + +
+ +
+ + +
+

{{ 'core.fileuploader.startrecordinginstructions' | translate }}

+ + + + +
+ +
+ + + +
+
+
+ + +
+ +
+ + + + + + + +
+ +
+ + + +
+
+
+ +
+ + +
+
+ + + +
+ +
+ + {{ 'core.save' | translate }} + +
+
+
+
diff --git a/src/core/features/fileuploader/components/audio-recorder/audio-recorder.module.ts b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.module.ts new file mode 100644 index 00000000000..3b50dd3b767 --- /dev/null +++ b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.module.ts @@ -0,0 +1,32 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreSharedModule } from '@/core/shared.module'; +import { NgModule } from '@angular/core'; +import { CoreFileUploaderAudioHistogramComponent } from '@features/fileuploader/components/audio-histogram/audio-histogram'; + +import { CoreFileUploaderAudioRecorderComponent } from './audio-recorder.component'; + +export { CoreFileUploaderAudioRecorderComponent }; + +@NgModule({ + imports: [ + CoreSharedModule, + ], + declarations: [ + CoreFileUploaderAudioRecorderComponent, + CoreFileUploaderAudioHistogramComponent, + ], +}) +export class CoreFileUploaderAudioRecorderComponentModule {} diff --git a/src/core/features/fileuploader/components/audio-recorder/audio-recorder.scss b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.scss new file mode 100644 index 00000000000..6bd83cef0e9 --- /dev/null +++ b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.scss @@ -0,0 +1,119 @@ +:host { + color: var(--ion-text-color, #000); + + header { + display: flex; + justify-content: space-between; + align-items: center; + + h1 { + margin: 0; + font-size: 16px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0.15px; + } + + ion-button { + --padding-start: 0; + --padding-end: 0; + --icon-size: 1.8em; + + // Offset padding for visual alignment. + margin: calc((var(--icon-size) - var(--a11y-min-target-size)) / 2); + } + + } + + hr { + background: var(--gray-300); + margin: 16px 0; + } + + .core-audio-recorder--wrapper { + display: flex; + flex-direction: column; + align-items: center; + + p { + font-size: 14px; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.25px; + text-align: center; + opacity: 0.6; + margin-top: 0; + margin-bottom: 16px; + } + + ion-button[shape="round"] { + --border-radius: 99px; + --padding-start: 16px; + --padding-end: 16px; + --padding-top: 16px; + --padding-bottom: 16px; + + height: max-content; + } + + core-audio-histogram { + width: 100%; + height: 35px; + display: block; + } + + audio { + width: 100%; + margin-bottom: 16px; + } + + .core-audio-recorder--controls { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + + .core-audio-recorder--control { + width: 33%; + text-align: center; + + &:first-child { + text-align: start; + } + + &:last-child { + text-align: end; + } + + &.chrono { + padding: 0 16px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + } + + ion-button { + margin: 0; + } + + .core-audio-recorder--recording-marker { + width: 8px; + height: 8px; + margin-inline-end: 4px; + border-radius: 4px; + background: var(--danger); + } + + core-chrono.recording { + color: var(--danger); + } + + } + + } + + } + +} diff --git a/src/core/features/fileuploader/components/audio-recorder/audio-recorder.worker.ts b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.worker.ts new file mode 100644 index 00000000000..9b46372b273 --- /dev/null +++ b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.worker.ts @@ -0,0 +1,32 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { isInitAudioEncoderMessage } from '@features/fileuploader/utils/worker-messages'; +import { initMp3MediaEncoder } from 'mp3-mediarecorder/worker'; + +/** + * Handle worker message. + * + * @param event Worker message event. + */ +function onMessage(event: MessageEvent): void { + if (!isInitAudioEncoderMessage(event.data)) { + return; + } + + removeEventListener('message', onMessage); + initMp3MediaEncoder(event.data.config); +} + +addEventListener('message', onMessage); diff --git a/src/core/features/fileuploader/lang.json b/src/core/features/fileuploader/lang.json index 22d14df4a11..588b497ee49 100644 --- a/src/core/features/fileuploader/lang.json +++ b/src/core/features/fileuploader/lang.json @@ -1,9 +1,11 @@ { "addfiletext": "Add file", "audio": "Audio", + "audiotitle": "Record audio", "camera": "Camera", "confirmuploadfile": "You are about to upload {{size}}. Are you sure you want to continue?", "confirmuploadunknownsize": "It was not possible to calculate the size of the upload. Are you sure you want to continue?", + "discardrecording": "Discard recording", "errorcapturingaudio": "Error capturing audio.", "errorcapturingimage": "Error capturing image.", "errorcapturingvideo": "Error capturing video.", @@ -17,13 +19,20 @@ "filesofthesetypes": "Accepted file types:", "invalidfiletype": "{{$a}} filetype cannot be accepted.", "maxbytesfile": "The file {{$a.file}} is too large. The maximum size you can upload is {{$a.size}}.", + "microphonepermissiondenied": "Permission to access the microphone has been denied.", + "microphonepermissionrestricted": "Microphone access is restricted.", "more": "More", + "pauserecording": "Pause recording", "photoalbums": "Photo albums", "readingfile": "Reading file", "readingfileperc": "Reading file: {{$a}}%", + "resumerecording": "Resume recording", "selectafile": "Select a file", + "startrecording": "Start recording", + "startrecordinginstructions": "Tap to start recording", + "stoprecording": "Stop recording", "uploadafile": "Upload a file", "uploading": "Uploading", "uploadingperc": "Uploading: {{$a}}%", "video": "Video" -} \ No newline at end of file +} diff --git a/src/core/features/fileuploader/services/fileuploader-helper.ts b/src/core/features/fileuploader/services/fileuploader-helper.ts index 30fe5ebf155..cba30e2a7f5 100644 --- a/src/core/features/fileuploader/services/fileuploader-helper.ts +++ b/src/core/features/fileuploader/services/fileuploader-helper.ts @@ -19,7 +19,6 @@ import { ChooserResult } from '@ionic-native/chooser/ngx'; import { FileEntry, IFile } from '@ionic-native/file/ngx'; import { MediaFile } from '@ionic-native/media-capture/ngx'; -import { CoreApp } from '@services/app'; import { CoreNetwork } from '@services/network'; import { CoreFile, CoreFileProvider, CoreFileProgressEvent } from '@services/file'; import { CoreDomUtils } from '@services/utils/dom'; @@ -30,9 +29,14 @@ import { makeSingleton, Translate, Camera, Chooser, ActionSheetController } from import { CoreLogger } from '@singletons/logger'; import { CoreCanceledError } from '@classes/errors/cancelederror'; import { CoreError } from '@classes/errors/error'; -import { CoreFileUploader, CoreFileUploaderProvider, CoreFileUploaderOptions } from './fileuploader'; +import { + CoreFileUploader, + CoreFileUploaderProvider, + CoreFileUploaderOptions, + CoreFileUploaderAudioRecording, +} from './fileuploader'; import { CoreFileUploaderDelegate } from './fileuploader-delegate'; -import { CoreCaptureError } from '@classes/errors/captureerror'; +import { CAPTURE_ERROR_NO_MEDIA_FILES, CoreCaptureError } from '@classes/errors/captureerror'; import { CoreIonLoadingElement } from '@classes/ion-loading'; import { CoreWSUploadFileResult } from '@services/ws'; import { CoreSites } from '@services/sites'; @@ -467,9 +471,9 @@ export class CoreFileUploaderHelperProvider { * @param defaultMessage Key of the default message to show. */ protected treatCaptureError(error: CoreCaptureError, defaultMessage: string): void { - // Cancelled or error. If cancelled, error is an object with code = 3. + // Cancelled or error. If cancelled, error is an object with code = CAPTURE_EROR_NO_MEDIA_FILES. if (error) { - if (error.code != 3) { + if (error.code !== CAPTURE_ERROR_NO_MEDIA_FILES) { // Error, not cancelled. this.logger.error('Error while recording audio/video', error); @@ -515,7 +519,7 @@ export class CoreFileUploaderHelperProvider { } return new CoreError(error); - } else if ('code' in error && error.code == 3) { + } else if ('code' in error && error.code === CAPTURE_ERROR_NO_MEDIA_FILES) { throw new CoreCanceledError(); } else { throw error; @@ -540,34 +544,22 @@ export class CoreFileUploaderHelperProvider { ): Promise { this.logger.debug('Trying to record a ' + (isAudio ? 'audio' : 'video') + ' file'); - // The mimetypes param is only for browser, the Cordova plugin doesn't support it. - const captureOptions = { limit: 1, mimetypes: mimetypes }; - let media: MediaFile; + let media: MediaFile | CoreFileUploaderAudioRecording; try { - const medias = isAudio ? await CoreFileUploader.captureAudio(captureOptions) : - await CoreFileUploader.captureVideo(captureOptions); + const medias = isAudio + ? await CoreFileUploader.captureAudio() + : await CoreFileUploader.captureVideo({ limit: 1 }); media = medias[0]; // We used limit 1, we only want 1 media. } catch (error) { + const defaultError = isAudio ? 'core.fileuploader.errorcapturingaudio' : 'core.fileuploader.errorcapturingvideo'; - if (isAudio && this.isNoAppError(error) && CorePlatform.isMobile()) { - // No app to record audio, fallback to capture it ourselves. - try { - media = await CoreFileUploader.captureAudioInApp(); - } catch (error) { - throw this.treatCaptureError(error, 'core.fileuploader.errorcapturingaudio'); // Throw the right error. - } - - } else { - const defaultError = isAudio ? 'core.fileuploader.errorcapturingaudio' : 'core.fileuploader.errorcapturingvideo'; - - throw this.treatCaptureError(error, defaultError); // Throw the right error. - } + throw this.treatCaptureError(error, defaultError); // Throw the right error. } let path = media.fullPath; - const error = CoreFileUploader.isInvalidMimetype(mimetypes, path); // Verify that the mimetype is supported. + const error = CoreFileUploader.isInvalidMimetype(mimetypes, media.fullPath); if (error) { throw new Error(error); @@ -652,7 +644,7 @@ export class CoreFileUploaderHelperProvider { options.mediaType = Camera.MediaType.PICTURE; } else if (!imageSupported && videoSupported) { options.mediaType = Camera.MediaType.VIDEO; - } else if (CoreApp.isIOS()) { + } else if (CorePlatform.isIOS()) { // Only get all media in iOS because in Android using this option allows uploading any kind of file. options.mediaType = Camera.MediaType.ALLMEDIA; } @@ -774,7 +766,6 @@ export class CoreFileUploaderHelperProvider { options: CoreFileUploaderOptions, siteId?: string, ): Promise { - const errorStr = Translate.instant('core.error'); const retryStr = Translate.instant('core.retry'); const uploadingStr = Translate.instant('core.fileuploader.uploading'); diff --git a/src/core/features/fileuploader/services/fileuploader.ts b/src/core/features/fileuploader/services/fileuploader.ts index 4949c64ff43..9926844dab6 100644 --- a/src/core/features/fileuploader/services/fileuploader.ts +++ b/src/core/features/fileuploader/services/fileuploader.ts @@ -15,10 +15,9 @@ import { Injectable } from '@angular/core'; import { CameraOptions } from '@ionic-native/camera/ngx'; import { FileEntry } from '@ionic-native/file/ngx'; -import { MediaFile, CaptureError, CaptureAudioOptions, CaptureVideoOptions } from '@ionic-native/media-capture/ngx'; +import { MediaFile, CaptureError, CaptureVideoOptions } from '@ionic-native/media-capture/ngx'; import { Subject } from 'rxjs'; -import { CoreApp } from '@services/app'; import { CoreFile, CoreFileProvider } from '@services/file'; import { CoreFilepool } from '@services/filepool'; import { CoreSites } from '@services/sites'; @@ -26,13 +25,14 @@ import { CoreMimetypeUtils } from '@services/utils/mimetype'; import { CoreTimeUtils } from '@services/utils/time'; import { CoreUtils } from '@services/utils/utils'; import { CoreWSFile, CoreWSFileUploadOptions, CoreWSUploadFileResult } from '@services/ws'; -import { makeSingleton, Translate, MediaCapture, ModalController, Camera } from '@singletons'; +import { makeSingleton, Translate, MediaCapture, Camera } from '@singletons'; import { CoreLogger } from '@singletons/logger'; -import { CoreEmulatorCaptureMediaComponent } from '@features/emulator/components/capture-media/capture-media'; import { CoreError } from '@classes/errors/error'; import { CoreSite } from '@classes/site'; import { CoreFileEntry, CoreFileHelper } from '@services/file-helper'; import { CorePath } from '@singletons/path'; +import { CorePlatform } from '@services/platform'; +import { CoreModals } from '@services/modals'; /** * File upload options. @@ -132,14 +132,21 @@ export class CoreFileUploaderProvider { /** * Start the audio recorder application and return information about captured audio clip files. * - * @param options Options. * @returns Promise resolved with the result. */ - async captureAudio(options: CaptureAudioOptions): Promise { + async captureAudio(): Promise { this.onAudioCapture.next(true); try { - return await MediaCapture.captureAudio(options); + if (!CorePlatform.supportsMediaCapture() || !CorePlatform.supportsWebAssembly()) { + const media = await MediaCapture.captureAudio({ limit: 1 }); + + return media; + } + + const recording = await this.captureAudioInApp(); + + return [recording]; } finally { this.onAudioCapture.next(false); } @@ -150,27 +157,17 @@ export class CoreFileUploaderProvider { * * @returns Promise resolved with the file. */ - async captureAudioInApp(): Promise { - const params = { - type: 'audio', - }; + async captureAudioInApp(): Promise { + const { CoreFileUploaderAudioRecorderComponent } = + await import('@features/fileuploader/components/audio-recorder/audio-recorder.module'); - const modal = await ModalController.create({ - component: CoreEmulatorCaptureMediaComponent, - cssClass: 'core-modal-fullscreen', - componentProps: params, - backdropDismiss: false, - }); - - await modal.present(); + const recording = await CoreModals.openSheet(CoreFileUploaderAudioRecorderComponent); - const result = await modal.onWillDismiss(); - - if (result.role == 'success') { - return result.data[0]; - } else { - throw result.data; + if (!recording) { + throw new Error('Recording missing from audio capture'); } + + return recording; } /** @@ -236,7 +233,7 @@ export class CoreFileUploaderProvider { getCameraUploadOptions(uri: string, isFromAlbum?: boolean): CoreFileUploaderOptions { const extension = CoreMimetypeUtils.guessExtensionFromUrl(uri); const mimetype = CoreMimetypeUtils.getMimeType(extension); - const isIOS = CoreApp.isIOS(); + const isIOS = CorePlatform.isIOS(); const options: CoreFileUploaderOptions = { deleteAfterUpload: !isFromAlbum, mimeType: mimetype, @@ -259,7 +256,7 @@ export class CoreFileUploaderProvider { // If the file was picked from the album, delete it only if it was copied to the app's folder. options.deleteAfterUpload = CoreFile.isFileInAppFolder(uri); - if (CoreApp.isAndroid()) { + if (CorePlatform.isAndroid()) { // Picking an image from album in Android adds a timestamp at the end of the file. Delete it. options.fileName = options.fileName.replace(/(\.[^.]*)\?[^.]*$/, '$1'); } @@ -335,7 +332,7 @@ export class CoreFileUploaderProvider { * @param mediaFile File object to upload. * @returns Options. */ - getMediaUploadOptions(mediaFile: MediaFile): CoreFileUploaderOptions { + getMediaUploadOptions(mediaFile: MediaFile | CoreFileUploaderAudioRecording): CoreFileUploaderOptions { const options: CoreFileUploaderOptions = {}; let filename = mediaFile.name; @@ -781,3 +778,9 @@ export type CoreFileUploaderTypeListInfoEntry = { name?: string; extlist: string; }; + +export type CoreFileUploaderAudioRecording = { + name: string; + fullPath: string; + type: string; +}; diff --git a/src/core/features/fileuploader/services/handlers/audio.ts b/src/core/features/fileuploader/services/handlers/audio.ts index 99c4030ed7f..cdce9f086ac 100644 --- a/src/core/features/fileuploader/services/handlers/audio.ts +++ b/src/core/features/fileuploader/services/handlers/audio.ts @@ -45,10 +45,10 @@ export class CoreFileUploaderAudioHandlerService implements CoreFileUploaderHand * @returns Supported mimetypes. */ getSupportedMimetypes(mimetypes: string[]): string[] { - if (CoreApp.isIOS()) { + if (CorePlatform.isIOS()) { // In iOS it's recorded as WAV. return CoreUtils.filterByRegexp(mimetypes, /^audio\/wav$/); - } else if (CoreApp.isAndroid()) { + } else if (CorePlatform.isAndroid()) { // In Android we don't know the format the audio will be recorded, so accept any audio mimetype. return CoreUtils.filterByRegexp(mimetypes, /^audio\//); } else { diff --git a/src/core/features/fileuploader/services/handlers/file.ts b/src/core/features/fileuploader/services/handlers/file.ts index 54407e38796..0ed301042f5 100644 --- a/src/core/features/fileuploader/services/handlers/file.ts +++ b/src/core/features/fileuploader/services/handlers/file.ts @@ -14,7 +14,6 @@ import { Injectable } from '@angular/core'; -import { CoreApp } from '@services/app'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreFileUploaderHandler, CoreFileUploaderHandlerData, CoreFileUploaderHandlerResult } from '../fileuploader-delegate'; import { CoreFileUploaderHelper } from '../fileuploader-helper'; @@ -94,7 +93,7 @@ export class CoreFileUploaderFileHandlerService implements CoreFileUploaderHandl const input = document.createElement('input'); input.setAttribute('type', 'file'); input.classList.add('core-fileuploader-file-handler-input'); - if (mimetypes && mimetypes.length && (!CoreApp.isAndroid() || mimetypes.length == 1)) { + if (mimetypes && mimetypes.length && (!CorePlatform.isAndroid() || mimetypes.length == 1)) { // Don't use accept attribute in Android with several mimetypes, it's not supported. input.setAttribute('accept', mimetypes.join(', ')); } @@ -134,7 +133,7 @@ export class CoreFileUploaderFileHandlerService implements CoreFileUploaderHandl } }); - if (CoreApp.isIOS()) { + if (CorePlatform.isIOS()) { // In iOS, the click on the input stopped working for some reason. We need to put it 1 level higher. element.parentElement?.appendChild(input); diff --git a/src/core/features/fileuploader/services/handlers/video.ts b/src/core/features/fileuploader/services/handlers/video.ts index b22f0750f87..0472f22485e 100644 --- a/src/core/features/fileuploader/services/handlers/video.ts +++ b/src/core/features/fileuploader/services/handlers/video.ts @@ -45,10 +45,10 @@ export class CoreFileUploaderVideoHandlerService implements CoreFileUploaderHand * @returns Supported mimetypes. */ getSupportedMimetypes(mimetypes: string[]): string[] { - if (CoreApp.isIOS()) { + if (CorePlatform.isIOS()) { // In iOS it's recorded as MOV. return CoreUtils.filterByRegexp(mimetypes, /^video\/quicktime$/); - } else if (CoreApp.isAndroid()) { + } else if (CorePlatform.isAndroid()) { // In Android we don't know the format the video will be recorded, so accept any video mimetype. return CoreUtils.filterByRegexp(mimetypes, /^video\//); } else { diff --git a/src/core/features/fileuploader/utils/worker-messages.ts b/src/core/features/fileuploader/utils/worker-messages.ts new file mode 100644 index 00000000000..f31572a8c00 --- /dev/null +++ b/src/core/features/fileuploader/utils/worker-messages.ts @@ -0,0 +1,46 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 type { Mp3WorkerConfig } from 'mp3-mediarecorder/types/config.type'; + +export interface InitAudioEncoderMessage { + name: 'init-audio-encoder'; + config: Mp3WorkerConfig; +} + +/** + * Check whether the given data is an init audio encoder message. + * + * @param message Message. + * @returns Whether the data is an init audio encoder message. + */ +export function isInitAudioEncoderMessage(message: unknown): message is InitAudioEncoderMessage { + return typeof message === 'object' + && message !== null + && 'name' in message + && message['name'] === 'init-audio-encoder'; +} + +/** + * Create an init audio encoder message. + * + * @param config Audio encoder config. + * @returns Message. + */ +export function initAudioEncoderMessage(config: Mp3WorkerConfig): InitAudioEncoderMessage { + return { + name: 'init-audio-encoder', + config, + }; +} diff --git a/src/core/features/h5p/classes/core.ts b/src/core/features/h5p/classes/core.ts index 6bdd1de7494..c99712a9d20 100644 --- a/src/core/features/h5p/classes/core.ts +++ b/src/core/features/h5p/classes/core.ts @@ -271,8 +271,13 @@ export class CoreH5PCore { if (addon.addTo?.content?.types?.length) { for (let i = 0; i < addon.addTo.content.types.length; i++) { const type = addon.addTo.content.types[i]; + let regex = type?.text?.regex; + if (regex && regex[0] === '/' && regex.slice(-1) === '/') { + // Regex designed for PHP. Remove the starting and ending slashes to convert them to JS format. + regex = regex.substring(1, regex.length - 1); + } - if (type && type.text && type.text.regex && this.textAddonMatches(params.params, type.text.regex)) { + if (regex && this.textAddonMatches(params.params, regex)) { await validator.addon(addon); // An addon shall only be added once. diff --git a/src/core/features/login/components/login-methods/login-methods.ts b/src/core/features/login/components/login-methods/login-methods.ts index c4180647c11..b0c78e2991d 100644 --- a/src/core/features/login/components/login-methods/login-methods.ts +++ b/src/core/features/login/components/login-methods/login-methods.ts @@ -11,6 +11,7 @@ // 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 } from '@angular/core'; import { CoreLoginHelper, CoreLoginMethod } from '@features/login/services/login-helper'; import { CoreSites } from '@services/sites'; diff --git a/src/core/features/login/components/site-help/site-help.html b/src/core/features/login/components/site-help/site-help.html index 25304a89a14..21ce3f3348a 100644 --- a/src/core/features/login/components/site-help/site-help.html +++ b/src/core/features/login/components/site-help/site-help.html @@ -1,7 +1,7 @@ -

{{ 'core.login.help' | translate }}

+

{{ 'core.login.help' | translate }}

@@ -14,7 +14,7 @@

{{ 'core.login.help' | translate }}

-

{{ 'core.login.faqwhatisurlquestion' | translate }}

+

{{ 'core.login.faqwhatisurlquestion' | translate }}

@@ -24,7 +24,7 @@

{{ 'core.login.faqwhatisurlquestion' | translate }}

-

{{ 'core.login.faqcannotfindmysitequestion' | translate }}

+

{{ 'core.login.faqcannotfindmysitequestion' | translate }}

@@ -34,7 +34,7 @@

{{ 'core.login.faqcannotfindmysitequestion' | translate }}< -

{{ 'core.login.faqsetupsitequestion' | translate }}

+

{{ 'core.login.faqsetupsitequestion' | translate }}

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

{{ 'core.login.faqsetupsitequestion' | translate }}

-

{{ 'core.login.faqtestappquestion' | translate }}

+

{{ 'core.login.faqtestappquestion' | translate }}

@@ -57,7 +57,7 @@

{{ 'core.login.faqtestappquestion' | translate }}

-

{{ 'core.login.faqwhereisqrcode' | translate }}

+

{{ 'core.login.faqwhereisqrcode' | translate }}

-

{{'core.login.onboardingwelcome' | translate}} -

+

-

+

-

+

{{siteUrl}}

@@ -78,7 +78,7 @@

@@ -32,7 +32,7 @@

detail="true" [attr.aria-label]="'core.user.profile' | translate"> -

{{ siteInfo.fullname }}

+

{{ siteInfo.fullname }}

diff --git a/src/core/features/mainmenu/pages/menu/menu.ts b/src/core/features/mainmenu/pages/menu/menu.ts index 39211bd1b6f..f75066f04bd 100644 --- a/src/core/features/mainmenu/pages/menu/menu.ts +++ b/src/core/features/mainmenu/pages/menu/menu.ts @@ -17,7 +17,6 @@ import { IonTabs } from '@ionic/angular'; import { BackButtonEvent } from '@ionic/core'; import { Subscription } from 'rxjs'; -import { CoreApp } from '@services/app'; import { CoreEvents, CoreEventObserver } from '@singletons/events'; import { CoreMainMenu, CoreMainMenuProvider } from '../../services/mainmenu'; import { CoreMainMenuDelegate, CoreMainMenuHandlerToDisplay } from '../../services/mainmenu-delegate'; @@ -31,6 +30,7 @@ import { trigger, state, style, transition, animate } from '@angular/animations' import { CoreSites } from '@services/sites'; import { CoreDom } from '@singletons/dom'; import { CoreLogger } from '@singletons/logger'; +import { CorePlatform } from '@services/platform'; const ANIMATION_DURATION = 500; @@ -135,7 +135,7 @@ export class CoreMainMenuPage implements OnInit, OnDestroy { }); document.addEventListener('ionBackButton', this.backButtonFunction); - if (CoreApp.isIOS()) { + if (CorePlatform.isIOS()) { // In iOS, the resize event is triggered before the keyboard is opened/closed and not triggered again once done. // Init handlers again once keyboard is closed since the resize event doesn't have the updated height. this.keyboardObserver = CoreEvents.on(CoreEvents.KEYBOARD_CHANGE, (kbHeight: number) => { diff --git a/src/core/features/mainmenu/services/mainmenu.ts b/src/core/features/mainmenu/services/mainmenu.ts index 39cd39869dd..583d6b0dacd 100644 --- a/src/core/features/mainmenu/services/mainmenu.ts +++ b/src/core/features/mainmenu/services/mainmenu.ts @@ -14,7 +14,6 @@ import { Injectable } from '@angular/core'; -import { CoreApp } from '@services/app'; import { CoreLang, CoreLangLanguage } from '@services/lang'; import { CoreSites } from '@services/sites'; import { CoreConstants } from '@/core/constants'; @@ -23,6 +22,7 @@ import { Device, makeSingleton } from '@singletons'; import { CoreArray } from '@singletons/array'; import { CoreTextUtils } from '@services/utils/text'; import { CoreScreen } from '@services/screen'; +import { CorePlatform } from '@services/platform'; declare module '@singletons/events' { @@ -196,9 +196,9 @@ export class CoreMainMenuProvider { osversion: Device.version, }; - if (CoreApp.isAndroid()) { + if (CorePlatform.isAndroid()) { replacements.devicetype = 'Android'; - } else if (CoreApp.isIOS()) { + } else if (CorePlatform.isIOS()) { replacements.devicetype = 'iPhone or iPad'; } else { replacements.devicetype = 'Other'; diff --git a/src/core/features/native/native.module.ts b/src/core/features/native/native.module.ts index 3a8887ca06c..c0a82490f5c 100644 --- a/src/core/features/native/native.module.ts +++ b/src/core/features/native/native.module.ts @@ -29,7 +29,6 @@ import { InAppBrowser } from '@ionic-native/in-app-browser/ngx'; import { WebView } from '@ionic-native/ionic-webview/ngx'; import { Keyboard } from '@ionic-native/keyboard/ngx'; import { LocalNotifications } from '@ionic-native/local-notifications/ngx'; -import { Media } from '@ionic-native/media/ngx'; import { MediaCapture } from '@ionic-native/media-capture/ngx'; import { Push } from '@ionic-native/push/ngx'; import { QRScanner } from '@ionic-native/qr-scanner/ngx'; @@ -54,7 +53,6 @@ export const CORE_NATIVE_SERVICES = [ InAppBrowser, Keyboard, LocalNotifications, - Media, MediaCapture, Push, QRScanner, @@ -82,7 +80,6 @@ export const CORE_NATIVE_SERVICES = [ InAppBrowser, Keyboard, LocalNotifications, - Media, MediaCapture, Push, QRScanner, diff --git a/src/core/features/pushnotifications/services/pushnotifications.ts b/src/core/features/pushnotifications/services/pushnotifications.ts index 583f8145106..417ee38499b 100644 --- a/src/core/features/pushnotifications/services/pushnotifications.ts +++ b/src/core/features/pushnotifications/services/pushnotifications.ts @@ -228,7 +228,7 @@ export class CorePushNotificationsProvider { * @returns Promise resolved when done. */ protected async createDefaultChannel(): Promise { - if (!CoreApp.isAndroid()) { + if (!CorePlatform.isAndroid()) { return; } @@ -481,7 +481,7 @@ export class CorePushNotificationsProvider { text: notification.message, channel: 'PushPluginChannel', }; - const isAndroid = CoreApp.isAndroid(); + const isAndroid = CorePlatform.isAndroid(); const extraFeatures = CoreUtils.isTrueOrOne(data.extrafeatures); if (extraFeatures && isAndroid && CoreUtils.isFalseOrZero(data.notif)) { diff --git a/src/core/features/question/components/question/question.ts b/src/core/features/question/components/question/question.ts index af9c2d08e4f..9d6d349ef66 100644 --- a/src/core/features/question/components/question/question.ts +++ b/src/core/features/question/components/question/question.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Component, Input, Output, OnInit, EventEmitter, ChangeDetectorRef, Type, ElementRef } from '@angular/core'; -import { AsyncComponent } from '@classes/async-component'; +import { AsyncDirective } from '@classes/async-directive'; import { CorePromisedValue } from '@classes/promised-value'; import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; @@ -22,7 +22,7 @@ import { CoreQuestionBehaviourButton, CoreQuestionHelper, CoreQuestionQuestion } import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons'; -import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CoreDirectivesRegistry } from '@singletons/directives-registry'; import { CoreLogger } from '@singletons/logger'; /** @@ -33,7 +33,7 @@ import { CoreLogger } from '@singletons/logger'; templateUrl: 'core-question.html', styleUrls: ['../../question.scss'], }) -export class CoreQuestionComponent implements OnInit, AsyncComponent { +export class CoreQuestionComponent implements OnInit, AsyncDirective { @Input() question?: CoreQuestionQuestion; // The question to render. @Input() component?: string; // The component the question belongs to. @@ -66,7 +66,7 @@ export class CoreQuestionComponent implements OnInit, AsyncComponent { constructor(protected changeDetector: ChangeDetectorRef, private element: ElementRef) { this.logger = CoreLogger.getInstance('CoreQuestionComponent'); this.promisedReady = new CorePromisedValue(); - CoreComponentsRegistry.register(this.element.nativeElement, this); + CoreDirectivesRegistry.register(this.element.nativeElement, this); } async ready(): Promise { diff --git a/src/core/features/rating/components/ratings/ratings-modal.html b/src/core/features/rating/components/ratings/ratings-modal.html index 484398ae5a8..2cb53b2881c 100644 --- a/src/core/features/rating/components/ratings/ratings-modal.html +++ b/src/core/features/rating/components/ratings/ratings-modal.html @@ -1,7 +1,7 @@ -

{{ 'core.rating.ratings' | translate }}

+

{{ 'core.rating.ratings' | translate }}

@@ -16,7 +16,7 @@

{{ 'core.rating.ratings' | translate }}

-

{{ rating.userfullname }}

+

{{ rating.userfullname }}

{{ rating.rating }}

diff --git a/src/core/features/reminders/components/set-reminder-custom/set-reminder-custom.html b/src/core/features/reminders/components/set-reminder-custom/set-reminder-custom.html index 023ea6791ce..a3b7baf4ed4 100644 --- a/src/core/features/reminders/components/set-reminder-custom/set-reminder-custom.html +++ b/src/core/features/reminders/components/set-reminder-custom/set-reminder-custom.html @@ -1,7 +1,7 @@ -

{{ 'core.reminders.customreminder' | translate }}

+

{{ 'core.reminders.customreminder' | translate }}

diff --git a/src/core/features/reminders/components/set-reminder-menu/set-reminder-menu.html b/src/core/features/reminders/components/set-reminder-menu/set-reminder-menu.html index c7d1c24e815..45a4bd24143 100644 --- a/src/core/features/reminders/components/set-reminder-menu/set-reminder-menu.html +++ b/src/core/features/reminders/components/set-reminder-menu/set-reminder-menu.html @@ -1,7 +1,7 @@ -

{{ 'core.reminders.setareminder' | translate }}

+

{{ 'core.reminders.setareminder' | translate }}

diff --git a/src/core/features/reportbuilder/classes/reports-source.ts b/src/core/features/reportbuilder/classes/reports-source.ts new file mode 100644 index 00000000000..04f5e27ae25 --- /dev/null +++ b/src/core/features/reportbuilder/classes/reports-source.ts @@ -0,0 +1,55 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source'; +import { CoreReportBuilder, CoreReportBuilderReport, REPORTS_LIST_LIMIT } from '../services/reportbuilder'; + +/** + * Provides a list of reports. + */ +export class CoreReportBuilderReportsSource extends CoreRoutedItemsManagerSource { + + /** + * @inheritdoc + */ + getItemPath(report: CoreReportBuilderReport): string { + return report.id.toString(); + } + + /** + * @inheritdoc + */ + protected async loadPageItems(page: number): Promise<{ items: CoreReportBuilderReport[]; hasMoreItems: boolean }> { + const reports = await CoreReportBuilder.getReports(page, this.getPageLength()); + + return { items: reports, hasMoreItems: reports.length > 0 }; + } + + /** + * @inheritdoc + */ + protected setItems(reports: CoreReportBuilderReport[], hasMoreItems: boolean): void { + const sortedReports = reports.slice(0); + sortedReports.sort((a, b) => a.timemodified < b.timemodified ? 1 : -1); + super.setItems(sortedReports, hasMoreItems); + } + + /** + * @inheritdoc + */ + protected getPageLength(): number { + return REPORTS_LIST_LIMIT; + } + +} diff --git a/src/core/features/reportbuilder/components/components.module.ts b/src/core/features/reportbuilder/components/components.module.ts new file mode 100644 index 00000000000..f8f269fc395 --- /dev/null +++ b/src/core/features/reportbuilder/components/components.module.ts @@ -0,0 +1,36 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreSharedModule } from '@/core/shared.module'; +import { CoreReportBuilderReportColumnComponent } from './report-column/report-column'; +import { CoreReportBuilderReportDetailComponent } from './report-detail/report-detail'; +import { CoreReportBuilderReportSummaryComponent } from './report-summary/report-summary'; + +@NgModule({ + imports: [ + CoreSharedModule, + ], + declarations: [ + CoreReportBuilderReportDetailComponent, + CoreReportBuilderReportColumnComponent, + CoreReportBuilderReportSummaryComponent, + ], + exports: [ + CoreReportBuilderReportDetailComponent, + CoreReportBuilderReportColumnComponent, + CoreReportBuilderReportSummaryComponent, + ], +}) +export class CoreReportBuilderComponentsModule {} diff --git a/src/core/features/reportbuilder/components/report-column/report-column.html b/src/core/features/reportbuilder/components/report-column/report-column.html new file mode 100644 index 00000000000..2825771f64d --- /dev/null +++ b/src/core/features/reportbuilder/components/report-column/report-column.html @@ -0,0 +1,23 @@ + + +

{{ header }}

+

+ + + {{ column }} +

+ + + + + {{ column }} + +
+ +
diff --git a/src/core/features/reportbuilder/components/report-column/report-column.scss b/src/core/features/reportbuilder/components/report-column/report-column.scss new file mode 100644 index 00000000000..65df0f95372 --- /dev/null +++ b/src/core/features/reportbuilder/components/report-column/report-column.scss @@ -0,0 +1,11 @@ +@import "~theme/globals"; + +:host { + --rotate-expandable: rotate(180deg); + + .expandable-status-icon { + font-size: var(--text-size); + @include margin-horizontal(0, 2px); + @include core-transition(transform, 200ms); + } +} diff --git a/src/core/features/reportbuilder/components/report-column/report-column.ts b/src/core/features/reportbuilder/components/report-column/report-column.ts new file mode 100644 index 00000000000..5d086c93abf --- /dev/null +++ b/src/core/features/reportbuilder/components/report-column/report-column.ts @@ -0,0 +1,45 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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, EventEmitter, Input, Output } from '@angular/core'; +import { CoreReportBuilder } from '@features/reportbuilder/services/reportbuilder'; + +@Component({ + selector: 'core-report-builder-report-column', + templateUrl: './report-column.html', + styleUrls: ['./report-column.scss'], +}) +export class CoreReportBuilderReportColumnComponent { + + @Input() isExpanded = false; + @Input() isExpandable = false; + @Input() showFirstTitle = false; + @Input() columnIndex!: number; + @Input() rowIndex!: number; + @Input() column!: string | number; + @Input() contextId!: number; + @Input() header!: string; + @Input() source!: string; + @Output() onToggleRow: EventEmitter = new EventEmitter(); + + isString = (value: unknown): boolean => CoreReportBuilder.isString(value); + + /** + * Emits row click + */ + toggleRow(): void { + this.onToggleRow.emit(this.rowIndex); + } + +} diff --git a/src/core/features/reportbuilder/components/report-detail/report-detail.html b/src/core/features/reportbuilder/components/report-detail/report-detail.html new file mode 100644 index 00000000000..0e16eba65ea --- /dev/null +++ b/src/core/features/reportbuilder/components/report-detail/report-detail.html @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{ header }} +
+ + + {{ column }} +
+
+ +
+ + + +

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

+
+
+ + + + +
+ +
diff --git a/src/core/features/reportbuilder/components/report-detail/report-detail.scss b/src/core/features/reportbuilder/components/report-detail/report-detail.scss new file mode 100644 index 00000000000..1f9dcd1430d --- /dev/null +++ b/src/core/features/reportbuilder/components/report-detail/report-detail.scss @@ -0,0 +1,52 @@ +@import "~theme/globals"; + +:host { + --header-background: var(--white); + --border-color: var(--stroke); + + .report-title { + ion-item { + width: 100%; + } + } + + table { + width: 98%; + margin: 1em auto; + border-collapse: collapse; + color: var(--ion-text-color); + overflow-x: auto; + display: block; + + tbody { + display: table; + } + + th { + background-color: var(--header-background); + } + + tr { + border-bottom: 1px solid var(--border-color); + + &:nth-child(even) { + background: var(--light); + } + } + + th, td { + @include padding(8px, 8px, 8px, null); + text-align: start; + min-width: 200px; + } + + } + + core-empty-box { + color: var(--gray-500); + p { + color: var(--gray-900); + } + } + +} diff --git a/src/core/features/reportbuilder/components/report-detail/report-detail.ts b/src/core/features/reportbuilder/components/report-detail/report-detail.ts new file mode 100644 index 00000000000..873dc76f9b9 --- /dev/null +++ b/src/core/features/reportbuilder/components/report-detail/report-detail.ts @@ -0,0 +1,249 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; +import { + CoreReportBuilder, + CoreReportBuilderReportDetail, + CoreReportBuilderRetrieveReportMapped, + REPORT_ROWS_LIMIT, +} from '@features/reportbuilder/services/reportbuilder'; +import { IonRefresher } from '@ionic/angular'; +import { CoreNavigator } from '@services/navigator'; +import { CoreScreen } from '@services/screen'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextErrorObject } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@Component({ + selector: 'core-report-builder-report-detail', + templateUrl: './report-detail.html', + styleUrls: ['./report-detail.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CoreReportBuilderReportDetailComponent implements OnInit { + + @Input() reportId!: string; + @Input() isBlock = true; + @Input() perPage?: number; + @Input() layout: 'card' | 'table' | 'adaptative' = 'adaptative'; + @Output() onReportLoaded = new EventEmitter(); + + get isCardLayout(): boolean { + return this.layout === 'card' || (CoreScreen.isMobile && this.layout === 'adaptative'); + } + + state$: Readonly> = new BehaviorSubject({ + report: null, + loaded: false, + canLoadMoreRows: true, + errorLoadingRows: false, + cardviewShowFirstTitle: false, + cardVisibleColumns: 1, + page: 0, + }); + + source$: Observable; + + isString = (value: unknown): boolean => CoreReportBuilder.isString(value); + + constructor() { + this.source$ = this.state$.pipe( + map(state => { + const splittedSource = state.report?.details.source.split('\\'); + const source = splittedSource?.[splittedSource?.length - 1]; + + return source ?? 'system'; + }), + ); + } + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + await this.getReport(); + this.updateState({ loaded: true }); + } + + /** + * Get report data. + */ + async getReport(): Promise { + try { + if (!this.reportId) { + CoreDomUtils.showErrorModal(new CoreError('No report found')); + CoreNavigator.back(); + + return; + } + + const { page } = this.state$.getValue(); + + const report = await CoreReportBuilder.loadReport(parseInt(this.reportId), page,this.perPage ?? REPORT_ROWS_LIMIT); + + if (!report) { + CoreDomUtils.showErrorModal(new CoreError('No report found')); + CoreNavigator.back(); + + return; + } + + await CoreReportBuilder.viewReport(this.reportId); + + this.updateState({ + report, + cardVisibleColumns: report.details.settingsdata.cardviewVisibleColumns, + cardviewShowFirstTitle: report.details.settingsdata.cardviewShowFirstTitle, + }); + + this.onReportLoaded.emit(report.details); + } catch { + const errorConfig: CoreTextErrorObject = { + title: Translate.instant('core.error'), + body: ` +

${Translate.instant('addon.mod_page.errorwhileloadingthepage')}

+

${Translate.instant('core.course.useactivityonbrowser')}

+ `, + buttons: [ + { + text: Translate.instant('core.cancel'), + role: 'cancel', + handler: async () => await CoreNavigator.back(), + }, + { + text: Translate.instant('core.openinbrowser'), + role: 'confirm', + handler: async () => { + const site = CoreSites.getRequiredCurrentSite(); + const href = `${site.getURL()}/reportbuilder/view.php?id=${this.reportId}`; + await CoreUtils.openInBrowser(href, { showBrowserWarning: false }); + await CoreNavigator.back(); + }, + }, + ], + }; + + await CoreDomUtils.showErrorModal(errorConfig); + } + } + + updateState(state: Partial): void { + const previousState = this.state$.getValue(); + this.state$.next({ ...previousState, ...state }); + } + + /** + * Update report data. + * + * @param ionRefresher ionic refresher. + */ + async refreshReport(ionRefresher?: IonRefresher): Promise { + await CoreUtils.ignoreErrors(CoreReportBuilder.invalidateReport()); + this.updateState({ page: 0, canLoadMoreRows: false }); + await CoreUtils.ignoreErrors(this.getReport()); + await ionRefresher?.complete(); + this.updateState({ canLoadMoreRows: true }); + } + + /** + * Increment page of report rows. + */ + protected incrementPage(): void { + const { page } = this.state$.getValue(); + this.updateState({ page: page + 1 }); + } + + /** + * Load a new batch of pages. + * + * @param complete Completion callback. + */ + async fetchMoreInfo(complete: () => void): Promise { + const { canLoadMoreRows, report } = this.state$.getValue(); + + if (!canLoadMoreRows) { + complete(); + + return; + } + + try { + this.incrementPage(); + + const { page: currentPage } = this.state$.getValue(); + + const newReport = await CoreReportBuilder.loadReport(parseInt(this.reportId), currentPage, REPORT_ROWS_LIMIT); + + if (!report || !newReport || newReport.data.rows.length === 0) { + this.updateState({ canLoadMoreRows: false }); + complete(); + + return; + } + + this.updateState({ + report: { + ...report, + data: { + ...report.data, + rows: [ + ...report.data.rows, + ...newReport.data.rows, + ], + }, + }, + }); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error loading more reports'); + + this.updateState({ canLoadMoreRows: false }); + this.updateState({ errorLoadingRows: true }); + } + + complete(); + } + + /** + * Expand or close card. + * + * @param rowIndex card to expand or close. + */ + toggleRow(rowIndex: number): void { + const { report } = this.state$.getValue(); + + if (!report?.data?.rows[rowIndex]) { + return; + } + + report.data.rows[rowIndex].isExpanded = !report.data.rows[rowIndex].isExpanded; + this.updateState({ report }); + } + +} + +export type CoreReportBuilderReportDetailState = { + report: CoreReportBuilderRetrieveReportMapped | null; + loaded: boolean; + canLoadMoreRows: boolean; + errorLoadingRows: boolean; + cardviewShowFirstTitle: boolean; + cardVisibleColumns: number; + page: number; +}; diff --git a/src/core/features/reportbuilder/components/report-summary/report-summary.html b/src/core/features/reportbuilder/components/report-summary/report-summary.html new file mode 100644 index 00000000000..08bbdecd46d --- /dev/null +++ b/src/core/features/reportbuilder/components/report-summary/report-summary.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + +
+ + +

+ + +

+
+
+ + + +

{{ item.title | translate }}

+ + +
+
+
+
+ + + + +

+ + + +

+
+
+
diff --git a/src/core/features/reportbuilder/components/report-summary/report-summary.scss b/src/core/features/reportbuilder/components/report-summary/report-summary.scss new file mode 100644 index 00000000000..32bb1fe4ef4 --- /dev/null +++ b/src/core/features/reportbuilder/components/report-summary/report-summary.scss @@ -0,0 +1,14 @@ +@import "~theme/globals"; + +.filters-info { + padding-bottom: 1rem; +} + +ion-footer { + ion-icon { + font-size: 16px; + color: $blue; + margin-right: .3rem; + vertical-align: middle; + } +} diff --git a/src/core/features/reportbuilder/components/report-summary/report-summary.ts b/src/core/features/reportbuilder/components/report-summary/report-summary.ts new file mode 100644 index 00000000000..a08e8cd95fa --- /dev/null +++ b/src/core/features/reportbuilder/components/report-summary/report-summary.ts @@ -0,0 +1,61 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { CoreReportBuilderReportDetail } from '@features/reportbuilder/services/reportbuilder'; +import { CoreFormatDatePipe } from '@pipes/format-date'; +import { CoreSites } from '@services/sites'; +import { ModalController } from '@singletons'; + +@Component({ + selector: 'core-report-builder-report-summary', + templateUrl: './report-summary.html', + styleUrls: ['./report-summary.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CoreReportBuilderReportSummaryComponent implements OnInit { + + @Input() reportDetail!: CoreReportBuilderReportDetail; + reportUrl!: string; + reportDetailToDisplay!: { title: string; text: string }[]; + + ngOnInit(): void { + const formatDate = new CoreFormatDatePipe(); + const site = CoreSites.getRequiredCurrentSite(); + this.reportUrl = `${site.getURL()}/reportbuilder/view.php?id=${this.reportDetail.id}`; + this.reportDetailToDisplay = [ + { + title: 'core.reportbuilder.reportsource', + text: this.reportDetail.sourcename, + }, + { + title: 'core.reportbuilder.timecreated', + text: formatDate.transform(this.reportDetail.timecreated * 1000), + }, + { + title: 'addon.mod_data.timemodified', + text: formatDate.transform(this.reportDetail.timemodified * 1000), + }, + { + title: 'core.reportbuilder.modifiedby', + text: this.reportDetail.modifiedby.fullname, + }, + ]; + } + + closeModal(): void { + ModalController.dismiss(); + } + +} diff --git a/src/core/features/reportbuilder/lang.json b/src/core/features/reportbuilder/lang.json new file mode 100644 index 00000000000..08f70940e3f --- /dev/null +++ b/src/core/features/reportbuilder/lang.json @@ -0,0 +1,9 @@ +{ + "modifiedby": "Modified by", + "reports": "Reports", + "filtersapplied": "There may be filters applied to this view. To edit filters or change the sorting order,
open this report on your browser.", + "reportsource": "Report source", + "timecreated": "Time created", + "showcolumns": "Show columns", + "hidecolumns": "Hide columns" +} diff --git a/src/core/features/reportbuilder/pages/list/list.html b/src/core/features/reportbuilder/pages/list/list.html new file mode 100644 index 00000000000..066eb5352a9 --- /dev/null +++ b/src/core/features/reportbuilder/pages/list/list.html @@ -0,0 +1,37 @@ + + + + + + +

{{ 'core.reportbuilder.reports' | translate }}

+
+
+
+ + + + + + + + +

+ +

+

{{ report.sourcename }}

+
+
+
+ + + + + + + + +
+
diff --git a/src/core/features/reportbuilder/pages/list/list.ts b/src/core/features/reportbuilder/pages/list/list.ts new file mode 100644 index 00000000000..2538cb26010 --- /dev/null +++ b/src/core/features/reportbuilder/pages/list/list.ts @@ -0,0 +1,128 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { AfterViewInit, ChangeDetectionStrategy, Component, OnDestroy } from '@angular/core'; +import { CoreListItemsManager } from '@classes/items-management/list-items-manager'; +import { CoreRoutedItemsManagerSourcesTracker } from '@classes/items-management/routed-items-manager-sources-tracker'; +import { CoreReportBuilderReportsSource } from '@features/reportbuilder/classes/reports-source'; +import { CoreReportBuilder, CoreReportBuilderReport, REPORTS_LIST_LIMIT } from '@features/reportbuilder/services/reportbuilder'; +import { IonRefresher } from '@ionic/angular'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { BehaviorSubject } from 'rxjs'; + +@Component({ + selector: 'core-report-builder-list', + templateUrl: './list.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CoreReportBuilderListPage implements AfterViewInit, OnDestroy { + + reports!: CoreListItemsManager; + + state$: Readonly> = new BehaviorSubject({ + page: 1, + perpage: REPORTS_LIST_LIMIT, + loaded: false, + loadMoreError: false, + }); + + constructor() { + try { + const source = CoreRoutedItemsManagerSourcesTracker.getOrCreateSource(CoreReportBuilderReportsSource, []); + this.reports = new CoreListItemsManager(source, CoreReportBuilderListPage); + } catch (error) { + CoreDomUtils.showErrorModal(error); + CoreNavigator.back(); + } + } + + /** + * @inheritdoc + */ + async ngAfterViewInit(): Promise { + try { + await this.fetchReports(true); + this.updateState({ loaded: true }); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error loading reports'); + + this.reports.reset(); + } + } + + /** + * Update reports list or loads it. + * + * @param reload is reoading or not. + */ + async fetchReports(reload: boolean): Promise { + reload ? await this.reports.reload() : await this.reports.load(); + this.updateState({ loadMoreError: false }); + } + + /** + * Properties of the state to update. + * + * @param state Object to update. + */ + updateState(state: Partial): void { + const previousState = this.state$.getValue(); + this.state$.next({ ...previousState, ...state }); + } + + /** + * Load a new batch of Reports. + * + * @param complete Completion callback. + */ + async fetchMoreReports(complete: () => void): Promise { + try { + await this.fetchReports(false); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error loading more reports'); + + this.updateState({ loadMoreError: true }); + } + + complete(); + } + + /** + * Refresh reports list. + * + * @param ionRefresher ionRefresher. + */ + async refreshReports(ionRefresher?: IonRefresher): Promise { + await CoreUtils.ignoreErrors(CoreReportBuilder.invalidateReportsList()); + await CoreUtils.ignoreErrors(this.fetchReports(true)); + await ionRefresher?.complete(); + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.reports.destroy(); + } + +} + +type CoreReportBuilderListState = { + page: number; + perpage: number; + loaded: boolean; + loadMoreError: boolean; +}; diff --git a/src/core/features/reportbuilder/pages/report/report.html b/src/core/features/reportbuilder/pages/report/report.html new file mode 100644 index 00000000000..f7151d711db --- /dev/null +++ b/src/core/features/reportbuilder/pages/report/report.html @@ -0,0 +1,23 @@ + + + + + + + + + + + +

+ +

+

{{ reportDetail.sourcename }}

+
+
+
+ + + + + diff --git a/src/core/features/reportbuilder/pages/report/report.ts b/src/core/features/reportbuilder/pages/report/report.ts new file mode 100644 index 00000000000..958509675c9 --- /dev/null +++ b/src/core/features/reportbuilder/pages/report/report.ts @@ -0,0 +1,52 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 } from '@angular/core'; +import { CoreReportBuilderReportSummaryComponent } from '@features/reportbuilder/components/report-summary/report-summary'; +import { CoreReportBuilderReportDetail } from '@features/reportbuilder/services/reportbuilder'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; + +@Component({ + selector: 'core-report-builder-report', + templateUrl: './report.html', +}) +export class CoreReportBuilderReportPage implements OnInit { + + reportId!: string; + reportDetail?: CoreReportBuilderReportDetail; + /** + * @inheritdoc + */ + ngOnInit(): void { + this.reportId = CoreNavigator.getRequiredRouteParam('id'); + } + + /** + * Save the report detail + * + * @param reportDetail it contents the detail of the report. + */ + loadReportDetail(reportDetail: CoreReportBuilderReportDetail): void { + this.reportDetail = reportDetail; + } + + openInfo(): void { + CoreDomUtils.openSideModal({ + component: CoreReportBuilderReportSummaryComponent, + componentProps: { reportDetail: this.reportDetail }, + }); + } + +} diff --git a/src/core/features/reportbuilder/reportbuilder-lazy.module.ts b/src/core/features/reportbuilder/reportbuilder-lazy.module.ts new file mode 100644 index 00000000000..c5064d38860 --- /dev/null +++ b/src/core/features/reportbuilder/reportbuilder-lazy.module.ts @@ -0,0 +1,44 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreSharedModule } from '@/core/shared.module'; +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { CoreReportBuilderComponentsModule } from './components/components.module'; +import { CoreReportBuilderListPage } from './pages/list/list'; +import { CoreReportBuilderReportPage } from './pages/report/report'; + +const routes: Routes = [ + { + path: '', + component: CoreReportBuilderListPage, + }, + { + path: ':id', + component: CoreReportBuilderReportPage, + }, +]; + +@NgModule({ + imports: [ + CoreSharedModule, + CoreReportBuilderComponentsModule, + RouterModule.forChild(routes), + ], + declarations: [ + CoreReportBuilderListPage, + CoreReportBuilderReportPage, + ], +}) +export class CoreReportBuilderLazyModule {} diff --git a/src/core/features/reportbuilder/reportbuilder.module.ts b/src/core/features/reportbuilder/reportbuilder.module.ts new file mode 100644 index 00000000000..a905a6cedc0 --- /dev/null +++ b/src/core/features/reportbuilder/reportbuilder.module.ts @@ -0,0 +1,44 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { APP_INITIALIZER, NgModule } from '@angular/core'; +import { Routes } from '@angular/router'; +import { CoreMainMenuRoutingModule } from '@features/mainmenu/mainmenu-routing.module'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CoreUserDelegate } from '@features/user/services/user-delegate'; +import { CoreReportBuilderHandler, CoreReportBuilderHandlerService } from './services/handlers/reportbuilder'; + +const routes: Routes = [ + { + path: CoreReportBuilderHandlerService.PAGE_NAME, + loadChildren: () => import('./reportbuilder-lazy.module').then(m => m.CoreReportBuilderLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuTabRoutingModule.forChild(routes), + ], + exports: [CoreMainMenuRoutingModule], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + useValue: () => { + CoreUserDelegate.registerHandler(CoreReportBuilderHandler.instance); + }, + }, + ], +}) +export class CoreReportBuilderModule {} diff --git a/src/core/features/reportbuilder/services/handlers/reportbuilder.ts b/src/core/features/reportbuilder/services/handlers/reportbuilder.ts new file mode 100644 index 00000000000..9aacb15abc6 --- /dev/null +++ b/src/core/features/reportbuilder/services/handlers/reportbuilder.ts @@ -0,0 +1,59 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreUserDelegateService, CoreUserProfileHandler, CoreUserProfileHandlerData } from '@features/user/services/user-delegate'; +import { CoreNavigator } from '@services/navigator'; +import { makeSingleton } from '@singletons'; +import { CoreReportBuilder } from '../reportbuilder'; + +/** + * Handler to visualize custom reports. + */ +@Injectable({ providedIn: 'root' }) +export class CoreReportBuilderHandlerService implements CoreUserProfileHandler { + + static readonly PAGE_NAME = 'reportbuilder'; + + type = CoreUserDelegateService.TYPE_NEW_PAGE; + cacheEnabled = true; + name = 'CoreReportBuilderDelegate'; + priority = 350; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return await CoreReportBuilder.isEnabled(); + } + + /** + * @inheritdoc + */ + getDisplayData(): CoreUserProfileHandlerData { + return { + class: 'core-report-builder', + icon: 'fa-list-alt', + title: 'core.reportbuilder.reports', + action: async (event): Promise => { + event.preventDefault(); + event.stopPropagation(); + await CoreNavigator.navigateToSitePath(CoreReportBuilderHandlerService.PAGE_NAME); + }, + }; + } + +} + +export const CoreReportBuilderHandler = makeSingleton(CoreReportBuilderHandlerService); diff --git a/src/core/features/reportbuilder/services/reportbuilder.ts b/src/core/features/reportbuilder/services/reportbuilder.ts new file mode 100644 index 00000000000..00dfac43f4c --- /dev/null +++ b/src/core/features/reportbuilder/services/reportbuilder.ts @@ -0,0 +1,267 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreError } from '@classes/errors/error'; +import { CoreSiteWSPreSets } from '@classes/site'; +import { CoreSites } from '@services/sites'; +import { CoreWSExternalWarning } from '@services/ws'; +import { makeSingleton } from '@singletons'; + +const ROOT_CACHE_KEY = 'mmaReportBuilder:'; +export const REPORTS_LIST_LIMIT = 20; +export const REPORT_ROWS_LIMIT = 20; + +@Injectable({ providedIn: 'root' }) +export class CoreReportBuilderService { + + /** + * Obtain the reports list. + * + * @param page Current page. + * @param perpage Reports obtained per page. + * @returns Reports list. + */ + async getReports(page?: number, perpage?: number): Promise { + const site = CoreSites.getRequiredCurrentSite(); + const preSets: CoreSiteWSPreSets = { cacheKey: this.getReportBuilderCacheKey() }; + const response = await site.read( + 'core_reportbuilder_list_reports', + { page, perpage }, + preSets, + ); + + return response.reports; + } + + /** + * Get the detail of a report. + * + * @param reportid Report id + * @param page Current page. + * @param perpage Rows obtained per page. + * @returns Detail of the report. + */ + async loadReport(reportid: number, page?: number, perpage?: number): Promise { + const site = CoreSites.getRequiredCurrentSite(); + const preSets: CoreSiteWSPreSets = { cacheKey: this.getReportBuilderReportCacheKey() }; + const report = await site.read( + 'core_reportbuilder_retrieve_report', + { reportid, page, perpage }, + preSets, + ); + + if (!report) { + throw new CoreError('An error ocurred.'); + } + + const settingsData: { + // eslint-disable-next-line @typescript-eslint/naming-convention + cardview_showfirsttitle: number; + // eslint-disable-next-line @typescript-eslint/naming-convention + cardview_visiblecolumns: number; + } = report.details.settingsdata ? JSON.parse(report.details.settingsdata) : {}; + + const mappedSettingsData: CoreReportBuilderReportDetailSettingsData = { + cardviewShowFirstTitle: settingsData.cardview_showfirsttitle === 1, + cardviewVisibleColumns: settingsData.cardview_visiblecolumns ?? 1, + }; + + return { + ...report, + details: { + ...report.details, + settingsdata: mappedSettingsData, + }, + data: { + ...report.data, + rows: [...report.data.rows.map(row => ({ columns: row.columns, isExpanded: row.isExpanded ?? false }))], + }, + }; + } + + /** + * View a report. + * + * @param reportid Report viewed. + * @returns Response of the WS. + */ + async viewReport(reportid: string): Promise { + const site = CoreSites.getRequiredCurrentSite(); + + await site.write('core_reportbuilder_view_report', { reportid }); + } + + /** + * Check if the feature is enabled or disabled. + * + * @returns Feature enabled or disabled. + */ + async isEnabled(): Promise { + const site = CoreSites.getRequiredCurrentSite(); + const hasTheVersionRequired = site.isVersionGreaterEqualThan('4.1'); + const hasAdvancedFeatureEnabled = site.canUseAdvancedFeature('enablecustomreports'); + const isFeatureDisabled = site.isFeatureDisabled('CoreReportBuilderDelegate'); + + return hasTheVersionRequired && hasAdvancedFeatureEnabled && !isFeatureDisabled; + } + + /** + * Invalidates reports list WS calls. + * + * @returns Promise resolved when the list is invalidated. + */ + async invalidateReportsList(): Promise { + const site = CoreSites.getRequiredCurrentSite(); + await site.invalidateWsCacheForKey(this.getReportBuilderCacheKey()); + } + + /** + * Invalidates report WS calls. + * + * @returns Promise resolved when report is invalidated. + */ + async invalidateReport(): Promise { + const site = CoreSites.getCurrentSite(); + + if (!site) { + return; + } + + await site.invalidateWsCacheForKey(this.getReportBuilderReportCacheKey()); + } + + /** + * Get cache key for report builder list WS calls. + * + * @returns Cache key. + */ + protected getReportBuilderCacheKey(): string { + return ROOT_CACHE_KEY + 'list'; + } + + /** + * Get cache key for report builder report WS calls. + * + * @returns Cache key. + */ + protected getReportBuilderReportCacheKey(): string { + return ROOT_CACHE_KEY + 'report'; + } + + isString(value: unknown): boolean { + return typeof value === 'string'; + } + +} + +export const CoreReportBuilder = makeSingleton(CoreReportBuilderService); + +type CoreReportBuilderPagination = { + page?: number; + perpage?: number; +}; + +export type CoreReportBuilderRetrieveReportWSParams = CoreReportBuilderPagination & { + reportid: number; // Report ID. +}; + +/** + * Data returned by core_reportbuilder_list_reports WS. + */ +export type CoreReportBuilderListReportsWSResponse = { + reports: CoreReportBuilderReportWSResponse[]; + warnings?: CoreWSExternalWarning[]; +}; + +export type CoreReportBuilderReportWSResponse = { + name: string; // Name. + source: string; // Source. + type: number; // Type. + uniquerows: boolean; // Uniquerows. + conditiondata: string; // Conditiondata. + settingsdata: string | null; // Settingsdata. + contextid: number; // Contextid. + component: string; // Component. + area: string; // Area. + itemid: number; // Itemid. + usercreated: number; // Usercreated. + id: number; // Id. + timecreated: number; // Timecreated. + timemodified: number; // Timemodified. + usermodified: number; // Usermodified. + sourcename: string; // Sourcename. + modifiedby: { + id: number; // Id. + email: string; // Email. + idnumber: string; // Idnumber. + phone1: string; // Phone1. + phone2: string; // Phone2. + department: string; // Department. + institution: string; // Institution. + fullname: string; // Fullname. + identity: string; // Identity. + profileurl: string; // Profileurl. + profileimageurl: string; // Profileimageurl. + profileimageurlsmall: string; // Profileimageurlsmall. + }; +}; + +/** + * Data returned by core_reportbuilder_retrieve_report WS. + */ +export type CoreReportBuilderRetrieveReportWSResponse = { + details: CoreReportBuilderReportWSResponse; + data: CoreReportBuilderReportDataWSResponse; + warnings?: CoreWSExternalWarning[]; +}; + +export interface CoreReportBuilderRetrieveReportMapped extends Omit { + details: CoreReportBuilderReportDetail; +} + +export type CoreReportBuilderReportDataWSResponse = { + headers: string[]; // Headers. + rows: { // Rows. + columns: (string | number)[]; // Columns. + isExpanded: boolean; + }[]; + totalrowcount: number; // Totalrowcount. +}; + +/** + * Params of core_reportbuilder_view_report WS. + */ +export type CoreReportBuilderViewReportWSParams = { + reportid: number; // Report ID. +}; + +/** + * Data returned by core_reportbuilder_view_report WS. + */ +export type CoreReportBuilderViewReportWSResponse = { + status: boolean; // Success. + warnings?: CoreWSExternalWarning[]; +}; + +export interface CoreReportBuilderReportDetail extends Omit { + settingsdata: CoreReportBuilderReportDetailSettingsData; +} + +export type CoreReportBuilderReportDetailSettingsData = { + cardviewShowFirstTitle: boolean; + cardviewVisibleColumns: number; +}; + +export interface CoreReportBuilderReport extends CoreReportBuilderReportWSResponse {}; diff --git a/src/core/features/reportbuilder/tests/behat/reportbuilder.feature b/src/core/features/reportbuilder/tests/behat/reportbuilder.feature new file mode 100644 index 00000000000..fc004127dcf --- /dev/null +++ b/src/core/features/reportbuilder/tests/behat/reportbuilder.feature @@ -0,0 +1,146 @@ +@app @javascript @core_reportbuilder @lms_from4.1 +Feature: Report builder + + Background: + Given the Moodle site is compatible with this feature + And the following "core_reportbuilder > Reports" exist: + | name | source | default | + | My report 01 | core_user\reportbuilder\datasource\users | 1 | + | My report 02 | core_user\reportbuilder\datasource\users | 2 | + | My report 03 | core_user\reportbuilder\datasource\users | 3 | + | My report 04 | core_user\reportbuilder\datasource\users | 4 | + | My report 05 | core_user\reportbuilder\datasource\users | 5 | + | My report 06 | core_user\reportbuilder\datasource\users | 6 | + | My report 07 | core_user\reportbuilder\datasource\users | 7 | + | My report 08 | core_user\reportbuilder\datasource\users | 8 | + | My report 09 | core_user\reportbuilder\datasource\users | 9 | + | My report 10 | core_user\reportbuilder\datasource\users | 10 | + | My report 11 | core_user\reportbuilder\datasource\users | 11 | + | My report 12 | core_user\reportbuilder\datasource\users | 12 | + | My report 13 | core_user\reportbuilder\datasource\users | 13 | + | My report 14 | core_user\reportbuilder\datasource\users | 14 | + | My report 15 | core_user\reportbuilder\datasource\users | 15 | + | My report 16 | core_user\reportbuilder\datasource\users | 16 | + | My report 17 | core_user\reportbuilder\datasource\users | 17 | + | My report 18 | core_user\reportbuilder\datasource\users | 18 | + | My report 19 | core_user\reportbuilder\datasource\users | 19 | + | My report 20 | core_user\reportbuilder\datasource\users | 20 | + | My report 21 | core_user\reportbuilder\datasource\users | 21 | + | My report 22 | core_user\reportbuilder\datasource\users | 22 | + | My report 23 | core_user\reportbuilder\datasource\users | 23 | + | My report 24 | core_user\reportbuilder\datasource\users | 24 | + | My report 25 | core_user\reportbuilder\datasource\users | 25 | + | My report 26 | core_user\reportbuilder\datasource\users | 26 | + | My report 27 | core_user\reportbuilder\datasource\users | 27 | + | My report 28 | core_user\reportbuilder\datasource\users | 28 | + | My report 29 | core_user\reportbuilder\datasource\users | 29 | + | My report 30 | core_user\reportbuilder\datasource\users | 30 | + | My report 31 | core_user\reportbuilder\datasource\users | 31 | + | My report 32 | core_user\reportbuilder\datasource\users | 32 | + | My report 33 | core_user\reportbuilder\datasource\users | 33 | + | My report 34 | core_user\reportbuilder\datasource\users | 34 | + | My report 35 | core_user\reportbuilder\datasource\users | 35 | + And the following "core_reportbuilder > Columns" exist: + | report | uniqueidentifier | + | My report 01 | user:fullname | + | My report 02 | user:fullname | + | My report 03 | user:fullname | + | My report 04 | user:fullname | + | My report 05 | user:fullname | + | My report 06 | user:fullname | + | My report 07 | user:fullname | + | My report 08 | user:fullname | + | My report 09 | user:fullname | + | My report 10 | user:fullname | + | My report 11 | user:fullname | + | My report 12 | user:fullname | + | My report 13 | user:fullname | + | My report 14 | user:fullname | + | My report 15 | user:fullname | + | My report 16 | user:fullname | + | My report 17 | user:fullname | + | My report 18 | user:fullname | + | My report 19 | user:fullname | + | My report 20 | user:fullname | + | My report 21 | user:fullname | + | My report 22 | user:fullname | + | My report 23 | user:fullname | + | My report 24 | user:fullname | + | My report 25 | user:fullname | + | My report 26 | user:fullname | + | My report 27 | user:fullname | + | My report 28 | user:fullname | + | My report 29 | user:fullname | + | My report 30 | user:fullname | + | My report 31 | user:fullname | + | My report 32 | user:fullname | + | My report 33 | user:fullname | + | My report 34 | user:fullname | + | My report 35 | user:fullname | + And the following "core_reportbuilder > Audiences" exist: + | report | configdata | classname | + | My report 01 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 02 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 03 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 04 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 05 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 06 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 07 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 08 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 09 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 10 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 11 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 12 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 13 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 14 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 15 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 16 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 17 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 18 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 19 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 20 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 21 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 22 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 23 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 24 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 25 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 26 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 27 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 28 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 29 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 30 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 31 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 32 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 33 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 34 | | core_reportbuilder\reportbuilder\audience\allusers | + | My report 35 | | core_reportbuilder\reportbuilder\audience\allusers | + And the following "users" exist: + | username | firstname | lastname | email | city | + | student1 | Lionel | Smith | lionel@example.com | Bilbao | + + Scenario: Open report in mobile + Given I enter the app + And I log in as "student1" + And I press the user menu button in the app + When I press "Reports" in the app + + # Find report in the screen + Then I should find "My report 03" in the app + And I press "My report 03" in the app + And I should find "My report 03" in the app + And I should find "Lionel Smith" in the app + But I should not find "My report 02" in the app + + Scenario: Open report in tablet + Given I enter the app + And I change viewport size to "1200x640" + And I log in as "student1" + And I press the user menu button in the app + When I press "Reports" in the app + + # Find report in the screen + Then I should find "My report 02" in the app + And I press "My report 02" in the app + And I should find "My report 02" in the app + And I should find "Lionel Smith" in the app + But I should not find "My report 03" in the app diff --git a/src/core/features/settings/classes/settings-sections-source.ts b/src/core/features/settings/classes/settings-sections-source.ts index d3bcd02c997..caf7554880b 100644 --- a/src/core/features/settings/classes/settings-sections-source.ts +++ b/src/core/features/settings/classes/settings-sections-source.ts @@ -16,7 +16,7 @@ import { CoreConstants } from '@/core/constants'; import { Params } from '@angular/router'; import { CoreRoutedItemsManagerSource } from '@classes/items-management/routed-items-manager-source'; import { SHAREDFILES_PAGE_NAME } from '@features/sharedfiles/sharedfiles.module'; -import { CoreApp } from '@services/app'; +import { CorePlatform } from '@services/platform'; /** * Provides a collection of setting sections. @@ -45,7 +45,7 @@ export class CoreSettingsSectionsSource extends CoreRoutedItemsManagerSource -

{{ appName }} {{ versionName }}

+

{{ appName }} {{ versionName }}

diff --git a/src/core/features/settings/pages/dev/dev.html b/src/core/features/settings/pages/dev/dev.html index bf055c1ca20..d1a0deffd58 100644 --- a/src/core/features/settings/pages/dev/dev.html +++ b/src/core/features/settings/pages/dev/dev.html @@ -19,35 +19,35 @@

{{ 'core.settings.developeroptions' | translate }}

-

Text direction

+

Text direction

{{ direction }}

-

Force safe area margins

+

Force safe area margins

-

Enable remote styles {{remoteStylesCount}} -

+

Enable remote styles {{remoteStylesCount}} +

-

Enable site plugin styles {{pluginStylesCount}} -

+

Enable site plugin styles {{pluginStylesCount}} +

-

Reset user tours

+

Reset user tours

@@ -61,7 +61,7 @@

Disabled features

-

{{ feature }}

+

{{ feature }}

@@ -72,7 +72,7 @@

Site plugins

-

{{ plugin.addon }} ({{plugin.component}})

+

{{ plugin.addon }} ({{plugin.component}})

{{plugin.version}}

diff --git a/src/core/features/settings/pages/dev/dev.ts b/src/core/features/settings/pages/dev/dev.ts index 63be91d598f..c7de23c2477 100644 --- a/src/core/features/settings/pages/dev/dev.ts +++ b/src/core/features/settings/pages/dev/dev.ts @@ -13,8 +13,10 @@ // limitations under the License. import { Component, OnInit } from '@angular/core'; +import { CoreLoginHelperProvider } from '@features/login/services/login-helper'; import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins'; import { CoreUserTours } from '@features/usertours/services/user-tours'; +import { CoreConfig } from '@services/config'; import { CorePlatform } from '@services/platform'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; @@ -149,6 +151,9 @@ export class CoreSettingsDevPage implements OnInit { */ async resetUserTours(): Promise { await CoreUserTours.resetTours(); + + await CoreConfig.delete(CoreLoginHelperProvider.ONBOARDING_DONE); + CoreDomUtils.showToast('User tours have been reseted'); } diff --git a/src/core/features/settings/pages/deviceinfo/deviceinfo.html b/src/core/features/settings/pages/deviceinfo/deviceinfo.html index cd049841de0..22ee220d5f9 100644 --- a/src/core/features/settings/pages/deviceinfo/deviceinfo.html +++ b/src/core/features/settings/pages/deviceinfo/deviceinfo.html @@ -21,26 +21,27 @@

- {{ 'core.settings.developeroptions' | translate }} +

{{ 'core.settings.developeroptions' | translate }}

-

{{ 'core.settings.appversion' | translate}}

+

{{ 'core.settings.appversion' | translate}}

{{ deviceInfo.versionName }} ({{ deviceInfo.versionCode }})

-

{{ 'core.settings.compilationinfo' | translate }}

+

{{ 'core.settings.compilationinfo' | translate }}

{{ deviceInfo.compilationTime | coreFormatDate: "LLL Z": false }}

{{ deviceInfo.lastCommit }}

-

{{ 'core.settings.siteinfo' | translate }} * -

+

{{ 'core.settings.siteinfo' | translate }} + * +

{{ deviceInfo.siteUrl }}

{{ deviceInfo.siteVersion }}

{{ deviceInfo.siteId }}

@@ -48,7 +49,7 @@

{{ 'core.settings.siteinfo' | translate }} -

{{ 'core.settings.filesystemroot' | translate }}

+

{{ 'core.settings.filesystemroot' | translate }}

{{ deviceInfo.fileSystemRoot }} @@ -59,97 +60,97 @@

{{ 'core.settings.filesystemroot' | translate }}

-

{{ 'core.settings.navigatoruseragent' | translate }}

+

{{ 'core.settings.navigatoruseragent' | translate }}

{{ deviceInfo.userAgent }}

-

{{ 'core.settings.navigatorlanguage' | translate }}

+

{{ 'core.settings.navigatorlanguage' | translate }}

{{ deviceInfo.browserLanguage }}

-

{{ 'core.settings.currentlanguage' | translate }}

+

{{ 'core.settings.currentlanguage' | translate }}

{{ currentLangName }} ({{ deviceInfo.currentLanguage }})

-

{{ 'core.settings.locationhref' | translate }}

+

{{ 'core.settings.locationhref' | translate }}

{{ deviceInfo.locationHref }}

-

{{ 'core.settings.displayformat' | translate }}

+

{{ 'core.settings.displayformat' | translate }}

{{ 'core.' + deviceInfo.deviceType | translate }}

-

{{ 'core.settings.deviceos' | translate}}

+

{{ 'core.settings.deviceos' | translate}}

{{ deviceOsTranslated }}

-

{{ 'core.settings.screen' | translate }}

+

{{ 'core.settings.screen' | translate }}

{{ deviceInfo.screen }}

-

{{ 'core.settings.networkstatus' | translate}}

+

{{ 'core.settings.networkstatus' | translate}}

{{ 'core.' + deviceInfo.networkStatus | translate }}

-

{{ 'core.settings.wificonnection' | translate}}

+

{{ 'core.settings.wificonnection' | translate}}

{{ 'core.' + deviceInfo.wifiConnection | translate }}

-

{{ 'core.settings.cordovaversion' | translate }}

+

{{ 'core.settings.cordovaversion' | translate }}

{{ deviceInfo.cordovaVersion }}

-

{{ 'core.settings.cordovadeviceplatform' | translate }}

+

{{ 'core.settings.cordovadeviceplatform' | translate }}

{{ deviceInfo.platform }}

-

{{ 'core.settings.cordovadeviceosversion' | translate }}

+

{{ 'core.settings.cordovadeviceosversion' | translate }}

{{ deviceInfo.osVersion }}

-

{{ 'core.settings.cordovadevicemodel' | translate}}

+

{{ 'core.settings.cordovadevicemodel' | translate}}

{{ deviceInfo.model }}

-

{{ 'core.settings.cordovadeviceuuid' | translate}}

+

{{ 'core.settings.cordovadeviceuuid' | translate}}

{{ deviceInfo.uuid }}

-

{{ 'core.settings.pushid' | translate }}

+

{{ 'core.settings.pushid' | translate }}

{{ deviceInfo.pushId }}

-

{{ 'core.settings.localnotifavailable' | translate }}

+

{{ 'core.settings.localnotifavailable' | translate }}

{{ 'core.' + deviceInfo.localNotifAvailable | translate }}

diff --git a/src/core/features/settings/pages/deviceinfo/deviceinfo.ts b/src/core/features/settings/pages/deviceinfo/deviceinfo.ts index 1ce2ff0102e..81d20558c9a 100644 --- a/src/core/features/settings/pages/deviceinfo/deviceinfo.ts +++ b/src/core/features/settings/pages/deviceinfo/deviceinfo.ts @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { CoreApp } from '@services/app'; import { Component, OnDestroy } from '@angular/core'; import { CoreConstants } from '@/core/constants'; import { CoreLocalNotifications } from '@services/local-notifications'; @@ -111,10 +110,10 @@ export class CoreSettingsDeviceInfoPage implements OnDestroy { if (CorePlatform.isMobile()) { this.deviceInfo.deviceType = CorePlatform.is('tablet') ? 'tablet' : 'phone'; - if (CoreApp.isAndroid()) { + if (CorePlatform.isAndroid()) { this.deviceInfo.deviceOs = 'android'; this.deviceOsTranslated = 'Android'; - } else if (CoreApp.isIOS()) { + } else if (CorePlatform.isIOS()) { this.deviceInfo.deviceOs = 'ios'; this.deviceOsTranslated = 'iOS'; } else { diff --git a/src/core/features/settings/pages/general/general.html b/src/core/features/settings/pages/general/general.html index c3de108b35d..9cc006e5710 100644 --- a/src/core/features/settings/pages/general/general.html +++ b/src/core/features/settings/pages/general/general.html @@ -13,7 +13,7 @@

{{ 'core.settings.general' | translate }}

-

{{ 'core.settings.language' | translate }}

+

{{ 'core.settings.language' | translate }}

@@ -22,7 +22,7 @@

{{ 'core.settings.language' | translate }}

-

{{ 'core.settings.fontsize' | translate }}

+

{{ 'core.settings.fontsize' | translate }}

{{ 'core.settings.fontsize' | translate }}

-

{{ 'core.settings.colorscheme' | translate }}

+

{{ 'core.settings.colorscheme' | translate }}

{{ 'core.settings.forcedsetting' | translate }}

{{ 'core.settings.colorscheme' | translate }}

-

{{ 'core.settings.enablerichtexteditor' | translate }}

+

{{ 'core.settings.enablerichtexteditor' | translate }}

{{ 'core.settings.enablerichtexteditordescription' | translate }}

-

{{ 'core.settings.ioscookies' | translate }}

+

{{ 'core.settings.ioscookies' | translate }}

{{ 'core.settings.ioscookiesdescription' | translate }}

{{ 'core.opensettings' | translate }} @@ -69,14 +69,14 @@

{{ 'core.settings.ioscookies' | translate }}

-

{{ 'core.settings.debugdisplay' | translate }}

+

{{ 'core.settings.debugdisplay' | translate }}

{{ 'core.settings.debugdisplaydescription' | translate }}

-

{{ 'core.settings.enablefirebaseanalytics' | translate }}

+

{{ 'core.settings.enablefirebaseanalytics' | translate }}

{{ 'core.settings.enablefirebaseanalyticsdescription' | translate }}

diff --git a/src/core/features/settings/pages/general/general.ts b/src/core/features/settings/pages/general/general.ts index d24b3cc2c52..874028c8ede 100644 --- a/src/core/features/settings/pages/general/general.ts +++ b/src/core/features/settings/pages/general/general.ts @@ -20,13 +20,13 @@ import { CoreLang } from '@services/lang'; import { CoreDomUtils } from '@services/utils/dom'; import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications'; import { CoreSettingsHelper, CoreColorScheme, CoreZoomLevel } from '../../services/settings-helper'; -import { CoreApp } from '@services/app'; import { CoreIframeUtils } from '@services/utils/iframe'; import { Diagnostic, Translate } from '@singletons'; import { CoreSites } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; import { AlertButton } from '@ionic/angular'; import { CoreNavigator } from '@services/navigator'; +import { CorePlatform } from '@services/platform'; /** * Page that displays the general settings. @@ -81,7 +81,7 @@ export class CoreSettingsGeneralPage { this.colorSchemes.push(CoreColorScheme.LIGHT); this.selectedScheme = this.colorSchemes[0]; } else { - this.isAndroid = CoreApp.isAndroid(); + this.isAndroid = CorePlatform.isAndroid(); this.colorSchemes = CoreSettingsHelper.getAllowedColorSchemes(); this.selectedScheme = await CoreConfig.get(CoreConstants.SETTINGS_COLOR_SCHEME, CoreColorScheme.LIGHT); } diff --git a/src/core/features/settings/pages/space-usage/space-usage.html b/src/core/features/settings/pages/space-usage/space-usage.html index ec3524adb10..0f5040f7521 100644 --- a/src/core/features/settings/pages/space-usage/space-usage.html +++ b/src/core/features/settings/pages/space-usage/space-usage.html @@ -57,7 +57,7 @@

{{ 'core.settings.spaceusage' | translate }}

-

{{ 'core.settings.total' | translate }}

+

{{ 'core.settings.total' | translate }}

{{ totalSpaceUsage | coreBytesToSize }} diff --git a/src/core/features/sharedfiles/components/list-modal/list-modal.html b/src/core/features/sharedfiles/components/list-modal/list-modal.html index 682496ad493..8fe9bb42a87 100644 --- a/src/core/features/sharedfiles/components/list-modal/list-modal.html +++ b/src/core/features/sharedfiles/components/list-modal/list-modal.html @@ -1,7 +1,7 @@ -

{{ title }}

+

{{ title }}

diff --git a/src/core/features/sharedfiles/services/handlers/settings.ts b/src/core/features/sharedfiles/services/handlers/settings.ts index a501481b3f8..bba96ad8975 100644 --- a/src/core/features/sharedfiles/services/handlers/settings.ts +++ b/src/core/features/sharedfiles/services/handlers/settings.ts @@ -15,7 +15,7 @@ import { Injectable } from '@angular/core'; import { CoreSettingsHandler, CoreSettingsHandlerData } from '@features/settings/services/settings-delegate'; import { SHAREDFILES_PAGE_NAME } from '@features/sharedfiles/sharedfiles.module'; -import { CoreApp } from '@services/app'; +import { CorePlatform } from '@services/platform'; import { makeSingleton } from '@singletons'; /** @@ -33,7 +33,7 @@ export class CoreSharedFilesSettingsHandlerService implements CoreSettingsHandle * @returns Whether or not the handler is enabled on a site level. */ async isEnabled(): Promise { - return CoreApp.isIOS(); + return CorePlatform.isIOS(); } /** diff --git a/src/core/features/sharedfiles/services/handlers/upload.ts b/src/core/features/sharedfiles/services/handlers/upload.ts index a32da22fd81..c0ff3719599 100644 --- a/src/core/features/sharedfiles/services/handlers/upload.ts +++ b/src/core/features/sharedfiles/services/handlers/upload.ts @@ -19,7 +19,7 @@ import { CoreFileUploaderHandlerData, CoreFileUploaderHandlerResult, } from '@features/fileuploader/services/fileuploader-delegate'; -import { CoreApp } from '@services/app'; +import { CorePlatform } from '@services/platform'; import { makeSingleton } from '@singletons'; import { CoreSharedFilesHelper } from '../sharedfiles-helper'; /** @@ -37,7 +37,7 @@ export class CoreSharedFilesUploadHandlerService implements CoreFileUploaderHand * @returns True or promise resolved with true if enabled. */ async isEnabled(): Promise { - return CoreApp.isIOS(); + return CorePlatform.isIOS(); } /** diff --git a/src/core/features/sharedfiles/services/sharedfiles-helper.ts b/src/core/features/sharedfiles/services/sharedfiles-helper.ts index c3ab8d4c3fb..4fa26d913a5 100644 --- a/src/core/features/sharedfiles/services/sharedfiles-helper.ts +++ b/src/core/features/sharedfiles/services/sharedfiles-helper.ts @@ -18,7 +18,6 @@ import { FileEntry } from '@ionic-native/file/ngx'; import { CoreCanceledError } from '@classes/errors/cancelederror'; import { CoreFileUploader } from '@features/fileuploader/services/fileuploader'; import { CoreFileUploaderHandlerResult } from '@features/fileuploader/services/fileuploader-delegate'; -import { CoreApp } from '@services/app'; import { CoreFile } from '@services/file'; import { CoreNavigator } from '@services/navigator'; import { CoreSites } from '@services/sites'; @@ -49,7 +48,7 @@ export class CoreSharedFilesHelperProvider { * Initialize. */ initialize(): void { - if (!CoreApp.isIOS()) { + if (!CorePlatform.isIOS()) { return; } diff --git a/src/core/features/sitehome/pages/index/index.html b/src/core/features/sitehome/pages/index/index.html index b780c5565e7..62f5682f8ac 100644 --- a/src/core/features/sitehome/pages/index/index.html +++ b/src/core/features/sitehome/pages/index/index.html @@ -59,7 +59,7 @@ -

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

+

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

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

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

-

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

+

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

@@ -87,7 +87,7 @@

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

-

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

+

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

@@ -98,7 +98,7 @@

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

-

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

+

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

diff --git a/src/core/features/siteplugins/pages/module-index/module-index.ts b/src/core/features/siteplugins/pages/module-index/module-index.ts index 0dccf03fecd..053eb374f16 100644 --- a/src/core/features/siteplugins/pages/module-index/module-index.ts +++ b/src/core/features/siteplugins/pages/module-index/module-index.ts @@ -19,6 +19,9 @@ import { CoreCourseModuleData } from '@features/course/services/course-helper'; import { CanLeave } from '@guards/can-leave'; import { CoreNavigator } from '@services/navigator'; import { CoreSitePluginsModuleIndexComponent } from '../../components/module-index/module-index'; +import { CoreSites } from '@services/sites'; +import { CoreFilterFormatTextOptions } from '@features/filter/services/filter'; +import { CoreFilterHelper } from '@features/filter/services/filter-helper'; /** * Page to render the index page of a module site plugin. @@ -38,10 +41,31 @@ export class CoreSitePluginsModuleIndexPage implements OnInit, CanLeave { /** * @inheritdoc */ - ngOnInit(): void { + async ngOnInit(): Promise { this.title = CoreNavigator.getRouteParam('title'); this.module = CoreNavigator.getRouteParam('module'); this.courseId = CoreNavigator.getRouteNumberParam('courseId'); + + if (this.title) { + const siteId = CoreSites.getCurrentSiteId(); + + const options: CoreFilterFormatTextOptions = { + clean: false, + courseId: this.courseId, + wsNotFiltered: false, + singleLine: true, + }; + + const filteredTitle = await CoreFilterHelper.getFiltersAndFormatText( + this.title.trim(), + 'module', + this.module?.id ?? -1, + options, + siteId, + ); + + this.title = filteredTitle.text; + } } /** diff --git a/src/core/features/siteplugins/services/siteplugins.ts b/src/core/features/siteplugins/services/siteplugins.ts index 6cc006cd43f..77247e9b797 100644 --- a/src/core/features/siteplugins/services/siteplugins.ts +++ b/src/core/features/siteplugins/services/siteplugins.ts @@ -97,7 +97,7 @@ export class CoreSitePluginsProvider { }; if (args.appismobile) { - defaultArgs.appplatform = CoreApp.isIOS() ? 'ios' : 'android'; + defaultArgs.appplatform = CorePlatform.isIOS() ? 'ios' : 'android'; } return { diff --git a/src/core/features/tag/components/feed/core-tag-feed.html b/src/core/features/tag/components/feed/core-tag-feed.html index 9e8f4430953..17b3de5ca41 100644 --- a/src/core/features/tag/components/feed/core-tag-feed.html +++ b/src/core/features/tag/components/feed/core-tag-feed.html @@ -6,7 +6,7 @@ -

{{ item.heading }}

+

{{ item.heading }}

{{ text }}

diff --git a/src/core/features/tag/pages/index/index.html b/src/core/features/tag/pages/index/index.html index 151b3f93f48..2a07a407416 100644 --- a/src/core/features/tag/pages/index/index.html +++ b/src/core/features/tag/pages/index/index.html @@ -21,13 +21,13 @@

{{ 'core.tag.tag' | translate }}: {{ tagName }}

{{ 'core.tag.warningareasnotsupported' | translate }} + (click)="openArea(area)" [attr.aria-current]="area.id == selectedAreaId ? 'page' : 'false'" button detail="true"> -

{{ area!.nameKey | translate }}

+

{{ area.nameKey | translate }}

- - - {{ 'core.tag.tagareabadgedescription' | translate:{ count: area!.badge } }} + + + {{ 'core.tag.tagareabadgedescription' | translate:{ count: area.badge } }}
diff --git a/src/core/features/user/components/tag-area/core-user-tag-area.html b/src/core/features/user/components/tag-area/core-user-tag-area.html index 3531973e08c..ccba8b62643 100644 --- a/src/core/features/user/components/tag-area/core-user-tag-area.html +++ b/src/core/features/user/components/tag-area/core-user-tag-area.html @@ -2,7 +2,7 @@ -

{{ item.heading }}

+

{{ item.heading }}

diff --git a/src/core/features/user/pages/about/about.html b/src/core/features/user/pages/about/about.html index 751d3dd524d..bf0f04d8126 100644 --- a/src/core/features/user/pages/about/about.html +++ b/src/core/features/user/pages/about/about.html @@ -32,12 +32,12 @@

{{ user.fullname }}

-

{{ 'core.user.contact' | translate}}

+

{{ 'core.user.contact' | translate}}

-

{{ 'core.user.email' | translate }}

+

{{ 'core.user.email' | translate }}

{{ user.email }}

@@ -45,7 +45,7 @@

{{ 'core.user.email' | translate }}

-

{{ 'core.user.phone1' | translate}}

+

{{ 'core.user.phone1' | translate}}

{{ user.phone1 }}

@@ -53,7 +53,7 @@

{{ 'core.user.phone1' | translate}}

-

{{ 'core.user.phone2' | translate}}

+

{{ 'core.user.phone2' | translate}}

{{ user.phone2 }}

@@ -61,7 +61,7 @@

{{ 'core.user.phone2' | translate}}

-

{{ 'core.user.address' | translate}}

+

{{ 'core.user.address' | translate}}

{{ formattedAddress }}

@@ -69,13 +69,13 @@

{{ 'core.user.address' | translate}}

-

{{ 'core.user.city' | translate}}

+

{{ 'core.user.city' | translate}}

{{ user.city }}

-

{{ 'core.user.country' | translate}}

+

{{ 'core.user.country' | translate}}

{{ user.country }}

@@ -83,12 +83,12 @@

{{ 'core.user.country' | translate}}

-

{{ 'core.userdetails' | translate}}

+

{{ 'core.userdetails' | translate}}

-

{{ 'core.user.webpage' | translate}}

+

{{ 'core.user.webpage' | translate}}

{{ user.url }}

@@ -96,7 +96,7 @@

{{ 'core.user.webpage' | translate}}