Merge pull request #3570 from moodlehq/integration

Integration
main
Moodle Mobile Team 2023-02-28 11:01:52 +01:00 committed by GitHub
commit 15fafef5f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
329 changed files with 6615 additions and 1449 deletions

View File

@ -26,7 +26,7 @@ jobs:
env: env:
MOODLE_DOCKER_DB: pgsql MOODLE_DOCKER_DB: pgsql
MOODLE_DOCKER_BROWSER: chrome 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_BRANCH: ${{ github.event.inputs.moodle_branch || 'master' }}
MOODLE_REPOSITORY: ${{ github.event.inputs.moodle_repository || 'https://github.com/moodle/moodle' }} MOODLE_REPOSITORY: ${{ github.event.inputs.moodle_repository || 'https://github.com/moodle/moodle' }}
BEHAT_TAGS: ${{ github.event.inputs.behat_tags || '~@performance' }} 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 git clone --branch master --depth 1 https://github.com/moodlehq/moodle-docker $GITHUB_WORKSPACE/moodle-docker
- name: Install npm packages - name: Install npm packages
run: npm ci --no-audit 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 - name: Generate Behat tests plugin
run: | run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
@ -50,12 +56,21 @@ jobs:
run: | run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle
cp $GITHUB_WORKSPACE/moodle-docker/config.docker-template.php $GITHUB_WORKSPACE/moodle/config.php 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_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_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 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 pull
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose up -d $GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-compose up -d
$GITHUB_WORKSPACE/moodle-docker/bin/moodle-docker-wait-for-db $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 - name: Compile & launch app with Docker
run: | run: |
docker build --build-arg build_command="npm run build:test" -t moodlehq/moodleapp:behat . docker build --build-arg build_command="npm run build:test" -t moodlehq/moodleapp:behat .
@ -65,8 +80,20 @@ jobs:
- name: Init Behat - name: Init Behat
run: | run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle 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 - name: Run Behat tests
run: | run: |
export MOODLE_DOCKER_WWWROOT=$GITHUB_WORKSPACE/moodle 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

View File

@ -8,7 +8,7 @@ jobs:
env: env:
MOODLE_DOCKER_DB: pgsql MOODLE_DOCKER_DB: pgsql
MOODLE_DOCKER_BROWSER: chrome MOODLE_DOCKER_BROWSER: chrome
MOODLE_DOCKER_PHP_VERSION: 7.4 MOODLE_DOCKER_PHP_VERSION: '8.0'
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3

View File

@ -6,6 +6,8 @@ WORKDIR /app
# Prepare node dependencies # Prepare node dependencies
RUN apt-get update && apt-get install libsecret-1-0 -y RUN apt-get update && apt-get install libsecret-1-0 -y
COPY package*.json ./ COPY package*.json ./
COPY patches ./patches
RUN echo "unsafe-perm=true" > ./.npmrc
RUN npm ci --no-audit RUN npm ci --no-audit
# Build source # Build source

View File

