diff --git a/scripts/langindex.json b/scripts/langindex.json
index 7cd0b2a57..fc60c508f 100644
--- a/scripts/langindex.json
+++ b/scripts/langindex.json
@@ -2363,6 +2363,7 @@
"core.user.roles": "moodle",
"core.user.sendemail": "local_moodlemobileapp",
"core.user.student": "moodle/defaultcoursestudent",
+ "core.user.support": "local_moodlemobileapp",
"core.user.teacher": "moodle/noneditingteacher",
"core.user.useraccount": "moodle",
"core.user.userwithid": "local_moodlemobileapp",
diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts
index 0af78f156..0c8ef1484 100644
--- a/src/core/classes/site.ts
+++ b/src/core/classes/site.ts
@@ -60,6 +60,7 @@ import {
import { Observable, ObservableInput, ObservedValueOf, OperatorFunction, Subject } from 'rxjs';
import { finalize, map, mergeMap } from 'rxjs/operators';
import { firstValueFrom } from '../utils/rxjs';
+import { CoreUserSupport } from '@features/user/services/support';
/**
* QR Code type enumeration.
@@ -262,6 +263,19 @@ export class CoreSite {
return this.db;
}
+ /**
+ * Get url to contact site support.
+ *
+ * @returns Site support page url.
+ */
+ getSupportPageUrl(): string | null {
+ if (!this.config || !this.canContactSupport()) {
+ return null;
+ }
+
+ return CoreUserSupport.getSupportPageUrl(this.config, this.siteUrl);
+ }
+
/**
* Get site user's ID.
*
@@ -421,6 +435,19 @@ export class CoreSite {
return !!(info && (info.usercanmanageownfiles === undefined || info.usercanmanageownfiles));
}
+ /**
+ * Check whether this site has a support url available.
+ *
+ * @returns Whether this site has a support url.
+ */
+ canContactSupport(): boolean {
+ if (this.isFeatureDisabled('NoDelegate_CoreUserSupport')) {
+ return false;
+ }
+
+ return !!this.config && CoreUserSupport.canContactSupport(this.config);
+ }
+
/**
* Can the user download files?
*
@@ -2777,6 +2804,7 @@ export type CoreSitePublicConfigResponse = {
agedigitalconsentverification?: boolean; // Whether age digital consent verification is enabled.
supportname?: string; // Site support contact name (only if age verification is enabled).
supportemail?: string; // Site support contact email (only if age verification is enabled).
+ supportpage?: string; // Site support contact url.
autolang?: number; // Whether to detect default language from browser setting.
lang?: string; // Default language for the site.
langmenu?: number; // Whether the language menu should be displayed.
diff --git a/src/core/features/mainmenu/components/user-menu/user-menu.html b/src/core/features/mainmenu/components/user-menu/user-menu.html
index d6e138a39..1533ef68d 100644
--- a/src/core/features/mainmenu/components/user-menu/user-menu.html
+++ b/src/core/features/mainmenu/components/user-menu/user-menu.html
@@ -66,6 +66,14 @@
{{ 'core.settings.preferences' | translate }}
+
+
diff --git a/src/core/features/mainmenu/components/user-menu/user-menu.ts b/src/core/features/mainmenu/components/user-menu/user-menu.ts
index 4b02fa22e..c58468c3d 100644
--- a/src/core/features/mainmenu/components/user-menu/user-menu.ts
+++ b/src/core/features/mainmenu/components/user-menu/user-menu.ts
@@ -18,6 +18,7 @@ import { CoreSite, CoreSiteInfo } from '@classes/site';
import { CoreFilter } from '@features/filter/services/filter';
import { CoreLoginSitesComponent } from '@features/login/components/sites/sites';
import { CoreLoginHelper } from '@features/login/services/login-helper';
+import { CoreUserSupport } from '@features/user/services/support';
import { CoreUser, CoreUserProfile } from '@features/user/services/user';
import {
CoreUserProfileHandlerData,
@@ -51,6 +52,7 @@ export class CoreMainMenuUserMenuComponent implements OnInit, OnDestroy {
handlersLoaded = false;
user?: CoreUserProfile;
displaySwitchAccount = true;
+ displayContactSupport = false;
removeAccountOnLogout = false;
protected subscription!: Subscription;
@@ -65,6 +67,7 @@ export class CoreMainMenuUserMenuComponent implements OnInit, OnDestroy {
this.siteName = currentSite.getSiteName();
this.siteUrl = currentSite.getURL();
this.displaySwitchAccount = !currentSite.isFeatureDisabled('NoDelegate_SwitchAccount');
+ this.displayContactSupport = currentSite.canContactSupport();
this.removeAccountOnLogout = !!CoreConstants.CONFIG.removeaccountonlogout;
this.loadSiteLogo(currentSite);
@@ -173,6 +176,16 @@ export class CoreMainMenuUserMenuComponent implements OnInit, OnDestroy {
handler.action(event, this.user, CoreUserDelegateContext.USER_MENU);
}
+ /**
+ * Contact site support.
+ *
+ * @param event Click event.
+ */
+ async contactSupport(event: Event): Promise {
+ await this.close(event);
+ await CoreUserSupport.contact();
+ }
+
/**
* Logout the user.
*
diff --git a/src/core/features/user/lang.json b/src/core/features/user/lang.json
index 147b26797..c2b42e2d1 100644
--- a/src/core/features/user/lang.json
+++ b/src/core/features/user/lang.json
@@ -28,6 +28,7 @@
"roles": "Roles",
"sendemail": "Email",
"student": "Student",
+ "support": "Support",
"teacher": "Non-editing teacher",
"userwithid": "User with ID {{id}}",
"webpage": "Web page"
diff --git a/src/core/features/user/services/support.ts b/src/core/features/user/services/support.ts
new file mode 100644
index 000000000..2d59772ea
--- /dev/null
+++ b/src/core/features/user/services/support.ts
@@ -0,0 +1,112 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Injectable } from '@angular/core';
+import { CoreError } from '@classes/errors/error';
+import { CoreSiteConfig, CoreSitePublicConfigResponse } from '@classes/site';
+import { InAppBrowserObject } from '@ionic-native/in-app-browser';
+import { CorePlatform } from '@services/platform';
+import { CoreSites } from '@services/sites';
+import { CoreUtils } from '@services/utils/utils';
+import { makeSingleton } from '@singletons';
+import { CoreEvents } from '@singletons/events';
+import { CoreSubscriptions } from '@singletons/subscriptions';
+
+/**
+ * Handle site support.
+ */
+@Injectable({ providedIn: 'root' })
+export class CoreUserSupportService {
+
+ /**
+ * Contact site support.
+ *
+ * @param options Options to configure the interaction with support.
+ */
+ async contact(options: CoreUserSupportContactOptions = {}): Promise {
+ const supportPageUrl = options.supportPageUrl ?? CoreSites.getRequiredCurrentSite().getSupportPageUrl();
+
+ if (!supportPageUrl) {
+ throw new CoreError('Could not get support url');
+ }
+
+ const autoLoginUrl = await CoreSites.getCurrentSite()?.getAutoLoginUrl(supportPageUrl, false);
+ const browser = CoreUtils.openInApp(autoLoginUrl ?? supportPageUrl);
+
+ if (supportPageUrl.endsWith('/user/contactsitesupport.php')) {
+ this.populateSupportForm(browser, options.subject, options.message);
+ }
+
+ await CoreEvents.waitUntil(CoreEvents.IAB_EXIT);
+ }
+
+ /**
+ * Get support page url from site config.
+ *
+ * @param config Site config.
+ * @returns Support page url.
+ */
+ getSupportPageUrl(config: CoreSitePublicConfigResponse): string;
+ getSupportPageUrl(config: CoreSiteConfig, siteUrl: string): string;
+ getSupportPageUrl(config: CoreSiteConfig | CoreSitePublicConfigResponse, siteUrl?: string): string {
+ return config.supportpage?.trim()
+ || `${config.httpswwwroot ?? config.wwwroot ?? siteUrl}/user/contactsitesupport.php`;
+ }
+
+ /**
+ * Check whether a site config allows contacting support.
+ *
+ * @param config Site config.
+ * @returns Whether site support can be contacted.
+ */
+ canContactSupport(config: CoreSiteConfig | CoreSitePublicConfigResponse): boolean {
+ return 'supportpage' in config;
+ }
+
+ /**
+ * Inject error details into contact support form.
+ *
+ * @param browser In App browser containing the support form.
+ * @param subject Title to fill into the form.
+ * @param message Details to fill into the form.
+ */
+ protected populateSupportForm(browser: InAppBrowserObject, subject?: string | null, message?: string | null): void {
+ if (!CorePlatform.isMobile()) {
+ return;
+ }
+
+ const unsubscribe = CoreSubscriptions.once(browser.on('loadstop'), () => {
+ browser.executeScript({
+ code: `
+ document.querySelector('#id_subject').value = ${JSON.stringify(subject ?? '')};
+ document.querySelector('#id_message').value = ${JSON.stringify(message ?? '')};
+ `,
+ });
+ });
+
+ CoreEvents.once(CoreEvents.IAB_EXIT, () => unsubscribe());
+ }
+
+}
+
+export const CoreUserSupport = makeSingleton(CoreUserSupportService);
+
+/**
+ * Options to configure interaction with support.
+ */
+export interface CoreUserSupportContactOptions {
+ supportPageUrl?: string | null;
+ subject?: string | null;
+ message?: string | null;
+}
diff --git a/src/core/features/user/tests/behat/support-311.feature b/src/core/features/user/tests/behat/support-311.feature
new file mode 100644
index 000000000..17277e00f
--- /dev/null
+++ b/src/core/features/user/tests/behat/support-311.feature
@@ -0,0 +1,13 @@
+@core @core_user @app @javascript @lms_upto3.11
+Feature: Site support
+
+ Background:
+ Given the following "users" exist:
+ | username | firstname | lastname |
+ | student1 | Student | Student |
+
+ Scenario: Cannot contact support
+ Given I entered the app as "student1"
+ When I press the user menu button in the app
+ Then I should find "Blog entries" in the app
+ But I should not find "Support" in the app
diff --git a/src/core/features/user/tests/behat/support.feature b/src/core/features/user/tests/behat/support.feature
new file mode 100644
index 000000000..5f6166135
--- /dev/null
+++ b/src/core/features/user/tests/behat/support.feature
@@ -0,0 +1,33 @@
+@core @core_user @app @javascript @lms_from4.0
+Feature: Site support
+
+ Background:
+ Given the following "users" exist:
+ | username | firstname | lastname |
+ | student1 | Student | Student |
+
+ Scenario: Uses default support page
+ Given I entered the app as "student1"
+ When I press the user menu button in the app
+ Then I should find "Support" in the app
+
+ When I press "Support" in the app
+ Then the app should have opened a browser tab with url ".*\/user\/contactsitesupport\.php"
+
+ Scenario: Uses custom support page
+ Given the following config values are set as admin:
+ | supportpage | https://campus.example.edu/support |
+ And I entered the app as "student1"
+ When I press the user menu button in the app
+ Then I should find "Support" in the app
+
+ When I press "Support" in the app
+ Then the app should have opened a browser tab with url "https:\/\/campus\.example\.edu\/support"
+
+ Scenario: Cannot contact support
+ Given the following config values are set as admin:
+ | disabledfeatures | NoDelegate_CoreUserSupport | tool_mobile |
+ And I entered the app as "student1"
+ When I press the user menu button in the app
+ Then I should find "Blog entries" in the app
+ But I should not find "Support" in the app
diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts
index 3ee795149..c241d3c2b 100644
--- a/src/core/services/utils/utils.ts
+++ b/src/core/services/utils/utils.ts
@@ -1044,11 +1044,7 @@ export class CoreUtilsProvider {
* @param options Override default options passed to InAppBrowser.
* @return The opened window.
*/
- openInApp(url: string, options?: InAppBrowserOptions): InAppBrowserObject | undefined {
- if (!url) {
- return;
- }
-
+ openInApp(url: string, options?: InAppBrowserOptions): InAppBrowserObject {
options = options || {};
options.usewkwebview = 'yes'; // Force WKWebView in iOS.
options.enableViewPortScale = options.enableViewPortScale ?? 'yes'; // Enable zoom on iOS by default.
diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts
index 6d4d7213d..6dfdf5ea0 100644
--- a/src/core/singletons/events.ts
+++ b/src/core/singletons/events.ts
@@ -282,6 +282,15 @@ export class CoreEvents {
}
}
+ /**
+ * Wait until an event has been emitted.
+ *
+ * @param eventName Event name.
+ */
+ static waitUntil(eventName: string): Promise {
+ return new Promise(resolve => this.once(eventName, () => resolve()));
+ }
+
}
/**