2022-03-08 17:39:22 +01:00
|
|
|
// (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.
|
|
|
|
|
2022-03-08 18:12:33 +01:00
|
|
|
import { CoreConstants } from '@/core/constants';
|
2022-03-08 17:39:22 +01:00
|
|
|
import { asyncInstance } from '@/core/utils/async-instance';
|
|
|
|
import { Injectable } from '@angular/core';
|
2022-03-30 14:33:07 +02:00
|
|
|
import { CoreCancellablePromise } from '@classes/cancellable-promise';
|
2022-03-08 17:39:22 +01:00
|
|
|
import { CoreDatabaseTable } from '@classes/database/database-table';
|
|
|
|
import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy';
|
|
|
|
import { CoreApp } from '@services/app';
|
|
|
|
import { CoreUtils } from '@services/utils/utils';
|
|
|
|
import { AngularFrameworkDelegate, makeSingleton } from '@singletons';
|
2023-01-25 10:34:13 +01:00
|
|
|
import { CoreDirectivesRegistry } from '@singletons/directives-registry';
|
2022-03-30 14:33:07 +02:00
|
|
|
import { CoreDom } from '@singletons/dom';
|
2022-03-17 11:46:45 +01:00
|
|
|
import { CoreSubscriptions } from '@singletons/subscriptions';
|
2022-03-08 17:39:22 +01:00
|
|
|
import { CoreUserToursUserTourComponent } from '../components/user-tour/user-tour';
|
|
|
|
import { APP_SCHEMA, CoreUserToursDBEntry, USER_TOURS_TABLE_NAME } from './database/user-tours';
|
2023-08-31 12:19:44 +02:00
|
|
|
import { CorePromisedValue } from '@classes/promised-value';
|
2022-03-08 17:39:22 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Service to manage User Tours.
|
|
|
|
*/
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
|
|
export class CoreUserToursService {
|
|
|
|
|
|
|
|
protected table = asyncInstance<CoreDatabaseTable<CoreUserToursDBEntry>>();
|
2022-03-30 14:33:07 +02:00
|
|
|
protected tours: { component: CoreUserToursUserTourComponent; visible: boolean }[] = [];
|
2023-08-31 12:19:44 +02:00
|
|
|
protected toursListeners: Partial<Record<string, CorePromisedValue<void>[]>> = {};
|
2022-03-08 17:39:22 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Initialize database.
|
|
|
|
*/
|
|
|
|
async initializeDatabase(): Promise<void> {
|
|
|
|
await CoreUtils.ignoreErrors(CoreApp.createTablesFromSchema(APP_SCHEMA));
|
|
|
|
|
|
|
|
this.table.setLazyConstructor(async () => {
|
|
|
|
const table = new CoreDatabaseTableProxy<CoreUserToursDBEntry>(
|
|
|
|
{ cachingStrategy: CoreDatabaseCachingStrategy.Eager },
|
|
|
|
CoreApp.getDB(),
|
|
|
|
USER_TOURS_TABLE_NAME,
|
|
|
|
['id'],
|
|
|
|
);
|
|
|
|
|
|
|
|
await table.initialize();
|
|
|
|
|
|
|
|
return table;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Check whether a User Tour is pending or not.
|
|
|
|
*
|
|
|
|
* @param id User Tour id.
|
|
|
|
* @returns Whether the User Tour is pending or not.
|
|
|
|
*/
|
|
|
|
async isPending(id: string): Promise<boolean> {
|
2022-04-05 11:01:10 +02:00
|
|
|
if (this.isDisabled(id)) {
|
2022-03-08 18:12:33 +01:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2022-03-08 17:39:22 +01:00
|
|
|
const isAcknowledged = await this.table.hasAnyByPrimaryKey({ id });
|
|
|
|
|
|
|
|
return !isAcknowledged;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Confirm that a User Tour has been seen by the user.
|
|
|
|
*
|
|
|
|
* @param id User Tour id.
|
|
|
|
*/
|
|
|
|
async acknowledge(id: string): Promise<void> {
|
|
|
|
await this.table.insert({ id, acknowledgedTime: Date.now() });
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Show a User Tour if it's pending.
|
|
|
|
*
|
|
|
|
* @param options User Tour options.
|
2022-03-30 14:33:07 +02:00
|
|
|
* @returns User tour controller, if any.
|
2022-03-08 17:39:22 +01:00
|
|
|
*/
|
2022-03-30 14:33:07 +02:00
|
|
|
async showIfPending(options: CoreUserToursBasicOptions): Promise<CoreUserToursUserTour | null>;
|
|
|
|
async showIfPending(options: CoreUserToursFocusedOptions): Promise<CoreUserToursUserTour | null>;
|
|
|
|
async showIfPending(
|
|
|
|
options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions,
|
|
|
|
): Promise<CoreUserToursUserTour | null> {
|
2022-03-08 17:39:22 +01:00
|
|
|
const isPending = await CoreUserTours.isPending(options.id);
|
|
|
|
|
|
|
|
if (!isPending) {
|
2022-03-30 14:33:07 +02:00
|
|
|
return null;
|
2022-03-08 17:39:22 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
return this.show(options);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Show a User Tour.
|
|
|
|
*
|
|
|
|
* @param options User Tour options.
|
2022-03-30 14:33:07 +02:00
|
|
|
* @returns User tour controller.
|
2022-03-08 17:39:22 +01:00
|
|
|
*/
|
2022-03-30 14:33:07 +02:00
|
|
|
protected async show(options: CoreUserToursBasicOptions): Promise<CoreUserToursUserTour>;
|
|
|
|
protected async show(options: CoreUserToursFocusedOptions): Promise<CoreUserToursUserTour>;
|
|
|
|
protected async show(options: CoreUserToursBasicOptions | CoreUserToursFocusedOptions): Promise<CoreUserToursUserTour> {
|
2022-03-08 17:39:22 +01:00
|
|
|
const { delay, ...componentOptions } = options;
|
|
|
|
|
|
|
|
await CoreUtils.wait(delay ?? 200);
|
|
|
|
|
2023-08-31 12:19:44 +02:00
|
|
|
options.after && await this.waitForUserTour(options.after, options.afterTimeout);
|
|
|
|
|
2022-03-08 17:39:22 +01:00
|
|
|
const container = document.querySelector('ion-app') ?? document.body;
|
2023-02-14 09:21:39 +01:00
|
|
|
const viewContainer = container.querySelector('ion-router-outlet, ion-nav, #ion-view-container-root');
|
2022-03-08 17:39:22 +01:00
|
|
|
const element = await AngularFrameworkDelegate.attachViewToDom(
|
|
|
|
container,
|
|
|
|
CoreUserToursUserTourComponent,
|
|
|
|
{ ...componentOptions, container },
|
|
|
|
);
|
2023-01-25 10:34:13 +01:00
|
|
|
const tour = CoreDirectivesRegistry.require(element, CoreUserToursUserTourComponent);
|
2022-03-08 17:39:22 +01:00
|
|
|
|
2023-02-14 09:21:39 +01:00
|
|
|
viewContainer?.setAttribute('aria-hidden', 'true');
|
|
|
|
|
2023-08-31 12:19:44 +02:00
|
|
|
this.toursListeners[options.id]?.forEach(listener => listener.resolve());
|
|
|
|
|
2022-03-30 14:33:07 +02:00
|
|
|
return this.startTour(tour, options.watch ?? (options as CoreUserToursFocusedOptions).focus);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Dismiss the active User Tour, if any.
|
|
|
|
*
|
|
|
|
* @param acknowledge Whether to acknowledge that the user has seen this User Tour or not.
|
|
|
|
*/
|
|
|
|
async dismiss(acknowledge: boolean = true): Promise<void> {
|
2022-04-06 14:12:43 +02:00
|
|
|
await this.getForegroundTour()?.dismiss(acknowledge);
|
2023-02-14 09:21:39 +01:00
|
|
|
|
|
|
|
if (this.hasVisibleTour()) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const container = document.querySelector('ion-app') ?? document.body;
|
|
|
|
const viewContainer = container.querySelector('ion-router-outlet, ion-nav, #ion-view-container-root');
|
|
|
|
|
|
|
|
viewContainer?.removeAttribute('aria-hidden');
|
2022-03-30 14:33:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Activate a tour component and bind its lifecycle to an element if provided.
|
|
|
|
*
|
|
|
|
* @param tour User tour.
|
|
|
|
* @param watchElement Element to watch in order to update tour lifecycle.
|
|
|
|
* @returns User tour controller.
|
|
|
|
*/
|
|
|
|
protected startTour(tour: CoreUserToursUserTourComponent, watchElement?: HTMLElement | false): CoreUserToursUserTour {
|
|
|
|
if (!watchElement) {
|
|
|
|
this.activateTour(tour);
|
|
|
|
|
|
|
|
return {
|
|
|
|
cancel: () => tour.dismiss(false),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
let unsubscribeVisible: (() => void) | undefined;
|
|
|
|
let visiblePromise: CoreCancellablePromise | undefined = CoreDom.waitToBeInViewport(watchElement);
|
|
|
|
|
|
|
|
// eslint-disable-next-line promise/catch-or-return, promise/always-return
|
|
|
|
visiblePromise.then(() => {
|
|
|
|
visiblePromise = undefined;
|
|
|
|
|
|
|
|
this.activateTour(tour);
|
2022-03-17 11:46:45 +01:00
|
|
|
|
2022-03-30 14:33:07 +02:00
|
|
|
unsubscribeVisible = CoreDom.watchElementInViewport(
|
|
|
|
watchElement,
|
|
|
|
visible => visible ? this.activateTour(tour) : this.deactivateTour(tour),
|
|
|
|
);
|
|
|
|
|
|
|
|
CoreSubscriptions.once(tour.beforeDismiss, () => {
|
|
|
|
unsubscribeVisible?.();
|
|
|
|
|
|
|
|
visiblePromise = undefined;
|
|
|
|
unsubscribeVisible = undefined;
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
return {
|
|
|
|
cancel: async () => {
|
|
|
|
visiblePromise?.cancel();
|
|
|
|
|
|
|
|
if (!unsubscribeVisible) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
unsubscribeVisible();
|
|
|
|
|
|
|
|
await tour.dismiss(false);
|
|
|
|
},
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Activate the given user tour.
|
|
|
|
*
|
|
|
|
* @param tour User tour.
|
|
|
|
*/
|
|
|
|
protected activateTour(tour: CoreUserToursUserTourComponent): void {
|
|
|
|
// Handle show/dismiss lifecycle.
|
2022-03-17 11:46:45 +01:00
|
|
|
CoreSubscriptions.once(tour.beforeDismiss, () => {
|
2022-04-06 14:12:43 +02:00
|
|
|
const index = this.getTourIndex(tour);
|
2022-03-17 11:46:45 +01:00
|
|
|
|
|
|
|
if (index === -1) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.tours.splice(index, 1);
|
|
|
|
|
2022-04-06 14:12:43 +02:00
|
|
|
this.getForegroundTour()?.show();
|
2022-03-17 11:46:45 +01:00
|
|
|
});
|
|
|
|
|
2022-03-30 14:33:07 +02:00
|
|
|
// Add to existing tours and show it if it's on top.
|
2022-04-06 14:12:43 +02:00
|
|
|
const index = this.getTourIndex(tour);
|
|
|
|
const previousForegroundTour = this.getForegroundTour();
|
|
|
|
|
|
|
|
if (previousForegroundTour?.id === tour.id) {
|
|
|
|
// Already activated.
|
|
|
|
return;
|
|
|
|
}
|
2022-03-30 14:33:07 +02:00
|
|
|
|
|
|
|
if (index !== -1) {
|
|
|
|
this.tours[index].visible = true;
|
|
|
|
} else {
|
|
|
|
this.tours.push({
|
|
|
|
visible: true,
|
|
|
|
component: tour,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2022-04-06 14:12:43 +02:00
|
|
|
if (this.getForegroundTour()?.id !== tour.id) {
|
|
|
|
// Another tour is in use.
|
2022-03-30 14:33:07 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
tour.show();
|
2022-03-08 17:39:22 +01:00
|
|
|
}
|
|
|
|
|
2022-04-06 14:12:43 +02:00
|
|
|
/**
|
|
|
|
* Returns the first visible tour in the stack.
|
|
|
|
*
|
2022-12-01 12:31:00 +01:00
|
|
|
* @returns foreground tour if found or undefined.
|
2022-04-06 14:12:43 +02:00
|
|
|
*/
|
|
|
|
protected getForegroundTour(): CoreUserToursUserTourComponent | undefined {
|
|
|
|
return this.tours.find(({ visible }) => visible)?.component;
|
|
|
|
}
|
|
|
|
|
2023-02-14 09:21:39 +01:00
|
|
|
/**
|
|
|
|
* Check whether any tour is visible.
|
|
|
|
*
|
|
|
|
* @returns Whether any tour is visible.
|
|
|
|
*/
|
|
|
|
protected hasVisibleTour(): boolean {
|
|
|
|
return this.tours.some(({ visible }) => visible);
|
|
|
|
}
|
|
|
|
|
2022-04-06 14:12:43 +02:00
|
|
|
/**
|
|
|
|
* Returns the tour index in the stack.
|
|
|
|
*
|
2022-12-01 12:31:00 +01:00
|
|
|
* @returns Tour index if found or -1 otherwise.
|
2022-04-06 14:12:43 +02:00
|
|
|
*/
|
|
|
|
protected getTourIndex(tour: CoreUserToursUserTourComponent): number {
|
|
|
|
return this.tours.findIndex(({ component }) => component === tour);
|
|
|
|
}
|
|
|
|
|
2022-03-08 17:39:22 +01:00
|
|
|
/**
|
2022-03-30 14:33:07 +02:00
|
|
|
* Hide User Tour if visible.
|
2022-03-08 17:39:22 +01:00
|
|
|
*
|
2022-03-30 14:33:07 +02:00
|
|
|
* @param tour User tour.
|
2022-03-08 17:39:22 +01:00
|
|
|
*/
|
2022-03-30 14:33:07 +02:00
|
|
|
protected deactivateTour(tour: CoreUserToursUserTourComponent): void {
|
2022-04-06 14:12:43 +02:00
|
|
|
const index = this.getTourIndex(tour);
|
2022-03-30 14:33:07 +02:00
|
|
|
if (index === -1) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2022-04-06 14:12:43 +02:00
|
|
|
const foregroundTour = this.getForegroundTour();
|
2022-03-30 14:33:07 +02:00
|
|
|
|
2022-04-06 14:12:43 +02:00
|
|
|
this.tours[index].visible = false;
|
2022-03-30 14:33:07 +02:00
|
|
|
|
2022-04-06 14:12:43 +02:00
|
|
|
if (foregroundTour?.id !== tour.id) {
|
|
|
|
// Another tour is in use.
|
|
|
|
return;
|
2022-03-30 14:33:07 +02:00
|
|
|
}
|
2022-04-06 14:12:43 +02:00
|
|
|
|
|
|
|
tour.hide();
|
2022-03-08 17:39:22 +01:00
|
|
|
}
|
|
|
|
|
2022-04-05 11:01:10 +02:00
|
|
|
/**
|
|
|
|
* Is user Tour disabled?
|
|
|
|
*
|
|
|
|
* @param tourId Tour Id or undefined to check all user tours.
|
2023-08-31 12:19:44 +02:00
|
|
|
* @returns Whether a particular or all user tours are disabled.
|
2022-04-05 11:01:10 +02:00
|
|
|
*/
|
|
|
|
isDisabled(tourId?: string): boolean {
|
|
|
|
if (CoreConstants.CONFIG.disableUserTours) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
return !!tourId && !!CoreConstants.CONFIG.disabledUserTours?.includes(tourId);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* It will reset all user tours.
|
|
|
|
*/
|
|
|
|
async resetTours(): Promise<void> {
|
|
|
|
if (this.isDisabled()) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
await this.table.delete();
|
|
|
|
}
|
|
|
|
|
2023-08-31 12:19:44 +02:00
|
|
|
/**
|
|
|
|
* Wait for another user tour to be shown.
|
|
|
|
*
|
|
|
|
* @param id Id of the user tour to wait for.
|
|
|
|
* @param timeout Timeout after which the waiting will end regardless of the user tour having been shown or not.
|
|
|
|
*/
|
|
|
|
async waitForUserTour(id: string, timeout?: number): Promise<void> {
|
|
|
|
const listener = new CorePromisedValue<void>();
|
|
|
|
const listeners = this.toursListeners[id] = this.toursListeners[id] ?? [];
|
|
|
|
const removeListener = () => {
|
|
|
|
const index = listeners.indexOf(listener);
|
|
|
|
|
|
|
|
if (index !== -1) {
|
|
|
|
listeners.splice(index, 1);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
listeners.push(listener);
|
|
|
|
listener.then(removeListener).catch(removeListener);
|
|
|
|
timeout && setTimeout(() => listener.resolve(), timeout);
|
|
|
|
|
|
|
|
await listener;
|
|
|
|
}
|
|
|
|
|
2022-03-08 17:39:22 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export const CoreUserTours = makeSingleton(CoreUserToursService);
|
|
|
|
|
2022-03-30 14:33:07 +02:00
|
|
|
/**
|
|
|
|
* User Tour controller.
|
|
|
|
*/
|
|
|
|
export interface CoreUserToursUserTour {
|
|
|
|
|
|
|
|
/**
|
2023-08-31 12:19:44 +02:00
|
|
|
* Cancelling a User Tour removes it from the queue if it was pending or dismisses it without
|
|
|
|
* acknowledging.
|
2022-03-30 14:33:07 +02:00
|
|
|
*/
|
|
|
|
cancel(): Promise<void>;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
2022-03-08 17:39:22 +01:00
|
|
|
/**
|
|
|
|
* User Tour side.
|
|
|
|
*/
|
|
|
|
export const enum CoreUserToursSide {
|
|
|
|
Top = 'top',
|
|
|
|
Bottom = 'bottom',
|
|
|
|
Right = 'right',
|
|
|
|
Left = 'left',
|
|
|
|
Start = 'start',
|
|
|
|
End = 'end',
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* User Tour alignment.
|
|
|
|
*/
|
|
|
|
export const enum CoreUserToursAlignment {
|
|
|
|
Start = 'start',
|
|
|
|
Center = 'center',
|
|
|
|
End = 'end',
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Basic options to create a User Tour.
|
|
|
|
*/
|
|
|
|
export interface CoreUserToursBasicOptions {
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Unique identifier.
|
|
|
|
*/
|
|
|
|
id: string;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* User Tour component.
|
|
|
|
*/
|
|
|
|
component: unknown;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Properties to pass to the User Tour component.
|
|
|
|
*/
|
|
|
|
componentProps?: Record<string, unknown>;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Milliseconds to wait until the User Tour is shown.
|
|
|
|
*
|
|
|
|
* Defaults to 200ms.
|
|
|
|
*/
|
|
|
|
delay?: number;
|
|
|
|
|
2022-03-30 14:33:07 +02:00
|
|
|
/**
|
|
|
|
* Whether to watch an element to bind the User Tour lifecycle. Whenever this element appears or
|
|
|
|
* leaves the screen, the user tour will do it as well. Focused user tours do it by default with
|
|
|
|
* the focused element, but it can be disabled by explicitly using `false` here.
|
|
|
|
*/
|
|
|
|
watch?: HTMLElement | false;
|
|
|
|
|
2023-08-31 12:19:44 +02:00
|
|
|
/**
|
|
|
|
* Whether to show this user tour only after another user tour with the given id has been shown.
|
|
|
|
*/
|
|
|
|
after?: string;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Whether to show this user tour after the given timeout (in milliseconds) if the other user-tour hasn't been shown.
|
|
|
|
*/
|
|
|
|
afterTimeout?: number;
|
|
|
|
|
2022-03-08 17:39:22 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Options to create a focused User Tour.
|
|
|
|
*/
|
|
|
|
export interface CoreUserToursFocusedOptions extends CoreUserToursBasicOptions {
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Element to focus.
|
|
|
|
*/
|
|
|
|
focus: HTMLElement;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Position relative to the focused element.
|
|
|
|
*/
|
|
|
|
side: CoreUserToursSide;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Alignment relative to the focused element.
|
|
|
|
*/
|
|
|
|
alignment: CoreUserToursAlignment;
|
|
|
|
|
|
|
|
}
|