@ -4,10 +4,12 @@ Moodle App
This is the primary repository of source code for the official mobile app for Moodle. This is the primary repository of source code for the official mobile app for Moodle.
* [User documentation](https://docs.moodle.org/en/Moodle_app) * [User documentation](https://docs.moodle.org/en/Moodle_app)
* [Developer documentation](http://docs.moodle.org/dev/Moodle_App) * [Developer documentation](https://moodledev.io/general/app)
* [Development environment setup](https://docs.moodle.org/dev/Setting_up_your_development_environment_for_the_Moodle_App) * [Development environment setup](https://moodledev.io/general/app/development/setup)
* [Bug Tracker](https://tracker.moodle.org/browse/MOBILE) * [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 License
------- -------

View File

@ -42,7 +42,8 @@
"input": "src/theme/theme.scss" "input": "src/theme/theme.scss"
} }
], ],
"scripts": [] "scripts": [],
"webWorkerTsConfig": "tsconfig.worker.json"
}, },
"configurations": { "configurations": {
"production": { "production": {
@ -50,6 +51,10 @@
{ {
"replace": "src/testing/testing.module.ts", "replace": "src/testing/testing.module.ts",
"with": "src/testing/testing.module.prod.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": { "optimization": {

View File

@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<widget android-versionCode="41001" id="com.moodle.moodlemobile" ios-CFBundleVersion="4.1.0.1" version="4.1.0" versionCode="41001" xmlns="http://www.w3.org/ns/widgets" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:cdv="http://cordova.apache.org/ns/1.0"> <widget android-versionCode="41100" id="com.moodle.moodlemobile" ios-CFBundleVersion="4.1.1.0" version="4.1.1" versionCode="41100" xmlns="http://www.w3.org/ns/widgets" xmlns:android="http://schemas.android.com/apk/res/android" xmlns:cdv="http://cordova.apache.org/ns/1.0">
<name>Moodle</name> <name>Moodle</name>
<description>Moodle official app</description> <description>Moodle official app</description>
<author email="mobile@moodle.com" href="http://moodle.com">Moodle Mobile team</author> <author email="mobile@moodle.com" href="http://moodle.com">Moodle Mobile team</author>
@ -27,7 +27,7 @@
<preference name="UIWebViewBounce" value="false" /> <preference name="UIWebViewBounce" value="false" />
<preference name="DisallowOverscroll" value="true" /> <preference name="DisallowOverscroll" value="true" />
<preference name="prerendered-icon" value="true" /> <preference name="prerendered-icon" value="true" />
<preference name="AppendUserAgent" value="MoodleMobile 4.1.0 (41001)" /> <preference name="AppendUserAgent" value="MoodleMobile 4.1.1 (41100)" />
<preference name="BackupWebStorage" value="none" /> <preference name="BackupWebStorage" value="none" />
<preference name="ScrollEnabled" value="false" /> <preference name="ScrollEnabled" value="false" />
<preference name="KeyboardDisplayRequiresUserAction" value="false" /> <preference name="KeyboardDisplayRequiresUserAction" value="false" />
@ -196,13 +196,9 @@
<param name="android-package" value="com.adobe.phonegap.push.PushPlugin" /> <param name="android-package" value="com.adobe.phonegap.push.PushPlugin" />
</feature> </feature>
</config-file> </config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="Media">
<param name="android-package" value="org.apache.cordova.media.AudioHandler" />
</feature>
</config-file>
<config-file parent="/*" target="AndroidManifest.xml"> <config-file parent="/*" target="AndroidManifest.xml">
<uses-feature android:name="android.hardware.bluetooth" android:required="false" /> <uses-feature android:name="android.hardware.bluetooth" android:required="false" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
</config-file> </config-file>
<config-file parent="/*" target="AndroidManifest.xml"> <config-file parent="/*" target="AndroidManifest.xml">
<queries> <queries>
@ -236,7 +232,7 @@
<true /> <true />
</edit-config> </edit-config>
<edit-config file="*-Info.plist" mode="merge" target="CFBundleShortVersionString"> <edit-config file="*-Info.plist" mode="merge" target="CFBundleShortVersionString">
<string>4.1.0</string> <string>4.1.1</string>
</edit-config> </edit-config>
<edit-config file="*-Info.plist" mode="overwrite" target="CFBundleLocalizations"> <edit-config file="*-Info.plist" mode="overwrite" target="CFBundleLocalizations">
<array> <array>

View File

@ -71,5 +71,5 @@ gulp.task('watch', () => {
}); });
gulp.task('watch-behat', () => { 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'));
}); });

View File

@ -19,6 +19,7 @@
require_once(__DIR__ . '/../../../../lib/behat/behat_base.php'); require_once(__DIR__ . '/../../../../lib/behat/behat_base.php');
require_once(__DIR__ . '/behat_app_helper.php'); require_once(__DIR__ . '/behat_app_helper.php');
use Behat\Behat\Hook\Scope\ScenarioScope;
use Behat\Gherkin\Node\TableNode; use Behat\Gherkin\Node\TableNode;
use Behat\Mink\Exception\DriverException; use Behat\Mink\Exception\DriverException;
use Behat\Mink\Exception\ExpectationException; use Behat\Mink\Exception\ExpectationException;
@ -45,6 +46,27 @@ class behat_app extends behat_app_helper {
protected $windowsize = '360x720'; 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. * Opens the Moodle App in the browser and optionally logs in.
* *
@ -215,13 +237,21 @@ class behat_app extends behat_app_helper {
/** /**
* Trigger swipe gesture. * 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 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) { public function i_swipe_in_the_app(string $direction, bool $hasLocator = false, string $locator = '') {
$method = 'swipe' . ucwords($direction); if ($hasLocator) {
$locator = $this->parse_element_locator($locator);
}
$this->zone_js("getAngularInstance('ion-content', 'CoreSwipeNavigationDirective').$method()"); $result = $this->zone_js("swipe('$direction'" . ($hasLocator ? ", $locator" : '') . ')');
if ($result !== 'OK') {
throw new DriverException('Error when swiping - ' . $result);
}
$this->wait_for_pending_js(); $this->wait_for_pending_js();

View File

@ -1,8 +1,8 @@
{ {
"app_id": "com.moodle.moodlemobile", "app_id": "com.moodle.moodlemobile",
"appname": "Moodle Mobile", "appname": "Moodle Mobile",
"versioncode": 41001, "versioncode": 41100,
"versionname": "4.1.0", "versionname": "4.1.1",
"cache_update_frequency_usually": 420000, "cache_update_frequency_usually": 420000,
"cache_update_frequency_often": 1200000, "cache_update_frequency_often": 1200000,
"cache_update_frequency_sometimes": 3600000, "cache_update_frequency_sometimes": 3600000,

357
package-lock.json generated
View File

@ -1,6 +1,6 @@
{ {
"name": "moodlemobile", "name": "moodlemobile",
"version": "4.1.0", "version": "4.1.1",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@ -3618,7 +3618,6 @@
"version": "7.9.6", "version": "7.9.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.6.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.6.tgz",
"integrity": "sha512-64AF1xY3OAkFHqOb9s4jpgk1Mm5vDZ4L3acHvAml+53nO1XbXLuDodsVpO4OIUsmemlUHMxNdYMNJmsvOwLrvQ==", "integrity": "sha512-64AF1xY3OAkFHqOb9s4jpgk1Mm5vDZ4L3acHvAml+53nO1XbXLuDodsVpO4OIUsmemlUHMxNdYMNJmsvOwLrvQ==",
"dev": true,
"requires": { "requires": {
"regenerator-runtime": "^0.13.4" "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": { "@ionic-native/media-capture": {
"version": "5.36.0", "version": "5.36.0",
"resolved": "https://registry.npmjs.org/@ionic-native/media-capture/-/media-capture-5.36.0.tgz", "resolved": "https://registry.npmjs.org/@ionic-native/media-capture/-/media-capture-5.36.0.tgz",
@ -4319,11 +4303,11 @@
} }
}, },
"@ionic/angular": { "@ionic/angular": {
"version": "5.9.2", "version": "5.9.4",
"resolved": "https://registry.npmjs.org/@ionic/angular/-/angular-5.9.2.tgz", "resolved": "https://registry.npmjs.org/@ionic/angular/-/angular-5.9.4.tgz",
"integrity": "sha512-5GzKg+l4au3xFECky2v/USlRsmTAXgvNO5Zalt7NUXc//VJIL2lQvswojE6FBWuM/xR5W0CWbJdFth19TaZWVQ==", "integrity": "sha512-U/85FePF48VaZXTudTwpVXDqhGmYfarl/7vki7a4umnIORnWtHqD2/pXsqqZ/O1EcbALwULYIeVXAfkFpPd2wQ==",
"requires": { "requires": {
"@ionic/core": "5.9.2", "@ionic/core": "5.9.4",
"tslib": "^1.9.3" "tslib": "^1.9.3"
}, },
"dependencies": { "dependencies": {
@ -4666,9 +4650,9 @@
} }
}, },
"@ionic/core": { "@ionic/core": {
"version": "5.9.2", "version": "5.9.4",
"resolved": "https://registry.npmjs.org/@ionic/core/-/core-5.9.2.tgz", "resolved": "https://registry.npmjs.org/@ionic/core/-/core-5.9.4.tgz",
"integrity": "sha512-1ZqSBS8R6tGQsc+LsLxIRv0q3Ww6jwgJXLvdn6FmVWfpPbBvT+CjCuU9hqJ5qwM+atErblUMYSexvvpws8lGAA==", "integrity": "sha512-Ngz9yVT6fIiGdSxxBer8uJxP4w6PasvohYpLxhtMgYiWnyIu0vZra2ui3HrYukCzUo5/SbNPiUr1l7cj1E+7qw==",
"requires": { "requires": {
"@stencil/core": "^2.4.0", "@stencil/core": "^2.4.0",
"ionicons": "^5.5.3", "ionicons": "^5.5.3",
@ -5852,9 +5836,9 @@
} }
}, },
"@stencil/core": { "@stencil/core": {
"version": "2.11.0", "version": "2.22.2",
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.11.0.tgz", "resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.22.2.tgz",
"integrity": "sha512-/IubCWhVXCguyMUp/3zGrg3c882+RJNg/zpiKfyfJL3kRCOwe+/MD8OoAXVGdd+xAohZKIi1Ik+EHFlsptsjLg==" "integrity": "sha512-r+vbxsGNcBaV1VDOYW25lv4QfXTlNoIb5GpUX7rZ+cr59yqYCZC5tlV+IzX6YgHKW62ulCc9M3RYtTfHtNbNNw=="
}, },
"@storybook/addon-controls": { "@storybook/addon-controls": {
"version": "6.1.21", "version": "6.1.21",
@ -9255,6 +9239,71 @@
"eslint-visitor-keys": "^2.0.0" "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": { "@webassemblyjs/ast": {
"version": "1.9.0", "version": "1.9.0",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz",
@ -9430,6 +9479,11 @@
"@xtuc/long": "4.2.2" "@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": { "@xtuc/ieee754": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "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": { "agent-base": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", "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", "resolved": "https://registry.npmjs.org/cordova-plugin-ionic-keyboard/-/cordova-plugin-ionic-keyboard-2.2.0.tgz",
"integrity": "sha512-yDUG+9ieKVRitq5mGlNxjaZh/MgEhFFIgTIPhqSbUaQ8UuZbawy5mhJAVClqY97q8/rcQtL6dCDa7x2sEtCLcA==" "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": { "cordova-plugin-media-capture": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/cordova-plugin-media-capture/-/cordova-plugin-media-capture-3.0.3.tgz", "resolved": "https://registry.npmjs.org/cordova-plugin-media-capture/-/cordova-plugin-media-capture-3.0.3.tgz",
@ -15674,8 +15749,7 @@
"dom-walk": { "dom-walk": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==", "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
"dev": true
}, },
"domain-browser": { "domain-browser": {
"version": "1.2.0", "version": "1.2.0",
@ -16760,6 +16834,11 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" "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": { "eventemitter3": {
"version": "4.0.7", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
@ -18627,7 +18706,6 @@
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
"dev": true,
"requires": { "requires": {
"min-document": "^2.19.0", "min-document": "^2.19.0",
"process": "^0.11.10" "process": "^0.11.10"
@ -20030,6 +20108,11 @@
"integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=", "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=",
"dev": true "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": { "infer-owner": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz",
@ -20545,8 +20628,7 @@
"is-function": { "is-function": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz",
"integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==", "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ=="
"dev": true
}, },
"is-generator-fn": { "is-generator-fn": {
"version": "2.1.0", "version": "2.1.0",
@ -22371,6 +22453,11 @@
"source-map-support": "^0.5.5" "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": { "keytar": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/keytar/-/keytar-7.2.0.tgz", "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.2.0.tgz",
@ -22940,6 +23027,31 @@
"yallist": "^4.0.0" "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": { "macos-release": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.0.tgz", "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.0.tgz",
@ -23425,7 +23537,6 @@
"version": "2.19.0", "version": "2.19.0",
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
"integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==", "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==",
"dev": true,
"requires": { "requires": {
"dom-walk": "^0.1.0" "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": { "ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" "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": { "nan": {
"version": "2.14.1", "version": "2.14.1",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz", "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz",
@ -24875,6 +25045,29 @@
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
"dev": true "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": { "on-finished": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@ -25905,6 +26098,14 @@
"node-modules-regexp": "^1.0.0" "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": { "pkg-dir": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
@ -26858,8 +27059,7 @@
"process": { "process": {
"version": "0.11.10", "version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI="
"dev": true
}, },
"process-nextick-args": { "process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
@ -28270,8 +28470,7 @@
"regenerator-runtime": { "regenerator-runtime": {
"version": "0.13.5", "version": "0.13.5",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz",
"integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA==", "integrity": "sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA=="
"dev": true
}, },
"regenerator-transform": { "regenerator-transform": {
"version": "0.14.5", "version": "0.14.5",
@ -28944,6 +29143,14 @@
"aproba": "^1.1.1" "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": { "rxjs": {
"version": "6.5.5", "version": "6.5.5",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.5.tgz", "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", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" "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": { "safe-regex": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
@ -32936,6 +33151,11 @@
"prepend-http": "^2.0.0" "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": { "use": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
@ -33118,6 +33338,54 @@
"extsprintf": "^1.2.0" "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": { "vinyl": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.2.1.tgz", "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": { "w3c-hr-time": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",

View File

@ -1,6 +1,6 @@
{ {
"name": "moodlemobile", "name": "moodlemobile",
"version": "4.1.0", "version": "4.1.1",
"description": "The official app for Moodle.", "description": "The official app for Moodle.",
"author": { "author": {
"name": "Moodle Pty Ltd.", "name": "Moodle Pty Ltd.",
@ -24,7 +24,7 @@
"build": "ionic build", "build": "ionic build",
"build:prod": "NODE_ENV=production ionic build --prod", "build:prod": "NODE_ENV=production ionic build --prod",
"build:test": "NODE_ENV=testing ionic build --configuration=testing", "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", "dev:ios": "ionic cordova run ios",
"prod:android": "NODE_ENV=production ionic cordova run android --prod", "prod:android": "NODE_ENV=production ionic cordova run android --prod",
"prod:ios": "NODE_ENV=production ionic cordova run ios --prod", "prod:ios": "NODE_ENV=production ionic cordova run ios --prod",
@ -63,7 +63,6 @@
"@ionic-native/ionic-webview": "5.36.0", "@ionic-native/ionic-webview": "5.36.0",
"@ionic-native/keyboard": "5.36.0", "@ionic-native/keyboard": "5.36.0",
"@ionic-native/local-notifications": "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/media-capture": "5.36.0",
"@ionic-native/network": "5.36.0", "@ionic-native/network": "5.36.0",
"@ionic-native/push": "5.36.0", "@ionic-native/push": "5.36.0",
@ -73,7 +72,7 @@
"@ionic-native/status-bar": "5.36.0", "@ionic-native/status-bar": "5.36.0",
"@ionic-native/web-intent": "5.36.0", "@ionic-native/web-intent": "5.36.0",
"@ionic-native/zip": "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-opener": "3.0.5-moodle.1",
"@moodlehq/cordova-plugin-file-transfer": "1.7.1-moodle.5", "@moodlehq/cordova-plugin-file-transfer": "1.7.1-moodle.5",
"@moodlehq/cordova-plugin-inappbrowser": "5.0.0-moodle.3", "@moodlehq/cordova-plugin-inappbrowser": "5.0.0-moodle.3",
@ -104,7 +103,6 @@
"cordova-plugin-file": "6.0.2", "cordova-plugin-file": "6.0.2",
"cordova-plugin-geolocation": "4.1.0", "cordova-plugin-geolocation": "4.1.0",
"cordova-plugin-ionic-keyboard": "2.2.0", "cordova-plugin-ionic-keyboard": "2.2.0",
"cordova-plugin-media": "5.0.4",
"cordova-plugin-media-capture": "3.0.3", "cordova-plugin-media-capture": "3.0.3",
"cordova-plugin-network-information": "3.0.0", "cordova-plugin-network-information": "3.0.0",
"cordova-plugin-prevent-override": "1.0.1", "cordova-plugin-prevent-override": "1.0.1",
@ -122,10 +120,13 @@
"mathjax": "2.7.9", "mathjax": "2.7.9",
"moment": "2.29.4", "moment": "2.29.4",
"moment-timezone": "0.5.38", "moment-timezone": "0.5.38",
"mp3-mediarecorder": "^4.0.5",
"nl.kingsquare.cordova.background-audio": "1.0.1", "nl.kingsquare.cordova.background-audio": "1.0.1",
"ogv": "1.8.9",
"rxjs": "6.5.5", "rxjs": "6.5.5",
"ts-md5": "1.2.7", "ts-md5": "1.2.7",
"tslib": "2.3.1", "tslib": "2.3.1",
"video.js": "7.21.1",
"zone.js": "0.10.3" "zone.js": "0.10.3"
}, },
"devDependencies": { "devDependencies": {
@ -222,9 +223,6 @@
"ANDROID_SUPPORT_V4_VERSION": "26.+" "ANDROID_SUPPORT_V4_VERSION": "26.+"
}, },
"cordova-plugin-media-capture": {}, "cordova-plugin-media-capture": {},
"cordova-plugin-media": {
"KEEP_AVAUDIOSESSION_ALWAYS_ACTIVE": "NO"
},
"cordova-plugin-network-information": {}, "cordova-plugin-network-information": {},
"@moodlehq/cordova-plugin-qrscanner": {}, "@moodlehq/cordova-plugin-qrscanner": {},
"cordova-plugin-splashscreen": {}, "cordova-plugin-splashscreen": {},

View File

@ -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<TEventMap extends Record<string, Event>, TMode extends "standard" | "strict"> = EventTarget<TEventMap, TMode> & defineEventAttribute.EventAttributes<any, TEventMap>;
+ type CustomEventTarget<TEventMap extends Record<string, Event>, TMode extends "standard" | "strict"> = EventTarget<TEventMap, TMode> & defineEventAttribute.EventAttributes<any>;
}
/**
* 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<TEventTarget extends EventTarget, TEventType extends string, TEventConstrucor extends typeof Event>(target: TEventTarget, type: TEventType, _eventClass?: TEventConstrucor): asserts target is TEventTarget & defineEventAttribute.EventAttributes<TEventTarget, Record<TEventType, InstanceType<TEventConstrucor>>>;
+export declare function defineEventAttribute<TEventTarget extends EventTarget, TEventType extends string, TEventConstrucor extends typeof Event>(target: TEventTarget, type: TEventType, _eventClass?: TEventConstrucor): asserts target is TEventTarget & defineEventAttribute.EventAttributes<TEventTarget>;
export declare namespace defineEventAttribute {
/**
* Definition of event attributes.
*/
- type EventAttributes<TEventTarget extends EventTarget<any, any>, TEventMap extends Record<string, Event>> = {
- [P in string & keyof TEventMap as `on${P}`]: EventTarget.CallbackFunction<TEventTarget, TEventMap[P]> | null;
- };
+ type EventAttributes<TEventTarget extends EventTarget<any, any>> = Record<string, EventTarget.CallbackFunction<TEventTarget, any> | null>;
}
/**
* Set the warning handler.

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -76,37 +76,46 @@ async function main() {
}; };
writeFileSync(pluginFilePath, replaceArguments(fileContents, replacements)); writeFileSync(pluginFilePath, replaceArguments(fileContents, replacements));
// Copy feature files. // Copy feature and snapshot files.
if (!excludeFeatures) { if (!excludeFeatures) {
const behatTempFeaturesPath = `${pluginPath}/behat-tmp`; const behatTempFeaturesPath = `${pluginPath}/behat-tmp`;
copySync(projectPath('src'), behatTempFeaturesPath, { filter: isFeatureFileOrDirectory }); copySync(projectPath('src'), behatTempFeaturesPath, { filter: shouldCopyFileOrDirectory });
const behatFeaturesPath = `${pluginPath}/tests/behat`; const behatFeaturesPath = `${pluginPath}/tests/behat`;
if (!existsSync(behatFeaturesPath)) { if (!existsSync(behatFeaturesPath)) {
mkdirSync(behatFeaturesPath, {recursive: true}); mkdirSync(behatFeaturesPath, {recursive: true});
} }
for await (const featureFile of getDirectoryFiles(behatTempFeaturesPath)) { for await (const file of getDirectoryFiles(behatTempFeaturesPath)) {
const featurePath = dirname(featureFile); const filePath = dirname(file);
if (!featurePath.endsWith('/tests/behat')) {
if (filePath.endsWith('/tests/behat/snapshots')) {
renameSync(file, behatFeaturesPath + '/snapshots/' + basename(file));
continue; continue;
} }
const newPath = featurePath.substring(0, featurePath.length - ('/tests/behat'.length)); if (!filePath.endsWith('/tests/behat')) {
continue;
}
const newPath = filePath.substring(0, filePath.length - ('/tests/behat'.length));
const searchRegExp = /\//g; const searchRegExp = /\//g;
const prefix = relative(behatTempFeaturesPath, newPath).replace(searchRegExp,'-') || 'core'; const prefix = relative(behatTempFeaturesPath, newPath).replace(searchRegExp,'-') || 'core';
const featureFilename = prefix + '-' + basename(featureFile); const featureFilename = prefix + '-' + basename(file);
renameSync(featureFile, behatFeaturesPath + '/' + featureFilename); renameSync(file, behatFeaturesPath + '/' + featureFilename);
} }
rmSync(behatTempFeaturesPath, {recursive: true}); rmSync(behatTempFeaturesPath, {recursive: true});
} }
} }
function isFeatureFileOrDirectory(src) { function shouldCopyFileOrDirectory(path) {
const stats = statSync(src); const stats = statSync(path);
return stats.isDirectory() || extname(src) === '.feature'; return stats.isDirectory()
|| extname(path) === '.feature'
|| extname(path) === '.png';
} }
function isExcluded(file, exclusions) { function isExcluded(file, exclusions) {

View File

@ -27,7 +27,10 @@ const ASSETS = {
'/node_modules/mathjax/jax/output/SVG': '/lib/mathjax/jax/output/SVG', '/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/jax/output/PreviewHTML': '/lib/mathjax/jax/output/PreviewHTML',
'/node_modules/mathjax/localization': '/lib/mathjax/localization', '/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', '/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) { module.exports = function(ctx) {

View File

@ -1652,6 +1652,13 @@
"core.courses.totalcoursesearchresults": "local_moodlemobileapp", "core.courses.totalcoursesearchresults": "local_moodlemobileapp",
"core.currentdevice": "local_moodlemobileapp", "core.currentdevice": "local_moodlemobileapp",
"core.custom": "form", "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.datastoredoffline": "local_moodlemobileapp",
"core.date": "moodle", "core.date": "moodle",
"core.datecreated": "repository", "core.datecreated": "repository",
@ -1735,9 +1742,11 @@
"core.filenotfound": "resource", "core.filenotfound": "resource",
"core.fileuploader.addfiletext": "repository", "core.fileuploader.addfiletext": "repository",
"core.fileuploader.audio": "local_moodlemobileapp", "core.fileuploader.audio": "local_moodlemobileapp",
"core.fileuploader.audiotitle": "tiny_recordrtc",
"core.fileuploader.camera": "local_moodlemobileapp", "core.fileuploader.camera": "local_moodlemobileapp",
"core.fileuploader.confirmuploadfile": "local_moodlemobileapp", "core.fileuploader.confirmuploadfile": "local_moodlemobileapp",
"core.fileuploader.confirmuploadunknownsize": "local_moodlemobileapp", "core.fileuploader.confirmuploadunknownsize": "local_moodlemobileapp",
"core.fileuploader.discardrecording": "local_moodlemobileapp",
"core.fileuploader.errorcapturingaudio": "local_moodlemobileapp", "core.fileuploader.errorcapturingaudio": "local_moodlemobileapp",
"core.fileuploader.errorcapturingimage": "local_moodlemobileapp", "core.fileuploader.errorcapturingimage": "local_moodlemobileapp",
"core.fileuploader.errorcapturingvideo": "local_moodlemobileapp", "core.fileuploader.errorcapturingvideo": "local_moodlemobileapp",
@ -1751,11 +1760,18 @@
"core.fileuploader.fileuploaded": "local_moodlemobileapp", "core.fileuploader.fileuploaded": "local_moodlemobileapp",
"core.fileuploader.invalidfiletype": "repository", "core.fileuploader.invalidfiletype": "repository",
"core.fileuploader.maxbytesfile": "local_moodlemobileapp", "core.fileuploader.maxbytesfile": "local_moodlemobileapp",
"core.fileuploader.microphonepermissiondenied": "local_moodlemobileapp",
"core.fileuploader.microphonepermissionrestricted": "local_moodlemobileapp",
"core.fileuploader.more": "data", "core.fileuploader.more": "data",
"core.fileuploader.pauserecording": "local_moodlemobileapp",
"core.fileuploader.photoalbums": "local_moodlemobileapp", "core.fileuploader.photoalbums": "local_moodlemobileapp",
"core.fileuploader.readingfile": "local_moodlemobileapp", "core.fileuploader.readingfile": "local_moodlemobileapp",
"core.fileuploader.readingfileperc": "local_moodlemobileapp", "core.fileuploader.readingfileperc": "local_moodlemobileapp",
"core.fileuploader.resumerecording": "local_moodlemobileapp",
"core.fileuploader.selectafile": "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.uploadafile": "local_moodlemobileapp",
"core.fileuploader.uploading": "local_moodlemobileapp", "core.fileuploader.uploading": "local_moodlemobileapp",
"core.fileuploader.uploadingperc": "local_moodlemobileapp", "core.fileuploader.uploadingperc": "local_moodlemobileapp",
@ -2098,6 +2114,7 @@
"core.nopasswordchangeforced": "local_moodlemobileapp", "core.nopasswordchangeforced": "local_moodlemobileapp",
"core.nopermissionerror": "local_moodlemobileapp", "core.nopermissionerror": "local_moodlemobileapp",
"core.nopermissions": "error", "core.nopermissions": "error",
"core.nopermissiontoaccesspage": "error",
"core.noresults": "moodle", "core.noresults": "moodle",
"core.noselection": "form", "core.noselection": "form",
"core.notapplicable": "local_moodlemobileapp", "core.notapplicable": "local_moodlemobileapp",

View File

@ -33,7 +33,7 @@
</ion-item-divider> </ion-item-divider>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ 'core.name' | translate}}</h2> <p class="item-heading">{{ 'core.name' | translate}}</p>
<p>{{ user.fullname }}</p> <p>{{ user.fullname }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
@ -48,13 +48,13 @@
</ion-item-divider> </ion-item-divider>
<ion-item class="ion-text-wrap" *ngIf="badge.issuername"> <ion-item class="ion-text-wrap" *ngIf="badge.issuername">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.issuername' | translate}}</h2> <p class="item-heading">{{ 'addon.badges.issuername' | translate}}</p>
<p>{{ badge.issuername }}</p> <p>{{ badge.issuername }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.issuercontact"> <ion-item class="ion-text-wrap" *ngIf="badge.issuercontact">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.contact' | translate}}</h2> <p class="item-heading">{{ 'addon.badges.contact' | translate}}</p>
<p><a href="mailto:{{badge.issuercontact}}" core-link auto-login="no" [showBrowserWarning]="false"> <p><a href="mailto:{{badge.issuercontact}}" core-link auto-login="no" [showBrowserWarning]="false">
{{ badge.issuercontact }} {{ badge.issuercontact }}
</a></p> </a></p>
@ -70,37 +70,37 @@
</ion-item-divider> </ion-item-divider>
<ion-item class="ion-text-wrap" *ngIf="badge.name"> <ion-item class="ion-text-wrap" *ngIf="badge.name">
<ion-label> <ion-label>
<h2>{{ 'core.name' | translate}}</h2> <p class="item-heading">{{ 'core.name' | translate}}</p>
<p>{{ badge.name }}</p> <p>{{ badge.name }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.version"> <ion-item class="ion-text-wrap" *ngIf="badge.version">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.version' | translate}}</h2> <p class="item-heading">{{ 'addon.badges.version' | translate}}</p>
<p>{{ badge.version }}</p> <p>{{ badge.version }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.language"> <ion-item class="ion-text-wrap" *ngIf="badge.language">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.language' | translate}}</h2> <p class="item-heading">{{ 'addon.badges.language' | translate}}</p>
<p>{{ badge.language }}</p> <p>{{ badge.language }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.description"> <ion-item class="ion-text-wrap" *ngIf="badge.description">
<ion-label> <ion-label>
<h2>{{ 'core.description' | translate}}</h2> <p class="item-heading">{{ 'core.description' | translate}}</p>
<p>{{ badge.description }}</p> <p>{{ badge.description }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.imageauthorname"> <ion-item class="ion-text-wrap" *ngIf="badge.imageauthorname">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.imageauthorname' | translate}}</h2> <p class="item-heading">{{ 'addon.badges.imageauthorname' | translate}}</p>
<p>{{ badge.imageauthorname }}</p> <p>{{ badge.imageauthorname }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.imageauthoremail"> <ion-item class="ion-text-wrap" *ngIf="badge.imageauthoremail">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.imageauthoremail' | translate}}</h2> <p class="item-heading">{{ 'addon.badges.imageauthoremail' | translate}}</p>
<p><a href="mailto:{{badge.imageauthoremail}}" core-link auto-login="no" [showBrowserWarning]="false"> <p><a href="mailto:{{badge.imageauthoremail}}" core-link auto-login="no" [showBrowserWarning]="false">
{{ badge.imageauthoremail }} {{ badge.imageauthoremail }}
</a></p> </a></p>
@ -108,19 +108,19 @@
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.imageauthorurl"> <ion-item class="ion-text-wrap" *ngIf="badge.imageauthorurl">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.imageauthorurl' | translate}}</h2> <p class="item-heading">{{ 'addon.badges.imageauthorurl' | translate}}</p>
<p><a [href]="badge.imageauthorurl" core-link auto-login="no"> {{ badge.imageauthorurl }} </a></p> <p><a [href]="badge.imageauthorurl" core-link auto-login="no"> {{ badge.imageauthorurl }} </a></p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.imagecaption"> <ion-item class="ion-text-wrap" *ngIf="badge.imagecaption">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.imagecaption' | translate}}</h2> <p class="item-heading">{{ 'addon.badges.imagecaption' | translate}}</p>
<p>{{ badge.imagecaption }}</p> <p>{{ badge.imagecaption }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="course"> <ion-item class="ion-text-wrap" *ngIf="course">
<ion-label> <ion-label>
<h2>{{ 'core.course' | translate}}</h2> <p class="item-heading">{{ 'core.course' | translate}}</p>
<p> <p>
<core-format-text [text]="course.fullname" contextLevel="course" [contextInstanceId]="courseId"> <core-format-text [text]="course.fullname" contextLevel="course" [contextInstanceId]="courseId">
</core-format-text> </core-format-text>
@ -138,13 +138,13 @@
</ion-item-divider> </ion-item-divider>
<ion-item class="ion-text-wrap" *ngIf="badge.dateissued"> <ion-item class="ion-text-wrap" *ngIf="badge.dateissued">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.dateawarded' | translate}}</h2> <p class="item-heading">{{ 'addon.badges.dateawarded' | translate}}</p>
<p>{{badge.dateissued * 1000 | coreFormatDate }}</p> <p>{{badge.dateissued * 1000 | coreFormatDate }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.dateexpire"> <ion-item class="ion-text-wrap" *ngIf="badge.dateexpire">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.expirydate' | translate}}</h2> <p class="item-heading">{{ 'addon.badges.expirydate' | translate}}</p>
<p> <p>
{{ badge.dateexpire * 1000 | coreFormatDate }} {{ badge.dateexpire * 1000 | coreFormatDate }}
<span class="text-danger" *ngIf="currentTime >= badge.dateexpire"> <span class="text-danger" *ngIf="currentTime >= badge.dateexpire">
@ -165,13 +165,13 @@
</ion-item-divider> </ion-item-divider>
<ion-item class="ion-text-wrap" *ngIf="badge.endorsement.issuername"> <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.issuername">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.issuername' | translate}}</h2> <p class="item-heading">{{ 'addon.badges.issuername' | translate}}</p>
<p>{{ badge.endorsement.issuername }}</p> <p>{{ badge.endorsement.issuername }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.endorsement.issueremail"> <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.issueremail">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.issueremail' | translate}}</h2> <p class="item-heading">{{ 'addon.badges.issueremail' | translate}}</p>
<p> <p>
<a href="mailto:{{badge.endorsement.issueremail}}" core-link auto-login="no" [showBrowserWarning]="false"> <a href="mailto:{{badge.endorsement.issueremail}}" core-link auto-login="no" [showBrowserWarning]="false">
{{ badge.endorsement.issueremail }} {{ badge.endorsement.issueremail }}
@ -181,25 +181,25 @@
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.endorsement.issuerurl"> <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.issuerurl">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.issuerurl' | translate}}</h2> <p class="item-heading">{{ 'addon.badges.issuerurl' | translate}}</p>
<p><a [href]="badge.endorsement.issuerurl" core-link auto-login="no"> {{ badge.endorsement.issuerurl }} </a></p> <p><a [href]="badge.endorsement.issuerurl" core-link auto-login="no"> {{ badge.endorsement.issuerurl }} </a></p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.endorsement.dateissued"> <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.dateissued">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.dateawarded' | translate}}</h2> <p class="item-heading">{{ 'addon.badges.dateawarded' | translate}}</p>
<p>{{ badge.endorsement.dateissued * 1000 | coreFormatDate }}</p> <p>{{ badge.endorsement.dateissued * 1000 | coreFormatDate }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.endorsement.claimid"> <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.claimid">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.claimid' | translate}}</h2> <p class="item-heading">{{ 'addon.badges.claimid' | translate}}</p>
<p><a [href]="badge.endorsement.claimid" core-link auto-login="no"> {{ badge.endorsement.claimid }} </a></p> <p><a [href]="badge.endorsement.claimid" core-link auto-login="no"> {{ badge.endorsement.claimid }} </a></p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.endorsement.claimcomment"> <ion-item class="ion-text-wrap" *ngIf="badge.endorsement.claimcomment">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.claimcomment' | translate}}</h2> <p class="item-heading">{{ 'addon.badges.claimcomment' | translate}}</p>
<p>{{ badge.endorsement.claimcomment }}</p> <p>{{ badge.endorsement.claimcomment }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
@ -214,12 +214,12 @@
</ion-item-divider> </ion-item-divider>
<ion-item class="ion-text-wrap" *ngFor="let relatedBadge of badge.relatedbadges"> <ion-item class="ion-text-wrap" *ngFor="let relatedBadge of badge.relatedbadges">
<ion-label> <ion-label>
<h2>{{ relatedBadge.name }}</h2> <p class="item-heading">{{ relatedBadge.name }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.relatedbadges.length == 0"> <ion-item class="ion-text-wrap" *ngIf="badge.relatedbadges.length == 0">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.norelated' | translate}}</h2> <p class="item-heading">{{ 'addon.badges.norelated' | translate}}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
</ion-item-group> </ion-item-group>
@ -234,12 +234,12 @@
<ion-item class="ion-text-wrap" *ngFor="let alignment of badge.alignment" [href]="alignment.targeturl" core-link <ion-item class="ion-text-wrap" *ngFor="let alignment of badge.alignment" [href]="alignment.targeturl" core-link
auto-login="no"> auto-login="no">
<ion-label> <ion-label>
<h2>{{ alignment.targetname }}</h2> <p class="item-heading">{{ alignment.targetname }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="badge.alignment.length == 0"> <ion-item class="ion-text-wrap" *ngIf="badge.alignment.length == 0">
<ion-label> <ion-label>
<h2>{{ 'addon.badges.noalignment' | translate}}</h2> <p class="item-heading">{{ 'addon.badges.noalignment' | translate}}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
</ion-item-group> </ion-item-group>

View File

@ -25,11 +25,11 @@ Feature: Timeline block.
| assign | C1 | assign03 | Assignment 03 | ##tomorrow## | | assign | C1 | assign03 | Assignment 03 | ##tomorrow## |
| assign | C2 | assign04 | Assignment 04 | ##+2 days## | | assign | C2 | assign04 | Assignment 04 | ##+2 days## |
| assign | C1 | assign05 | Assignment 05 | ##+5 days## | | assign | C1 | assign05 | Assignment 05 | ##+5 days## |
| assign | C2 | assign06 | Assignment 06 | ##+1 month## | | assign | C2 | assign06 | Assignment 06 | ##+31 days## |
| assign | C2 | assign07 | Assignment 07 | ##+1 month## | | assign | C2 | assign07 | Assignment 07 | ##+31 days## |
| assign | C3 | assign08 | Assignment 08 | ##+1 month## | | assign | C3 | assign08 | Assignment 08 | ##+31 days## |
| assign | C2 | assign09 | Assignment 09 | ##+1 month## | | assign | C2 | assign09 | Assignment 09 | ##+31 days## |
| assign | C1 | assign10 | Assignment 10 | ##+1 month## | | assign | C1 | assign10 | Assignment 10 | ##+31 days## |
| assign | C1 | assign11 | Assignment 11 | ##+6 months## | | assign | C1 | assign11 | Assignment 11 | ##+6 months## |
| assign | C1 | assign12 | Assignment 12 | ##+6 months## | | assign | C1 | assign12 | Assignment 12 | ##+6 months## |
| assign | C1 | assign13 | Assignment 13 | ##+6 months## | | assign | C1 | assign13 | Assignment 13 | ##+6 months## |

View File

@ -25,11 +25,11 @@ Feature: Timeline block.
| assign | C1 | assign03 | Assignment 03 | ##tomorrow## | | assign | C1 | assign03 | Assignment 03 | ##tomorrow## |
| assign | C2 | assign04 | Assignment 04 | ##+2 days## | | assign | C2 | assign04 | Assignment 04 | ##+2 days## |
| assign | C1 | assign05 | Assignment 05 | ##+5 days## | | assign | C1 | assign05 | Assignment 05 | ##+5 days## |
| assign | C2 | assign06 | Assignment 06 | ##+1 month## | | assign | C2 | assign06 | Assignment 06 | ##+31 days## |
| assign | C2 | assign07 | Assignment 07 | ##+1 month## | | assign | C2 | assign07 | Assignment 07 | ##+31 days## |
| assign | C3 | assign08 | Assignment 08 | ##+1 month## | | assign | C3 | assign08 | Assignment 08 | ##+31 days## |
| assign | C2 | assign09 | Assignment 09 | ##+1 month## | | assign | C2 | assign09 | Assignment 09 | ##+31 days## |
| assign | C1 | assign10 | Assignment 10 | ##+1 month## | | assign | C1 | assign10 | Assignment 10 | ##+31 days## |
| assign | C1 | assign11 | Assignment 11 | ##+6 months## | | assign | C1 | assign11 | Assignment 11 | ##+6 months## |
| assign | C1 | assign12 | Assignment 12 | ##+6 months## | | assign | C1 | assign12 | Assignment 12 | ##+6 months## |
| assign | C1 | assign13 | Assignment 13 | ##+6 months## | | assign | C1 | assign13 | Assignment 13 | ##+6 months## |

View File

@ -27,19 +27,22 @@
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<core-user-avatar [user]="entry.user" slot="start" [courseId]="entry.courseid"></core-user-avatar> <core-user-avatar [user]="entry.user" slot="start" [courseId]="entry.courseid"></core-user-avatar>
<ion-label> <ion-label>
<p class="item-heading"> <div class="flex-row ion-justify-content-between ion-align-items-center">
<core-format-text [text]="entry.subject" [contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId"> <h2>
</core-format-text> <core-format-text [text]="entry.subject" [contextLevel]="contextLevel"
<ion-note class="ion-float-end ion-padding-start ion-text-end"> [contextInstanceId]="contextInstanceId">
</core-format-text>
</h2>
<ion-note class="ion-text-end">
{{ 'addon.blog.' + entry.publishTranslated! | translate}} {{ 'addon.blog.' + entry.publishTranslated! | translate}}
</ion-note> </ion-note>
</p> </div>
<p> <div class="flex-row ion-justify-content-between ion-align-items-center">
<ion-note class="ion-float-end ion-text-end"> {{entry.user && entry.user.fullname}}
<ion-note class="ion-text-end">
{{entry.created | coreDateDayOrTime}} {{entry.created | coreDateDayOrTime}}
</ion-note> </ion-note>
{{entry.user && entry.user!.fullname}} </div>
</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-card-content> <ion-card-content>

View File

@ -33,7 +33,7 @@
</ion-grid> </ion-grid>
<core-swipe-slides [manager]="manager"> <core-swipe-slides [manager]="manager">
<ng-template let-month="item"> <ng-template let-month="item" let-activeView="active">
<!-- Calendar view. --> <!-- Calendar view. -->
<ion-grid class="addon-calendar-months" role="table" aria-describedby="addon-calendar-monthname"> <ion-grid class="addon-calendar-months" role="table" aria-describedby="addon-calendar-monthname">
<div role="rowgroup"> <div role="rowgroup">
@ -57,9 +57,9 @@
"today": month.isCurrentMonth && day.istoday, "today": month.isCurrentMonth && day.istoday,
"weekend": day.isweekend, "weekend": day.isweekend,
"duration_finish": day.haslastdayofevent "duration_finish": day.haslastdayofevent
}' [class.addon-calendar-event-past-day]="month.isPastMonth || day.ispast" role="cell" tabindex="0" }' [class.addon-calendar-event-past-day]="month.isPastMonth || day.ispast" role="cell"
(ariaButtonClick)="dayClicked(day.mday)"> (ariaButtonClick)="dayClicked(day.mday)" [tabindex]="activeView ? 0 : -1">
<p class="addon-calendar-day-number" role="button"> <p class="addon-calendar-day-number">
<span aria-hidden="true">{{ day.mday }}</span> <span aria-hidden="true">{{ day.mday }}</span>
<span class="sr-only">{{ day.periodName | translate }}</span> <span class="sr-only">{{ day.periodName | translate }}</span>
</p> </p>
@ -72,8 +72,8 @@
<div class="ion-hide-md-down addon-calendar-day-events" *ngIf="day.filteredEvents"> <div class="ion-hide-md-down addon-calendar-day-events" *ngIf="day.filteredEvents">
<ng-container *ngFor="let event of day.filteredEvents | slice:0:4; let index = index"> <ng-container *ngFor="let event of day.filteredEvents | slice:0:4; let index = index">
<div *ngIf="index < 3 || day.filteredEvents.length == 4" class="addon-calendar-event" <div *ngIf="index < 3 || day.filteredEvents.length == 4" class="addon-calendar-event"
[class.addon-calendar-event-past]="event.ispast" role="button" tabindex="0" [class.addon-calendar-event-past]="event.ispast" (ariaButtonClick)="eventClicked(event, $event)"
(ariaButtonClick)="eventClicked(event, $event)"> [tabindex]="activeView ? 0 : -1">
<span class="calendar_event_type calendar_event_{{event.formattedType}}"></span> <span class="calendar_event_type calendar_event_{{event.formattedType}}"></span>
<ion-icon *ngIf="event.offline && !event.deleted" name="fas-clock" <ion-icon *ngIf="event.offline && !event.deleted" name="fas-clock"
[attr.aria-label]="'core.notsent' | translate"></ion-icon> [attr.aria-label]="'core.notsent' | translate"></ion-icon>

View File

@ -25,7 +25,6 @@
@include border-end(1px, solid var(--addon-calendar-border-color)); @include border-end(1px, solid var(--addon-calendar-border-color));
overflow: hidden; overflow: hidden;
min-height: 60px; min-height: 60px;
cursor: pointer;
&:first-child { &:first-child {
@include padding-horizontal(10px, null); @include padding-horizontal(10px, null);
@ -99,7 +98,7 @@
.addon-calendar-period { .addon-calendar-period {
flex-grow: 3; flex-grow: 3;
h3 { h2 {
margin-top: 10px; margin-top: 10px;
font-size: 1.2rem; font-size: 1.2rem;
} }

View File

@ -38,7 +38,7 @@
</ion-button> </ion-button>
</ion-col> </ion-col>
<ion-col class="ion-text-center addon-calendar-period"> <ion-col class="ion-text-center addon-calendar-period">
<h3>{{ periodName }}</h3> <h2>{{ periodName }}</h2>
</ion-col> </ion-col>
<ion-col class="ion-text-end"> <ion-col class="ion-text-end">
<ion-button fill="clear" (click)="loadNext()" [attr.aria-label]="'addon.calendar.daynext' | translate"> <ion-button fill="clear" (click)="loadNext()" [attr.aria-label]="'addon.calendar.daynext' | translate">

View File

@ -6,7 +6,7 @@
.addon-calendar-period { .addon-calendar-period {
flex-grow: 3; flex-grow: 3;
h3 { h2 {
margin-top: 10px; margin-top: 10px;
font-size: 1.2rem; font-size: 1.2rem;
} }

View File

@ -60,7 +60,7 @@
</ion-item> </ion-item>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ 'addon.calendar.when' | translate }}</h2> <p class="item-heading">{{ 'addon.calendar.when' | translate }}</p>
<core-format-text [text]="event.formattedtime" [contextLevel]="event.contextLevel" <core-format-text [text]="event.formattedtime" [contextLevel]="event.contextLevel"
[contextInstanceId]="event.contextInstanceId"></core-format-text> [contextInstanceId]="event.contextInstanceId"></core-format-text>
</ion-label> </ion-label>
@ -70,13 +70,13 @@
</ion-item> </ion-item>
<ion-item> <ion-item>
<ion-label> <ion-label>
<h2>{{ 'addon.calendar.eventtype' | translate }}</h2> <p class="item-heading">{{ 'addon.calendar.eventtype' | translate }}</p>
<p>{{ 'addon.calendar.type' + event.formattedType | translate }}</p> <p>{{ 'addon.calendar.type' + event.formattedType | translate }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="courseName" [href]="courseUrl" core-link capture="true"> <ion-item class="ion-text-wrap" *ngIf="courseName" [href]="courseUrl" core-link capture="true">
<ion-label> <ion-label>
<h2>{{ 'core.course' | translate}}</h2> <p class="item-heading">{{ 'core.course' | translate}}</p>
<p> <p>
<core-format-text [text]="courseName" contextLevel="course" [contextInstanceId]="courseId"> <core-format-text [text]="courseName" contextLevel="course" [contextInstanceId]="courseId">
</core-format-text> </core-format-text>
@ -85,13 +85,13 @@
</ion-item> </ion-item>
<ion-item class="ion-text-wrap core-groupname" *ngIf="groupName"> <ion-item class="ion-text-wrap core-groupname" *ngIf="groupName">
<ion-label> <ion-label>
<h2>{{ 'core.group' | translate}}</h2> <p class="item-heading">{{ 'core.group' | translate}}</p>
<p>{{ groupName }}</p> <p>{{ groupName }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="categoryPath"> <ion-item class="ion-text-wrap" *ngIf="categoryPath">
<ion-label> <ion-label>
<h2>{{ 'core.category' | translate}}</h2> <p class="item-heading">{{ 'core.category' | translate}}</p>
<p> <p>
<core-format-text [text]="categoryPath" contextLevel="coursecat" [contextInstanceId]="event.categoryid"> <core-format-text [text]="categoryPath" contextLevel="coursecat" [contextInstanceId]="event.categoryid">
</core-format-text> </core-format-text>
@ -100,7 +100,7 @@
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="event.description"> <ion-item class="ion-text-wrap" *ngIf="event.description">
<ion-label> <ion-label>
<h2>{{ 'core.description' | translate}}</h2> <p class="item-heading">{{ 'core.description' | translate}}</p>
<p> <p>
<core-format-text [text]="event.description" [contextLevel]="event.contextLevel" <core-format-text [text]="event.description" [contextLevel]="event.contextLevel"
[contextInstanceId]="event.contextInstanceId"></core-format-text> [contextInstanceId]="event.contextInstanceId"></core-format-text>
@ -109,7 +109,7 @@
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="event.location"> <ion-item class="ion-text-wrap" *ngIf="event.location">
<ion-label> <ion-label>
<h2>{{ 'core.location' | translate}}</h2> <p class="item-heading">{{ 'core.location' | translate}}</p>
<p> <p>
<a [href]="event.encodedLocation" core-link auto-login="no"> <a [href]="event.encodedLocation" core-link auto-login="no">
<core-format-text [text]="event.location" [contextLevel]="event.contextLevel" <core-format-text [text]="event.location" [contextLevel]="event.contextLevel"

View File

@ -19,7 +19,7 @@
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<core-user-avatar [user]="user" slot="start"></core-user-avatar> <core-user-avatar [user]="user" slot="start"></core-user-avatar>
<ion-label> <ion-label>
<h2>{{ user.fullname }}</h2> <p class="item-heading">{{ user.fullname }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
</ion-card> </ion-card>
@ -115,7 +115,7 @@
</ion-card> </ion-card>
<div *ngIf="competency"> <div *ngIf="competency">
<h3 class="ion-margin-horizontal">{{ 'addon.competency.evidence' | translate }}</h3> <h2 class="ion-margin-horizontal">{{ 'addon.competency.evidence' | translate }}</h2>
<p class="ion-margin-horizontal" *ngIf="competency.evidence.length == 0"> <p class="ion-margin-horizontal" *ngIf="competency.evidence.length == 0">
{{ 'addon.competency.noevidence' | translate }} {{ 'addon.competency.noevidence' | translate }}
</p> </p>

View File

@ -53,7 +53,7 @@
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<core-user-avatar [user]="user" slot="start"></core-user-avatar> <core-user-avatar [user]="user" slot="start"></core-user-avatar>
<ion-label> <ion-label>
<h2>{{ user.fullname }}</h2> <p class="item-heading">{{ user.fullname }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
</ion-card> </ion-card>

View File

@ -17,7 +17,7 @@
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<core-user-avatar [user]="user" slot="start"></core-user-avatar> <core-user-avatar [user]="user" slot="start"></core-user-avatar>
<h2>{{ user.fullname }}</h2> <p class="item-heading">{{ user.fullname }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
</ion-card> </ion-card>

View File

@ -16,20 +16,20 @@
<ion-item class="ion-text-wrap" *ngIf="user"> <ion-item class="ion-text-wrap" *ngIf="user">
<core-user-avatar [user]="user" [courseId]="courseId" slot="start" [linkProfile]="false"></core-user-avatar> <core-user-avatar [user]="user" [courseId]="courseId" slot="start" [linkProfile]="false"></core-user-avatar>
<ion-label> <ion-label>
<h2>{{user!.fullname}}</h2> <p class="item-heading">{{user.fullname}}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-card *ngIf="completion && tracked"> <ion-card *ngIf="completion && tracked">
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ 'addon.coursecompletion.status' | translate }}</h2> <p class="item-heading">{{ 'addon.coursecompletion.status' | translate }}</p>
<p>{{ statusText! | translate }}</p> <p>{{ statusText! | translate }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ 'addon.coursecompletion.required' | translate }}</h2> <p class="item-heading">{{ 'addon.coursecompletion.required' | translate }}</p>
<p *ngIf="completion.aggregation === 1">{{ 'addon.coursecompletion.criteriarequiredall' | translate }}</p> <p *ngIf="completion.aggregation === 1">{{ 'addon.coursecompletion.criteriarequiredall' | translate }}</p>
<p *ngIf="completion.aggregation === 2">{{ 'addon.coursecompletion.criteriarequiredany' | translate }}</p> <p *ngIf="completion.aggregation === 2">{{ 'addon.coursecompletion.criteriarequiredany' | translate }}</p>
</ion-label> </ion-label>

View File

@ -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 = <T>(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 (<HTMLVideoElement> this.el_).videoWidth ?? 0;
}
/**
* Get the video height.
*
* @returns Video heigth.
*/
videoHeight(): number {
return (<HTMLVideoElement> 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 {
(<HTMLVideoElement> this.el_).poster = url;
}
/**
* Is the media preloaded or not.
*
* @returns Whether it's preloaded.
*/
preload(): PreloadOption {
return <PreloadOption> 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;
};

View File

@ -26,7 +26,9 @@ import { AddonFilterMediaPluginHandler } from './services/handlers/mediaplugin';
{ {
provide: APP_INITIALIZER, provide: APP_INITIALIZER,
multi: true, multi: true,
useValue: () => CoreFilterDelegate.registerHandler(AddonFilterMediaPluginHandler.instance), useValue: () => {
CoreFilterDelegate.registerHandler(AddonFilterMediaPluginHandler.instance);
},
}, },
], ],
}) })

View File

@ -12,12 +12,12 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { AddonFilterMediaPluginVideoJS } from '@addons/filter/mediaplugin/services/videojs';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CoreFilterDefaultHandler } from '@features/filter/services/handlers/default-filter'; 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 { makeSingleton } from '@singletons';
import { CoreMedia } from '@singletons/media';
/** /**
* Handler to support the Multimedia filter. * Handler to support the Multimedia filter.
@ -33,58 +33,38 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl
/** /**
* @inheritdoc * @inheritdoc
*/ */
filter( filter(text: string): string | Promise<string> {
text: string,
): string | Promise<string> {
this.template.innerHTML = text; this.template.innerHTML = text;
const videos = Array.from(this.template.content.querySelectorAll('video')); const videos = Array.from(this.template.content.querySelectorAll('video'));
videos.forEach((video) => { videos.forEach((video) => {
this.treatVideoFilters(video); AddonFilterMediaPluginVideoJS.treatYoutubeVideos(video);
}); });
return this.template.innerHTML; return this.template.innerHTML;
} }
/** /**
* Treat video filters. Currently only treating youtube video using video JS. * @inheritdoc
*
* @param video Video element.
*/ */
protected treatVideoFilters(video: HTMLElement): void { handleHtml(container: HTMLElement): void {
// Treat Video JS Youtube video links and translate them to iframes. const mediaElements = Array.from(container.querySelectorAll<HTMLVideoElement | HTMLAudioElement>('video, audio'));
if (!video.classList.contains('video-js')) {
return;
}
const dataSetupString = video.getAttribute('data-setup') || video.getAttribute('data-setup-lazy') || '{}'; mediaElements.forEach((mediaElement) => {
const data = <VideoDataSetup> CoreTextUtils.parseJSON(dataSetupString, {}); if (CoreMedia.mediaUsesJavascriptPlayer(mediaElement)) {
const youtubeUrl = data.techOrder?.[0] == 'youtube' && CoreUrlUtils.getYoutubeEmbedUrl(data.sources?.[0]?.src); AddonFilterMediaPluginVideoJS.createPlayer(mediaElement);
if (!youtubeUrl) { return;
return; }
}
const iframe = document.createElement('iframe'); // Remove the VideoJS classes and data if present.
iframe.id = video.id; mediaElement.classList.remove('video-js');
iframe.src = youtubeUrl; mediaElement.removeAttribute('data-setup');
iframe.setAttribute('frameborder', '0'); mediaElement.removeAttribute('data-setup-lazy');
iframe.setAttribute('allowfullscreen', '1'); });
iframe.width = '100%';
iframe.height = '300';
// Replace video tag by the iframe.
video.parentNode?.replaceChild(iframe, video);
} }
} }
export const AddonFilterMediaPluginHandler = makeSingleton(AddonFilterMediaPluginHandlerService); export const AddonFilterMediaPluginHandler = makeSingleton(AddonFilterMediaPluginHandlerService);
type VideoDataSetup = {
techOrder?: string[];
sources?: {
src?: string;
}[];
};

View File

@ -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<typeof videojs>;
/**
* Create a VideoJS player.
*
* @param element Media element.
*/
async createPlayer(element: HTMLVideoElement | HTMLAudioElement): Promise<void> {
// 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<VideoJSOptions>(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<VideoJSPlayer | null> {
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<VideoJSOptions>(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<typeof videojs> {
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;
};

View File

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

View File

@ -1,7 +1,7 @@
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-title> <ion-title>
<h2>{{ 'addon.messages.groupinfo' | translate }}</h2> <h1>{{ 'addon.messages.groupinfo' | translate }}</h1>
</ion-title> </ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate"> <ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
@ -19,18 +19,18 @@
<ion-item class="ion-text-center" *ngIf="conversation"> <ion-item class="ion-text-center" *ngIf="conversation">
<ion-label> <ion-label>
<div class="large-avatar"> <div class="large-avatar">
<img class="avatar" [src]="conversation!.imageurl" core-external-content [alt]="conversation!.name" <img class="avatar" [src]="conversation.imageurl" core-external-content [alt]="conversation.name"
onError="this.src='assets/img/group-avatar.svg'"> onError="this.src='assets/img/group-avatar.svg'">
</div> </div>
<h2> <h2>
<core-format-text [text]="conversation!.name" contextLevel="system" [contextInstanceId]="0"></core-format-text> <core-format-text [text]="conversation.name" contextLevel="system" [contextInstanceId]="0"></core-format-text>
</h2> </h2>
<p> <p>
<core-format-text *ngIf="conversation!.subname" [text]="conversation!.subname" contextLevel="system" <core-format-text *ngIf="conversation.subname" [text]="conversation.subname" contextLevel="system"
[contextInstanceId]="0"> [contextInstanceId]="0">
</core-format-text> </core-format-text>
</p> </p>
<p>{{ 'addon.messages.numparticipants' | translate:{$a: conversation!.membercount} }}</p> <p>{{ 'addon.messages.numparticipants' | translate:{$a: conversation.membercount} }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>

View File

@ -7,6 +7,8 @@
<h1>{{ 'addon.messages.contacts' | translate }}</h1> <h1>{{ 'addon.messages.contacts' | translate }}</h1>
</ion-title> </ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<!-- Add an empty context menu so split view pages can add items, otherwise the menu disappears in some cases. -->
<core-context-menu></core-context-menu>
</ion-buttons> </ion-buttons>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // 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 { IonRefresher } from '@ionic/angular';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { import {
@ -29,6 +29,7 @@ import { ActivatedRoute } from '@angular/router';
import { Translate } from '@singletons'; import { Translate } from '@singletons';
import { CoreScreen } from '@services/screen'; import { CoreScreen } from '@services/screen';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
/** /**
* Page that displays the list of contacts. * Page that displays the list of contacts.
@ -40,6 +41,8 @@ import { CoreNavigator } from '@services/navigator';
}) })
export class AddonMessagesContacts35Page implements OnInit, OnDestroy { export class AddonMessagesContacts35Page implements OnInit, OnDestroy {
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
protected searchingMessages: string; protected searchingMessages: string;
protected loadingMessages: string; protected loadingMessages: string;
protected siteId: string; protected siteId: string;
@ -244,7 +247,9 @@ export class AddonMessagesContacts35Page implements OnInit, OnDestroy {
const path = CoreNavigator.getRelativePathToParent('/messages/contacts-35') + `discussion/user/${discussionUserId}`; const path = CoreNavigator.getRelativePathToParent('/messages/contacts-35') + `discussion/user/${discussionUserId}`;
// @todo Check why this is failing on ngInit. // @todo Check why this is failing on ngInit.
CoreNavigator.navigate(path); CoreNavigator.navigate(path, {
reset: CoreScreen.isTablet && !!this.splitView && !this.splitView.isNested,
});
} }
/** /**

View File

@ -10,6 +10,8 @@
<ion-button fill="clear" (click)="gotoSearch()" [attr.aria-label]="'addon.messages.searchcombined' | translate"> <ion-button fill="clear" (click)="gotoSearch()" [attr.aria-label]="'addon.messages.searchcombined' | translate">
<ion-icon name="fas-search" slot="icon-only" aria-hidden="true"></ion-icon> <ion-icon name="fas-search" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button> </ion-button>
<!-- Add an empty context menu so split view pages can add items, otherwise the menu disappears in some cases. -->
<core-context-menu></core-context-menu>
</ion-buttons> </ion-buttons>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // 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 { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { import {
@ -24,6 +24,7 @@ import { CoreNavigator } from '@services/navigator';
import { CoreScreen } from '@services/screen'; import { CoreScreen } from '@services/screen';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { IonRefresher } from '@ionic/angular'; import { IonRefresher } from '@ionic/angular';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
/** /**
* Page that displays contacts and contact requests. * Page that displays contacts and contact requests.
@ -37,6 +38,8 @@ import { IonRefresher } from '@ionic/angular';
}) })
export class AddonMessagesContactsPage implements OnInit, OnDestroy { export class AddonMessagesContactsPage implements OnInit, OnDestroy {
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
selected: 'confirmed' | 'requests' = 'confirmed'; selected: 'confirmed' | 'requests' = 'confirmed';
requestsBadge = ''; requestsBadge = '';
selectedUserId?: number; // User id of the conversation opened in the split view. 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; this.selectedUserId = userId;
const path = CoreNavigator.getRelativePathToParent('/messages/contacts') + `discussion/user/${userId}`; const path = CoreNavigator.getRelativePathToParent('/messages/contacts') + `discussion/user/${userId}`;
CoreNavigator.navigate(path); CoreNavigator.navigate(path, {
reset: CoreScreen.isTablet && !!this.splitView && !this.splitView.isNested,
});
} }
/** /**

View File

@ -7,6 +7,8 @@
<h1>{{ 'addon.messages.messages' | translate }}</h1> <h1>{{ 'addon.messages.messages' | translate }}</h1>
</ion-title> </ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<!-- Add an empty context menu so split view pages can add items, otherwise the menu disappears in some cases. -->
<core-context-menu></core-context-menu>
<core-user-menu-button></core-user-menu-button> <core-user-menu-button></core-user-menu-button>
</ion-buttons> </ion-buttons>
</ion-toolbar> </ion-toolbar>
@ -29,7 +31,7 @@
[attr.aria-label]="'addon.messages.contacts' | translate" detail="true" button> [attr.aria-label]="'addon.messages.contacts' | translate" detail="true" button>
<ion-icon name="fas-address-book" slot="start" aria-hidden="true"></ion-icon> <ion-icon name="fas-address-book" slot="start" aria-hidden="true"></ion-icon>
<ion-label> <ion-label>
<h2>{{ 'addon.messages.contacts' | translate }}</h2> <p class="item-heading">{{ 'addon.messages.contacts' | translate }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // 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 { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { import {
@ -34,6 +34,7 @@ import { CoreNavigator } from '@services/navigator';
import { CoreScreen } from '@services/screen'; import { CoreScreen } from '@services/screen';
import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager'; import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager';
import { CorePlatform } from '@services/platform'; import { CorePlatform } from '@services/platform';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
/** /**
* Page that displays the list of discussions. * Page that displays the list of discussions.
@ -45,6 +46,8 @@ import { CorePlatform } from '@services/platform';
}) })
export class AddonMessagesDiscussions35Page implements OnInit, OnDestroy { export class AddonMessagesDiscussions35Page implements OnInit, OnDestroy {
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
protected newMessagesObserver: CoreEventObserver; protected newMessagesObserver: CoreEventObserver;
protected readChangedObserver: CoreEventObserver; protected readChangedObserver: CoreEventObserver;
protected appResumeSubscription: Subscription; protected appResumeSubscription: Subscription;
@ -264,7 +267,10 @@ export class AddonMessagesDiscussions35Page implements OnInit, OnDestroy {
const path = CoreNavigator.getRelativePathToParent('/messages/index') + `discussion/user/${discussionUserId}`; 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,
});
} }
/** /**

View File

@ -13,6 +13,8 @@
<ion-button (click)="gotoSettings()" [attr.aria-label]="'addon.messages.messagepreferences' | translate"> <ion-button (click)="gotoSettings()" [attr.aria-label]="'addon.messages.messagepreferences' | translate">
<ion-icon name="fas-cog" slot="icon-only" aria-hidden="true"></ion-icon> <ion-icon name="fas-cog" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button> </ion-button>
<!-- Add an empty context menu so split view pages can add items, otherwise the menu disappears in some cases. -->
<core-context-menu></core-context-menu>
<core-user-menu-button></core-user-menu-button> <core-user-menu-button></core-user-menu-button>
</ion-buttons> </ion-buttons>
</ion-toolbar> </ion-toolbar>

View File

@ -38,6 +38,7 @@ import { CoreNavigator } from '@services/navigator';
import { CoreScreen } from '@services/screen'; import { CoreScreen } from '@services/screen';
import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager'; import { CoreMainMenuDeepLinkManager } from '@features/mainmenu/classes/deep-link-manager';
import { CorePlatform } from '@services/platform'; import { CorePlatform } from '@services/platform';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
/** /**
* Page that displays the list of conversations, including group conversations. * 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 { export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
@ViewChild(IonContent) content?: IonContent; @ViewChild(IonContent) content?: IonContent;
@ViewChild('favlist') favListEl?: ElementRef; @ViewChild('favlist') favListEl?: ElementRef;
@ViewChild('grouplist') groupListEl?: ElementRef; @ViewChild('grouplist') groupListEl?: ElementRef;
@ -526,7 +529,10 @@ export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
const path = CoreNavigator.getRelativePathToParent('/messages/group-conversations') + 'discussion/' + const path = CoreNavigator.getRelativePathToParent('/messages/group-conversations') + 'discussion/' +
(conversationId ? conversationId : `user/${userId}`); (conversationId ? conversationId : `user/${userId}`);
await CoreNavigator.navigate(path, { params }); await CoreNavigator.navigate(path, {
params,
reset: CoreScreen.isTablet && !!this.splitView && !this.splitView.isNested,
});
} }
/** /**

View File

@ -7,6 +7,8 @@
<h1>{{ 'addon.messages.searchcombined' | translate }}</h1> <h1>{{ 'addon.messages.searchcombined' | translate }}</h1>
</ion-title> </ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<!-- Add an empty context menu so split view pages can add items, otherwise the menu disappears in some cases. -->
<core-context-menu></core-context-menu>
</ion-buttons> </ion-buttons>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { Component, OnDestroy } from '@angular/core'; import { Component, OnDestroy, ViewChild } from '@angular/core';
import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreEventObserver, CoreEvents } from '@singletons/events';
import { CoreSites } from '@services/sites'; import { CoreSites } from '@services/sites';
import { import {
@ -25,6 +25,7 @@ import { CoreDomUtils } from '@services/utils/dom';
import { CoreApp } from '@services/app'; import { CoreApp } from '@services/app';
import { CoreNavigator } from '@services/navigator'; import { CoreNavigator } from '@services/navigator';
import { CoreScreen } from '@services/screen'; import { CoreScreen } from '@services/screen';
import { CoreSplitViewComponent } from '@components/split-view/split-view';
/** /**
* Page for searching users. * Page for searching users.
@ -35,6 +36,8 @@ import { CoreScreen } from '@services/screen';
}) })
export class AddonMessagesSearchPage implements OnDestroy { export class AddonMessagesSearchPage implements OnDestroy {
@ViewChild(CoreSplitViewComponent) splitView!: CoreSplitViewComponent;
disableSearch = false; disableSearch = false;
displaySearching = false; displaySearching = false;
displayResults = false; displayResults = false;
@ -260,7 +263,9 @@ export class AddonMessagesSearchPage implements OnDestroy {
const path = CoreNavigator.getRelativePathToParent('/messages/search') + 'discussion/' + const path = CoreNavigator.getRelativePathToParent('/messages/search') + 'discussion/' +
(conversationId ? conversationId : `user/${userId}`); (conversationId ? conversationId : `user/${userId}`);
CoreNavigator.navigate(path); CoreNavigator.navigate(path, {
reset: CoreScreen.isTablet && !!this.splitView && !this.splitView.isNested,
});
} }
} }

View File

@ -1,7 +1,7 @@
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-title> <ion-title>
<h2>{{ plugin.name }}</h2> <h1>{{ plugin.name }}</h1>
</ion-title> </ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate"> <ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -25,7 +25,7 @@
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ 'core.course.hiddenfromstudents' | translate }}</h2> <p class="item-heading">{{ 'core.course.hiddenfromstudents' | translate }}</p>
<p *ngIf="module.visible">{{ 'core.no' | translate }}</p> <p *ngIf="module.visible">{{ 'core.no' | translate }}</p>
<p *ngIf="!module.visible">{{ 'core.yes' | translate }}</p> <p *ngIf="!module.visible">{{ 'core.yes' | translate }}</p>
</ion-label> </ion-label>
@ -33,13 +33,13 @@
<ion-item class="ion-text-wrap" *ngIf="timeRemaining"> <ion-item class="ion-text-wrap" *ngIf="timeRemaining">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.timeremaining' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.timeremaining' | translate }}</p>
<p>{{ timeRemaining }}</p> <p>{{ timeRemaining }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="lateSubmissions"> <ion-item class="ion-text-wrap" *ngIf="lateSubmissions">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.latesubmissions' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.latesubmissions' | translate }}</p>
<p>{{ lateSubmissions }}</p> <p>{{ lateSubmissions }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
@ -47,8 +47,8 @@
<!-- Summary of all submissions. --> <!-- Summary of all submissions. -->
<ion-item class="ion-text-wrap" *ngIf="summary && summary.participantcount" (click)="goToSubmissionList()" detail="true" button> <ion-item class="ion-text-wrap" *ngIf="summary && summary.participantcount" (click)="goToSubmissionList()" detail="true" button>
<ion-label> <ion-label>
<h2 *ngIf="assign.teamsubmission">{{ 'addon.mod_assign.numberofteams' | translate }}</h2> <p class="item-heading" *ngIf="assign.teamsubmission">{{ 'addon.mod_assign.numberofteams' | translate }}</p>
<h2 *ngIf="!assign.teamsubmission">{{ 'addon.mod_assign.numberofparticipants' | translate }}</h2> <p class="item-heading" *ngIf="!assign.teamsubmission">{{ 'addon.mod_assign.numberofparticipants' | translate }}</p>
</ion-label> </ion-label>
<ion-badge slot="end" color="primary"> <ion-badge slot="end" color="primary">
<span aria-hidden="true">{{ summary.participantcount }}</span> <span aria-hidden="true">{{ summary.participantcount }}</span>
@ -66,7 +66,7 @@
[class.hide-detail]="!summary.submissiondraftscount" [detail]="true" [button]="summary.submissiondraftscount" [class.hide-detail]="!summary.submissiondraftscount" [detail]="true" [button]="summary.submissiondraftscount"
(click)="goToSubmissionList(submissionStatusDraft, !!summary.submissiondraftscount)"> (click)="goToSubmissionList(submissionStatusDraft, !!summary.submissiondraftscount)">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.numberofdraftsubmissions' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.numberofdraftsubmissions' | translate }}</p>
</ion-label> </ion-label>
<ion-badge slot="end" color="primary"> <ion-badge slot="end" color="primary">
<span aria-hidden="true">{{ summary.submissiondraftscount }}</span> <span aria-hidden="true">{{ summary.submissiondraftscount }}</span>
@ -82,7 +82,7 @@
[class.hide-detail]="!summary.submissionssubmittedcount" [detail]="true" [button]="summary.submissionssubmittedcount" [class.hide-detail]="!summary.submissionssubmittedcount" [detail]="true" [button]="summary.submissionssubmittedcount"
(click)="goToSubmissionList(submissionStatusSubmitted, !!summary.submissionssubmittedcount)"> (click)="goToSubmissionList(submissionStatusSubmitted, !!summary.submissionssubmittedcount)">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.numberofsubmittedassignments' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.numberofsubmittedassignments' | translate }}</p>
</ion-label> </ion-label>
<ion-badge slot="end" color="primary"> <ion-badge slot="end" color="primary">
<span aria-hidden="true">{{ summary.submissionssubmittedcount }}</span> <span aria-hidden="true">{{ summary.submissionssubmittedcount }}</span>
@ -98,7 +98,7 @@
[class.hide-detail]="!needsGradingAvailable" [detail]="true" [button]="needsGradingAvailable" [class.hide-detail]="!needsGradingAvailable" [detail]="true" [button]="needsGradingAvailable"
(click)="goToSubmissionList(needGrading, needsGradingAvailable)"> (click)="goToSubmissionList(needGrading, needsGradingAvailable)">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.numberofsubmissionsneedgrading' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.numberofsubmissionsneedgrading' | translate }}</p>
</ion-label> </ion-label>
<ion-badge slot="end" color="primary"> <ion-badge slot="end" color="primary">
<span aria-hidden="true">{{ summary.submissionsneedgradingcount }}</span> <span aria-hidden="true">{{ summary.submissionsneedgradingcount }}</span>

View File

@ -15,7 +15,7 @@
[attr.aria-label]="user!.fullname"> [attr.aria-label]="user!.fullname">
<core-user-avatar [user]="user" slot="start" [linkProfile]="false"></core-user-avatar> <core-user-avatar [user]="user" slot="start" [linkProfile]="false"></core-user-avatar>
<ion-label> <ion-label>
<h2>{{ user!.fullname }}</h2> <p class="item-heading">{{ user!.fullname }}</p>
<ng-container *ngTemplateOutlet="submissionStatus"></ng-container> <ng-container *ngTemplateOutlet="submissionStatus"></ng-container>
</ion-label> </ion-label>
</ion-item> </ion-item>
@ -23,7 +23,7 @@
<!-- Status of the submission if user is blinded. --> <!-- Status of the submission if user is blinded. -->
<ion-item class="ion-text-wrap" *ngIf="blindMarking && !user"> <ion-item class="ion-text-wrap" *ngIf="blindMarking && !user">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.hiddenuser' | translate }} {{blindId}}</h2> <p class="item-heading">{{ 'addon.mod_assign.hiddenuser' | translate }} {{blindId}}</p>
<ng-container *ngTemplateOutlet="submissionStatus"></ng-container> <ng-container *ngTemplateOutlet="submissionStatus"></ng-container>
</ion-label> </ion-label>
</ion-item> </ion-item>
@ -31,7 +31,7 @@
<!-- Status of the submission in the rest of cases. --> <!-- Status of the submission in the rest of cases. -->
<ion-item class="ion-text-wrap" *ngIf="(blindMarking && user) || (!blindMarking && !user)"> <ion-item class="ion-text-wrap" *ngIf="(blindMarking && user) || (!blindMarking && !user)">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.submissionstatus' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.submissionstatus' | translate }}</p>
<ng-container *ngTemplateOutlet="submissionStatus"></ng-container> <ng-container *ngTemplateOutlet="submissionStatus"></ng-container>
</ion-label> </ion-label>
</ion-item> </ion-item>
@ -44,7 +44,7 @@
<!-- Render some data about the submission. --> <!-- Render some data about the submission. -->
<ion-item class="ion-text-wrap" *ngIf="currentAttempt && !isGrading"> <ion-item class="ion-text-wrap" *ngIf="currentAttempt && !isGrading">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.attemptnumber' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.attemptnumber' | translate }}</p>
<p *ngIf="assign!.maxattempts == unlimitedAttempts"> <p *ngIf="assign!.maxattempts == unlimitedAttempts">
{{ 'addon.mod_assign.outof' | translate : {{ 'addon.mod_assign.outof' | translate :
{'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }} {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }}
@ -59,7 +59,7 @@
<!-- Submission is locked. --> <!-- Submission is locked. -->
<ion-item class="ion-text-wrap" *ngIf="lastAttempt?.locked"> <ion-item class="ion-text-wrap" *ngIf="lastAttempt?.locked">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.submissionslocked' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.submissionslocked' | translate }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
@ -77,7 +77,7 @@
<ion-item class="ion-text-wrap" *ngIf="showDates && assign!.duedate && !isSubmittedForGrading"> <ion-item class="ion-text-wrap" *ngIf="showDates && assign!.duedate && !isSubmittedForGrading">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.duedate' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.duedate' | translate }}</p>
<p *ngIf="assign!.duedate">{{ assign!.duedate * 1000 | coreFormatDate }}</p> <p *ngIf="assign!.duedate">{{ assign!.duedate * 1000 | coreFormatDate }}</p>
<p *ngIf="!assign!.duedate">{{ 'addon.mod_assign.duedateno' | translate }}</p> <p *ngIf="!assign!.duedate">{{ 'addon.mod_assign.duedateno' | translate }}</p>
</ion-label> </ion-label>
@ -85,14 +85,14 @@
<ion-item class="ion-text-wrap" *ngIf="assign!.duedate && assign!.cutoffdate && isSubmittedForGrading"> <ion-item class="ion-text-wrap" *ngIf="assign!.duedate && assign!.cutoffdate && isSubmittedForGrading">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.cutoffdate' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.cutoffdate' | translate }}</p>
<p>{{ assign!.cutoffdate * 1000 | coreFormatDate }}</p> <p>{{ assign!.cutoffdate * 1000 | coreFormatDate }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="assign!.duedate && lastAttempt?.extensionduedate && !isSubmittedForGrading"> <ion-item class="ion-text-wrap" *ngIf="assign!.duedate && lastAttempt?.extensionduedate && !isSubmittedForGrading">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.extensionduedate' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.extensionduedate' | translate }}</p>
<p>{{ lastAttempt!.extensionduedate * 1000 | coreFormatDate }}</p> <p>{{ lastAttempt!.extensionduedate * 1000 | coreFormatDate }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
@ -100,7 +100,7 @@
<!-- Time remaining. --> <!-- Time remaining. -->
<ion-item class="ion-text-wrap" *ngIf="timeRemaining || timeLimitEndTime > 0" [ngClass]="[timeRemainingClass]"> <ion-item class="ion-text-wrap" *ngIf="timeRemaining || timeLimitEndTime > 0" [ngClass]="[timeRemainingClass]">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.timeremaining' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.timeremaining' | translate }}</p>
<p *ngIf="!timeLimitEndTime" [innerHTML]="timeRemaining"></p> <p *ngIf="!timeLimitEndTime" [innerHTML]="timeRemaining"></p>
<core-timer *ngIf="timeLimitEndTime > 0" [endTime]="timeLimitEndTime" mode="basic" timeUpText="00:00:00" <core-timer *ngIf="timeLimitEndTime > 0" [endTime]="timeLimitEndTime" mode="basic" timeUpText="00:00:00"
[timeLeftClassThreshold]="-1" [underTimeClassThresholds]="[300, 900]" (finished)="timeUp()"> [timeLeftClassThreshold]="-1" [underTimeClassThresholds]="[300, 900]" (finished)="timeUp()">
@ -111,7 +111,7 @@
<!-- Time limit. --> <!-- Time limit. -->
<ion-item class="ion-text-wrap" *ngIf="assign && assign.timelimit"> <ion-item class="ion-text-wrap" *ngIf="assign && assign.timelimit">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.timelimit' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.timelimit' | translate }}</p>
<p>{{ assign.timelimit | coreDuration }}</p> <p>{{ assign.timelimit | coreDuration }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
@ -120,7 +120,7 @@
<ion-item class="ion-text-wrap" *ngIf="lastAttempt && isSubmittedForGrading && lastAttempt!.caneditowner !== undefined" <ion-item class="ion-text-wrap" *ngIf="lastAttempt && isSubmittedForGrading && lastAttempt!.caneditowner !== undefined"
[ngClass]="{submissioneditable: lastAttempt!.caneditowner, submissionnoteditable: !lastAttempt!.caneditowner}"> [ngClass]="{submissioneditable: lastAttempt!.caneditowner, submissionnoteditable: !lastAttempt!.caneditowner}">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.editingstatus' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.editingstatus' | translate }}</p>
<p *ngIf="lastAttempt!.caneditowner">{{ 'addon.mod_assign.submissioneditable' | translate }}</p> <p *ngIf="lastAttempt!.caneditowner">{{ 'addon.mod_assign.submissioneditable' | translate }}</p>
<p *ngIf="!lastAttempt!.caneditowner">{{ 'addon.mod_assign.submissionnoteditable' | translate }}</p> <p *ngIf="!lastAttempt!.caneditowner">{{ 'addon.mod_assign.submissionnoteditable' | translate }}</p>
</ion-label> </ion-label>
@ -130,7 +130,7 @@
<ion-item class="ion-text-wrap" <ion-item class="ion-text-wrap"
*ngIf="userSubmission && userSubmission!.status != statusNew && userSubmission!.timemodified"> *ngIf="userSubmission && userSubmission!.status != statusNew && userSubmission!.timemodified">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.timemodified' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.timemodified' | translate }}</p>
<p>{{ userSubmission!.timemodified * 1000 | coreFormatDate }}</p> <p>{{ userSubmission!.timemodified * 1000 | coreFormatDate }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
@ -151,7 +151,7 @@
[attr.aria-label]="user.fullname"> [attr.aria-label]="user.fullname">
<core-user-avatar [user]="user" slot="start" [linkProfile]="false"></core-user-avatar> <core-user-avatar [user]="user" slot="start" [linkProfile]="false"></core-user-avatar>
<ion-label> <ion-label>
<h2>{{ user.fullname }}</h2> <p class="item-heading">{{ user.fullname }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
</ng-container> </ng-container>
@ -257,7 +257,7 @@
<ion-item class="ion-text-wrap core-grading-summary" <ion-item class="ion-text-wrap core-grading-summary"
*ngIf="feedback?.gradefordisplay && (!isGrading || grade.method != 'simple')"> *ngIf="feedback?.gradefordisplay && (!isGrading || grade.method != 'simple')">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.currentgrade' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.currentgrade' | translate }}</p>
<p> <p>
<core-format-text [text]="feedback!.gradefordisplay" [filter]="false"></core-format-text> <core-format-text [text]="feedback!.gradefordisplay" [filter]="false"></core-format-text>
</p> </p>
@ -273,7 +273,7 @@
Use a text input because otherwise we cannot readthe value if it has an invalid character. --> Use a text input because otherwise we cannot readthe value if it has an invalid character. -->
<ion-item class="ion-text-wrap" *ngIf="grade.method == 'simple' && !grade.scale"> <ion-item class="ion-text-wrap" *ngIf="grade.method == 'simple' && !grade.scale">
<ion-label position="stacked"> <ion-label position="stacked">
<h2>{{ 'addon.mod_assign.gradeoutof' | translate: {$a: gradeInfo!.grade} }}</h2> <p class="item-heading">{{ 'addon.mod_assign.gradeoutof' | translate: {$a: gradeInfo!.grade} }}</p>
</ion-label> </ion-label>
<ion-input *ngIf="!grade.disabled" type="text" [(ngModel)]="grade.grade" min="0" [max]="gradeInfo!.grade" <ion-input *ngIf="!grade.disabled" type="text" [(ngModel)]="grade.grade" min="0" [max]="gradeInfo!.grade"
[lang]="grade.lang"> [lang]="grade.lang">
@ -284,7 +284,7 @@
<!-- Grade using a scale. --> <!-- Grade using a scale. -->
<ion-item class="ion-text-wrap" *ngIf="grade.method == 'simple' && grade.scale"> <ion-item class="ion-text-wrap" *ngIf="grade.method == 'simple' && grade.scale">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.grade' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.grade' | translate }}</p>
</ion-label> </ion-label>
<ion-select [(ngModel)]="grade.grade" interface="action-sheet" [disabled]="grade.disabled" <ion-select [(ngModel)]="grade.grade" interface="action-sheet" [disabled]="grade.disabled"
[interfaceOptions]="{header: 'addon.mod_assign.grade' | translate}"> [interfaceOptions]="{header: 'addon.mod_assign.grade' | translate}">
@ -297,7 +297,7 @@
<!-- Outcomes. --> <!-- Outcomes. -->
<ion-item class="ion-text-wrap" *ngFor="let outcome of gradeInfo!.outcomes"> <ion-item class="ion-text-wrap" *ngFor="let outcome of gradeInfo!.outcomes">
<ion-label> <ion-label>
<h2>{{ outcome.name }}</h2> <p class="item-heading">{{ outcome.name }}</p>
</ion-label> </ion-label>
<ion-select *ngIf="canSaveGrades && outcome.itemNumber" [(ngModel)]="outcome.selectedId" <ion-select *ngIf="canSaveGrades && outcome.itemNumber" [(ngModel)]="outcome.selectedId"
interface="action-sheet" [disabled]="gradeInfo!.disabled" [interfaceOptions]="{header: outcome.name }"> interface="action-sheet" [disabled]="gradeInfo!.disabled" [interfaceOptions]="{header: outcome.name }">
@ -311,7 +311,7 @@
<!-- Gradebook grade for simple grading. --> <!-- Gradebook grade for simple grading. -->
<ion-item class="ion-text-wrap" *ngIf="grade.method == 'simple'"> <ion-item class="ion-text-wrap" *ngIf="grade.method == 'simple'">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.currentgrade' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.currentgrade' | translate }}</p>
<p *ngIf="grade.gradebookGrade && !grade.scale"> <p *ngIf="grade.gradebookGrade && !grade.scale">
{{ grade.gradebookGrade }} {{ grade.gradebookGrade }}
</p> </p>
@ -332,7 +332,7 @@
<!-- Workflow status. --> <!-- Workflow status. -->
<ion-item class="ion-text-wrap" *ngIf="workflowStatusTranslationId"> <ion-item class="ion-text-wrap" *ngIf="workflowStatusTranslationId">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.markingworkflowstate' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.markingworkflowstate' | translate }}</p>
<p>{{ workflowStatusTranslationId | translate }}</p> <p>{{ workflowStatusTranslationId | translate }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
@ -340,7 +340,7 @@
<!--- Apply grade to all team members. --> <!--- Apply grade to all team members. -->
<ion-item class="ion-text-wrap" *ngIf="assign!.teamsubmission && canSaveGrades"> <ion-item class="ion-text-wrap" *ngIf="assign!.teamsubmission && canSaveGrades">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.groupsubmissionsettings' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.groupsubmissionsettings' | translate }}</p>
<p>{{ 'addon.mod_assign.applytoteam' | translate }}</p> <p>{{ 'addon.mod_assign.applytoteam' | translate }}</p>
</ion-label> </ion-label>
<ion-toggle [(ngModel)]="grade.applyToAll"></ion-toggle> <ion-toggle [(ngModel)]="grade.applyToAll"></ion-toggle>
@ -350,7 +350,7 @@
<ng-container *ngIf="isGrading && assign!.attemptreopenmethod != attemptReopenMethodNone"> <ng-container *ngIf="isGrading && assign!.attemptreopenmethod != attemptReopenMethodNone">
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.attemptsettings' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.attemptsettings' | translate }}</p>
<p *ngIf="assign!.maxattempts == unlimitedAttempts"> <p *ngIf="assign!.maxattempts == unlimitedAttempts">
{{ 'addon.mod_assign.outof' | translate : {{ 'addon.mod_assign.outof' | translate :
{'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }} {'$a': {'current': currentAttempt, 'total': maxAttemptsText} } }}
@ -376,8 +376,8 @@
[attr.aria-label]="grader!.fullname" detail="true"> [attr.aria-label]="grader!.fullname" detail="true">
<core-user-avatar [user]="grader" slot="start" [linkProfile]="false"></core-user-avatar> <core-user-avatar [user]="grader" slot="start" [linkProfile]="false"></core-user-avatar>
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.gradedby' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.gradedby' | translate }}</p>
<h2>{{ grader!.fullname }}</h2> <p class="item-heading">{{ grader!.fullname }}</p>
<p *ngIf="feedback!.gradeddate">{{ feedback!.gradeddate * 1000 | coreFormatDate }}</p> <p *ngIf="feedback!.gradeddate">{{ feedback!.gradeddate * 1000 | coreFormatDate }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
@ -385,7 +385,7 @@
<!-- Grader is hidden, display only the grade date. --> <!-- Grader is hidden, display only the grade date. -->
<ion-item class="ion-text-wrap" *ngIf="!grader && feedback?.gradeddate"> <ion-item class="ion-text-wrap" *ngIf="!grader && feedback?.gradeddate">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.gradedon' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.gradedon' | translate }}</p>
<p>{{ feedback!.gradeddate * 1000 | coreFormatDate }}</p> <p>{{ feedback!.gradeddate * 1000 | coreFormatDate }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>

View File

@ -20,7 +20,7 @@
</ion-item-divider> </ion-item-divider>
<ion-item class="ion-text-wrap" *ngIf="wordLimitEnabled && words >= 0"> <ion-item class="ion-text-wrap" *ngIf="wordLimitEnabled && words >= 0">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_assign.wordlimit' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_assign.wordlimit' | translate }}</p>
<p>{{ 'core.numwords' | translate: {'$a': words + ' / ' + wordLimit} }}</p> <p>{{ 'core.numwords' | translate: {'$a': words + ' / ' + wordLimit} }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>

View File

@ -19,13 +19,13 @@
<ng-container *ngIf="meetingInfo && showRoom"> <ng-container *ngIf="meetingInfo && showRoom">
<ion-item class="ion-text-wrap" *ngIf="meetingInfo.openingtime"> <ion-item class="ion-text-wrap" *ngIf="meetingInfo.openingtime">
<ion-label> <ion-label>
<h3>{{ 'addon.mod_bigbluebuttonbn.mod_form_field_openingtime' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_bigbluebuttonbn.mod_form_field_openingtime' | translate }}</p>
</ion-label> </ion-label>
<p slot="end">{{ meetingInfo.openingtime * 1000 | coreFormatDate }}</p> <p slot="end">{{ meetingInfo.openingtime * 1000 | coreFormatDate }}</p>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="meetingInfo.closingtime"> <ion-item class="ion-text-wrap" *ngIf="meetingInfo.closingtime">
<ion-label> <ion-label>
<h3>{{ 'addon.mod_bigbluebuttonbn.mod_form_field_closingtime' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_bigbluebuttonbn.mod_form_field_closingtime' | translate }}</p>
</ion-label> </ion-label>
<p slot="end">{{ meetingInfo.closingtime * 1000 | coreFormatDate }}</p> <p slot="end">{{ meetingInfo.closingtime * 1000 | coreFormatDate }}</p>
</ion-item> </ion-item>
@ -45,31 +45,31 @@
<ion-item class="ion-text-wrap" *ngIf="meetingInfo.startedat"> <ion-item class="ion-text-wrap" *ngIf="meetingInfo.startedat">
<ion-label> <ion-label>
<h3>{{ 'addon.mod_bigbluebuttonbn.view_message_session_started_at' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_bigbluebuttonbn.view_message_session_started_at' | translate }}</p>
</ion-label> </ion-label>
<p slot="end">{{ meetingInfo.startedat * 1000 | coreFormatDate: "strftimetime" }}</p> <p slot="end">{{ meetingInfo.startedat * 1000 | coreFormatDate: "strftimetime" }}</p>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h3 *ngIf="meetingInfo.moderatorplural"> <p class="item-heading" *ngIf="meetingInfo.moderatorplural">
{{ 'addon.mod_bigbluebuttonbn.view_message_moderators' | translate }} {{ 'addon.mod_bigbluebuttonbn.view_message_moderators' | translate }}
</h3> </p>
<h3 *ngIf="!meetingInfo.moderatorplural"> <p class="item-heading" *ngIf="!meetingInfo.moderatorplural">
{{ 'addon.mod_bigbluebuttonbn.view_message_moderator' | translate }} {{ 'addon.mod_bigbluebuttonbn.view_message_moderator' | translate }}
</h3> </p>
</ion-label> </ion-label>
<p slot="end">{{ meetingInfo.moderatorcount }}</p> <p slot="end">{{ meetingInfo.moderatorcount }}</p>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h3 *ngIf="meetingInfo.participantplural"> <p class="item-heading" *ngIf="meetingInfo.participantplural">
{{ 'addon.mod_bigbluebuttonbn.view_message_viewers' | translate }} {{ 'addon.mod_bigbluebuttonbn.view_message_viewers' | translate }}
</h3> </p>
<h3 *ngIf="!meetingInfo.participantplural"> <p class="item-heading" *ngIf="!meetingInfo.participantplural">
{{ 'addon.mod_bigbluebuttonbn.view_message_viewer' | translate }} {{ 'addon.mod_bigbluebuttonbn.view_message_viewer' | translate }}
</h3> </p>
</ion-label> </ion-label>
<p slot="end">{{ meetingInfo.participantcount }}</p> <p slot="end">{{ meetingInfo.participantcount }}</p>
</ion-item> </ion-item>
@ -108,7 +108,7 @@
<div [hidden]="!recording.expanded" class="addon-mod_bbb-recording-details"> <div [hidden]="!recording.expanded" class="addon-mod_bbb-recording-details">
<ion-item *ngFor="let data of recording.details" class="ion-text-wrap"> <ion-item *ngFor="let data of recording.details" class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ data.label }}</h2> <p class="item-heading">{{ data.label }}</p>
<p *ngIf="data.allowHTML"> <p *ngIf="data.allowHTML">
<core-format-text [text]="data.value" [component]="component" [componentId]="module.id" contextLevel="module" <core-format-text [text]="data.value" [component]="component" [componentId]="module.id" contextLevel="module"
[contextInstanceId]="module.id" [courseId]="module.course"></core-format-text> [contextInstanceId]="module.id" [courseId]="module.course"></core-format-text>

View File

@ -24,8 +24,8 @@
(click)="openBook(chapter.id)"> (click)="openBook(chapter.id)">
<ion-label> <ion-label>
<p [class.ion-padding-start]="addPadding && chapter.level == 1 ? true : null"> <p [class.ion-padding-start]="addPadding && chapter.level == 1 ? true : null">
<span *ngIf="showNumbers" class="addon-mod-book-number">{{chapter.indexNumber}}&nbsp;</span> <span *ngIf="showNumbers" class="addon-mod-book-number">{{chapter.indexNumber}} </span>
<span *ngIf="showBullets" class="addon-mod-book-bullet">&bull;&nbsp;</span> <span *ngIf="showBullets" class="addon-mod-book-bullet">&bull; </span>
<core-format-text [text]="chapter.title" contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId"> <core-format-text [text]="chapter.title" contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-format-text> </core-format-text>
</p> </p>

View File

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

View File

@ -1,7 +1,7 @@
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-title> <ion-title>
<h2>{{ 'addon.mod_book.toc' | translate }}</h2> <h1>{{ 'addon.mod_book.toc' | translate }}</h1>
</ion-title> </ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate"> <ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
@ -17,8 +17,8 @@
[attr.aria-current]="selected == chapter.id ? 'page' : 'false'" button [class.item-dimmed]="chapter.hidden" detail="false"> [attr.aria-current]="selected == chapter.id ? 'page' : 'false'" button [class.item-dimmed]="chapter.hidden" detail="false">
<ion-label> <ion-label>
<p [class.ion-padding-start]="addPadding && chapter.level == 1 ? true : null" class="item-heading"> <p [class.ion-padding-start]="addPadding && chapter.level == 1 ? true : null" class="item-heading">
<span *ngIf="showNumbers" class="addon-mod-book-number">{{chapter.indexNumber}}&nbsp;</span> <span *ngIf="showNumbers" class="addon-mod-book-number">{{chapter.indexNumber}} </span>
<span *ngIf="showBullets" class="addon-mod-book-bullet">&bull;&nbsp;</span> <span *ngIf="showBullets" class="addon-mod-book-bullet">&bull; </span>
<core-format-text [text]="chapter.title" contextLevel="module" [contextInstanceId]="moduleId" [courseId]="courseId"> <core-format-text [text]="chapter.title" contextLevel="module" [contextInstanceId]="moduleId" [courseId]="courseId">
</core-format-text> </core-format-text>
</p> </p>

View File

@ -291,7 +291,9 @@ export class AddonModBookProvider {
}); });
} }
chapterNumber++; if (!parseInt(chapter.hidden, 10)) {
chapterNumber++;
}
}); });
return chapters; return chapters;

View File

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

View File

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

View File

@ -1,7 +1,7 @@
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-title> <ion-title>
<h2>{{ 'addon.mod_chat.currentusers' | translate }}</h2> <h1>{{ 'addon.mod_chat.currentusers' | translate }}</h1>
</ion-title> </ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate"> <ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -1,7 +1,7 @@
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-title> <ion-title>
<h2>{{ 'addon.mod_data.search' | translate }}</h2> <h1>{{ 'addon.mod_data.search' | translate }}</h1>
</ion-title> </ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate"> <ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -11,6 +11,7 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { import {
AddonModDataEntryField, AddonModDataEntryField,
AddonModDataField, AddonModDataField,

View File

@ -11,6 +11,7 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data'; import { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data';
import { AddonModDataFieldPluginBaseComponent } from '@addons/mod/data/classes/base-field-plugin-component'; import { AddonModDataFieldPluginBaseComponent } from '@addons/mod/data/classes/base-field-plugin-component';

View File

@ -18,8 +18,8 @@ import { Component } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { FormBuilder } from '@angular/forms';
import { SafeUrl } from '@angular/platform-browser'; import { SafeUrl } from '@angular/platform-browser';
import { CoreAnyError } from '@classes/errors/error'; import { CoreAnyError } from '@classes/errors/error';
import { CoreApp } from '@services/app';
import { CoreGeolocation, CoreGeolocationError, CoreGeolocationErrorReason } from '@services/geolocation'; import { CoreGeolocation, CoreGeolocationError, CoreGeolocationErrorReason } from '@services/geolocation';
import { CorePlatform } from '@services/platform';
import { CoreDomUtils } from '@services/utils/dom'; import { CoreDomUtils } from '@services/utils/dom';
import { DomSanitizer } from '@singletons'; import { DomSanitizer } from '@singletons';
@ -73,7 +73,7 @@ export class AddonModDataFieldLatlongComponent extends AddonModDataFieldPluginBa
const northFixed = north ? north.toFixed(4) : '0.0000'; const northFixed = north ? north.toFixed(4) : '0.0000';
const eastFixed = east ? east.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; url = 'http://maps.apple.com/?ll=' + northFixed + ',' + eastFixed + '&near=' + northFixed + ',' + eastFixed;
} else { } else {
url = 'geo:' + northFixed + ',' + eastFixed; url = 'geo:' + northFixed + ',' + eastFixed;

View File

@ -45,17 +45,17 @@ export class AddonModDataFieldNumberHandlerService extends AddonModDataFieldText
originalFieldData: AddonModDataEntryField, originalFieldData: AddonModDataEntryField,
): boolean { ): boolean {
const fieldName = 'f_' + field.id; const fieldName = 'f_' + field.id;
const input = inputData[fieldName] || ''; const input = inputData[fieldName] ?? '';
const content = originalFieldData?.content || ''; const content = originalFieldData?.content ?? '';
return input != content; return input !== content;
} }
/** /**
* @inheritdoc * @inheritdoc
*/ */
getFieldsNotifications(field: AddonModDataField, inputData: AddonModDataSubfieldData[]): string | undefined { 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'); return Translate.instant('addon.mod_data.errormustsupplyvalue');
} }
} }

View File

@ -11,6 +11,7 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data'; import { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { CoreFileEntry, CoreFileHelper } from '@services/file-helper'; import { CoreFileEntry, CoreFileHelper } from '@services/file-helper';

View File

@ -11,6 +11,7 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { AddonModDataFieldPluginBaseComponent } from '../../../classes/base-field-plugin-component'; import { AddonModDataFieldPluginBaseComponent } from '../../../classes/base-field-plugin-component';

View File

@ -70,7 +70,7 @@ export class AddonModDataFieldTextHandlerService implements AddonModDataFieldHan
return [{ return [{
fieldid: field.id, fieldid: field.id,
value: inputData[fieldName] || '', value: inputData[fieldName] ?? '',
}]; }];
} }
@ -83,10 +83,10 @@ export class AddonModDataFieldTextHandlerService implements AddonModDataFieldHan
originalFieldData: AddonModDataEntryField, originalFieldData: AddonModDataEntryField,
): boolean { ): boolean {
const fieldName = 'f_' + field.id; const fieldName = 'f_' + field.id;
const input = inputData[fieldName] || ''; const input = inputData[fieldName] ?? '';
const content = originalFieldData?.content || ''; const content = originalFieldData?.content ?? '';
return input != content; return input !== content;
} }
/** /**
@ -102,7 +102,7 @@ export class AddonModDataFieldTextHandlerService implements AddonModDataFieldHan
* @inheritdoc * @inheritdoc
*/ */
overrideData(originalContent: AddonModDataEntryField, offlineContent: CoreFormFields<string>): AddonModDataEntryField { overrideData(originalContent: AddonModDataEntryField, offlineContent: CoreFormFields<string>): AddonModDataEntryField {
originalContent.content = offlineContent[''] || ''; originalContent.content = offlineContent[''] ?? '';
return originalContent; return originalContent;
} }

View File

@ -11,6 +11,7 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { AddonModDataEntryField } from '@addons/mod/data/services/data'; import { AddonModDataEntryField } from '@addons/mod/data/services/data';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { AddonModDataFieldPluginBaseComponent } from '../../../classes/base-field-plugin-component'; import { AddonModDataFieldPluginBaseComponent } from '../../../classes/base-field-plugin-component';

View File

@ -42,6 +42,7 @@ import {
import { AddonModDataHelper } from '../../services/data-helper'; import { AddonModDataHelper } from '../../services/data-helper';
import { CoreDom } from '@singletons/dom'; import { CoreDom } from '@singletons/dom';
import { AddonModDataEntryFieldInitialized } from '../../classes/base-field-plugin-component'; import { AddonModDataEntryFieldInitialized } from '../../classes/base-field-plugin-component';
import { CoreTextUtils } from '@services/utils/text';
/** /**
* Page that displays the view edit page. * Page that displays the view edit page.
@ -368,9 +369,18 @@ export class AddonModDataEditPage implements OnInit {
} }
}); });
} }
this.jsData!.errors = this.errors; this.jsData!.errors = this.errors;
this.scrollToFirstError(); this.scrollToFirstError();
if (updateEntryResult.generalnotifications?.length) {
CoreDomUtils.showAlertWithOptions({
header: Translate.instant('core.notice'),
message: CoreTextUtils.buildMessage(updateEntryResult.generalnotifications),
buttons: [Translate.instant('core.ok')],
});
}
} }
} finally { } finally {
modal.dismiss(); modal.dismiss();

View File

@ -590,8 +590,8 @@ export class AddonModDataHelperProvider {
// WS wants values in JSON format. // WS wants values in JSON format.
entryFieldDataToSend.push({ entryFieldDataToSend.push({
fieldid: fieldSubdata.fieldid, fieldid: fieldSubdata.fieldid,
subfield: fieldSubdata.subfield || '', subfield: fieldSubdata.subfield ?? '',
value: value ? JSON.stringify(value) : '', value: (value || value === 0) ? JSON.stringify(value) : '',
}); });
return; return;

View File

@ -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 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 press "Delete" in the app
And I should not find "Moodle Cloud" 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

View File

@ -37,7 +37,8 @@
<div collapsible-footer *ngIf="!showLoading" slot="fixed"> <div collapsible-footer *ngIf="!showLoading" slot="fixed">
<div class="list-item-limited-width adaptable-buttons-row" <div class="list-item-limited-width adaptable-buttons-row"
*ngIf="access && (access.canedititems || access.canviewreports || !access.isempty)"> *ngIf="access && (access.canedititems || access.canviewreports || !access.isempty)">
<ion-button expand="block" fill="outline" (click)="gotoAnswerQuestions(true)" class="ion-margin ion-text-wrap"> <ion-button *ngIf="access.canedititems || access.canviewreports" expand="block" fill="outline"
(click)="gotoAnswerQuestions(true)" class="ion-margin ion-text-wrap">
<ion-icon name="fas-search" slot="start" aria-hidden="true"></ion-icon> <ion-icon name="fas-search" slot="start" aria-hidden="true"></ion-icon>
{{ 'addon.mod_feedback.preview' | translate }} {{ 'addon.mod_feedback.preview' | translate }}
</ion-button> </ion-button>
@ -64,7 +65,7 @@
<ion-item class="ion-text-wrap" (click)="openAttempts()" [class.hide-detail]="!(access.canviewreports && completedCount > 0)" <ion-item class="ion-text-wrap" (click)="openAttempts()" [class.hide-detail]="!(access.canviewreports && completedCount > 0)"
[detail]="true" [button]="access.canviewreports && completedCount > 0"> [detail]="true" [button]="access.canviewreports && completedCount > 0">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_feedback.completed_feedbacks' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_feedback.completed_feedbacks' | translate }}</p>
</ion-label> </ion-label>
<ion-badge slot="end"> <ion-badge slot="end">
<span aria-hidden="true">{{completedCount}}</span> <span aria-hidden="true">{{completedCount}}</span>
@ -76,12 +77,12 @@
<ion-item class="ion-text-wrap" *ngIf="!access.isanonymous && access.canviewreports" (click)="openNonRespondents()" detail="true" <ion-item class="ion-text-wrap" *ngIf="!access.isanonymous && access.canviewreports" (click)="openNonRespondents()" detail="true"
button> button>
<ion-label> <ion-label>
<h2>{{ 'addon.mod_feedback.show_nonrespondents' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_feedback.show_nonrespondents' | translate }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_feedback.questions' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_feedback.questions' | translate }}</p>
</ion-label> </ion-label>
<ion-badge slot="end"> <ion-badge slot="end">
<span aria-hidden="true">{{itemsCount}}</span> <span aria-hidden="true">{{itemsCount}}</span>
@ -115,19 +116,19 @@
<ion-list *ngIf="access && (access.canedititems || access.canviewreports || !access.isempty)"> <ion-list *ngIf="access && (access.canedititems || access.canviewreports || !access.isempty)">
<ion-item class="ion-text-wrap" *ngIf="access.canedititems && overview.timeopen"> <ion-item class="ion-text-wrap" *ngIf="access.canedititems && overview.timeopen">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_feedback.feedbackopen' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_feedback.feedbackopen' | translate }}</p>
<p>{{overview.openTimeReadable}}</p> <p>{{overview.openTimeReadable}}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="access.canedititems && overview.timeclose"> <ion-item class="ion-text-wrap" *ngIf="access.canedititems && overview.timeclose">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_feedback.feedbackclose' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_feedback.feedbackclose' | translate }}</p>
<p>{{overview.closeTimeReadable}}</p> <p>{{overview.closeTimeReadable}}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="access.canedititems && feedback && feedback.page_after_submit"> <ion-item class="ion-text-wrap" *ngIf="access.canedititems && feedback && feedback.page_after_submit">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_feedback.page_after_submit' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_feedback.page_after_submit' | translate }}</p>
<core-format-text [component]="component" [componentId]="componentId" [text]="feedback.page_after_submit" <core-format-text [component]="component" [componentId]="componentId" [text]="feedback.page_after_submit"
contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId"> contextLevel="module" [contextInstanceId]="module.id" [courseId]="courseId">
</core-format-text> </core-format-text>
@ -136,7 +137,7 @@
<ng-container *ngIf="!access.isempty"> <ng-container *ngIf="!access.isempty">
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_feedback.mode' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_feedback.mode' | translate }}</p>
<p *ngIf="access.isanonymous">{{ 'addon.mod_feedback.anonymous' | translate }}</p> <p *ngIf="access.isanonymous">{{ 'addon.mod_feedback.anonymous' | translate }}</p>
<p *ngIf="!access.isanonymous">{{ 'addon.mod_feedback.non_anonymous' | translate }}</p> <p *ngIf="!access.isanonymous">{{ 'addon.mod_feedback.non_anonymous' | translate }}</p>
</ion-label> </ion-label>

View File

@ -33,12 +33,12 @@
<core-spacer *ngIf="item.typ == 'pagebreak'"></core-spacer> <core-spacer *ngIf="item.typ == 'pagebreak'"></core-spacer>
<ion-item class="ion-text-wrap" *ngIf="item.typ != 'pagebreak'" [color]="item.dependitem > 0 ? 'light' : ''"> <ion-item class="ion-text-wrap" *ngIf="item.typ != 'pagebreak'" [color]="item.dependitem > 0 ? 'light' : ''">
<ion-label> <ion-label>
<h2 *ngIf="item.name" [core-mark-required]="item.required"> <p class="item-heading" *ngIf="item.name" [core-mark-required]="item.required">
<span *ngIf="feedback!.autonumbering && item.itemnumber">{{item.itemnumber}}. </span> <span *ngIf="feedback!.autonumbering && item.itemnumber">{{item.itemnumber}}. </span>
<core-format-text [component]="component" [componentId]="cmId" [text]="item.name" contextLevel="module" <core-format-text [component]="component" [componentId]="cmId" [text]="item.name" contextLevel="module"
[contextInstanceId]="cmId" [courseId]="courseId"> [contextInstanceId]="cmId" [courseId]="courseId">
</core-format-text> </core-format-text>
</h2> </p>
<p *ngIf="item.submittedValue"> <p *ngIf="item.submittedValue">
<core-format-text [component]="component" [componentId]="cmId" [text]="item.submittedValue" <core-format-text [component]="component" [componentId]="cmId" [text]="item.submittedValue"
contextLevel="module" [contextInstanceId]="cmId" [courseId]="courseId"> contextLevel="module" [contextInstanceId]="cmId" [courseId]="courseId">

View File

@ -17,7 +17,7 @@
<ion-list class="ion-no-margin has-spacer"> <ion-list class="ion-no-margin has-spacer">
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_feedback.mode' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_feedback.mode' | translate }}</p>
<p *ngIf="access!.isanonymous">{{ 'addon.mod_feedback.anonymous' | translate }}</p> <p *ngIf="access!.isanonymous">{{ 'addon.mod_feedback.anonymous' | translate }}</p>
<p *ngIf="!access!.isanonymous">{{ 'addon.mod_feedback.non_anonymous' | translate }}</p> <p *ngIf="!access!.isanonymous">{{ 'addon.mod_feedback.non_anonymous' | translate }}</p>
</ion-label> </ion-label>

View File

@ -109,6 +109,14 @@ export class AddonModFeedbackFormPage implements OnInit, OnDestroy, CanLeave {
await this.fetchData(); 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) { if (!this.feedback) {
return; return;
} }

View File

@ -3,6 +3,8 @@
<ion-button fill="clear" (click)="openModuleSummary()" aria-haspopup="true" [attr.aria-label]="'core.info' | translate"> <ion-button fill="clear" (click)="openModuleSummary()" aria-haspopup="true" [attr.aria-label]="'core.info' | translate">
<ion-icon name="fas-info-circle" slot="icon-only" aria-hidden="true"></ion-icon> <ion-icon name="fas-info-circle" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button> </ion-button>
<!-- Add an empty context menu so split view pages can add items, otherwise the menu disappears in some cases. -->
<core-context-menu></core-context-menu>
</core-navbar-buttons> </core-navbar-buttons>
<!-- Content. --> <!-- Content. -->
@ -74,26 +76,20 @@
[lines]="discussion.groupname && 'none'" [attr.aria-current]="discussions?.getItemAriaCurrent(discussion)" [lines]="discussion.groupname && 'none'" [attr.aria-current]="discussions?.getItemAriaCurrent(discussion)"
(click)="discussions?.select(discussion)" button> (click)="discussions?.select(discussion)" button>
<ion-label> <ion-label>
<div class="addon-mod-forum-discussion-title"> <p class="addon-mod-forum-discussion-title ion-text-wrap item-heading">
<p class="ion-text-wrap item-heading"> <ion-icon name="fas-map-pin" *ngIf="discussion.pinned"
<ion-icon name="fas-map-pin" *ngIf="discussion.pinned" [attr.aria-label]="'addon.mod_forum.discussionpinned' | translate"></ion-icon>
[attr.aria-label]="'addon.mod_forum.discussionpinned' | translate"></ion-icon> <ion-icon name="fas-star" class="addon-forum-star" *ngIf="!discussion.pinned && discussion.starred"
<ion-icon name="fas-star" class="addon-forum-star" *ngIf="!discussion.pinned && discussion.starred" [attr.aria-label]="'addon.mod_forum.favourites' | translate"></ion-icon>
[attr.aria-label]="'addon.mod_forum.favourites' | translate"></ion-icon> <core-format-text [text]="discussion.subject" contextLevel="module" [contextInstanceId]="module && module.id"
<core-format-text [text]="discussion.subject" contextLevel="module" [contextInstanceId]="module && module.id" [courseId]="courseId">
[courseId]="courseId"> </core-format-text>
</core-format-text> <ion-icon name="fas-lock" *ngIf="discussion.locked" class="addon-mod-forum-locked-icon"
<ion-icon name="fas-lock" *ngIf="discussion.locked" class="addon-mod-forum-locked-icon" [attr.aria-label]="'addon.mod_forum.discussionlocked' | translate"></ion-icon>
[attr.aria-label]="'addon.mod_forum.discussionlocked' | translate"></ion-icon> </p>
</p>
<ion-button *ngIf="canPin || discussion.canlock || discussion.canfavourite" fill="clear"
[attr.aria-label]="('core.displayoptions' | translate)" (click)="showOptionsMenu($event, discussion)">
<ion-icon name="ellipsis-vertical" slot="icon-only" aria-hidden="true">
</ion-icon>
</ion-button>
</div>
<div class="addon-mod-forum-discussion-info"> <div class="addon-mod-forum-discussion-info">
<core-user-avatar *ngIf="discussion.userfullname" [user]="discussion" slot="start" [courseId]="courseId"> <core-user-avatar *ngIf="discussion.userfullname" [user]="discussion" slot="start" [courseId]="courseId"
[linkProfile]="false">
</core-user-avatar> </core-user-avatar>
<div class="addon-mod-forum-discussion-author"> <div class="addon-mod-forum-discussion-author">
<span *ngIf="discussion.userfullname">{{discussion.userfullname}}</span> <span *ngIf="discussion.userfullname">{{discussion.userfullname}}</span>
@ -136,6 +132,11 @@
</ion-col> </ion-col>
</ion-row> </ion-row>
</ion-label> </ion-label>
<ion-button *ngIf="canPin || discussion.canlock || discussion.canfavourite" fill="clear"
[attr.aria-label]="('core.displayoptions' | translate)" (click)="showOptionsMenu($event, discussion)" slot="end">
<ion-icon name="ellipsis-vertical" slot="icon-only" aria-hidden="true">
</ion-icon>
</ion-button>
</ion-item> </ion-item>
<core-infinite-loading [enabled]="discussions && discussions.loaded && !discussions.completed" [error]="fetchFailed" <core-infinite-loading [enabled]="discussions && discussions.loaded && !discussions.completed" [error]="fetchFailed"

View File

@ -7,7 +7,6 @@
} }
.addon-mod-forum-discussion.item { .addon-mod-forum-discussion.item {
ion-label { ion-label {
margin-top: 4px; margin-top: 4px;
@ -35,21 +34,30 @@
@include margin(0, 8px, 0, 0); @include margin(0, 8px, 0, 0);
} }
.addon-mod-forum-discussion-title,
.addon-mod-forum-discussion-info { .addon-mod-forum-discussion-info {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.addon-mod-forum-discussion-title .item-heading,
.addon-mod-forum-discussion-info .addon-mod-forum-discussion-author { .addon-mod-forum-discussion-info .addon-mod-forum-discussion-author {
flex-grow: 1; flex-grow: 1;
} }
.addon-mod-forum-discussion-title {
@include margin-horizontal(null, 8px);
line-height: 18px;
}
.addon-mod-forum-discussion-more-info.ios { .addon-mod-forum-discussion-more-info.ios {
font-size: 0.9rem; font-size: 0.9rem;
} }
ion-button {
position: absolute;
@include position (4px, 8px, null, null);
}
} }
.core-group-selector { .core-group-selector {

View File

@ -126,7 +126,7 @@
<ion-icon *ngIf="advanced" name="fas-chevron-down" slot="start" aria-hidden="true" class="expandable-status-icon"> <ion-icon *ngIf="advanced" name="fas-chevron-down" slot="start" aria-hidden="true" class="expandable-status-icon">
</ion-icon> </ion-icon>
<ion-label> <ion-label>
<h2>{{ 'addon.mod_forum.advanced' | translate }}</h2> <h3 class="item-heading">{{ 'addon.mod_forum.advanced' | translate }}</h3>
</ion-label> </ion-label>
</ion-item> </ion-item>
<div *ngIf="advanced" [id]="'addon-forum-reply-edit-form-advanced-' + uniqueId"> <div *ngIf="advanced" [id]="'addon-forum-reply-edit-form-advanced-' + uniqueId">

View File

@ -1,7 +1,7 @@
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-title> <ion-title>
<h2 id="addon-mod-forum-sort-order-label">{{ 'core.sort' | translate }}</h2> <h1 id="addon-mod-forum-sort-order-label">{{ 'core.sort' | translate }}</h1>
</ion-title> </ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate"> <ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
@ -17,7 +17,7 @@
[attr.aria-current]="selected == sortOrder.value ? 'page' : 'false'" [attr.aria-label]="sortOrder.label | translate" [attr.aria-current]="selected == sortOrder.value ? 'page' : 'false'" [attr.aria-label]="sortOrder.label | translate"
(click)="selectSortOrder(sortOrder)" button aria-haspopup="dialog"> (click)="selectSortOrder(sortOrder)" button aria-haspopup="dialog">
<ion-label> <ion-label>
<h2>{{ sortOrder.label | translate }}</h2> <p class="item-heading">{{ sortOrder.label | translate }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
</ng-container> </ng-container>

View File

@ -46,10 +46,10 @@
<ion-toggle [(ngModel)]="newDiscussion.postToAllGroups" name="postallgroups"></ion-toggle> <ion-toggle [(ngModel)]="newDiscussion.postToAllGroups" name="postallgroups"></ion-toggle>
</ion-item> </ion-item>
<ion-item *ngIf="showGroups" class="core-edit-set-group"> <ion-item *ngIf="showGroups" class="core-edit-set-group">
<ion-label id="addon-mod-forum-groupslabel">{{ 'addon.mod_forum.group' | translate }}</ion-label> <ion-label>{{ 'addon.mod_forum.group' | translate }}</ion-label>
<ion-select [(ngModel)]="newDiscussion.groupId" [disabled]="newDiscussion.postToAllGroups" <ion-select [(ngModel)]="newDiscussion.groupId" [disabled]="newDiscussion.postToAllGroups" interface="action-sheet"
aria-labelledby="addon-mod-forum-groupslabel" interface="action-sheet" name="groupid" name="groupid" [interfaceOptions]="{header: 'addon.mod_forum.group' | translate}"
[interfaceOptions]="{header: 'addon.mod_forum.group' | translate}" (ionChange)="calculateGroupName()"> (ionChange)="calculateGroupName()">
<ion-select-option *ngFor="let group of groups" [value]="group.id">{{ group.name }}</ion-select-option> <ion-select-option *ngFor="let group of groups" [value]="group.id">{{ group.name }}</ion-select-option>
</ion-select> </ion-select>
</ion-item> </ion-item>

View File

@ -28,11 +28,11 @@
</core-rich-text-editor> </core-rich-text-editor>
</ion-item> </ion-item>
<ion-item *ngIf="categories.length > 0"> <ion-item *ngIf="categories.length > 0">
<ion-label position="stacked" id="addon-mod-glossary-categories-label"> <ion-label position="stacked">
{{ 'addon.mod_glossary.categories' | translate }} {{ 'addon.mod_glossary.categories' | translate }}
</ion-label> </ion-label>
<ion-select [(ngModel)]="options.categories" multiple="true" aria-labelledby="addon-mod-glossary-categories-label" <ion-select [(ngModel)]="options.categories" multiple="true" interface="action-sheet"
interface="action-sheet" [placeholder]="'addon.mod_glossary.categories' | translate" name="categories" [placeholder]="'addon.mod_glossary.categories' | translate" name="categories"
[interfaceOptions]="{header: 'addon.mod_glossary.categories' | translate}"> [interfaceOptions]="{header: 'addon.mod_glossary.categories' | translate}">
<ion-select-option *ngFor="let category of categories" [value]="category.id"> <ion-select-option *ngFor="let category of categories" [value]="category.id">
{{ category.name }} {{ category.name }}
@ -40,11 +40,10 @@
</ion-select> </ion-select>
</ion-item> </ion-item>
<ion-item> <ion-item>
<ion-label position="stacked" id="addon-mod-glossary-aliases-label"> <ion-label position="stacked">
{{ 'addon.mod_glossary.aliases' | translate }} {{ 'addon.mod_glossary.aliases' | translate }}
</ion-label> </ion-label>
<ion-textarea [(ngModel)]="options.aliases" rows="1" [core-auto-rows]="options.aliases" <ion-textarea [(ngModel)]="options.aliases" rows="1" [core-auto-rows]="options.aliases" name="aliases">
aria-labelledby="addon-mod-glossary-aliases-label" name="aliases">
</ion-textarea> </ion-textarea>
</ion-item> </ion-item>
<ion-item-divider> <ion-item-divider>

View File

@ -200,6 +200,7 @@ Feature: Test glossary navigation
When I swipe to the left in the app When I swipe to the left in the app
Then I should find "Acerola is a fruit" in the app Then I should find "Acerola is a fruit" in the app
@ci_jenkins_skip
Scenario: Tablet navigation on glossary Scenario: Tablet navigation on glossary
Given I entered the course "Course 1" as "student1" in the app Given I entered the course "Course 1" as "student1" in the app
And I change viewport size to "1200x640" And I change viewport size to "1200x640"

View File

@ -57,7 +57,7 @@
<ion-item class="ion-text-center" *ngIf="downloading"> <ion-item class="ion-text-center" *ngIf="downloading">
<ion-label> <ion-label>
<ion-spinner [attr.aria-label]="'core.loading' | translate"></ion-spinner> <ion-spinner [attr.aria-label]="'core.loading' | translate"></ion-spinner>
<h2 *ngIf="progressMessage">{{ progressMessage | translate }}</h2> <p class="item-heading" *ngIf="progressMessage">{{ progressMessage | translate }}</p>
<core-progress-bar *ngIf="showPercentage" [progress]="percentage" [a11yText]="progressMessage"></core-progress-bar> <core-progress-bar *ngIf="showPercentage" [progress]="percentage" [a11yText]="progressMessage"></core-progress-bar>
</ion-label> </ion-label>
</ion-item> </ion-item>

View File

@ -23,13 +23,13 @@
[attr.aria-label]="user.fullname"> [attr.aria-label]="user.fullname">
<core-user-avatar [user]="user" slot="start" [courseId]="courseId" [linkProfile]="false"></core-user-avatar> <core-user-avatar [user]="user" slot="start" [courseId]="courseId" [linkProfile]="false"></core-user-avatar>
<ion-label> <ion-label>
<h2>{{ 'addon.mod_h5pactivity.attempt' | translate }} #{{attempt.attempt}}: {{user.fullname}}</h2> <p class="item-heading">{{ 'addon.mod_h5pactivity.attempt' | translate }} #{{attempt.attempt}}: {{user.fullname}}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<!-- Attempt number (if user not known). --> <!-- Attempt number (if user not known). -->
<ion-item class="ion-text-wrap" *ngIf="!user"> <ion-item class="ion-text-wrap" *ngIf="!user">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_h5pactivity.attempt' | translate }} #{{attempt.attempt}}</h2> <p class="item-heading">{{ 'addon.mod_h5pactivity.attempt' | translate }} #{{attempt.attempt}}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
@ -38,13 +38,13 @@
<ion-list> <ion-list>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_h5pactivity.startdate' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_h5pactivity.startdate' | translate }}</p>
<p>{{ attempt.timecreated | coreFormatDate:'strftimedatetime' }}</p> <p>{{ attempt.timecreated | coreFormatDate:'strftimedatetime' }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_h5pactivity.completion' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_h5pactivity.completion' | translate }}</p>
<p *ngIf="attempt.completion"> <p *ngIf="attempt.completion">
<img src="assets/img/completion/completion-auto-y.svg" role="presentation" alt=""> <img src="assets/img/completion/completion-auto-y.svg" role="presentation" alt="">
{{ 'addon.mod_h5pactivity.attempt_completion_yes' | translate }} {{ 'addon.mod_h5pactivity.attempt_completion_yes' | translate }}
@ -57,13 +57,13 @@
</ion-item> </ion-item>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_h5pactivity.duration' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_h5pactivity.duration' | translate }}</p>
<p>{{ attempt.durationReadable }}</p> <p>{{ attempt.durationReadable }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_h5pactivity.outcome' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_h5pactivity.outcome' | translate }}</p>
<p *ngIf="attempt.success !== null && attempt.success"> <p *ngIf="attempt.success !== null && attempt.success">
<ion-icon name="fas-check-circle" aria-hidden="true"></ion-icon> <ion-icon name="fas-check-circle" aria-hidden="true"></ion-icon>
{{ 'addon.mod_h5pactivity.attempt_success_pass' | translate }} {{ 'addon.mod_h5pactivity.attempt_success_pass' | translate }}
@ -79,7 +79,7 @@
</ion-item> </ion-item>
<ion-item *ngIf="attempt.maxscore" class="ion-text-wrap"> <ion-item *ngIf="attempt.maxscore" class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_h5pactivity.totalscore' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_h5pactivity.totalscore' | translate }}</p>
<p>{{ 'addon.mod_h5pactivity.score_out_of' | translate:{$a: attempt} }}</p> <p>{{ 'addon.mod_h5pactivity.score_out_of' | translate:{$a: attempt} }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>

View File

@ -37,7 +37,7 @@
<ng-container *ngIf="attemptsData.scored"> <ng-container *ngIf="attemptsData.scored">
<ion-item-divider> <ion-item-divider>
<ion-label> <ion-label>
<h2>{{ attemptsData.scored.title }}</h2> <h3 class="item-heading">{{ attemptsData.scored.title }}</h3>
</ion-label> </ion-label>
</ion-item-divider> </ion-item-divider>
<ng-container *ngTemplateOutlet="attemptsTemplate; context: {attempts: attemptsData.scored.attempts}"> <ng-container *ngTemplateOutlet="attemptsTemplate; context: {attempts: attemptsData.scored.attempts}">
@ -48,7 +48,7 @@
<ng-container *ngIf="attemptsData.attempts && attemptsData.attempts.length"> <ng-container *ngIf="attemptsData.attempts && attemptsData.attempts.length">
<ion-item-divider> <ion-item-divider>
<ion-label> <ion-label>
<h2>{{ 'addon.mod_h5pactivity.all_attempts' | translate }}</h2> <h3 class="item-heading">{{ 'addon.mod_h5pactivity.all_attempts' | translate }}</h3>
</ion-label> </ion-label>
</ion-item-divider> </ion-item-divider>
<ng-container *ngTemplateOutlet="attemptsTemplate; context: {attempts: attemptsData.attempts}"></ng-container> <ng-container *ngTemplateOutlet="attemptsTemplate; context: {attempts: attemptsData.attempts}"></ng-container>

View File

@ -1,7 +1,7 @@
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-title> <ion-title>
<h2>{{ 'addon.mod_imscp.toc' | translate }}</h2> <h1>{{ 'addon.mod_imscp.toc' | translate }}</h1>
</ion-title> </ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate"> <ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -77,7 +77,7 @@
<ion-grid class="ion-text-wrap ion-hide-md-down"> <ion-grid class="ion-text-wrap ion-hide-md-down">
<ion-row *ngIf="overview.lessonscored"> <ion-row *ngIf="overview.lessonscored">
<ion-col class="ion-text-center"> <ion-col class="ion-text-center">
<h3 class="item-heading">{{ 'addon.mod_lesson.averagescore' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.averagescore' | translate }}</p>
<p *ngIf="overview.numofattempts > 0"> <p *ngIf="overview.numofattempts > 0">
{{ 'core.percentagenumber' | translate:{$a: overview.avescore} }} {{ 'core.percentagenumber' | translate:{$a: overview.avescore} }}
</p> </p>
@ -85,7 +85,7 @@
</ion-col> </ion-col>
<ion-col class="ion-text-center"> <ion-col class="ion-text-center">
<h3 class="item-heading">{{ 'addon.mod_lesson.highscore' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.highscore' | translate }}</p>
<p *ngIf="overview.highscore != null"> <p *ngIf="overview.highscore != null">
{{ 'core.percentagenumber' | translate:{$a: overview.highscore} }} {{ 'core.percentagenumber' | translate:{$a: overview.highscore} }}
</p> </p>
@ -93,7 +93,7 @@
</ion-col> </ion-col>
<ion-col class="ion-text-center"> <ion-col class="ion-text-center">
<h3 class="item-heading">{{ 'addon.mod_lesson.lowscore' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.lowscore' | translate }}</p>
<p *ngIf="overview.lowscore != null"> <p *ngIf="overview.lowscore != null">
{{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }} {{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }}
</p> </p>
@ -102,7 +102,7 @@
</ion-row> </ion-row>
<ion-row> <ion-row>
<ion-col class="ion-text-center"> <ion-col class="ion-text-center">
<h3 class="item-heading">{{ 'addon.mod_lesson.averagetime' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.averagetime' | translate }}</p>
<p *ngIf="overview.avetime != null && overview.numofattempts">{{ avetimeReadable }}</p> <p *ngIf="overview.avetime != null && overview.numofattempts">{{ avetimeReadable }}</p>
<p *ngIf="overview.avetime == null || !overview.numofattempts"> <p *ngIf="overview.avetime == null || !overview.numofattempts">
{{ 'addon.mod_lesson.notcompleted' | translate }} {{ 'addon.mod_lesson.notcompleted' | translate }}
@ -110,13 +110,13 @@
</ion-col> </ion-col>
<ion-col class="ion-text-center"> <ion-col class="ion-text-center">
<h3 class="item-heading">{{ 'addon.mod_lesson.hightime' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.hightime' | translate }}</p>
<p *ngIf="overview.hightime != null">{{ hightimeReadable }}</p> <p *ngIf="overview.hightime != null">{{ hightimeReadable }}</p>
<p *ngIf="overview.hightime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p> <p *ngIf="overview.hightime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col> </ion-col>
<ion-col class="ion-text-center"> <ion-col class="ion-text-center">
<h3 class="item-heading">{{ 'addon.mod_lesson.lowtime' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.lowtime' | translate }}</p>
<p *ngIf="overview.lowtime != null">{{ lowtimeReadable }}</p> <p *ngIf="overview.lowtime != null">{{ lowtimeReadable }}</p>
<p *ngIf="overview.lowtime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p> <p *ngIf="overview.lowtime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col> </ion-col>
@ -127,7 +127,7 @@
<ion-grid class="ion-text-wrap ion-hide-md-up"> <ion-grid class="ion-text-wrap ion-hide-md-up">
<ion-row> <ion-row>
<ion-col class="ion-text-center" *ngIf="overview.lessonscored"> <ion-col class="ion-text-center" *ngIf="overview.lessonscored">
<h3 class="item-heading">{{ 'addon.mod_lesson.averagescore' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.averagescore' | translate }}</p>
<p *ngIf="overview.numofattempts > 0"> <p *ngIf="overview.numofattempts > 0">
{{ 'core.percentagenumber' | translate:{$a: overview.avescore} }} {{ 'core.percentagenumber' | translate:{$a: overview.avescore} }}
</p> </p>
@ -135,7 +135,7 @@
</ion-col> </ion-col>
<ion-col [ngClass]="{'ion-text-center': overview.lessonscored}"> <ion-col [ngClass]="{'ion-text-center': overview.lessonscored}">
<h3 class="item-heading">{{ 'addon.mod_lesson.averagetime' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.averagetime' | translate }}</p>
<p *ngIf="overview.avetime != null && overview.numofattempts">{{ avetimeReadable }}</p> <p *ngIf="overview.avetime != null && overview.numofattempts">{{ avetimeReadable }}</p>
<p *ngIf="overview.avetime == null || !overview.numofattempts"> <p *ngIf="overview.avetime == null || !overview.numofattempts">
{{ 'addon.mod_lesson.notcompleted' | translate }} {{ 'addon.mod_lesson.notcompleted' | translate }}
@ -144,7 +144,7 @@
</ion-row> </ion-row>
<ion-row> <ion-row>
<ion-col class="ion-text-center" *ngIf="overview.lessonscored"> <ion-col class="ion-text-center" *ngIf="overview.lessonscored">
<h3 class="item-heading">{{ 'addon.mod_lesson.highscore' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.highscore' | translate }}</p>
<p *ngIf="overview.highscore != null"> <p *ngIf="overview.highscore != null">
{{ 'core.percentagenumber' | translate:{$a: overview.highscore} }} {{ 'core.percentagenumber' | translate:{$a: overview.highscore} }}
</p> </p>
@ -152,14 +152,14 @@
</ion-col> </ion-col>
<ion-col [ngClass]="{'ion-text-center': overview.lessonscored}"> <ion-col [ngClass]="{'ion-text-center': overview.lessonscored}">
<h3 class="item-heading">{{ 'addon.mod_lesson.hightime' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.hightime' | translate }}</p>
<p *ngIf="overview.hightime != null">{{ hightimeReadable }}</p> <p *ngIf="overview.hightime != null">{{ hightimeReadable }}</p>
<p *ngIf="overview.hightime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p> <p *ngIf="overview.hightime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col> </ion-col>
</ion-row> </ion-row>
<ion-row> <ion-row>
<ion-col class="ion-text-center" *ngIf="overview.lessonscored"> <ion-col class="ion-text-center" *ngIf="overview.lessonscored">
<h3 class="item-heading">{{ 'addon.mod_lesson.lowscore' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.lowscore' | translate }}</p>
<p *ngIf="overview.lowscore != null"> <p *ngIf="overview.lowscore != null">
{{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }} {{ 'core.percentagenumber' | translate:{$a: overview.lowscore} }}
</p> </p>
@ -167,7 +167,7 @@
</ion-col> </ion-col>
<ion-col [ngClass]="{'ion-text-center': overview.lessonscored}"> <ion-col [ngClass]="{'ion-text-center': overview.lessonscored}">
<h3 class="item-heading">{{ 'addon.mod_lesson.lowtime' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.lowtime' | translate }}</p>
<p *ngIf="overview.lowtime != null">{{ lowtimeReadable }}</p> <p *ngIf="overview.lowtime != null">{{ lowtimeReadable }}</p>
<p *ngIf="overview.lowtime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p> <p *ngIf="overview.lowtime == null">{{ 'addon.mod_lesson.notcompleted' | translate }}</p>
</ion-col> </ion-col>

View File

@ -1,7 +1,7 @@
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-title> <ion-title>
<h2>{{ pageInstance?.lesson?.name }}</h2> <h1>{{ pageInstance?.lesson?.name }}</h1>
</ion-title> </ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate"> <ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -1,7 +1,7 @@
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-title> <ion-title>
<h2>{{ 'core.login.password' | translate }}</h2> <h1>{{ 'core.login.password' | translate }}</h1>
</ion-title> </ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate"> <ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -94,7 +94,7 @@
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="!question.textarea && question.useranswer"> <ion-item class="ion-text-wrap" *ngIf="!question.textarea && question.useranswer">
<ion-label> <ion-label>
<h3 class="item-heading">{{ 'addon.mod_lesson.youranswer' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.youranswer' | translate }}</p>
<p> <p>
<core-format-text [component]="component" [componentId]="lesson?.coursemodule" <core-format-text [component]="component" [componentId]="lesson?.coursemodule"
[text]="question.useranswer" contextLevel="module" [contextInstanceId]="lesson?.coursemodule" [text]="question.useranswer" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
@ -138,14 +138,12 @@
<ion-item class="ion-text-wrap" *ngFor="let row of question.rows"> <ion-item class="ion-text-wrap" *ngFor="let row of question.rows">
<ion-label> <ion-label>
<p> <p>
<core-format-text id="addon-mod_lesson-matching-{{row.id}}" [component]="component" <core-format-text [component]="component" [componentId]="lesson?.coursemodule" [text]="row.text"
[componentId]="lesson?.coursemodule" [text]="row.text" contextLevel="module" contextLevel="module" [contextInstanceId]="lesson?.coursemodule" [courseId]="courseId">
[contextInstanceId]="lesson?.coursemodule" [courseId]="courseId">
</core-format-text> </core-format-text>
</p> </p>
</ion-label> </ion-label>
<ion-select [id]="row.id" [formControlName]="row.name" interface="action-sheet" <ion-select [id]="row.id" [formControlName]="row.name" interface="action-sheet">
[attr.aria-labelledby]="'addon-mod_lesson-matching-' + row.id">
<ion-select-option *ngFor="let option of row.options" [value]="option.value"> <ion-select-option *ngFor="let option of row.options" [value]="option.value">
{{option.label}} {{option.label}}
</ion-select-option> </ion-select-option>

View File

@ -27,9 +27,8 @@
<!-- Retake selector if there is more than one retake. --> <!-- Retake selector if there is more than one retake. -->
<ion-item class="ion-text-wrap" *ngIf="student.attempts && student.attempts.length > 1"> <ion-item class="ion-text-wrap" *ngIf="student.attempts && student.attempts.length > 1">
<ion-label id="addon-mod_lesson-retakeslabel">{{ 'addon.mod_lesson.attemptheader' | translate }}</ion-label> <ion-label>{{ 'addon.mod_lesson.attemptheader' | translate }}</ion-label>
<ion-select [(ngModel)]="selectedRetake" (ionChange)="changeRetake(selectedRetake!)" <ion-select [(ngModel)]="selectedRetake" (ionChange)="changeRetake(selectedRetake!)" interface="action-sheet"
aria-labelledby="addon-mod_lesson-retakeslabel" interface="action-sheet"
[interfaceOptions]="{header: 'addon.mod_lesson.attemptheader' | translate}"> [interfaceOptions]="{header: 'addon.mod_lesson.attemptheader' | translate}">
<ion-select-option *ngFor="let retake of student.attempts" [value]="retake.try"> <ion-select-option *ngFor="let retake of student.attempts" [value]="retake.try">
{{retake.label}} {{retake.label}}
@ -44,12 +43,12 @@
<ion-grid class="ion-no-padding"> <ion-grid class="ion-no-padding">
<ion-row> <ion-row>
<ion-col> <ion-col>
<h3 class="item-heading">{{ 'addon.mod_lesson.grade' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.grade' | translate }}</p>
<p>{{ 'core.percentagenumber' | translate:{$a: retake.userstats.grade} }}</p> <p>{{ 'core.percentagenumber' | translate:{$a: retake.userstats.grade} }}</p>
</ion-col> </ion-col>
<ion-col> <ion-col>
<h3 class="item-heading">{{ 'addon.mod_lesson.rawgrade' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.rawgrade' | translate }}</p>
<p>{{ retake.userstats.gradeinfo.earned }} / {{ retake.userstats.gradeinfo.total }}</p> <p>{{ retake.userstats.gradeinfo.earned }} / {{ retake.userstats.gradeinfo.total }}</p>
</ion-col> </ion-col>
</ion-row> </ion-row>
@ -58,13 +57,13 @@
</ion-item> </ion-item>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h3 class="item-heading">{{ 'addon.mod_lesson.timetaken' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.timetaken' | translate }}</p>
<p>{{ timeTakenReadable }}</p> <p>{{ timeTakenReadable }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h3 class="item-heading">{{ 'addon.mod_lesson.completed' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.completed' | translate }}</p>
<p>{{ retake.userstats.completed * 1000 | coreFormatDate }}</p> <p>{{ retake.userstats.completed * 1000 | coreFormatDate }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
@ -85,7 +84,7 @@
</ion-card-header> </ion-card-header>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h3 class="item-heading">{{ 'addon.mod_lesson.question' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.question' | translate }}</p>
<p> <p>
<core-format-text [component]="component" [componentId]="lesson?.coursemodule" collapsible-item <core-format-text [component]="component" [componentId]="lesson?.coursemodule" collapsible-item
[text]="page.contents" contextLevel="module" [contextInstanceId]="lesson?.coursemodule" [text]="page.contents" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"
@ -96,7 +95,7 @@
</ion-item> </ion-item>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h3 class="item-heading">{{ 'addon.mod_lesson.answer' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.answer' | translate }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="!page.answerdata || !page.answerdata.answers || !page.answerdata.answers.length"> <ion-item class="ion-text-wrap" *ngIf="!page.answerdata || !page.answerdata.answers || !page.answerdata.answers.length">
@ -227,7 +226,7 @@
<ion-item class="ion-text-wrap" *ngIf="page.answerdata.response"> <ion-item class="ion-text-wrap" *ngIf="page.answerdata.response">
<ion-label> <ion-label>
<h3 class="item-heading">{{ 'addon.mod_lesson.response' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_lesson.response' | translate }}</p>
<p> <p>
<core-format-text [component]="component" [componentId]="lesson?.coursemodule" <core-format-text [component]="component" [componentId]="lesson?.coursemodule"
[text]="page.answerdata.response" contextLevel="module" [contextInstanceId]="lesson?.coursemodule" [text]="page.answerdata.response" contextLevel="module" [contextInstanceId]="lesson?.coursemodule"

View File

@ -1,6 +1,6 @@
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h3 class="item-heading">{{ 'core.settings.synchronization' | translate }}</h3> <p class="item-heading">{{ 'core.settings.synchronization' | translate }}</p>
<p>{{ 'addon.mod_quiz.confirmcontinueoffline' | translate:{$a: syncTimeReadable} }}</p> <p>{{ 'addon.mod_quiz.confirmcontinueoffline' | translate:{$a: syncTimeReadable} }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>

View File

@ -1,6 +1,6 @@
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h3 class="item-heading">{{ 'addon.mod_quiz.quizpassword' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_quiz.quizpassword' | translate }}</p>
<p>{{ 'addon.mod_quiz.requirepasswordmessage' | translate}}</p> <p>{{ 'addon.mod_quiz.requirepasswordmessage' | translate}}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>

View File

@ -1,6 +1,6 @@
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h3 class="item-heading">{{ 'addon.mod_quiz.confirmstartheader' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_quiz.confirmstartheader' | translate }}</p>
<p>{{ 'addon.mod_quiz.confirmstart' | translate:{$a: readableTimeLimit} }}</p> <p>{{ 'addon.mod_quiz.confirmstart' | translate:{$a: readableTimeLimit} }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>

View File

@ -1,7 +1,7 @@
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-title> <ion-title>
<h2>{{ 'addon.mod_quiz.quiznavigation' | translate }}</h2> <h1>{{ 'addon.mod_quiz.quiznavigation' | translate }}</h1>
</ion-title> </ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate"> <ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -1,7 +1,7 @@
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-title> <ion-title>
<h2>{{ title | translate }}</h2> <h1>{{ title | translate }}</h1>
</ion-title> </ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate"> <ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">

View File

@ -20,32 +20,32 @@
<ion-list *ngIf="attempt"> <ion-list *ngIf="attempt">
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_quiz.attemptnumber' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_quiz.attemptnumber' | translate }}</p>
<p *ngIf="attempt.preview">{{ 'addon.mod_quiz.preview' | translate }}</p> <p *ngIf="attempt.preview">{{ 'addon.mod_quiz.preview' | translate }}</p>
<p *ngIf="!attempt.preview">{{ attempt.attempt }}</p> <p *ngIf="!attempt.preview">{{ attempt.attempt }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_quiz.attemptstate' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_quiz.attemptstate' | translate }}</p>
<p *ngFor="let sentence of attempt.readableState">{{ sentence }}</p> <p *ngFor="let sentence of attempt.readableState">{{ sentence }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="quiz!.showMarkColumn && attempt.readableMark !== ''"> <ion-item class="ion-text-wrap" *ngIf="quiz!.showMarkColumn && attempt.readableMark !== ''">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_quiz.marks' | translate }} / {{ quiz!.sumGradesFormatted }}</h2> <p class="item-heading">{{ 'addon.mod_quiz.marks' | translate }} / {{ quiz!.sumGradesFormatted }}</p>
<p>{{ attempt.readableMark }}</p> <p>{{ attempt.readableMark }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="quiz!.showGradeColumn && attempt.readableGrade !== ''"> <ion-item class="ion-text-wrap" *ngIf="quiz!.showGradeColumn && attempt.readableGrade !== ''">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_quiz.grade' | translate }} / {{ quiz!.gradeFormatted }}</h2> <p class="item-heading">{{ 'addon.mod_quiz.grade' | translate }} / {{ quiz!.gradeFormatted }}</p>
<p>{{ attempt.readableGrade }}</p> <p>{{ attempt.readableGrade }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="quiz!.showFeedbackColumn && feedback"> <ion-item class="ion-text-wrap" *ngIf="quiz!.showFeedbackColumn && feedback">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_quiz.feedback' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_quiz.feedback' | translate }}</p>
<p> <p>
<core-format-text [component]="component" [componentId]="componentId" [text]="feedback" contextLevel="module" <core-format-text [component]="component" [componentId]="componentId" [text]="feedback" contextLevel="module"
[contextInstanceId]="cmId" [courseId]="courseId"> [contextInstanceId]="cmId" [courseId]="courseId">

View File

@ -132,7 +132,7 @@
<!-- List of messages explaining why the quiz cannot be submitted. --> <!-- List of messages explaining why the quiz cannot be submitted. -->
<ion-item class="ion-text-wrap" *ngIf="preventSubmitMessages.length"> <ion-item class="ion-text-wrap" *ngIf="preventSubmitMessages.length">
<ion-label> <ion-label>
<h3 class="item-heading">{{ 'addon.mod_quiz.cannotsubmitquizdueto' | translate }}</h3> <p class="item-heading">{{ 'addon.mod_quiz.cannotsubmitquizdueto' | translate }}</p>
<p *ngFor="let message of preventSubmitMessages">{{message}}</p> <p *ngFor="let message of preventSubmitMessages">{{message}}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>

View File

@ -47,7 +47,7 @@ import { CanLeave } from '@guards/can-leave';
import { CoreForms } from '@singletons/form'; import { CoreForms } from '@singletons/form';
import { CoreDom } from '@singletons/dom'; import { CoreDom } from '@singletons/dom';
import { CoreTime } from '@singletons/time'; import { CoreTime } from '@singletons/time';
import { CoreComponentsRegistry } from '@singletons/components-registry'; import { CoreDirectivesRegistry } from '@singletons/directives-registry';
/** /**
* Page that allows attempting a quiz. * Page that allows attempting a quiz.
@ -690,7 +690,7 @@ export class AddonModQuizPlayerPage implements OnInit, OnDestroy, CanLeave {
*/ */
protected async scrollToQuestion(slot: number): Promise<void> { protected async scrollToQuestion(slot: number): Promise<void> {
await CoreUtils.nextTick(); await CoreUtils.nextTick();
await CoreComponentsRegistry.waitComponentsReady(this.elementRef.nativeElement, 'core-question'); await CoreDirectivesRegistry.waitDirectivesReady(this.elementRef.nativeElement, 'core-question');
await CoreDom.scrollToElement( await CoreDom.scrollToElement(
this.elementRef.nativeElement, this.elementRef.nativeElement,
'#addon-mod_quiz-question-' + slot, '#addon-mod_quiz-question-' + slot,

View File

@ -26,43 +26,43 @@
<ion-list> <ion-list>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_quiz.startedon' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_quiz.startedon' | translate }}</p>
<p>{{ attempt.timestart! * 1000 | coreFormatDate }}</p> <p>{{ attempt.timestart! * 1000 | coreFormatDate }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap"> <ion-item class="ion-text-wrap">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_quiz.attemptstate' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_quiz.attemptstate' | translate }}</p>
<p>{{ readableState }}</p> <p>{{ readableState }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="showCompleted"> <ion-item class="ion-text-wrap" *ngIf="showCompleted">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_quiz.completedon' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_quiz.completedon' | translate }}</p>
<p>{{ attempt.timefinish! * 1000 | coreFormatDate }}</p> <p>{{ attempt.timefinish! * 1000 | coreFormatDate }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="timeTaken"> <ion-item class="ion-text-wrap" *ngIf="timeTaken">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_quiz.timetaken' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_quiz.timetaken' | translate }}</p>
<p>{{ timeTaken }}</p> <p>{{ timeTaken }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="overTime"> <ion-item class="ion-text-wrap" *ngIf="overTime">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_quiz.overdue' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_quiz.overdue' | translate }}</p>
<p>{{ overTime }}</p> <p>{{ overTime }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="readableMark"> <ion-item class="ion-text-wrap" *ngIf="readableMark">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_quiz.marks' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_quiz.marks' | translate }}</p>
<p>{{ readableMark }}</p> <p>{{ readableMark }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item class="ion-text-wrap" *ngIf="readableGrade"> <ion-item class="ion-text-wrap" *ngIf="readableGrade">
<ion-label> <ion-label>
<h2>{{ 'addon.mod_quiz.grade' | translate }}</h2> <p class="item-heading">{{ 'addon.mod_quiz.grade' | translate }}</p>
<p>{{ readableGrade }}</p> <p>{{ readableGrade }}</p>
</ion-label> </ion-label>
</ion-item> </ion-item>

Some files were not shown because too many files have changed in this diff Show More