commit
83a558f3ae
|
@ -18,3 +18,4 @@ jobs:
|
|||
- run: npx tslint -c ionic-migration.json -p tsconfig.json
|
||||
- run: npm run test:ci
|
||||
- run: npm run build:prod
|
||||
- run: result=$(find src -type f -iname '*.html' -exec grep -E 'class="[^"]+"[^>]+class="' {} \; | wc -l); test $result -eq 0
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
],
|
||||
"styles": [
|
||||
{
|
||||
"input": "src/theme/global.scss"
|
||||
"input": "src/theme/theme.scss"
|
||||
}
|
||||
],
|
||||
"scripts": [],
|
||||
|
|
|
@ -22,12 +22,14 @@ import { AddonBadgesModule } from './badges/badges.module';
|
|||
import { AddonCalendarModule } from './calendar/calendar.module';
|
||||
import { AddonNotificationsModule } from './notifications/notifications.module';
|
||||
import { AddonMessageOutputModule } from './messageoutput/messageoutput.module';
|
||||
import { AddonMessagesModule } from './messages/messages.module';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
AddonBlockModule,
|
||||
AddonBadgesModule,
|
||||
AddonCalendarModule,
|
||||
AddonMessagesModule,
|
||||
AddonPrivateFilesModule,
|
||||
AddonFilterModule,
|
||||
AddonUserProfileFieldModule,
|
||||
|
|
|
@ -13,7 +13,13 @@
|
|||
// limitations under the License.
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { Route, RouterModule, Routes } from '@angular/router';
|
||||
|
||||
|
||||
export const AddonBadgesIssueRoute: Route = {
|
||||
path: 'issue',
|
||||
loadChildren: () => import('./pages/issued-badge/issued-badge.module').then( m => m.AddonBadgesIssuedBadgePageModule),
|
||||
};
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
|
@ -21,10 +27,7 @@ const routes: Routes = [
|
|||
redirectTo: 'user',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'issue',
|
||||
loadChildren: () => import('./pages/issued-badge/issued-badge.module').then( m => m.AddonBadgesIssuedBadgePageModule),
|
||||
},
|
||||
AddonBadgesIssueRoute,
|
||||
{
|
||||
path: 'user',
|
||||
loadChildren: () => import('./pages/user-badges/user-badges.module').then( m => m.AddonBadgesUserBadgesPageModule),
|
||||
|
|
|
@ -22,6 +22,7 @@ import { AddonBadges, AddonBadgesUserBadge } from '../../services/badges';
|
|||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { CoreCourses, CoreEnrolledCourseData } from '@features/courses/services/courses';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
/**
|
||||
* Page that displays the list of calendar events.
|
||||
|
@ -42,16 +43,24 @@ export class AddonBadgesIssuedBadgePage implements OnInit {
|
|||
badgeLoaded = false;
|
||||
currentTime = 0;
|
||||
|
||||
constructor(
|
||||
protected route: ActivatedRoute,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* View loaded.
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId') || this.courseId; // Use 0 for site badges.
|
||||
this.userId = CoreNavigator.instance.getRouteNumberParam('userId') || CoreSites.instance.getCurrentSite()!.getUserId();
|
||||
this.badgeHash = CoreNavigator.instance.getRouteParam('badgeHash') || '';
|
||||
this.route.queryParams.subscribe(() => {
|
||||
this.badgeLoaded = false;
|
||||
|
||||
this.fetchIssuedBadge().finally(() => {
|
||||
this.badgeLoaded = true;
|
||||
this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId') || this.courseId; // Use 0 for site badges.
|
||||
this.userId = CoreNavigator.instance.getRouteNumberParam('userId') || CoreSites.instance.getCurrentSite()!.getUserId();
|
||||
this.badgeHash = CoreNavigator.instance.getRouteParam('badgeHash') || '';
|
||||
|
||||
this.fetchIssuedBadge().finally(() => {
|
||||
this.badgeLoaded = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -6,29 +6,31 @@
|
|||
<ion-title>{{ 'addon.badges.badges' | translate }}</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<!-- @todo <core-split-view>-->
|
||||
<ion-content>
|
||||
<ion-refresher slot="fixed" [disabled]="!badgesLoaded" (ionRefresh)="refreshBadges($event)">
|
||||
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
<core-loading [hideUntil]="badgesLoaded">
|
||||
<core-empty-box *ngIf="!badges || badges.length == 0" icon="fas-trophy" [message]="'addon.badges.nobadges' | translate">
|
||||
</core-empty-box>
|
||||
<core-split-view>
|
||||
<ion-refresher slot="fixed" [disabled]="!badgesLoaded" (ionRefresh)="refreshBadges($event)">
|
||||
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
<core-loading [hideUntil]="badgesLoaded">
|
||||
<core-empty-box *ngIf="!badges || badges.length == 0" icon="fas-trophy"
|
||||
[message]="'addon.badges.nobadges' | translate">
|
||||
</core-empty-box>
|
||||
|
||||
<ion-list *ngIf="badges && badges.length" class="ion-no-margin">
|
||||
<ion-item class="ion-text-wrap" *ngFor="let badge of badges" [title]="badge.name"
|
||||
(click)="loadIssuedBadge(badge.uniquehash)" [class.core-split-item-selected]="badge.uniquehash == badgeHash">
|
||||
<ion-avatar slot="start">
|
||||
<img [src]="badge.badgeurl" [alt]="badge.name" core-external-content>
|
||||
</ion-avatar>
|
||||
<ion-label>
|
||||
<h2>{{ badge.name }}</h2>
|
||||
<p>{{ badge.dateissued * 1000 | coreFormatDate :'strftimedatetimeshort' }}</p>
|
||||
</ion-label>
|
||||
<ion-badge slot="end" color="danger" *ngIf="badge.dateexpire && currentTime >= badge.dateexpire">
|
||||
{{ 'addon.badges.expired' | translate }}
|
||||
</ion-badge>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
</core-loading>
|
||||
<ion-list *ngIf="badges && badges.length" class="ion-no-margin">
|
||||
<ion-item class="ion-text-wrap" *ngFor="let badge of badges" [title]="badge.name"
|
||||
(click)="loadIssuedBadge(badge.uniquehash)" [class.core-selected-item]="badge.uniquehash == badgeHash">
|
||||
<ion-avatar slot="start">
|
||||
<img [src]="badge.badgeurl" [alt]="badge.name" core-external-content>
|
||||
</ion-avatar>
|
||||
<ion-label>
|
||||
<h2>{{ badge.name }}</h2>
|
||||
<p>{{ badge.dateissued * 1000 | coreFormatDate :'strftimedatetimeshort' }}</p>
|
||||
</ion-label>
|
||||
<ion-badge slot="end" color="danger" *ngIf="badge.dateexpire && currentTime >= badge.dateexpire">
|
||||
{{ 'addon.badges.expired' | translate }}
|
||||
</ion-badge>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
</core-loading>
|
||||
</core-split-view>
|
||||
</ion-content>
|
||||
|
|
|
@ -17,15 +17,34 @@ import { IonicModule } from '@ionic/angular';
|
|||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { conditionalRoutes } from '@/app/app-routing.module';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
import { AddonBadgesUserBadgesPage } from './user-badges.page';
|
||||
import { AddonBadgesIssueRoute } from '@addons/badges/badges-lazy.module';
|
||||
|
||||
const routes: Routes = [
|
||||
const mobileRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AddonBadgesUserBadgesPage,
|
||||
},
|
||||
AddonBadgesIssueRoute,
|
||||
];
|
||||
|
||||
const tabletRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AddonBadgesUserBadgesPage,
|
||||
children: [
|
||||
AddonBadgesIssueRoute,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const routes: Routes = [
|
||||
...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile),
|
||||
...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
|
|
@ -20,7 +20,7 @@ import { CoreDomUtils } from '@services/utils/dom';
|
|||
import { CoreSites } from '@services/sites';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
// @todo import { CoreSplitViewComponent } from '@components/split-view/split-view';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
|
||||
/**
|
||||
* Page that displays the list of calendar events.
|
||||
|
@ -31,8 +31,6 @@ import { CoreNavigator } from '@services/navigator';
|
|||
})
|
||||
export class AddonBadgesUserBadgesPage implements OnInit {
|
||||
|
||||
// @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent;
|
||||
|
||||
courseId = 0;
|
||||
userId!: number;
|
||||
|
||||
|
@ -50,11 +48,10 @@ export class AddonBadgesUserBadgesPage implements OnInit {
|
|||
this.userId = CoreNavigator.instance.getRouteNumberParam('userId') || CoreSites.instance.getCurrentSite()!.getUserId();
|
||||
|
||||
this.fetchBadges().finally(() => {
|
||||
// @todo splitview
|
||||
/* if (!this.badgeHash && this.splitviewCtrl.isOn() && this.badges.length > 0) {
|
||||
if (!this.badgeHash && CoreScreen.instance.isTablet && this.badges.length > 0) {
|
||||
// Take first and load it.
|
||||
this.loadIssuedBadge(this.badges[0].uniquehash);
|
||||
}*/
|
||||
}
|
||||
this.badgesLoaded = true;
|
||||
});
|
||||
}
|
||||
|
@ -99,9 +96,11 @@ export class AddonBadgesUserBadgesPage implements OnInit {
|
|||
loadIssuedBadge(badgeHash: string): void {
|
||||
this.badgeHash = badgeHash;
|
||||
const params = { courseId: this.courseId, userId: this.userId, badgeHash: badgeHash };
|
||||
// @todo use splitview.
|
||||
// this.splitviewCtrl.push('AddonBadgesIssuedBadgePage', params);
|
||||
CoreNavigator.instance.navigateToSitePath('/badges/issue', { params });
|
||||
|
||||
const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/badges/user/issue');
|
||||
const path = (splitViewLoaded ? '../' : '') + 'issue';
|
||||
|
||||
CoreNavigator.instance.navigate(path, { params });
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -33,10 +33,10 @@
|
|||
</core-context-menu>
|
||||
</ion-item-divider>
|
||||
<core-loading [hideUntil]="loaded" class="core-loading-center">
|
||||
<div class="ion-padding" [hidden]="showFilter || !showSelectorFilter" class="safe-padding-horizontal">
|
||||
<div class="ion-padding safe-padding-horizontal" [hidden]="showFilter || !showSelectorFilter">
|
||||
<!-- "Time" selector. -->
|
||||
<ion-select class="ion-text-start" [title]="'core.show' | translate" [(ngModel)]="selectedFilter"
|
||||
(ngModelChange)="selectedChanged()" interface="popover" class="core-button-select">
|
||||
<ion-select class="core-button-select ion-text-start" [title]="'core.show' | translate" [(ngModel)]="selectedFilter"
|
||||
(ngModelChange)="selectedChanged()" interface="popover">
|
||||
<ion-select-option value="allincludinghidden" *ngIf="showFilters.allincludinghidden != 'hidden'">
|
||||
{{ 'addon.block_myoverview.allincludinghidden' | translate }}
|
||||
</ion-select-option>
|
||||
|
|
|
@ -1,22 +1,27 @@
|
|||
<ion-item-divider>
|
||||
<ion-label>
|
||||
<h2>{{ 'addon.block_recentlyaccessedcourses.pluginname' | translate }}</h2>
|
||||
</ion-label>
|
||||
</ion-label>
|
||||
<div *ngIf="downloadCoursesEnabled && downloadEnabled && courses && courses.length > 1" class="core-button-spinner" slot="end">
|
||||
<ion-button *ngIf="prefetchCoursesData.icon && !prefetchCoursesData.loading" fill="clear" color="dark" (click)="prefetchCourses()">
|
||||
<ion-button *ngIf="prefetchCoursesData.icon && !prefetchCoursesData.loading" fill="clear" color="dark"
|
||||
(click)="prefetchCourses()">
|
||||
<ion-icon [name]="prefetchCoursesData.icon" slot="icon-only">
|
||||
</ion-icon>
|
||||
</ion-button>
|
||||
<ion-badge class="core-course-download-courses-progress" *ngIf="prefetchCoursesData.badge">{{prefetchCoursesData.badge}}</ion-badge>
|
||||
<ion-badge class="core-course-download-courses-progress" *ngIf="prefetchCoursesData.badge">
|
||||
{{prefetchCoursesData.badge}}
|
||||
</ion-badge>
|
||||
<ion-spinner *ngIf="!prefetchCoursesData.icon || prefetchCoursesData.loading"></ion-spinner>
|
||||
</div>
|
||||
</ion-item-divider>
|
||||
<core-loading [hideUntil]="loaded" class="core-loading-center safe-area-page">
|
||||
<core-empty-box *ngIf="courses.length == 0" image="assets/img/icons/courses.svg" [message]="'addon.block_recentlyaccessedcourses.nocourses' | translate"></core-empty-box>
|
||||
<core-empty-box *ngIf="courses.length == 0" image="assets/img/icons/courses.svg"
|
||||
[message]="'addon.block_recentlyaccessedcourses.nocourses' | translate"></core-empty-box>
|
||||
<!-- List of courses. -->
|
||||
<div class="core-horizontal-scroll">
|
||||
<ng-container *ngFor="let course of courses">
|
||||
<core-courses-course-progress [course]="course" class="core-recentlyaccessedcourses" [showDownload]="downloadCourseEnabled && downloadEnabled"></core-courses-course-progress>
|
||||
<core-courses-course-progress [course]="course" class="core-recentlyaccessedcourses"
|
||||
[showDownload]="downloadCourseEnabled && downloadEnabled"></core-courses-course-progress>
|
||||
</ng-container>
|
||||
</div>
|
||||
</core-loading>
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!--<core-course-module *ngFor="let module of mainMenuBlock.modules" [module]="module" [courseId]="siteHomeId" [downloadEnabled]="downloadEnabled" [section]="mainMenuBlock"></core-course-module>-->
|
||||
<!--<core-course-module *ngFor="let module of mainMenuBlock.modules" [module]="module" [courseId]="siteHomeId"
|
||||
[downloadEnabled]="downloadEnabled" [section]="mainMenuBlock"></core-course-module>-->
|
||||
</ng-container>
|
||||
</core-loading>
|
||||
|
|
|
@ -3,20 +3,25 @@
|
|||
<h2>{{ 'addon.block_starredcourses.pluginname' | translate }}</h2>
|
||||
</ion-label>
|
||||
<div *ngIf="downloadCoursesEnabled && downloadEnabled && courses && courses.length > 1" class="core-button-spinner" slot="end">
|
||||
<ion-button *ngIf="prefetchCoursesData.icon && !prefetchCoursesData.loading" fill="clear" color="dark" (click)="prefetchCourses()">
|
||||
<ion-button *ngIf="prefetchCoursesData.icon && !prefetchCoursesData.loading" fill="clear" color="dark"
|
||||
(click)="prefetchCourses()">
|
||||
<ion-icon [name]="prefetchCoursesData.icon" slot="icon-only">
|
||||
</ion-icon>
|
||||
</ion-button>
|
||||
<ion-badge class="core-course-download-courses-progress" *ngIf="prefetchCoursesData.badge">{{prefetchCoursesData.badge}}</ion-badge>
|
||||
<ion-badge class="core-course-download-courses-progress" *ngIf="prefetchCoursesData.badge">
|
||||
{{prefetchCoursesData.badge}}
|
||||
</ion-badge>
|
||||
<ion-spinner *ngIf="!prefetchCoursesData.icon || prefetchCoursesData.loading"></ion-spinner>
|
||||
</div>
|
||||
</ion-item-divider>
|
||||
<core-loading [hideUntil]="loaded" class="core-loading-center safe-area-page">
|
||||
<core-empty-box *ngIf="courses.length == 0" image="assets/img/icons/courses.svg" [message]="'addon.block_starredcourses.nocourses' | translate"></core-empty-box>
|
||||
<core-empty-box *ngIf="courses.length == 0" image="assets/img/icons/courses.svg"
|
||||
[message]="'addon.block_starredcourses.nocourses' | translate"></core-empty-box>
|
||||
<!-- List of courses. -->
|
||||
<div class="core-horizontal-scroll">
|
||||
<ng-container *ngFor="let course of courses">
|
||||
<core-courses-course-progress [course]="course" class="core-block_starredcourses" [showDownload]="downloadCourseEnabled && downloadEnabled"></core-courses-course-progress>
|
||||
<core-courses-course-progress [course]="course" class="core-block_starredcourses"
|
||||
[showDownload]="downloadCourseEnabled && downloadEnabled"></core-courses-course-progress>
|
||||
</ng-container>
|
||||
</div>
|
||||
</core-loading>
|
||||
|
|
|
@ -13,10 +13,21 @@
|
|||
// limitations under the License.
|
||||
|
||||
import { Injector, NgModule } from '@angular/core';
|
||||
import { RouterModule, ROUTES, Routes } from '@angular/router';
|
||||
import { Route, RouterModule, ROUTES, Routes } from '@angular/router';
|
||||
|
||||
import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module';
|
||||
|
||||
export const AddonCalendarEditRoute: Route = {
|
||||
path: 'edit',
|
||||
loadChildren: () =>
|
||||
import('@/addons/calendar/pages/edit-event/edit-event.module').then(m => m.AddonCalendarEditEventPageModule),
|
||||
};
|
||||
|
||||
export const AddonCalendarEventRoute: Route ={
|
||||
path: 'event',
|
||||
loadChildren: () => import('@/addons/calendar/pages/event/event.module').then(m => m.AddonCalendarEventPageModule),
|
||||
};
|
||||
|
||||
function buildRoutes(injector: Injector): Routes {
|
||||
return [
|
||||
{
|
||||
|
@ -37,16 +48,8 @@ function buildRoutes(injector: Injector): Routes {
|
|||
loadChildren: () =>
|
||||
import('@/addons/calendar/pages/day/day.module').then(m => m.AddonCalendarDayPageModule),
|
||||
},
|
||||
{
|
||||
path: 'event',
|
||||
loadChildren: () =>
|
||||
import('@/addons/calendar/pages/event/event.module').then(m => m.AddonCalendarEventPageModule),
|
||||
},
|
||||
{
|
||||
path: 'edit',
|
||||
loadChildren: () =>
|
||||
import('@/addons/calendar/pages/edit-event/edit-event.module').then(m => m.AddonCalendarEditEventPageModule),
|
||||
},
|
||||
AddonCalendarEventRoute,
|
||||
AddonCalendarEditRoute,
|
||||
...buildTabMainRoutes(injector, {
|
||||
redirectTo: 'index',
|
||||
pathMatch: 'full',
|
||||
|
|
|
@ -32,7 +32,7 @@
|
|||
<ion-grid class="addon-calendar-months">
|
||||
<!-- List of days. -->
|
||||
<ion-row>
|
||||
<ion-col class="ion-text-center" *ngFor="let day of weekDays" class="addon-calendar-weekday">
|
||||
<ion-col class="ion-text-center addon-calendar-weekday" *ngFor="let day of weekDays">
|
||||
<span class="ion-hide-md-up" [title]="day.fullname | translate">{{ day.shortname | translate }}</span>
|
||||
<span class="ion-hide-md-down">{{ day.fullname | translate }}</span>
|
||||
</ion-col>
|
||||
|
@ -42,10 +42,10 @@
|
|||
<ion-row *ngFor="let week of weeks" class="addon-calendar-week">
|
||||
<!-- Empty slots (first week). -->
|
||||
<ion-col *ngFor="let value of week.prepadding" class="dayblank addon-calendar-day"></ion-col>
|
||||
<ion-col class="ion-text-center" *ngFor="let day of week.days" (click)="dayClicked(day.mday)"
|
||||
<ion-col class="addon-calendar-day ion-text-center" *ngFor="let day of week.days" (click)="dayClicked(day.mday)"
|
||||
[ngClass]='{"hasevents": day.hasevents, "today": isCurrentMonth && day.istoday,
|
||||
"weekend": day.isweekend, "duration_finish": day.haslastdayofevent}'
|
||||
class="addon-calendar-day" [class.addon-calendar-event-past-day]="isPastMonth || day.ispast">
|
||||
[class.addon-calendar-event-past-day]="isPastMonth || day.ispast">
|
||||
<p class="addon-calendar-day-number"><span>{{ day.mday }}</span></p>
|
||||
|
||||
<!-- In phone, display some dots to indicate the type of events. -->
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
<ion-list *ngIf="filteredEvents && filteredEvents.length" class="ion-no-margin">
|
||||
<ng-container *ngFor="let event of filteredEvents">
|
||||
<ion-item class="ion-text-wrap" [title]="event.name" (click)="eventClicked(event)" class="addon-calendar-event"
|
||||
<ion-item class="ion-text-wrap addon-calendar-event" [title]="event.name" (click)="eventClicked(event)"
|
||||
[ngClass]="['addon-calendar-eventtype-'+event.eventtype]">
|
||||
<img *ngIf="event.moduleIcon" src="{{event.moduleIcon}}" slot="start" class="core-module-icon">
|
||||
<ion-icon *ngIf="event.eventIcon && !event.moduleIcon" [name]="event.eventIcon" slot="start">
|
||||
|
|
|
@ -59,9 +59,8 @@
|
|||
|
||||
<ion-list *ngIf="filteredEvents && filteredEvents.length" class="ion-no-margin">
|
||||
<ng-container *ngFor="let event of filteredEvents">
|
||||
<ion-item class="ion-text-wrap" [title]="event.name" (click)="gotoEvent(event.id)"
|
||||
[class.item-dimmed]="event.ispast" class="addon-calendar-event"
|
||||
[ngClass]="['addon-calendar-eventtype-'+event.eventtype]">
|
||||
<ion-item class="addon-calendar-event ion-text-wrap" [title]="event.name" (click)="gotoEvent(event.id)"
|
||||
[class.item-dimmed]="event.ispast" [ngClass]="['addon-calendar-eventtype-'+event.eventtype]">
|
||||
<img *ngIf="event.moduleIcon" src="{{event.moduleIcon}}" slot="start" class="core-module-icon">
|
||||
<ion-icon *ngIf="event.eventIcon && !event.moduleIcon" [name]="event.eventIcon" slot="start">
|
||||
</ion-icon>
|
||||
|
|
|
@ -97,7 +97,7 @@
|
|||
</ion-select>
|
||||
</ion-item>
|
||||
<!-- The course has no groups. -->
|
||||
<ion-item class="ion-text-wrap" *ngIf="!loadingGroups && courseGroupSet && !groups.length" class="core-danger-item">
|
||||
<ion-item class="ion-text-wrap core-danger-item" *ngIf="!loadingGroups && courseGroupSet && !groups.length">
|
||||
<ion-label><p>{{ 'core.coursenogroups' | translate }}</p></ion-label>
|
||||
</ion-item>
|
||||
<!-- Select the group. -->
|
||||
|
@ -119,7 +119,7 @@
|
|||
</ng-container>
|
||||
|
||||
<!-- Advanced options. -->
|
||||
<ion-item-divider class="ion-text-wrap" (click)="toggleAdvanced()" class="core-expandable">
|
||||
<ion-item-divider class="ion-text-wrap core-expandable" (click)="toggleAdvanced()">
|
||||
<ion-icon *ngIf="!advanced" name="fas-caret-right" slot="start"></ion-icon>
|
||||
<ion-icon *ngIf="advanced" name="fas-caret-down" slot="start"></ion-icon>
|
||||
<ion-label>
|
||||
|
@ -147,7 +147,7 @@
|
|||
</ion-item>
|
||||
|
||||
<!-- Duration. -->
|
||||
<div class="ion-text-wrap" class="addon-calendar-radio-container">
|
||||
<div class="ion-text-wrap addon-calendar-radio-container">
|
||||
<ion-radio-group formControlName="duration">
|
||||
<ion-item class="addon-calendar-radio-title">
|
||||
<ion-label>
|
||||
|
@ -192,7 +192,7 @@
|
|||
</ng-container>
|
||||
|
||||
<!-- Apply to all events or just this one (editing repeated events). -->
|
||||
<div *ngIf="eventRepeatId" class="ion-text-wrap" class="addon-calendar-radio-container">
|
||||
<div *ngIf="eventRepeatId" class="ion-text-wrap addon-calendar-radio-container">
|
||||
<ion-radio-group formControlName="repeateditall">
|
||||
<ion-item class="addon-calendar-radio-title">
|
||||
<ion-label>
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core';
|
||||
import { Component, OnInit, OnDestroy, ViewChild, ElementRef, Optional } from '@angular/core';
|
||||
import { FormControl, FormGroup, FormBuilder, Validators } from '@angular/forms';
|
||||
import { IonRefresher } from '@ionic/angular';
|
||||
import { CoreEvents } from '@singletons/events';
|
||||
|
@ -23,7 +23,7 @@ import { CoreDomUtils } from '@services/utils/dom';
|
|||
import { CoreTimeUtils } from '@services/utils/time';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { CoreCategoryData, CoreCourses, CoreCourseSearchedData, CoreEnrolledCourseData } from '@features/courses/services/courses';
|
||||
// @todo import { CoreSplitViewComponent } from '@components/split-view/split-view';
|
||||
import { CoreSplitViewComponent } from '@components/split-view/split-view';
|
||||
import { CoreEditorRichTextEditorComponent } from '@features/editor/components/rich-text-editor/rich-text-editor.ts';
|
||||
import {
|
||||
AddonCalendarProvider,
|
||||
|
@ -91,6 +91,7 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy {
|
|||
|
||||
constructor(
|
||||
protected fb: FormBuilder,
|
||||
@Optional() protected svComponent: CoreSplitViewComponent,
|
||||
) {
|
||||
|
||||
this.currentSite = CoreSites.instance.getCurrentSite()!;
|
||||
|
@ -569,14 +570,15 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
/* if (this.svComponent && this.svComponent.isOn()) {
|
||||
if (this.svComponent?.isOn()) {
|
||||
// Empty form.
|
||||
this.hasOffline = false;
|
||||
this.form.reset(this.originalData);
|
||||
this.originalData = CoreUtils.instance.clone(this.form.value);
|
||||
} else {*/
|
||||
this.originalData = undefined; // Avoid asking for confirmation.
|
||||
CoreNavigator.instance.back();
|
||||
} else {
|
||||
this.originalData = undefined; // Avoid asking for confirmation.
|
||||
CoreNavigator.instance.back();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Component, OnDestroy, OnInit, Optional } from '@angular/core';
|
||||
import { IonRefresher } from '@ionic/angular';
|
||||
import { AlertOptions } from '@ionic/core';
|
||||
import {
|
||||
|
@ -37,12 +37,14 @@ import { CoreLocalNotifications } from '@services/local-notifications';
|
|||
import { CoreCourse } from '@features/course/services/course';
|
||||
import { CoreTimeUtils } from '@services/utils/time';
|
||||
import { CoreGroups } from '@services/groups';
|
||||
// @todo import { CoreSplitViewComponent } from '@components/split-view/split-view';
|
||||
import { CoreSplitViewComponent } from '@components/split-view/split-view';
|
||||
import { Network, NgZone, Translate } from '@singletons';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { AddonCalendarReminderDBRecord } from '../../services/database/calendar';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
|
||||
/**
|
||||
* Page that displays a single calendar event.
|
||||
|
@ -85,11 +87,15 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
|
|||
syncIcon = 'spinner'; // Sync icon.
|
||||
isSplitViewOn = false;
|
||||
|
||||
constructor() {
|
||||
constructor(
|
||||
@Optional() protected svComponent: CoreSplitViewComponent,
|
||||
protected route: ActivatedRoute,
|
||||
) {
|
||||
|
||||
this.notificationsEnabled = CoreLocalNotifications.instance.isAvailable();
|
||||
this.siteHomeId = CoreSites.instance.getCurrentSiteHomeId();
|
||||
this.currentSiteId = CoreSites.instance.getCurrentSiteId();
|
||||
// this.isSplitViewOn = this.svComponent && this.svComponent.isOn();
|
||||
this.isSplitViewOn = this.svComponent?.isOn();
|
||||
|
||||
// Check if site supports editing and deleting. No need to check allowed types, event.canedit already does it.
|
||||
this.canEdit = AddonCalendar.instance.canEditEventsInSite();
|
||||
|
@ -145,18 +151,22 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
|
|||
* View loaded.
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
const eventId = CoreNavigator.instance.getRouteNumberParam('id');
|
||||
if (!eventId) {
|
||||
CoreDomUtils.instance.showErrorModal('Event ID not supplied.');
|
||||
CoreNavigator.instance.back();
|
||||
this.route.queryParams.subscribe(() => {
|
||||
this.eventLoaded = false;
|
||||
|
||||
return;
|
||||
}
|
||||
const eventId = CoreNavigator.instance.getRouteNumberParam('id');
|
||||
if (!eventId) {
|
||||
CoreDomUtils.instance.showErrorModal('Event ID not supplied.');
|
||||
CoreNavigator.instance.back();
|
||||
|
||||
this.eventId = eventId;
|
||||
this.syncIcon = 'spinner';
|
||||
return;
|
||||
}
|
||||
|
||||
this.fetchEvent();
|
||||
this.eventId = eventId;
|
||||
this.syncIcon = 'spinner';
|
||||
|
||||
this.fetchEvent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -501,9 +511,9 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
|
|||
CoreDomUtils.instance.showToast('addon.calendar.eventcalendareventdeleted', true, 3000);
|
||||
|
||||
// Event deleted, close the view.
|
||||
/* if (!this.svComponent || !this.svComponent.isOn()) {
|
||||
this.navCtrl.pop();
|
||||
}*/
|
||||
if (CoreScreen.instance.isMobile) {
|
||||
CoreNavigator.instance.back();
|
||||
}
|
||||
} else {
|
||||
// Event deleted in offline, just mark it as deleted.
|
||||
this.event.deleted = true;
|
||||
|
@ -558,9 +568,9 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy {
|
|||
CoreDomUtils.instance.showToast('addon.calendar.eventcalendareventdeleted', true, 3000);
|
||||
|
||||
// Event was deleted, close the view.
|
||||
/* if (!this.svComponent || !this.svComponent.isOn()) {
|
||||
this.navCtrl.pop();
|
||||
}*/
|
||||
if (CoreScreen.instance.isMobile) {
|
||||
CoreNavigator.instance.back();
|
||||
}
|
||||
} else if (data.events && (!isManual || data.source != 'event')) {
|
||||
const event = data.events.find((ev) => ev.id == this.eventId);
|
||||
|
||||
|
|
|
@ -89,9 +89,9 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy {
|
|||
this.currentSiteId = CoreSites.instance.getCurrentSiteId();
|
||||
|
||||
// Listen for events added. When an event is added, reload the data.
|
||||
this.newEventObserver = CoreEvents.on(
|
||||
this.newEventObserver = CoreEvents.on<AddonCalendarUpdatedEventEvent>(
|
||||
AddonCalendarProvider.NEW_EVENT_EVENT,
|
||||
(data: AddonCalendarUpdatedEventEvent) => {
|
||||
(data) => {
|
||||
if (data && data.eventId) {
|
||||
this.loaded = false;
|
||||
this.refreshData(true, false);
|
||||
|
@ -107,9 +107,9 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy {
|
|||
}, this.currentSiteId);
|
||||
|
||||
// Listen for events edited. When an event is edited, reload the data.
|
||||
this.editEventObserver = CoreEvents.on(
|
||||
this.editEventObserver = CoreEvents.on<AddonCalendarUpdatedEventEvent>(
|
||||
AddonCalendarProvider.EDIT_EVENT_EVENT,
|
||||
(data: AddonCalendarUpdatedEventEvent) => {
|
||||
(data) => {
|
||||
if (data && data.eventId) {
|
||||
this.loaded = false;
|
||||
this.refreshData(true, false);
|
||||
|
@ -125,7 +125,7 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy {
|
|||
}, this.currentSiteId);
|
||||
|
||||
// Refresh data if calendar events are synchronized manually but not by this page.
|
||||
this.manualSyncObserver = CoreEvents.on(AddonCalendarSyncProvider.MANUAL_SYNCED, (data: AddonCalendarSyncEvents) => {
|
||||
this.manualSyncObserver = CoreEvents.on<AddonCalendarSyncEvents>(AddonCalendarSyncProvider.MANUAL_SYNCED, (data) => {
|
||||
if (data && data.source != 'index') {
|
||||
this.loaded = false;
|
||||
this.refreshData(false, false);
|
||||
|
@ -143,9 +143,9 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy {
|
|||
this.hasOffline = await AddonCalendarOffline.instance.hasOfflineData();
|
||||
}, this.currentSiteId);
|
||||
|
||||
this.filterChangedObserver = CoreEvents.on(
|
||||
this.filterChangedObserver = CoreEvents.on<AddonCalendarFilter>(
|
||||
AddonCalendarProvider.FILTER_CHANGED_EVENT,
|
||||
async (filterData: AddonCalendarFilter) => {
|
||||
async (filterData) => {
|
||||
this.filter = filterData;
|
||||
|
||||
// Course viewed has changed, check if the user can create events for this course calendar.
|
||||
|
|
|
@ -19,8 +19,8 @@
|
|||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<!--<core-split-view>-->
|
||||
<ion-content>
|
||||
<ion-content>
|
||||
<core-split-view>
|
||||
<ion-refresher slot="fixed" [disabled]="!eventsLoaded" (ionRefresh)="doRefresh($event)">
|
||||
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
|
@ -42,9 +42,8 @@
|
|||
<ion-item-divider *ngIf="event.showDate">
|
||||
<ion-label>{{ event.timestart * 1000 | coreFormatDate: "strftimedayshort" }}</ion-label>
|
||||
</ion-item-divider>
|
||||
<ion-item class="ion-text-wrap" [title]="event.name" (click)="gotoEvent(event.id)"
|
||||
[class.core-split-item-selected]="event.id == eventId" class="addon-calendar-event"
|
||||
[ngClass]="['addon-calendar-eventtype-'+event.eventtype]">
|
||||
<ion-item class="addon-calendar-event ion-text-wrap" [title]="event.name" (click)="gotoEvent(event.id)"
|
||||
[class.core-selected-item]="event.id == eventId" [ngClass]="['addon-calendar-eventtype-'+event.eventtype]">
|
||||
<img *ngIf="event.moduleIcon" src="{{event.moduleIcon}}" slot="start" class="core-module-icon">
|
||||
<ion-icon *ngIf="event.eventIcon && !event.moduleIcon" [name]="event.eventIcon" slot="start">
|
||||
</ion-icon>
|
||||
|
@ -86,5 +85,5 @@
|
|||
<ion-icon name="fas-plus"></ion-icon>
|
||||
</ion-fab-button>
|
||||
</ion-fab>
|
||||
</ion-content>
|
||||
<!--</core-split-view>-->
|
||||
</core-split-view>
|
||||
</ion-content>
|
||||
|
|
|
@ -17,18 +17,40 @@ import { IonicModule } from '@ionic/angular';
|
|||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AddonCalendarEventRoute, AddonCalendarEditRoute } from '@addons/calendar/calendar-lazy.module';
|
||||
import { conditionalRoutes } from '@/app/app-routing.module';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
|
||||
import { AddonCalendarListPage } from './list.page';
|
||||
|
||||
const routes: Routes = [
|
||||
const splitviewRoutes = [AddonCalendarEditRoute, AddonCalendarEventRoute];
|
||||
|
||||
const mobileRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AddonCalendarListPage,
|
||||
},
|
||||
...splitviewRoutes,
|
||||
];
|
||||
|
||||
const tabletRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AddonCalendarListPage,
|
||||
children: [
|
||||
...splitviewRoutes,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const routes: Routes = [
|
||||
...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile),
|
||||
...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet),
|
||||
];
|
||||
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes),
|
||||
|
|
|
@ -30,7 +30,7 @@ import { CoreSites } from '@services/sites';
|
|||
import { CoreLocalNotifications } from '@services/local-notifications';
|
||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||
import { CoreApp } from '@services/app';
|
||||
// @todo import { CoreSplitViewComponent } from '@components/split-view/split-view';
|
||||
import { CoreSplitViewComponent } from '@components/split-view/split-view';
|
||||
import moment from 'moment';
|
||||
import { CoreConstants } from '@/core/constants';
|
||||
import { AddonCalendarFilterPopoverComponent } from '../../components/filter/filter';
|
||||
|
@ -52,7 +52,7 @@ import { CoreNavigator } from '@services/navigator';
|
|||
export class AddonCalendarListPage implements OnInit, OnDestroy {
|
||||
|
||||
@ViewChild(IonContent) content?: IonContent;
|
||||
// @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent;
|
||||
@ViewChild(CoreSplitViewComponent) splitviewCtrl?: CoreSplitViewComponent;
|
||||
|
||||
protected initialTime = 0;
|
||||
protected daysLoaded = 0;
|
||||
|
@ -117,30 +117,28 @@ export class AddonCalendarListPage implements OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
// Listen for events added. When an event is added, reload the data.
|
||||
this.newEventObserver = CoreEvents.on(AddonCalendarProvider.NEW_EVENT_EVENT, (data: AddonCalendarUpdatedEventEvent) => {
|
||||
this.newEventObserver = CoreEvents.on<AddonCalendarUpdatedEventEvent>(AddonCalendarProvider.NEW_EVENT_EVENT, (data) => {
|
||||
if (data && data.eventId) {
|
||||
/* if (this.splitviewCtrl.isOn()) {
|
||||
// Discussion added, clear details page.
|
||||
this.splitviewCtrl.emptyDetails();
|
||||
}*/
|
||||
|
||||
this.eventsLoaded = false;
|
||||
this.refreshEvents(true, false).finally(() => {
|
||||
|
||||
// In tablet mode try to open the event (only if it's an online event).
|
||||
/* if (this.splitviewCtrl.isOn() && data.event.id > 0) {
|
||||
this.gotoEvent(data.event.id);
|
||||
}*/
|
||||
if (this.splitviewCtrl?.isOn() && data.eventId > 0) {
|
||||
this.gotoEvent(data.eventId);
|
||||
} else if (this.splitviewCtrl?.isOn()) {
|
||||
// Discussion added, clear details page.
|
||||
this.emptySplitView();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, this.currentSiteId);
|
||||
|
||||
// Listen for new event discarded event. When it does, reload the data.
|
||||
this.discardedObserver = CoreEvents.on(AddonCalendarProvider.NEW_EVENT_DISCARDED_EVENT, () => {
|
||||
/* if (this.splitviewCtrl.isOn()) {
|
||||
if (this.splitviewCtrl?.isOn()) {
|
||||
// Discussion added, clear details page.
|
||||
this.splitviewCtrl.emptyDetails();
|
||||
}*/
|
||||
this.emptySplitView();
|
||||
}
|
||||
|
||||
this.eventsLoaded = false;
|
||||
this.refreshEvents(true, false);
|
||||
|
@ -155,14 +153,14 @@ export class AddonCalendarListPage implements OnInit, OnDestroy {
|
|||
}, this.currentSiteId);
|
||||
|
||||
// Refresh data if calendar events are synchronized automatically.
|
||||
this.syncObserver = CoreEvents.on(AddonCalendarSyncProvider.AUTO_SYNCED, () => {
|
||||
this.syncObserver = CoreEvents.on<AddonCalendarSyncEvents>(AddonCalendarSyncProvider.AUTO_SYNCED, (data) => {
|
||||
this.eventsLoaded = false;
|
||||
this.refreshEvents();
|
||||
|
||||
/* if (this.splitviewCtrl.isOn() && this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) {
|
||||
if (this.splitviewCtrl?.isOn() && this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) {
|
||||
// Current selected event was deleted. Clear details.
|
||||
this.splitviewCtrl.emptyDetails();
|
||||
} */
|
||||
this.emptySplitView();
|
||||
}
|
||||
}, this.currentSiteId);
|
||||
|
||||
// Refresh data if calendar events are synchronized manually but not by this page.
|
||||
|
@ -172,10 +170,10 @@ export class AddonCalendarListPage implements OnInit, OnDestroy {
|
|||
this.refreshEvents();
|
||||
}
|
||||
|
||||
/* if (this.splitviewCtrl.isOn() && this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) {
|
||||
if (this.splitviewCtrl?.isOn() && this.eventId && data && data.deleted && data.deleted.indexOf(this.eventId) != -1) {
|
||||
// Current selected event was deleted. Clear details.
|
||||
this.splitviewCtrl.emptyDetails();
|
||||
}*/
|
||||
this.emptySplitView();
|
||||
}
|
||||
}, this.currentSiteId);
|
||||
|
||||
// Update the list when an event is deleted.
|
||||
|
@ -183,15 +181,15 @@ export class AddonCalendarListPage implements OnInit, OnDestroy {
|
|||
AddonCalendarProvider.DELETED_EVENT_EVENT,
|
||||
(data: AddonCalendarUpdatedEventEvent) => {
|
||||
if (data && !data.sent) {
|
||||
// Event was deleted in offline. Just mark it as deleted, no need to refresh.
|
||||
// Event was deleted in offline. Just mark it as deleted, no need to refresh.
|
||||
this.markAsDeleted(data.eventId, true);
|
||||
this.deletedEvents.push(data.eventId);
|
||||
this.hasOffline = true;
|
||||
} else {
|
||||
// Event deleted, clear the details if needed and refresh the view.
|
||||
/* if (this.splitviewCtrl.isOn()) {
|
||||
this.splitviewCtrl.emptyDetails();
|
||||
} */
|
||||
// Event deleted, clear the details if needed and refresh the view.
|
||||
if (this.splitviewCtrl?.isOn()) {
|
||||
this.emptySplitView();
|
||||
}
|
||||
|
||||
this.eventsLoaded = false;
|
||||
this.refreshEvents();
|
||||
|
@ -259,14 +257,26 @@ export class AddonCalendarListPage implements OnInit, OnDestroy {
|
|||
|
||||
await this.fetchData(false, true, false);
|
||||
|
||||
/* if (!this.eventId && this.splitviewCtrl.isOn() && this.events.length > 0) {
|
||||
if (!this.eventId && this.splitviewCtrl?.isOn() && this.events.length > 0) {
|
||||
// Take first online event and load it. If no online event, load the first offline.
|
||||
if (this.onlineEvents[0]) {
|
||||
this.gotoEvent(this.onlineEvents[0].id);
|
||||
} else {
|
||||
this.gotoEvent(this.offlineEvents[0].id);
|
||||
}
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to clear detail view of the split view.
|
||||
*/
|
||||
protected emptySplitView(): void {
|
||||
// Empty details.
|
||||
const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/calendar/list/event') ||
|
||||
CoreNavigator.instance.isCurrentPathInTablet('**/calendar/list/edit');
|
||||
if (splitViewLoaded) {
|
||||
CoreNavigator.instance.navigate('../');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -642,7 +652,10 @@ export class AddonCalendarListPage implements OnInit, OnDestroy {
|
|||
params.courseId = this.filter.courseId;
|
||||
}
|
||||
|
||||
CoreNavigator.instance.navigateToSitePath('/calendar/edit', { params }); // @todo , this.splitviewCtrl);
|
||||
const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/calendar/list/event') ||
|
||||
CoreNavigator.instance.isCurrentPathInTablet('**/calendar/list/edit');
|
||||
const path = (splitViewLoaded ? '../' : '') + 'edit';
|
||||
CoreNavigator.instance.navigate(path, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -664,9 +677,12 @@ export class AddonCalendarListPage implements OnInit, OnDestroy {
|
|||
// It's an offline event, go to the edit page.
|
||||
this.openEdit(eventId);
|
||||
} else {
|
||||
/* this.splitviewCtrl.push('/calendar/event', {
|
||||
const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/calendar/list/event') ||
|
||||
CoreNavigator.instance.isCurrentPathInTablet('**/calendar/list/edit');
|
||||
const path = (splitViewLoaded ? '../' : '') + 'event';
|
||||
CoreNavigator.instance.navigate(path, { params: {
|
||||
id: eventId,
|
||||
});*/
|
||||
} });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ import { CoreCourses } from '@features/courses/services/courses';
|
|||
import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
|
||||
import { CoreStatusWithWarningsWSResponse, CoreWSExternalWarning } from '@services/ws';
|
||||
import { makeSingleton } from '@singletons';
|
||||
import { CoreError } from '@classes/errors/error';
|
||||
|
||||
const ROOT_CACHE_KEY = 'mmaCourseCompletion:';
|
||||
|
||||
|
@ -118,7 +119,7 @@ export class AddonCourseCompletionProvider {
|
|||
return result.completionstatus;
|
||||
}
|
||||
|
||||
throw null;
|
||||
throw new CoreError('Cannot fetch course completion status');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -165,7 +166,7 @@ export class AddonCourseCompletionProvider {
|
|||
*/
|
||||
async isPluginViewEnabledForCourse(courseId: number, preferCache: boolean = true): Promise<boolean> {
|
||||
if (!courseId) {
|
||||
throw null;
|
||||
throw new CoreError('No courseId provided');
|
||||
}
|
||||
|
||||
const course = await CoreCourses.instance.getUserCourse(courseId, preferCache);
|
||||
|
@ -260,7 +261,7 @@ export class AddonCourseCompletionProvider {
|
|||
const response = await site.write<CoreStatusWithWarningsWSResponse>('core_completion_mark_course_self_completed', params);
|
||||
|
||||
if (!response.status) {
|
||||
throw null;
|
||||
throw new CoreError('Cannot mark course as self completed');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -216,26 +216,26 @@ export class AddonFilterMathJaxLoaderHandlerService extends CoreFilterDefaultHan
|
|||
document.head.appendChild(script);
|
||||
|
||||
// Save the lang config until MathJax is actually loaded.
|
||||
this._lang = <string> params.lang; // eslint-disable-line no-underscore-dangle
|
||||
this._lang = <string> params.lang;
|
||||
},
|
||||
// Set the correct language for the MathJax menus.
|
||||
_setLocale: function (): void {
|
||||
if (!this._configured) { // eslint-disable-line no-underscore-dangle
|
||||
const lang = this._lang; // eslint-disable-line no-underscore-dangle
|
||||
if (!this._configured) {
|
||||
const lang = this._lang;
|
||||
|
||||
if (typeof that.window.MathJax != 'undefined') {
|
||||
that.window.MathJax.Hub.Queue(() => {
|
||||
that.window.MathJax.Localization.setLocale(lang);
|
||||
});
|
||||
that.window.MathJax.Hub.Configured();
|
||||
this._configured = true; // eslint-disable-line no-underscore-dangle
|
||||
this._configured = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
// Called by the filter when an equation is found while rendering the page.
|
||||
typeset: function (container: HTMLElement): void {
|
||||
if (!this._configured) { // eslint-disable-line no-underscore-dangle
|
||||
this._setLocale(); // eslint-disable-line no-underscore-dangle
|
||||
if (!this._configured) {
|
||||
this._setLocale();
|
||||
}
|
||||
|
||||
if (typeof that.window.MathJax != 'undefined') {
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
|
||||
import { AddonMessagesConversationInfoComponent } from './conversation-info/conversation-info';
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AddonMessagesConversationInfoComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
TranslateModule.forChild(),
|
||||
CoreSharedModule,
|
||||
],
|
||||
entryComponents: [
|
||||
AddonMessagesConversationInfoComponent,
|
||||
],
|
||||
})
|
||||
export class AddonMessagesComponentsModule {}
|
|
@ -0,0 +1,54 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ 'addon.messages.groupinfo' | translate }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
|
||||
<ion-icon name="close" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshData($event)">
|
||||
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
|
||||
<core-loading [hideUntil]="loaded">
|
||||
<ion-item class="ion-text-center" *ngIf="conversation">
|
||||
<ion-label>
|
||||
<div class="large-avatar">
|
||||
<img class="avatar" [src]="conversation!.imageurl" core-external-content [alt]="conversation!.name"
|
||||
role="presentation" onError="this.src='assets/img/group-avatar.png'">
|
||||
</div>
|
||||
<h2>
|
||||
<core-format-text [text]="conversation!.name" contextLevel="system" [contextInstanceId]="0"></core-format-text>
|
||||
</h2>
|
||||
<p>
|
||||
<core-format-text *ngIf="conversation!.subname" [text]="conversation!.subname" contextLevel="system"
|
||||
[contextInstanceId]="0">
|
||||
</core-format-text>
|
||||
</p>
|
||||
<p>{{ 'addon.messages.numparticipants' | translate:{$a: conversation!.membercount} }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item class="ion-text-wrap addon-messages-conversation-item" *ngFor="let member of members"
|
||||
(click)="closeModal(member.id)" detail>
|
||||
<core-user-avatar [user]="member" [linkProfile]="false" [checkOnline]="member.showonlinestatus" slot="start">
|
||||
</core-user-avatar>
|
||||
<ion-label>
|
||||
<h2>
|
||||
{{ member.fullname }}
|
||||
<ion-icon name="fas-user-slash" *ngIf="member.isblocked" [title]="'addon.messages.contactblocked' | translate">
|
||||
</ion-icon>
|
||||
</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<core-infinite-loading [enabled]="canLoadMore" (action)="loadMoreMembers($event)" [error]="loadMoreError">
|
||||
</core-infinite-loading>
|
||||
</core-loading>
|
||||
</ion-content>
|
|
@ -0,0 +1,147 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { IonRefresher } from '@ionic/angular';
|
||||
import {
|
||||
AddonMessagesConversationFormatted,
|
||||
AddonMessagesConversationMember,
|
||||
AddonMessages,
|
||||
} from '../../services/messages';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ModalController } from '@singletons';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
|
||||
/**
|
||||
* Component that displays the list of conversations, including group conversations.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'page-addon-messages-conversation-info',
|
||||
templateUrl: 'conversation-info.html',
|
||||
})
|
||||
export class AddonMessagesConversationInfoComponent implements OnInit {
|
||||
|
||||
loaded = false;
|
||||
conversation?: AddonMessagesConversationFormatted;
|
||||
members: AddonMessagesConversationMember[] = [];
|
||||
canLoadMore = false;
|
||||
loadMoreError = false;
|
||||
|
||||
protected conversationId!: number;
|
||||
|
||||
constructor(
|
||||
protected route: ActivatedRoute,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Component loaded.
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.route.queryParams.subscribe(async () => {
|
||||
this.conversationId = CoreNavigator.instance.getRouteNumberParam('conversationId') || 0;
|
||||
|
||||
this.loaded = false;
|
||||
this.fetchData().finally(() => {
|
||||
this.loaded = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the required data.
|
||||
*
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async fetchData(): Promise<void> {
|
||||
// Get the conversation data first.
|
||||
try {
|
||||
const conversation = await AddonMessages.instance.getConversation(this.conversationId, false, true, 0, 0);
|
||||
this.conversation = conversation;
|
||||
|
||||
// Now get the members.
|
||||
await this.fetchMembers();
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting members.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation members.
|
||||
*
|
||||
* @param loadingMore Whether we are loading more data or just the first ones.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async fetchMembers(loadingMore?: boolean): Promise<void> {
|
||||
this.loadMoreError = false;
|
||||
|
||||
const limitFrom = loadingMore ? this.members.length : 0;
|
||||
|
||||
const data = await AddonMessages.instance.getConversationMembers(this.conversationId, limitFrom);
|
||||
if (loadingMore) {
|
||||
this.members = this.members.concat(data.members);
|
||||
} else {
|
||||
this.members = data.members;
|
||||
}
|
||||
|
||||
this.canLoadMore = data.canLoadMore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to load more members.
|
||||
*
|
||||
* @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading.
|
||||
* @return Resolved when done.
|
||||
*/
|
||||
async loadMoreMembers(infiniteComplete?: () => void): Promise<void> {
|
||||
try {
|
||||
await this.fetchMembers(true);
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting members.');
|
||||
this.loadMoreError = true;
|
||||
} finally {
|
||||
infiniteComplete && infiniteComplete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the data.
|
||||
*
|
||||
* @param refresher Refresher.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async refreshData(refresher?: CustomEvent<IonRefresher>): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
promises.push(AddonMessages.instance.invalidateConversation(this.conversationId));
|
||||
promises.push(AddonMessages.instance.invalidateConversationMembers(this.conversationId));
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
await this.fetchData().finally(() => {
|
||||
refresher?.detail.complete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal.
|
||||
*
|
||||
* @param userId User conversation to load.
|
||||
*/
|
||||
closeModal(userId?: number): void {
|
||||
ModalController.instance.dismiss(userId);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
{
|
||||
"acceptandaddcontact": "Accept and add to contacts",
|
||||
"addcontact": "Add contact",
|
||||
"addcontactconfirm": "Are you sure you want to add {{$a}} to your contacts?",
|
||||
"addtofavourites": "Star conversation",
|
||||
"addtoyourcontacts": "Add to contacts",
|
||||
"blocknoncontacts": "Prevent non-contacts from messaging me",
|
||||
"blockuser": "Block user",
|
||||
"blockuserconfirm": "Are you sure you want to block {{$a}}?",
|
||||
"contactableprivacy": "Accept messages from:",
|
||||
"contactableprivacy_coursemember": "My contacts and anyone in my courses",
|
||||
"contactableprivacy_onlycontacts": "My contacts only",
|
||||
"contactableprivacy_site": "Anyone on the site",
|
||||
"contactblocked": "Contact blocked",
|
||||
"contactlistempty": "The contact list is empty",
|
||||
"contactname": "Contact name",
|
||||
"contactrequestsent": "Contact request sent",
|
||||
"contacts": "Contacts",
|
||||
"conversationactions": "Conversation actions menu",
|
||||
"decline": "Decline",
|
||||
"deleteallconfirm": "Are you sure you would like to delete this entire conversation? This will not delete it for other conversation participants.",
|
||||
"deleteallselfconfirm": "Are you sure you would like to delete this entire personal conversation?",
|
||||
"deleteconversation": "Delete conversation",
|
||||
"deleteforeveryone": "Delete for me and for everyone else",
|
||||
"deletemessage": "Delete message",
|
||||
"deletemessageconfirmation": "Are you sure you want to delete this message? It will only be deleted from your messaging history and will still be viewable by the user who sent or received the message.",
|
||||
"errordeletemessage": "Error while deleting the message.",
|
||||
"errorwhileretrievingcontacts": "Error while retrieving contacts from the server.",
|
||||
"errorwhileretrievingdiscussions": "Error while retrieving discussions from the server.",
|
||||
"errorwhileretrievingmessages": "Error while retrieving messages from the server.",
|
||||
"errorwhileretrievingusers": "Error while retrieving users from the server.",
|
||||
"groupconversations": "Group",
|
||||
"groupinfo": "Group info",
|
||||
"individualconversations": "Private",
|
||||
"info": "User info",
|
||||
"isnotinyourcontacts": "{{$a}} is not in your contacts",
|
||||
"message": "Message",
|
||||
"messagenotsent": "The message was not sent. Please try again later.",
|
||||
"messagepreferences": "Message preferences",
|
||||
"messages": "Messages",
|
||||
"muteconversation": "Mute",
|
||||
"mutedconversation": "Muted conversation",
|
||||
"newmessage": "New message",
|
||||
"newmessages": "New messages",
|
||||
"nocontactrequests": "No contact requests",
|
||||
"nocontactsgetstarted": "No contacts",
|
||||
"nofavourites": "No starred conversations",
|
||||
"nogroupconversations": "No group conversations",
|
||||
"noindividualconversations": "No private conversations",
|
||||
"nomessagesfound": "No messages were found",
|
||||
"noncontacts": "Non-contacts",
|
||||
"nousersfound": "No users found",
|
||||
"numparticipants": "{{$a}} participants",
|
||||
"removecontact": "Remove contact",
|
||||
"removecontactconfirm": "Are you sure you want to remove {{$a}} from your contacts?",
|
||||
"removefromfavourites": "Unstar conversation",
|
||||
"removefromyourcontacts": "Remove from contacts",
|
||||
"requests": "Requests",
|
||||
"requirecontacttomessage": "You need to request {{$a}} to add you as a contact to be able to message them.",
|
||||
"searchcombined": "Search people and messages",
|
||||
"selfconversation": "Personal space",
|
||||
"selfconversationdefaultmessage": "Save draft messages, links, notes etc. to access later.",
|
||||
"sendcontactrequest": "Send contact request",
|
||||
"showdeletemessages": "Show delete messages",
|
||||
"type_blocked": "Blocked",
|
||||
"type_offline": "Offline",
|
||||
"type_online": "Online",
|
||||
"type_search": "Search results",
|
||||
"type_strangers": "Others",
|
||||
"unabletomessage": "You are unable to message this user",
|
||||
"unblockuser": "Unblock user",
|
||||
"unblockuserconfirm": "Are you sure you want to unblock {{$a}}?",
|
||||
"unmuteconversation": "Unmute",
|
||||
"useentertosend": "Use enter to send",
|
||||
"useentertosenddescdesktop": "If disabled, you can use Ctrl+Enter to send the message.",
|
||||
"useentertosenddescmac": "If disabled, you can use Cmd+Enter to send the message.",
|
||||
"userwouldliketocontactyou": "{{$a}} would like to contact you",
|
||||
"warningconversationmessagenotsent": "Couldn't send message(s) to conversation {{conversation}}. {{error}}",
|
||||
"warningmessagenotsent": "Couldn't send message(s) to user {{user}}. {{error}}",
|
||||
"wouldliketocontactyou": "Would like to contact you",
|
||||
"you": "You:",
|
||||
"youhaveblockeduser": "You have blocked this user.",
|
||||
"yourcontactrequestpending": "Your contact request is pending with {{$a}}"
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
:host {
|
||||
.addon-messages-conversation-item,
|
||||
.addon-message-discussion {
|
||||
h2 {
|
||||
core-format-text {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.note {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin: 4px 8px;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.addon-message-last-message {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.addon-message-last-message-user {
|
||||
white-space: nowrap;
|
||||
color: var(--ion-text-color);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.addon-message-last-message-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.addon-message-discussion {
|
||||
h2 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context([dir=rtl]) {
|
||||
.addon-messages-conversation-item,
|
||||
.addon-message-discussion {
|
||||
h2 ion-icon {
|
||||
margin-right: 2px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.note {
|
||||
left: 0;
|
||||
right: unset;
|
||||
}
|
||||
|
||||
.addon-message-last-message-user {
|
||||
margin-left: 2px;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
// (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 { Injector, NgModule } from '@angular/core';
|
||||
import { Route, RouterModule, ROUTES, Routes } from '@angular/router';
|
||||
|
||||
import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module';
|
||||
import { AddonMessagesSettingsHandlerService } from './services/handlers/settings';
|
||||
|
||||
export const AddonMessagesDiscussionRoute: Route = {
|
||||
path: 'discussion',
|
||||
loadChildren: () => import('./pages/discussion/discussion.module')
|
||||
.then(m => m.AddonMessagesDiscussionPageModule),
|
||||
};
|
||||
|
||||
function buildRoutes(injector: Injector): Routes {
|
||||
return [
|
||||
{
|
||||
path: 'index', // 3.5 or lower.
|
||||
loadChildren: () =>
|
||||
import('./pages/discussions-35/discussions.module').then(m => m.AddonMessagesDiscussions35PageModule),
|
||||
},
|
||||
{
|
||||
path: 'contacts-35', // 3.5 or lower.
|
||||
loadChildren: () => import('./pages/contacts-35/contacts.module').then(m => m.AddonMessagesContacts35PageModule),
|
||||
},
|
||||
{
|
||||
path: 'group-conversations', // 3.6 or greater.
|
||||
loadChildren: () => import('./pages/group-conversations/group-conversations.module')
|
||||
.then(m => m.AddonMessagesGroupConversationsPageModule),
|
||||
},
|
||||
AddonMessagesDiscussionRoute,
|
||||
{
|
||||
path: 'search',
|
||||
loadChildren: () => import('./pages/search/search.module')
|
||||
.then(m => m.AddonMessagesSearchPageModule),
|
||||
},
|
||||
{
|
||||
path: AddonMessagesSettingsHandlerService.PAGE_NAME,
|
||||
loadChildren: () => import('./pages/settings/settings.module')
|
||||
.then(m => m.AddonMessagesSettingsPageModule),
|
||||
},
|
||||
{
|
||||
path: 'contacts', // 3.6 or greater.
|
||||
loadChildren: () => import('./pages/contacts/contacts.module')
|
||||
.then(m => m.AddonMessagesContactsPageModule),
|
||||
},
|
||||
...buildTabMainRoutes(injector, {
|
||||
redirectTo: 'index',
|
||||
pathMatch: 'full',
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@NgModule({
|
||||
exports: [RouterModule],
|
||||
providers: [
|
||||
{
|
||||
provide: ROUTES,
|
||||
multi: true,
|
||||
deps: [Injector],
|
||||
useFactory: buildRoutes,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AddonMessagesLazyModule { }
|
|
@ -0,0 +1,84 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { APP_INITIALIZER, NgModule } from '@angular/core';
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
import { CoreMainMenuRoutingModule } from '@features/mainmenu/mainmenu-routing.module';
|
||||
import { MESSAGES_OFFLINE_SITE_SCHEMA } from './services/database/messages';
|
||||
import { CORE_SITE_SCHEMAS } from '@services/sites';
|
||||
import { CoreMainMenuDelegate } from '@features/mainmenu/services/mainmenu-delegate';
|
||||
import { AddonMessagesMainMenuHandler, AddonMessagesMainMenuHandlerService } from './services/handlers/mainmenu';
|
||||
import { CoreCronDelegate } from '@services/cron';
|
||||
import { CoreSettingsDelegate } from '@features/settings/services/settings-delegate';
|
||||
import { AddonMessagesSettingsHandler } from './services/handlers/settings';
|
||||
import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module';
|
||||
import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate';
|
||||
import { AddonMessagesIndexLinkHandler } from './services/handlers/index-link';
|
||||
import { AddonMessagesDiscussionLinkHandler } from './services/handlers/discussion-link';
|
||||
import { AddonMessagesContactRequestLinkHandler } from './services/handlers/contact-request-link';
|
||||
import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate';
|
||||
import { AddonMessagesPushClickHandler } from './services/handlers/push-click';
|
||||
import { CoreUserDelegate } from '@features/user/services/user-delegate';
|
||||
import { AddonMessagesSendMessageUserHandler } from './services/handlers/user-send-message';
|
||||
import { Network, NgZone } from '@singletons';
|
||||
import { AddonMessagesSync } from './services/messages-sync';
|
||||
|
||||
const mainMenuChildrenRoutes: Routes = [
|
||||
{
|
||||
path: AddonMessagesMainMenuHandlerService.PAGE_NAME,
|
||||
loadChildren: () => import('./messages-lazy.module').then(m => m.AddonMessagesLazyModule),
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CoreMainMenuRoutingModule.forChild({ children: mainMenuChildrenRoutes }),
|
||||
CoreMainMenuTabRoutingModule.forChild( mainMenuChildrenRoutes),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: CORE_SITE_SCHEMAS,
|
||||
useValue: [MESSAGES_OFFLINE_SITE_SCHEMA],
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
multi: true,
|
||||
deps: [],
|
||||
useFactory: () => () => {
|
||||
// Register handlers.
|
||||
CoreMainMenuDelegate.instance.registerHandler(AddonMessagesMainMenuHandler.instance);
|
||||
CoreCronDelegate.instance.register(AddonMessagesMainMenuHandler.instance);
|
||||
CoreCronDelegate.instance.register(AddonMessagesPushClickHandler.instance);
|
||||
CoreSettingsDelegate.instance.registerHandler(AddonMessagesSettingsHandler.instance);
|
||||
CoreContentLinksDelegate.instance.registerHandler(AddonMessagesIndexLinkHandler.instance);
|
||||
CoreContentLinksDelegate.instance.registerHandler(AddonMessagesDiscussionLinkHandler.instance);
|
||||
CoreContentLinksDelegate.instance.registerHandler(AddonMessagesContactRequestLinkHandler.instance);
|
||||
CorePushNotificationsDelegate.instance.registerClickHandler(AddonMessagesPushClickHandler.instance);
|
||||
CoreUserDelegate.instance.registerHandler(AddonMessagesSendMessageUserHandler.instance);
|
||||
|
||||
// Sync some discussions when device goes online.
|
||||
Network.instance.onConnect().subscribe(() => {
|
||||
// Execute the callback in the Angular zone, so change detection doesn't stop working.
|
||||
NgZone.instance.run(() => {
|
||||
AddonMessagesSync.instance.syncAllDiscussions(undefined, true);
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
],
|
||||
})
|
||||
export class AddonMessagesModule {}
|
|
@ -0,0 +1,51 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ 'addon.messages.contacts' | translate }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<!-- Add an empty context menu so discussion page can add items in split view,
|
||||
otherwise the menu disappears in some cases. -->
|
||||
<core-context-menu></core-context-menu>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<core-split-view>
|
||||
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshData($event)">
|
||||
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
|
||||
<core-search-box (onSubmit)="search($event)" (onClear)="clearSearch()"
|
||||
[placeholder]="'addon.messages.contactname' | translate" autocorrect="off" spellcheck="false" lengthCheck="2"
|
||||
[disabled]="!loaded" searchArea="AddonMessagesContacts"></core-search-box>
|
||||
|
||||
<core-loading [hideUntil]="loaded" [message]="loadingMessage">
|
||||
<core-empty-box *ngIf="!hasContacts && searchString == ''" icon="fas-address-book"
|
||||
[message]="'addon.messages.contactlistempty' | translate"></core-empty-box>
|
||||
|
||||
<core-empty-box *ngIf="!hasContacts && searchString != ''" icon="fas-address-book"
|
||||
[message]="'addon.messages.nousersfound' | translate"></core-empty-box>
|
||||
|
||||
<ion-list *ngFor="let contactType of contactTypes" class="ion-no-margin">
|
||||
<ng-container *ngIf="contacts[contactType] && (contacts[contactType].length > 0 || contactType === searchType)">
|
||||
<ion-item-divider>
|
||||
<ion-label><h2>{{ 'addon.messages.type_' + contactType | translate }}</h2></ion-label>
|
||||
<ion-note slot="end" class="ion-padding-end"><ion-badge>{{ contacts[contactType].length }}</ion-badge></ion-note>
|
||||
</ion-item-divider>
|
||||
<ng-container *ngFor="let contact of contacts[contactType]">
|
||||
<!-- Don't show deleted users -->
|
||||
<ion-item class="ion-text-wrap addon-messages-conversation-item"
|
||||
*ngIf="contact.profileimageurl || contact.profileimageurlsmall"
|
||||
[title]="contact.fullname" (click)="gotoDiscussion(contact.id)" detail
|
||||
[class.core-selected-item]="contact.id == discussionUserId">
|
||||
<core-user-avatar [user]="contact" slot="start" [checkOnline]="contact.showonlinestatus"></core-user-avatar>
|
||||
<ion-label><h2>{{ contact.fullname }}</h2></ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ion-list>
|
||||
</core-loading>
|
||||
</core-split-view>
|
||||
</ion-content>
|
|
@ -0,0 +1,66 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { conditionalRoutes } from '@/app/app-routing.module';
|
||||
import { AddonMessagesDiscussionRoute } from '@addons/messages/messages-lazy.module';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
import { CoreSearchComponentsModule } from '@features/search/components/components.module';
|
||||
|
||||
import { AddonMessagesContacts35Page } from './contacts.page';
|
||||
|
||||
const mobileRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AddonMessagesContacts35Page,
|
||||
},
|
||||
AddonMessagesDiscussionRoute,
|
||||
];
|
||||
|
||||
const tabletRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AddonMessagesContacts35Page,
|
||||
children: [
|
||||
AddonMessagesDiscussionRoute,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const routes: Routes = [
|
||||
...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile),
|
||||
...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes),
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
TranslateModule.forChild(),
|
||||
CoreSharedModule,
|
||||
CoreSearchComponentsModule,
|
||||
],
|
||||
declarations: [
|
||||
AddonMessagesContacts35Page,
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AddonMessagesContacts35PageModule {}
|
|
@ -0,0 +1,273 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { IonRefresher } from '@ionic/angular';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import {
|
||||
AddonMessagesProvider,
|
||||
AddonMessagesGetContactsWSResponse,
|
||||
AddonMessagesSearchContactsContact,
|
||||
AddonMessagesGetContactsContact,
|
||||
AddonMessages,
|
||||
AddonMessagesMemberInfoChangedEventData,
|
||||
} from '../../services/messages';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreApp } from '@services/app';
|
||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||
import { ActivatedRoute, Params } from '@angular/router';
|
||||
import { Translate } from '@singletons';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
|
||||
/**
|
||||
* Page that displays the list of contacts.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'addon-messages-contacts',
|
||||
templateUrl: 'contacts.html',
|
||||
styleUrls: ['../../messages-common.scss'],
|
||||
})
|
||||
export class AddonMessagesContacts35Page implements OnInit, OnDestroy {
|
||||
|
||||
protected searchingMessages: string;
|
||||
protected loadingMessages: string;
|
||||
protected siteId: string;
|
||||
protected noSearchTypes = ['online', 'offline', 'blocked', 'strangers'];
|
||||
protected memberInfoObserver: CoreEventObserver;
|
||||
|
||||
loaded = false;
|
||||
discussionUserId?: number;
|
||||
contactTypes = ['online', 'offline', 'blocked', 'strangers'];
|
||||
searchType = 'search';
|
||||
loadingMessage = '';
|
||||
hasContacts = false;
|
||||
contacts: AddonMessagesGetContactsFormatted = {
|
||||
online: [],
|
||||
offline: [],
|
||||
strangers: [],
|
||||
search: [],
|
||||
};
|
||||
|
||||
searchString = '';
|
||||
|
||||
|
||||
constructor(
|
||||
protected route: ActivatedRoute,
|
||||
) {
|
||||
this.siteId = CoreSites.instance.getCurrentSiteId();
|
||||
this.searchingMessages = Translate.instance.instant('core.searching');
|
||||
this.loadingMessages = Translate.instance.instant('core.loading');
|
||||
this.loadingMessage = this.loadingMessages;
|
||||
|
||||
// Refresh the list when a contact request is confirmed.
|
||||
this.memberInfoObserver = CoreEvents.on<AddonMessagesMemberInfoChangedEventData>(
|
||||
AddonMessagesProvider.MEMBER_INFO_CHANGED_EVENT,
|
||||
(data) => {
|
||||
if (data.contactRequestConfirmed) {
|
||||
this.refreshData();
|
||||
}
|
||||
},
|
||||
CoreSites.instance.getCurrentSiteId(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component loaded.
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.route.queryParams.subscribe(async () => {
|
||||
const discussionUserId = CoreNavigator.instance.getRouteNumberParam('discussionUserId') ||
|
||||
CoreNavigator.instance.getRouteNumberParam('userId') || undefined;
|
||||
|
||||
if (this.loaded && this.discussionUserId == discussionUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.discussionUserId = discussionUserId;
|
||||
|
||||
if (this.discussionUserId) {
|
||||
// There is a discussion to load, open the discussion in a new state.
|
||||
this.gotoDiscussion(this.discussionUserId);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.fetchData();
|
||||
if (!this.discussionUserId && this.hasContacts && CoreScreen.instance.isTablet) {
|
||||
let contact: AddonMessagesGetContactsContact | undefined;
|
||||
for (const x in this.contacts) {
|
||||
if (this.contacts[x].length > 0) {
|
||||
contact = this.contacts[x][0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (contact) {
|
||||
// Take first and load it.
|
||||
this.gotoDiscussion(contact.id);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the data.
|
||||
*
|
||||
* @param refresher Refresher.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async refreshData(refresher?: CustomEvent<IonRefresher>): Promise<void> {
|
||||
try {
|
||||
if (this.searchString) {
|
||||
// User has searched, update the search.
|
||||
await this.performSearch(this.searchString);
|
||||
} else {
|
||||
// Update contacts.
|
||||
await AddonMessages.instance.invalidateAllContactsCache();
|
||||
await this.fetchData();
|
||||
}
|
||||
} finally {
|
||||
refresher?.detail.complete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch contacts.
|
||||
*
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async fetchData(): Promise<void> {
|
||||
this.loadingMessage = this.loadingMessages;
|
||||
|
||||
try {
|
||||
const contacts = await AddonMessages.instance.getAllContacts();
|
||||
for (const x in contacts) {
|
||||
if (contacts[x].length > 0) {
|
||||
this.contacts[x] = this.sortUsers(contacts[x]);
|
||||
} else {
|
||||
this.contacts[x] = [];
|
||||
}
|
||||
}
|
||||
|
||||
this.clearSearch();
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingcontacts', true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort user list by fullname
|
||||
*
|
||||
* @param list List to sort.
|
||||
* @return Sorted list.
|
||||
*/
|
||||
protected sortUsers(list: AddonMessagesSearchContactsContact[]): AddonMessagesSearchContactsContact[] {
|
||||
return list.sort((a, b) => {
|
||||
const compareA = a.fullname.toLowerCase();
|
||||
const compareB = b.fullname.toLowerCase();
|
||||
|
||||
return compareA.localeCompare(compareB);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear search and show all contacts again.
|
||||
*/
|
||||
clearSearch(): void {
|
||||
this.searchString = ''; // Reset searched string.
|
||||
this.contactTypes = this.noSearchTypes;
|
||||
|
||||
this.hasContacts = false;
|
||||
for (const x in this.contacts) {
|
||||
if (this.contacts[x].length > 0) {
|
||||
this.hasContacts = true;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search users from the UI.
|
||||
*
|
||||
* @param query Text to search for.
|
||||
* @return Resolved when done.
|
||||
*/
|
||||
search(query: string): Promise<void> {
|
||||
CoreApp.instance.closeKeyboard();
|
||||
|
||||
this.loaded = false;
|
||||
this.loadingMessage = this.searchingMessages;
|
||||
|
||||
return this.performSearch(query).finally(() => {
|
||||
this.loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the search of users.
|
||||
*
|
||||
* @param query Text to search for.
|
||||
* @return Resolved when done.
|
||||
*/
|
||||
protected async performSearch(query: string): Promise<void> {
|
||||
try {
|
||||
const result = await AddonMessages.instance.searchContacts(query);
|
||||
this.hasContacts = result.length > 0;
|
||||
this.searchString = query;
|
||||
this.contactTypes = ['search'];
|
||||
|
||||
this.contacts.search = this.sortUsers(result);
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingcontacts', true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a particular discussion.
|
||||
*
|
||||
* @param discussionUserId Discussion Id to load.
|
||||
*/
|
||||
gotoDiscussion(discussionUserId: number): void {
|
||||
this.discussionUserId = discussionUserId;
|
||||
|
||||
const params: Params = {
|
||||
userId: discussionUserId,
|
||||
};
|
||||
|
||||
const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/messages/contacts-35/discussion');
|
||||
const path = (splitViewLoaded ? '../' : '') + 'discussion';
|
||||
|
||||
// @todo Check why this is failing on ngInit.
|
||||
CoreNavigator.instance.navigate(path, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Component destroyed.
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.memberInfoObserver?.off();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Contacts with some calculated data.
|
||||
*/
|
||||
export type AddonMessagesGetContactsFormatted = AddonMessagesGetContactsWSResponse & {
|
||||
search?: AddonMessagesSearchContactsContact[]; // Calculated in the app. Result of searching users.
|
||||
};
|
|
@ -0,0 +1,92 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ 'addon.messages.contacts' | translate }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="gotoSearch()" [attr.aria-label]="'addon.messages.search' | translate">
|
||||
<ion-icon name="fas-search" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
<!-- Add an empty context menu so discussion page can add items in split view, otherwise the menu
|
||||
disappears in some cases. -->
|
||||
<core-context-menu></core-context-menu>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<core-split-view>
|
||||
<ion-tab-bar class="core-tabs-bar">
|
||||
<ion-row>
|
||||
<ion-col class="tab-slide" [attr.aria-selected]="selected == 'confirmed'" (click)="selectTab('confirmed')">
|
||||
<ion-label>{{ 'addon.messages.contacts' | translate}}</ion-label>
|
||||
</ion-col>
|
||||
<ion-col class="tab-slide" [attr.aria-selected]="selected != 'confirmed'" (click)="selectTab('requests')">
|
||||
<ion-label>
|
||||
{{ 'addon.messages.requests' | translate}}
|
||||
<ion-badge *ngIf="requestsBadge">{{ requestsBadge }}</ion-badge>
|
||||
</ion-label>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-tab-bar>
|
||||
<div *ngIf="selected == 'confirmed'">
|
||||
<ion-refresher slot="fixed" [disabled]="!confirmedLoaded" (ionRefresh)="refreshData($event)">
|
||||
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
<core-loading [hideUntil]="confirmedLoaded" class="core-loading-center">
|
||||
<ion-list class="ion-no-margin">
|
||||
<ion-item class="ion-text-wrap addon-messages-conversation-item" *ngFor="let contact of confirmedContacts"
|
||||
[title]="contact.fullname" (click)="selectUser(contact.id)" detail
|
||||
[class.core-selected-item]="contact.id == selectedUserId">
|
||||
<core-user-avatar slot="start" core-user-avatar [user]="contact" [checkOnline]="contact.showonlinestatus"
|
||||
[linkProfile]="false"></core-user-avatar>
|
||||
<ion-label>
|
||||
<h2>
|
||||
<core-format-text [text]="contact.fullname" contextLevel="system" [contextInstanceId]="0">
|
||||
</core-format-text>
|
||||
<ion-icon *ngIf="contact.isblocked" name="fas-user-slash" slot="end">
|
||||
</ion-icon>
|
||||
</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
<core-empty-box *ngIf="!confirmedContacts.length" icon="far-address-book"
|
||||
[message]="'addon.messages.nocontactsgetstarted' | translate">
|
||||
</core-empty-box>
|
||||
|
||||
<core-infinite-loading [enabled]="confirmedCanLoadMore" (action)="loadMore($event)" [error]="confirmedLoadMoreError"
|
||||
position="bottom">
|
||||
</core-infinite-loading>
|
||||
</core-loading>
|
||||
</div>
|
||||
<div *ngIf="selected != 'confirmed'">
|
||||
<ion-refresher slot="fixed" [disabled]="!requestsLoaded" (ionRefresh)="refreshData($event)">
|
||||
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
<core-loading [hideUntil]="requestsLoaded" class="core-loading-center">
|
||||
<ion-list class="ion-no-margin">
|
||||
<ion-item class="ion-text-wrap addon-messages-conversation-item" *ngFor="let request of requests"
|
||||
[title]="request.fullname" (click)="selectUser(request.id)"
|
||||
[class.core-selected-item]="request.id == selectedUserId" detail>
|
||||
<core-user-avatar slot="start" [user]="request" [linkProfile]="false"></core-user-avatar>
|
||||
<ion-label>
|
||||
<core-format-text [text]="request.fullname" contextLevel="system" [contextInstanceId]="0">
|
||||
</core-format-text>
|
||||
<p *ngIf="!request.iscontact">
|
||||
{{ 'addon.messages.wouldliketocontactyou' | translate }}
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
<core-empty-box *ngIf="!requests.length" icon="far-address-book"
|
||||
[message]="'addon.messages.nocontactrequests' | translate">
|
||||
</core-empty-box>
|
||||
<core-infinite-loading [enabled]="requestsCanLoadMore" (action)="loadMore($event)" [error]="requestsLoadMoreError"
|
||||
position="bottom">
|
||||
</core-infinite-loading>
|
||||
</core-loading>
|
||||
</div>
|
||||
|
||||
</core-split-view>
|
||||
</ion-content>
|
|
@ -0,0 +1,65 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { conditionalRoutes } from '@/app/app-routing.module';
|
||||
import { AddonMessagesDiscussionRoute } from '@addons/messages/messages-lazy.module';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
|
||||
import { AddonMessagesContactsPage } from './contacts.page';
|
||||
|
||||
const mobileRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AddonMessagesContactsPage,
|
||||
},
|
||||
AddonMessagesDiscussionRoute,
|
||||
];
|
||||
|
||||
const tabletRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AddonMessagesContactsPage,
|
||||
children: [
|
||||
AddonMessagesDiscussionRoute,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const routes: Routes = [
|
||||
...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile),
|
||||
...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet),
|
||||
];
|
||||
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes),
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
TranslateModule.forChild(),
|
||||
CoreSharedModule,
|
||||
],
|
||||
declarations: [
|
||||
AddonMessagesContactsPage,
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AddonMessagesContactsPageModule {}
|
|
@ -0,0 +1,274 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import {
|
||||
AddonMessages,
|
||||
AddonMessagesContactRequestCountEventData,
|
||||
AddonMessagesConversationMember,
|
||||
AddonMessagesMemberInfoChangedEventData,
|
||||
AddonMessagesProvider,
|
||||
} from '../../services/messages';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { IonRefresher } from '@ionic/angular';
|
||||
|
||||
/**
|
||||
* Page that displays contacts and contact requests.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'page-addon-messages-contacts',
|
||||
templateUrl: 'contacts.html',
|
||||
styleUrls: [
|
||||
'tabs.scss',
|
||||
'../../messages-common.scss',
|
||||
],
|
||||
})
|
||||
export class AddonMessagesContactsPage implements OnInit, OnDestroy {
|
||||
|
||||
selected = 'confirmed';
|
||||
requestsBadge = '';
|
||||
selectedUserId?: number; // User id of the conversation opened in the split view.
|
||||
|
||||
confirmedLoaded = false;
|
||||
confirmedCanLoadMore = false;
|
||||
confirmedLoadMoreError = false;
|
||||
confirmedContacts: AddonMessagesConversationMember[] = [];
|
||||
|
||||
requestsLoaded = false;
|
||||
requestsCanLoadMore = false;
|
||||
requestsLoadMoreError = false;
|
||||
requests: AddonMessagesConversationMember[] = [];
|
||||
|
||||
protected siteId: string;
|
||||
protected contactRequestsCountObserver: CoreEventObserver;
|
||||
protected memberInfoObserver: CoreEventObserver;
|
||||
|
||||
|
||||
constructor() {
|
||||
|
||||
this.siteId = CoreSites.instance.getCurrentSiteId();
|
||||
|
||||
// Update the contact requests badge.
|
||||
this.contactRequestsCountObserver = CoreEvents.on<AddonMessagesContactRequestCountEventData>(
|
||||
AddonMessagesProvider.CONTACT_REQUESTS_COUNT_EVENT,
|
||||
(data) => {
|
||||
this.requestsBadge = data.count > 0 ? String(data.count) : '';
|
||||
},
|
||||
this.siteId,
|
||||
);
|
||||
|
||||
// Update block status of a user.
|
||||
this.memberInfoObserver = CoreEvents.on<AddonMessagesMemberInfoChangedEventData>(
|
||||
AddonMessagesProvider.MEMBER_INFO_CHANGED_EVENT,
|
||||
(data) => {
|
||||
if (data.userBlocked || data.userUnblocked) {
|
||||
const user = this.confirmedContacts.find((user) => user.id == data.userId);
|
||||
if (user) {
|
||||
user.isblocked = !!data.userBlocked;
|
||||
}
|
||||
} else if (data.contactRemoved) {
|
||||
const index = this.confirmedContacts.findIndex((contact) => contact.id == data.userId);
|
||||
if (index >= 0) {
|
||||
this.confirmedContacts.splice(index, 1);
|
||||
}
|
||||
} else if (data.contactRequestConfirmed) {
|
||||
this.refreshData();
|
||||
}
|
||||
|
||||
if (data.contactRequestConfirmed || data.contactRequestDeclined) {
|
||||
const index = this.requests.findIndex((request) => request.id == data.userId);
|
||||
if (index >= 0) {
|
||||
this.requests.splice(index, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
CoreSites.instance.getCurrentSiteId(),
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Page being initialized.
|
||||
*/
|
||||
async ngOnInit(): Promise<void> {
|
||||
AddonMessages.instance.getContactRequestsCount(this.siteId); // Badge already updated by the observer.
|
||||
|
||||
if (this.selected == 'confirmed') {
|
||||
try {
|
||||
|
||||
await this.confirmedFetchData();
|
||||
if (this.confirmedContacts.length && CoreScreen.instance.isTablet) {
|
||||
this.selectUser(this.confirmedContacts[0].id, true);
|
||||
}
|
||||
} finally {
|
||||
this.confirmedLoaded = true;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await this.requestsFetchData();
|
||||
if (this.requests.length && CoreScreen.instance.isTablet) {
|
||||
this.selectUser(this.requests[0].id, true);
|
||||
}
|
||||
} finally {
|
||||
this.requestsLoaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch contacts.
|
||||
*
|
||||
* @param refresh True if we are refreshing contacts, false if we are loading more.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async confirmedFetchData(refresh: boolean = false): Promise<void> {
|
||||
this.confirmedLoadMoreError = false;
|
||||
|
||||
const limitFrom = refresh ? 0 : this.confirmedContacts.length;
|
||||
|
||||
if (limitFrom === 0) {
|
||||
// Always try to get latest data from server.
|
||||
await AddonMessages.instance.invalidateUserContacts();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await AddonMessages.instance.getUserContacts(limitFrom);
|
||||
this.confirmedContacts = refresh ? result.contacts : this.confirmedContacts.concat(result.contacts);
|
||||
this.confirmedCanLoadMore = result.canLoadMore;
|
||||
} catch (error) {
|
||||
this.confirmedLoadMoreError = true;
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingcontacts', true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch contact requests.
|
||||
*
|
||||
* @param refresh True if we are refreshing contact requests, false if we are loading more.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async requestsFetchData(refresh: boolean = false): Promise<void> {
|
||||
this.requestsLoadMoreError = false;
|
||||
|
||||
const limitFrom = refresh ? 0 : this.requests.length;
|
||||
|
||||
if (limitFrom === 0) {
|
||||
// Always try to get latest data from server.
|
||||
await AddonMessages.instance.invalidateContactRequestsCache();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await AddonMessages.instance.getContactRequests(limitFrom);
|
||||
this.requests = refresh ? result.requests : this.requests.concat(result.requests);
|
||||
this.requestsCanLoadMore = result.canLoadMore;
|
||||
} catch (error) {
|
||||
this.requestsLoadMoreError = true;
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingcontacts', true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh contacts or requests.
|
||||
*
|
||||
* @param refresher Refresher.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async refreshData(refresher?: CustomEvent<IonRefresher>): Promise<void> {
|
||||
try {
|
||||
if (this.selected == 'confirmed') {
|
||||
// No need to invalidate contacts, we always try to get the latest.
|
||||
await this.confirmedFetchData(true);
|
||||
} else {
|
||||
// Refresh the number of contacts requests to update badges.
|
||||
AddonMessages.instance.refreshContactRequestsCount();
|
||||
|
||||
// No need to invalidate contact requests, we always try to get the latest.
|
||||
await this.requestsFetchData(true);
|
||||
}
|
||||
} finally {
|
||||
refresher?.detail.complete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more contacts or requests.
|
||||
*
|
||||
* @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading.
|
||||
* @return Resolved when done.
|
||||
*/
|
||||
async loadMore(infiniteComplete?: () => void): Promise<void> {
|
||||
try {
|
||||
if (this.selected == 'confirmed') {
|
||||
// No need to invalidate contacts, we always try to get the latest.
|
||||
await this.confirmedFetchData();
|
||||
} else {
|
||||
await this.requestsFetchData();
|
||||
}
|
||||
} finally {
|
||||
infiniteComplete && infiniteComplete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the search page.
|
||||
*/
|
||||
gotoSearch(): void {
|
||||
CoreNavigator.instance.navigateToSitePath('search');
|
||||
}
|
||||
|
||||
selectTab(selected: string): void {
|
||||
this.selected = selected;
|
||||
|
||||
if ((this.selected == 'confirmed' && !this.confirmedLoaded) || (this.selected != 'confirmed' && !this.requestsLoaded)) {
|
||||
this.ngOnInit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the selected user and open the conversation in the split view if needed.
|
||||
*
|
||||
* @param userId Id of the selected user, undefined to use the last selected user in the tab.
|
||||
* @param onInit Whether the contact was selected on initial load.
|
||||
*/
|
||||
selectUser(userId: number, onInit = false): void {
|
||||
if (userId == this.selectedUserId && CoreScreen.instance.isTablet) {
|
||||
// No user conversation to open or it is already opened.
|
||||
return;
|
||||
}
|
||||
|
||||
if (onInit && CoreScreen.instance.isMobile) {
|
||||
// Do not open a conversation by default when split view is not visible.
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedUserId = userId;
|
||||
|
||||
const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/messages/contacts/discussion');
|
||||
const path = (splitViewLoaded ? '../' : '') + 'discussion';
|
||||
|
||||
CoreNavigator.instance.navigate(path, { params : { userId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Page destroyed.
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.contactRequestsCountObserver?.off();
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
:host {
|
||||
ion-tab-bar.core-tabs-bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: var(--core-tabs-background);
|
||||
color: var(--core-tab-color);
|
||||
-webkit-filter: drop-shadow(0px 3px 3px rgba(var(--drop-shadow)));
|
||||
filter: drop-shadow(0px 3px 3px rgba(var(--drop-shadow)));
|
||||
border: 0;
|
||||
|
||||
ion-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-slide {
|
||||
border-bottom: 2px solid transparent;
|
||||
min-width: 100px;
|
||||
min-height: 56px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin-bottom: 1px;
|
||||
|
||||
ion-label {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
word-wrap: break-word;
|
||||
max-width: 100%;
|
||||
line-height: 1.2em;
|
||||
margin: 16px auto;
|
||||
}
|
||||
|
||||
&[aria-selected=true] {
|
||||
color: var(--core-tab-border-color-active);
|
||||
border-bottom-color: var(--core-tab-color-active);
|
||||
ion-tab-button {
|
||||
color: var(--core-tab-border-color-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,172 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>
|
||||
<div class="toolbar-title">
|
||||
<img *ngIf="loaded && !otherMember && conversationImage" class="core-bar-button-image" [src]="conversationImage"
|
||||
[alt]="title" onError="this.src='assets/img/group-avatar.png'" core-external-content role="presentation"
|
||||
[siteId]="siteId || null">
|
||||
<core-user-avatar *ngIf="loaded && otherMember" class="core-bar-button-image" [user]="otherMember"
|
||||
[linkProfile]="false" [checkOnline]="otherMember.showonlinestatus" (click)="showInfo && viewInfo()">
|
||||
</core-user-avatar>
|
||||
<core-format-text [text]="title" contextLevel="system" [contextInstanceId]="0"
|
||||
(click)="showInfo && !isGroup && viewInfo()"></core-format-text>
|
||||
<ion-icon *ngIf="conversation && conversation.isfavourite" name="fas-star" [title]="'core.favourites' | translate">
|
||||
</ion-icon>
|
||||
<ion-icon *ngIf="conversation && conversation.ismuted" name="fas-bell-slash"
|
||||
[title]="'addon.messages.mutedconversation' | translate">
|
||||
</ion-icon>
|
||||
</div>
|
||||
</ion-title>
|
||||
<ion-buttons slot="end"></ion-buttons>
|
||||
</ion-toolbar>
|
||||
<core-navbar-buttons slot="end">
|
||||
<core-context-menu [aria-label]="'addon.messages.conversationactions' | translate">
|
||||
<core-context-menu-item [hidden]="isSelf || !showInfo || isGroup" [priority]="1000"
|
||||
[content]="'addon.messages.info' | translate" (action)="viewInfo()"
|
||||
iconAction="fas-info-circle"></core-context-menu-item>
|
||||
<core-context-menu-item [hidden]="isSelf || !showInfo || !isGroup" [priority]="1000"
|
||||
[content]="'addon.messages.groupinfo' | translate" (action)="viewInfo()"
|
||||
iconAction="fas-info-circle"></core-context-menu-item>
|
||||
<core-context-menu-item [hidden]="!groupMessagingEnabled || !conversation" [priority]="800"
|
||||
[content]="(conversation && conversation.isfavourite ? 'addon.messages.removefromfavourites' :
|
||||
'addon.messages.addtofavourites') | translate"
|
||||
(action)="changeFavourite($event)" [closeOnClick]="false" [iconAction]="favouriteIcon"
|
||||
[iconSlash]="favouriteIconSlash"></core-context-menu-item>
|
||||
<core-context-menu-item [hidden]="isSelf || !otherMember || otherMember.isblocked" [priority]="700"
|
||||
[content]="'addon.messages.blockuser' | translate" (action)="blockUser()" [iconAction]="blockIcon">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item [hidden]="isSelf || !otherMember || !otherMember.isblocked" [priority]="700"
|
||||
[content]="'addon.messages.unblockuser' | translate" (action)="unblockUser()" [iconAction]="blockIcon">
|
||||
</core-context-menu-item>
|
||||
<core-context-menu-item [hidden]="isSelf || !muteEnabled || !conversation" [priority]="600"
|
||||
[content]="(conversation && conversation.ismuted ? 'addon.messages.unmuteconversation' :
|
||||
'addon.messages.muteconversation') | translate" (action)="changeMute($event)" [closeOnClick]="false"
|
||||
[iconAction]="muteIcon"></core-context-menu-item>
|
||||
<core-context-menu-item [hidden]="!canDelete || !messages || !messages.length" [priority]="400"
|
||||
[content]="'addon.messages.showdeletemessages' | translate" (action)="toggleDelete()"
|
||||
[iconAction]="(showDelete ? 'far-check-square' : 'far-square')"></core-context-menu-item>
|
||||
<core-context-menu-item [hidden]="!groupMessagingEnabled || !conversationId || isGroup || !messages || !messages.length"
|
||||
[priority]="200" [content]="'addon.messages.deleteconversation' | translate" (action)="deleteConversation($event)"
|
||||
[closeOnClick]="false" [iconAction]="deleteIcon"></core-context-menu-item>
|
||||
<core-context-menu-item
|
||||
[hidden]="isSelf || !otherMember || otherMember.iscontact || requestContactSent || requestContactReceived"
|
||||
[priority]="100" [content]="'addon.messages.addtoyourcontacts' | translate" (action)="createContactRequest()"
|
||||
[iconAction]="addRemoveIcon"></core-context-menu-item>
|
||||
<core-context-menu-item [hidden]="isSelf || !otherMember || !otherMember.iscontact" [priority]="100"
|
||||
[content]="'addon.messages.removefromyourcontacts' | translate" (action)="removeContact()"
|
||||
[iconAction]="addRemoveIcon" [iconSlash]="true"></core-context-menu-item>
|
||||
</core-context-menu>
|
||||
</core-navbar-buttons>
|
||||
</ion-header>
|
||||
<ion-content class="has-footer" (ionScroll)="scrollFunction()">
|
||||
<core-loading [hideUntil]="loaded" class="safe-area-page">
|
||||
<!-- Load previous messages. -->
|
||||
<core-infinite-loading [enabled]="canLoadMore" (action)="loadPrevious($event)" position="top" [error]="loadMoreError">
|
||||
</core-infinite-loading>
|
||||
|
||||
<ng-container *ngIf="isSelf && !canLoadMore">
|
||||
<p class="ion-text-center">{{ 'addon.messages.selfconversation' | translate }}</p>
|
||||
<p class="ion-text-center"><i>{{ 'addon.messages.selfconversationdefaultmessage' | translate }}</i></p>
|
||||
</ng-container>
|
||||
|
||||
<ion-list class="addon-messages-discussion-container" [class.addon-messages-discussion-group]="isGroup"
|
||||
[attr.aria-live]="'polite'">
|
||||
<ng-container *ngFor="let message of messages; index as index; last as last">
|
||||
<h6 class="ion-text-center addon-messages-date" *ngIf="message.showDate">
|
||||
{{ message.timecreated | coreFormatDate: "strftimedayshort" }}
|
||||
</h6>
|
||||
|
||||
<ion-chip class="addon-messages-unreadfrom" *ngIf="unreadMessageFrom && message.id == unreadMessageFrom"
|
||||
color="light">
|
||||
<ion-label>{{ 'addon.messages.newmessages' | translate }}</ion-label>
|
||||
<ion-icon name="arrow-round-down"></ion-icon>
|
||||
</ion-chip>
|
||||
|
||||
<ion-item class="ion-text-wrap addon-message" (longPress)="copyMessage(message)"
|
||||
[class.addon-message-mine]="message.useridfrom == currentUserId"
|
||||
[class.addon-message-not-mine]="message.useridfrom != currentUserId"
|
||||
[class.addon-message-no-user]="!message.showUserData"
|
||||
[@coreSlideInOut]="message.useridfrom == currentUserId ? '' : 'fromLeft'">
|
||||
<ion-label>
|
||||
<!-- User data. -->
|
||||
<h2 class="addon-message-user">
|
||||
<core-user-avatar slot="start" [user]="members[message.useridfrom]" [linkProfile]="false"
|
||||
*ngIf="message.showUserData"></core-user-avatar>
|
||||
|
||||
<div *ngIf="message.showUserData">{{ members[message.useridfrom].fullname }}</div>
|
||||
|
||||
<ion-note *ngIf="!message.pending">{{ message.timecreated | coreFormatDate: "strftimetime" }}</ion-note>
|
||||
<ion-note *ngIf="message.pending"><ion-icon name="far-clock"></ion-icon></ion-note>
|
||||
</h2>
|
||||
|
||||
<!-- Some messages have <p> and some others don't. Add a <p> so they all have same styles. -->
|
||||
<p class="addon-message-text">
|
||||
<core-format-text (afterRender)="last && scrollToBottom()" [text]="message.text" contextLevel="system"
|
||||
[contextInstanceId]="0"></core-format-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-button fill="clear" *ngIf="!message.sending && showDelete" (click)="deleteMessage(message, index)"
|
||||
class="addon-messages-delete-button" [@coreSlideInOut]="'fromRight'"
|
||||
[attr.aria-label]=" 'addon.messages.deletemessage' | translate" slot="end">
|
||||
<ion-icon name="fas-trash" color="danger" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<div class="tail" *ngIf="message.showTail"></div>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ion-list>
|
||||
|
||||
<core-empty-box *ngIf="!messages || messages.length <= 0" icon="far-comments"
|
||||
[message]="'addon.messages.nomessagesfound' | translate"></core-empty-box>
|
||||
</core-loading>
|
||||
<!-- Scroll bottom. -->
|
||||
<ion-fab slot="fixed" core-fab vertical="bottom" horizontal="end" *ngIf="loaded && newMessages > 0">
|
||||
<ion-fab-button size="small" (click)="scrollToFirstUnreadMessage()" color="light"
|
||||
[attr.aria-label]="'addon.messages.newmessages' | translate">
|
||||
<ion-icon name="fas-arrow-down"></ion-icon>
|
||||
<span class="core-discussion-messages-badge">{{ newMessages }}</span>
|
||||
</ion-fab-button>
|
||||
</ion-fab>
|
||||
</ion-content>
|
||||
<ion-footer color="light" class="footer-adjustable" *ngIf="loaded && (!conversationId || conversation)">
|
||||
<ion-toolbar color="light">
|
||||
<p *ngIf="footerType == 'unable'" class="ion-text-center ion-margin-horizontal">
|
||||
{{ 'addon.messages.unabletomessage' | translate }}
|
||||
</p>
|
||||
<div *ngIf="footerType == 'blocked'" class="ion-padding-horizontal">
|
||||
<p class="ion-text-center">{{ 'addon.messages.youhaveblockeduser' | translate }}</p>
|
||||
<ion-button expand="block" class="ion-text-wrap ion-margin-bottom" (click)="unblockUser()">
|
||||
{{ 'addon.messages.unblockuser' | translate }}
|
||||
</ion-button>
|
||||
</div>
|
||||
<div *ngIf="footerType == 'requiresContact' && otherMember" class="ion-padding-horizontal">
|
||||
<p class="ion-text-center">
|
||||
<strong>{{ 'addon.messages.isnotinyourcontacts' | translate: {$a: otherMember.fullname} }}</strong>
|
||||
</p>
|
||||
<p class="ion-text-center">{{ 'addon.messages.requirecontacttomessage' | translate: {$a: otherMember.fullname} }}</p>
|
||||
<ion-button expand="block" class="ion-text-wrap ion-margin-bottom" (click)="createContactRequest()">
|
||||
{{ 'addon.messages.sendcontactrequest' | translate }}
|
||||
</ion-button>
|
||||
</div>
|
||||
<div *ngIf="footerType == 'requestReceived' && otherMember" class="ion-padding-horizontal">
|
||||
<p class="ion-text-center">{{ 'addon.messages.userwouldliketocontactyou' | translate: {$a: otherMember.fullname} }}</p>
|
||||
<ion-button expand="block" class="ion-text-wrap ion-margin-bottom" (click)="confirmContactRequest()">
|
||||
{{ 'addon.messages.acceptandaddcontact' | translate }}
|
||||
</ion-button>
|
||||
<ion-button expand="block" class="ion-text-wrap ion-margin-bottom" color="light" (click)="declineContactRequest()">
|
||||
{{ 'addon.messages.decline' | translate }}
|
||||
</ion-button>
|
||||
</div>
|
||||
<div *ngIf="footerType == 'requestSent' || (footerType == 'message' && requestContactSent)" class="ion-padding-horizontal">
|
||||
<p class="ion-text-center"><strong>{{ 'addon.messages.contactrequestsent' | translate }}</strong></p>
|
||||
<p class="ion-text-center" *ngIf="otherMember">
|
||||
{{ 'addon.messages.yourcontactrequestpending' | translate: {$a: otherMember.fullname} }}
|
||||
</p>
|
||||
</div>
|
||||
<core-send-message-form *ngIf="footerType == 'message'" (onSubmit)="sendMessage($event)" [showKeyboard]="showKeyboard"
|
||||
[placeholder]="'addon.messages.newmessage' | translate" (onResize)="resizeContent()"></core-send-message-form>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
|
@ -0,0 +1,48 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
|
||||
import { AddonMessagesDiscussionPage } from './discussion.page';
|
||||
import { AddonMessagesComponentsModule } from '@addons/messages/components/components.module';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AddonMessagesDiscussionPage,
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes),
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
TranslateModule.forChild(),
|
||||
CoreSharedModule,
|
||||
AddonMessagesComponentsModule,
|
||||
],
|
||||
declarations: [
|
||||
AddonMessagesDiscussionPage,
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AddonMessagesDiscussionPageModule {}
|
||||
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,300 @@
|
|||
:host {
|
||||
ion-content {
|
||||
background-color: var(--background-lighter);
|
||||
|
||||
&::part(scroll) {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.addon-messages-discussion-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 15px;
|
||||
background: var(--background-lighter);
|
||||
}
|
||||
|
||||
.addon-messages-date {
|
||||
font-weight: normal;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.addon-messages-unreadfrom {
|
||||
color: var(--core-color);
|
||||
background-color: transparent;
|
||||
margin-top: 6px;
|
||||
ion-icon {
|
||||
color: var(--core-color);
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// Message item.
|
||||
ion-item.addon-message {
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
margin: 8px 8px 0 8px;
|
||||
--background: var(--addon-messages-message-bg);
|
||||
background: var(--background);
|
||||
align-self: flex-start;
|
||||
width: 90%;
|
||||
max-width: 90%;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
-webkit-transition: width 500ms ease-in-out;
|
||||
transition: width 500ms ease-in-out;
|
||||
// This is needed to display bubble tails.
|
||||
overflow: visible;
|
||||
|
||||
&::part(native) {
|
||||
--inner-border-width: 0;
|
||||
--inner-padding-end: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
core-format-text > p:only-child {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.addon-message-user {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: .5rem;
|
||||
margin-top: 0;
|
||||
color: var(--ion-text-color);
|
||||
|
||||
core-user-avatar {
|
||||
display: block;
|
||||
--core-avatar-size: var(--addon-messages-avatar-size);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
div {
|
||||
font-weight: 500;
|
||||
flex-grow: 1;
|
||||
padding-right: .5rem;
|
||||
padding-left: .5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
ion-note {
|
||||
text-align: end;;
|
||||
color: var(--addon-messages-message-note-text);
|
||||
font-size: var(--addon-messages-message-note-font-size);
|
||||
}
|
||||
}
|
||||
|
||||
&.addon-message-no-user .addon-message-user ion-note {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:active {
|
||||
--background: var(--addon-messages-message-activated-bg);
|
||||
}
|
||||
|
||||
ion-label {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.addon-message-text {
|
||||
display: inline-flex;
|
||||
* {
|
||||
color: var(--ion-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.addon-messages-delete-button {
|
||||
min-height: initial;
|
||||
line-height: initial;
|
||||
margin: 0 0 0 10px;
|
||||
height: 1.6em !important;
|
||||
-webkit-align-self: flex-end;
|
||||
align-self: flex-end;
|
||||
vertical-align: middle;
|
||||
float: inline-end;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.4em;
|
||||
line-height: initial;
|
||||
color: var(--ion-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.tail {
|
||||
content: '';
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 0.5rem solid transparent;
|
||||
position: absolute;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
// Defines when an item-message is the user's.
|
||||
&.addon-message-mine {
|
||||
--background: var(--addon-messages-message-mine-bg);
|
||||
align-self: flex-end;
|
||||
|
||||
&:active {
|
||||
--background: var(--addon-messages-message-mine-activated-bg);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
float: inline-end;
|
||||
margin: 2px, -3px, -2px, 5px;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.tail {
|
||||
right: -8px;
|
||||
bottom: -8px;
|
||||
margin-right: -0.5rem;
|
||||
border-bottom-color: var(--addon-messages-message-mine-bg);
|
||||
}
|
||||
|
||||
&:active .tail {
|
||||
border-bottom-color: var(--addon-messages-message-mine-activated-bg);
|
||||
}
|
||||
}
|
||||
|
||||
&.addon-message-not-mine .tail {
|
||||
border-bottom-color: var(--addon-messages-message-bg);
|
||||
bottom: -8px;
|
||||
left: -8px;
|
||||
margin-left: -0.5rem;
|
||||
}
|
||||
|
||||
&.addon-message-not-mine.activated .tail {
|
||||
border-bottom-color: var(--addon-messages-message-activated-bg);
|
||||
}
|
||||
}
|
||||
|
||||
ion-item.addon-message.addon-message-mine + ion-item.addon-message.addon-message-no-user.addon-message-mine,
|
||||
ion-item.addon-message.addon-message-not-mine + ion-item.addon-message.addon-message-no-user.addon-message-not-mine {
|
||||
h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
margin-top: -8px;
|
||||
padding-top: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
|
||||
.has-fab .scroll-content {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
ion-fab ion-fab-button {
|
||||
&::part(native) {
|
||||
contain: unset;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.core-discussion-messages-badge {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
color: var(--addon-messages-discussion-badge-text);
|
||||
background-color: var(--addon-messages-discussion-badge);
|
||||
display: block;
|
||||
line-height: 20px;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
right: -6px;
|
||||
top: -6px;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
ion-header ion-toolbar .toolbar-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
|
||||
.core-bar-button-image {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
core-format-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context([dir=rtl]) {
|
||||
ion-header ion-toolbar .toolbar-title {
|
||||
.core-bar-button-image {
|
||||
margin-left: 6px;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
margin-right: 6px;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Message item.
|
||||
ion-item.addon-message {
|
||||
|
||||
.addon-messages-delete-button {
|
||||
margin-right: 10px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&.addon-message-mine {
|
||||
.spinner {
|
||||
margin-right: 5px;
|
||||
margin-left: -3px;
|
||||
}
|
||||
|
||||
.tail {
|
||||
right: unset;
|
||||
left: -8px;
|
||||
margin-right: unset;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.addon-message-not-mine .tail {
|
||||
right: -8px;
|
||||
margin-right: -0.5rem;
|
||||
left: unset;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ion-fab button {
|
||||
.core-discussion-messages-badge {
|
||||
left: -6px;
|
||||
right: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.ios) {
|
||||
ion-header ion-toolbar .toolbar-title {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
ion-footer .toolbar:last-child {
|
||||
padding-bottom: 4px;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ 'addon.messages.messages' | translate }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<!-- Add an empty context menu so discussion page can add items in split view,
|
||||
otherwise the menu disappears in some cases. -->
|
||||
<core-context-menu></core-context-menu>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<core-split-view>
|
||||
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshData($event)">
|
||||
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
|
||||
<core-search-box *ngIf="search.enabled" (onSubmit)="searchMessage($event)" (onClear)="clearSearch()"
|
||||
[placeholder]=" 'addon.messages.message' | translate" autocorrect="off" spellcheck="false" lengthCheck="2"
|
||||
[disabled]="!loaded" searchArea="AddonMessagesDiscussions"></core-search-box>
|
||||
|
||||
<core-loading [hideUntil]="loaded" [message]="loadingMessage">
|
||||
|
||||
<ion-list class="ion-no-margin">
|
||||
|
||||
<ion-item class="ion-text-wrap addon-message-discussion" (click)="gotoContacts()"
|
||||
[attr.aria-label]="'addon.messages.contacts' | translate" detail>
|
||||
<ion-icon name="fas-address-book" slot="start"></ion-icon>
|
||||
<ion-label><h2>{{ 'addon.messages.contacts' | translate }}</h2></ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ng-container *ngIf="search.showResults">
|
||||
<ion-item-divider>
|
||||
<ion-label>
|
||||
<h2>{{ 'core.searchresults' | translate }}</h2>
|
||||
</ion-label>
|
||||
<ion-note slot="end" class="ion-padding-end"><ion-badge>{{ search.results.length }}</ion-badge></ion-note>
|
||||
</ion-item-divider>
|
||||
<ion-item class="ion-text-wrap addon-message-discussion" *ngFor="let result of search.results" [title]="result.fullname"
|
||||
(click)="gotoDiscussion(result.userid, result.messageid)"
|
||||
[class.core-selected-item]="result.userid == discussionUserId">
|
||||
<core-user-avatar [user]="result" slot="start" [checkOnline]="result.showonlinestatus"></core-user-avatar>
|
||||
<ion-label>
|
||||
<h2>{{ result.fullname }}</h2>
|
||||
<p><core-format-text clean="true" singleLine="true" [text]="result.lastmessage" contextLevel="system"
|
||||
[contextInstanceId]="0"></core-format-text></p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!search.showResults">
|
||||
<ion-item class="ion-text-wrap addon-message-discussion" *ngFor="let discussion of discussions"
|
||||
[title]="discussion.fullname" (click)="gotoDiscussion(discussion.message!.user)"
|
||||
[class.core-selected-item]="discussion.message!.user == discussionUserId">
|
||||
<core-user-avatar [user]="discussion" slot="start" checkOnline="false"></core-user-avatar>
|
||||
<ion-label>
|
||||
<h2>{{ discussion.fullname }}</h2>
|
||||
<ion-note *ngIf="discussion.message!.timecreated > 0 || discussion.unread">
|
||||
<span *ngIf="discussion.unread" class="core-primary-circle"></span>
|
||||
<span *ngIf="discussion.message!.timecreated > 0">
|
||||
{{discussion.message!.timecreated / 1000 | coreDateDayOrTime}}
|
||||
</span>
|
||||
</ion-note>
|
||||
<p>
|
||||
<core-format-text clean="true" singleLine="true" [text]="discussion.message!.message"
|
||||
contextLevel="system" [contextInstanceId]="0">
|
||||
</core-format-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ion-list>
|
||||
|
||||
<core-empty-box *ngIf="(!discussions || discussions.length <= 0) && !search.showResults" icon="far-comments"
|
||||
[message]="'addon.messages.nomessagesfound' | translate"></core-empty-box>
|
||||
|
||||
<core-empty-box *ngIf="(!search.results || search.results.length <= 0) && search.showResults" icon="search"
|
||||
[message]="'core.noresults' | translate"></core-empty-box>
|
||||
</core-loading>
|
||||
</core-split-view>
|
||||
</ion-content>
|
|
@ -0,0 +1,66 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
import { conditionalRoutes } from '@/app/app-routing.module';
|
||||
import { AddonMessagesDiscussionRoute } from '@addons/messages/messages-lazy.module';
|
||||
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
import { CoreSearchComponentsModule } from '@features/search/components/components.module';
|
||||
|
||||
import { AddonMessagesDiscussions35Page } from './discussions.page';
|
||||
|
||||
const mobileRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AddonMessagesDiscussions35Page,
|
||||
},
|
||||
AddonMessagesDiscussionRoute,
|
||||
];
|
||||
|
||||
const tabletRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AddonMessagesDiscussions35Page,
|
||||
children: [
|
||||
AddonMessagesDiscussionRoute,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const routes: Routes = [
|
||||
...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile),
|
||||
...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes),
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
TranslateModule.forChild(),
|
||||
CoreSharedModule,
|
||||
CoreSearchComponentsModule,
|
||||
],
|
||||
declarations: [
|
||||
AddonMessagesDiscussions35Page,
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AddonMessagesDiscussions35PageModule {}
|
|
@ -0,0 +1,306 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import {
|
||||
AddonMessages,
|
||||
AddonMessagesDiscussion,
|
||||
AddonMessagesMessageAreaContact,
|
||||
AddonMessagesNewMessagedEventData,
|
||||
AddonMessagesProvider,
|
||||
AddonMessagesReadChangedEventData,
|
||||
} from '../../services/messages';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { CoreApp } from '@services/app';
|
||||
import { ActivatedRoute, Params } from '@angular/router';
|
||||
import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications';
|
||||
import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { Translate, Platform } from '@singletons';
|
||||
import { IonRefresher } from '@ionic/angular';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
|
||||
/**
|
||||
* Page that displays the list of discussions.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'addon-messages-discussions',
|
||||
templateUrl: 'discussions.html',
|
||||
styleUrls: ['../../messages-common.scss'],
|
||||
})
|
||||
export class AddonMessagesDiscussions35Page implements OnInit, OnDestroy {
|
||||
|
||||
protected newMessagesObserver: CoreEventObserver;
|
||||
protected readChangedObserver: CoreEventObserver;
|
||||
protected appResumeSubscription: Subscription;
|
||||
protected pushObserver: Subscription;
|
||||
protected loadingMessages: string;
|
||||
protected siteId: string;
|
||||
|
||||
loaded = false;
|
||||
loadingMessage = '';
|
||||
discussions: AddonMessagesDiscussion[] = [];
|
||||
discussionUserId?: number;
|
||||
|
||||
search = {
|
||||
enabled: false,
|
||||
showResults: false,
|
||||
results: <AddonMessagesMessageAreaContact[]> [],
|
||||
loading: '',
|
||||
text: '',
|
||||
};
|
||||
|
||||
constructor(
|
||||
protected route: ActivatedRoute,
|
||||
) {
|
||||
|
||||
this.search.loading = Translate.instance.instant('core.searching');
|
||||
this.loadingMessages = Translate.instance.instant('core.loading');
|
||||
this.siteId = CoreSites.instance.getCurrentSiteId();
|
||||
|
||||
// Update discussions when new message is received.
|
||||
this.newMessagesObserver = CoreEvents.on<AddonMessagesNewMessagedEventData>(
|
||||
AddonMessagesProvider.NEW_MESSAGE_EVENT,
|
||||
(data) => {
|
||||
if (data.userId && this.discussions) {
|
||||
const discussion = this.discussions.find((disc) => disc.message!.user == data.userId);
|
||||
|
||||
if (typeof discussion == 'undefined') {
|
||||
this.loaded = false;
|
||||
this.refreshData().finally(() => {
|
||||
this.loaded = true;
|
||||
});
|
||||
} else {
|
||||
// An existing discussion has a new message, update the last message.
|
||||
discussion.message!.message = data.message;
|
||||
discussion.message!.timecreated = data.timecreated;
|
||||
}
|
||||
}
|
||||
},
|
||||
this.siteId,
|
||||
);
|
||||
|
||||
// Update discussions when a message is read.
|
||||
this.readChangedObserver = CoreEvents.on<AddonMessagesReadChangedEventData>(
|
||||
AddonMessagesProvider.READ_CHANGED_EVENT,
|
||||
(data) => {
|
||||
if (data.userId && this.discussions) {
|
||||
const discussion = this.discussions.find((disc) => disc.message!.user == data.userId);
|
||||
|
||||
if (typeof discussion != 'undefined') {
|
||||
// A discussion has been read reset counter.
|
||||
discussion.unread = false;
|
||||
|
||||
// Conversations changed, invalidate them and refresh unread counts.
|
||||
AddonMessages.instance.invalidateConversations(this.siteId);
|
||||
AddonMessages.instance.refreshUnreadConversationCounts(this.siteId);
|
||||
}
|
||||
}
|
||||
},
|
||||
this.siteId,
|
||||
);
|
||||
|
||||
// Refresh the view when the app is resumed.
|
||||
this.appResumeSubscription = Platform.instance.resume.subscribe(() => {
|
||||
if (!this.loaded) {
|
||||
return;
|
||||
}
|
||||
this.loaded = false;
|
||||
this.refreshData();
|
||||
});
|
||||
|
||||
|
||||
// If a message push notification is received, refresh the view.
|
||||
this.pushObserver = CorePushNotificationsDelegate.instance.on<CorePushNotificationsNotificationBasicData>('receive')
|
||||
.subscribe((notification) => {
|
||||
// New message received. If it's from current site, refresh the data.
|
||||
if (CoreUtils.instance.isFalseOrZero(notification.notif) && notification.site == this.siteId) {
|
||||
// Don't refresh unread counts, it's refreshed from the main menu handler in this case.
|
||||
this.refreshData(undefined, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Component loaded.
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.route.queryParams.subscribe(async (params) => {
|
||||
const discussionUserId = CoreNavigator.instance.getRouteNumberParam('discussionUserId', params) ||
|
||||
CoreNavigator.instance.getRouteNumberParam('userId', params) || undefined;
|
||||
|
||||
if (this.loaded && this.discussionUserId == discussionUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.discussionUserId = discussionUserId;
|
||||
|
||||
if (this.discussionUserId) {
|
||||
// There is a discussion to load, open the discussion in a new state.
|
||||
this.gotoDiscussion(this.discussionUserId);
|
||||
}
|
||||
|
||||
await this.fetchData();
|
||||
|
||||
if (!this.discussionUserId && this.discussions.length > 0 && CoreScreen.instance.isTablet) {
|
||||
// Take first and load it.
|
||||
this.gotoDiscussion(this.discussions[0].message!.user);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the data.
|
||||
*
|
||||
* @param refresher Refresher.
|
||||
* @param refreshUnreadCounts Whteher to refresh unread counts.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async refreshData(refresher?: CustomEvent<IonRefresher>, refreshUnreadCounts: boolean = true): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
promises.push(AddonMessages.instance.invalidateDiscussionsCache(this.siteId));
|
||||
|
||||
if (refreshUnreadCounts) {
|
||||
promises.push(AddonMessages.instance.invalidateUnreadConversationCounts(this.siteId));
|
||||
}
|
||||
|
||||
await CoreUtils.instance.allPromises(promises).finally(() => this.fetchData().finally(() => {
|
||||
if (refresher) {
|
||||
refresher?.detail.complete();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch discussions.
|
||||
*
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async fetchData(): Promise<void> {
|
||||
this.loadingMessage = this.loadingMessages;
|
||||
this.search.enabled = AddonMessages.instance.isSearchMessagesEnabled();
|
||||
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
promises.push(AddonMessages.instance.getDiscussions(this.siteId).then((discussions) => {
|
||||
// Convert to an array for sorting.
|
||||
const discussionsSorted: AddonMessagesDiscussion[] = [];
|
||||
for (const userId in discussions) {
|
||||
discussions[userId].unread = !!discussions[userId].unread;
|
||||
|
||||
discussionsSorted.push(discussions[userId]);
|
||||
}
|
||||
|
||||
this.discussions = discussionsSorted.sort((a, b) => (b.message?.timecreated || 0) - (a.message?.timecreated || 0));
|
||||
|
||||
return;
|
||||
}));
|
||||
|
||||
promises.push(AddonMessages.instance.getUnreadConversationCounts(this.siteId));
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingdiscussions', true);
|
||||
}
|
||||
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear search and show discussions again.
|
||||
*/
|
||||
clearSearch(): void {
|
||||
this.loaded = false;
|
||||
this.search.showResults = false;
|
||||
this.search.text = ''; // Reset searched string.
|
||||
this.fetchData().finally(() => {
|
||||
this.loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search messages cotaining text.
|
||||
*
|
||||
* @param query Text to search for.
|
||||
* @return Resolved when done.
|
||||
*/
|
||||
async searchMessage(query: string): Promise<void> {
|
||||
CoreApp.instance.closeKeyboard();
|
||||
this.loaded = false;
|
||||
this.loadingMessage = this.search.loading;
|
||||
|
||||
try {
|
||||
const searchResults = await AddonMessages.instance.searchMessages(query, undefined, undefined, undefined, this.siteId);
|
||||
this.search.showResults = true;
|
||||
this.search.results = searchResults.messages;
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingmessages', true);
|
||||
}
|
||||
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a particular discussion.
|
||||
*
|
||||
* @param discussionUserId Discussion Id to load.
|
||||
* @param messageId Message to scroll after loading the discussion. Used when searching.
|
||||
* @param onlyWithSplitView Only go to Discussion if split view is on.
|
||||
*/
|
||||
gotoDiscussion(discussionUserId: number, messageId?: number): void {
|
||||
this.discussionUserId = discussionUserId;
|
||||
|
||||
const params: Params = {
|
||||
userId: discussionUserId,
|
||||
};
|
||||
|
||||
if (messageId) {
|
||||
params.message = messageId;
|
||||
}
|
||||
|
||||
const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/messages/index/discussion');
|
||||
const path = (splitViewLoaded ? '../' : '') + 'discussion';
|
||||
|
||||
CoreNavigator.instance.navigate(path, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to contacts view.
|
||||
*/
|
||||
gotoContacts(): void {
|
||||
const params: Params = {};
|
||||
|
||||
if (CoreScreen.instance.isTablet && this.discussionUserId) {
|
||||
params.discussionUserId = this.discussionUserId;
|
||||
}
|
||||
|
||||
CoreNavigator.instance.navigateToSitePath('contacts-35', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Component destroyed.
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.newMessagesObserver?.off();
|
||||
this.readChangedObserver?.off();
|
||||
this.appResumeSubscription?.unsubscribe();
|
||||
this.pushObserver?.unsubscribe();
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,143 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ 'addon.messages.messages' | translate }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="gotoSearch()" [attr.aria-label]="'addon.messages.search' | translate">
|
||||
<ion-icon name="fas-search" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button (click)="gotoSettings()" [attr.aria-label]="'addon.messages.messagepreferences' | translate">
|
||||
<ion-icon name="fas-cog" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
<!-- Add an empty context menu so discussion page can add items in split view,
|
||||
otherwise the menu disappears in some cases. -->
|
||||
<core-context-menu></core-context-menu>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="core-expand-max">
|
||||
<core-split-view>
|
||||
<ion-refresher slot="fixed" [disabled]="!loaded || !currentListEl" (ionRefresh)="refreshData($event)">
|
||||
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
|
||||
<core-loading [hideUntil]="loaded" [message]="loadingMessage">
|
||||
<ion-list>
|
||||
<ion-item class="ion-text-wrap addon-message-discussion" (click)="gotoContacts()"
|
||||
[attr.aria-label]="'addon.messages.contacts' | translate" detail>
|
||||
<ion-icon name="fas-address-book" slot="start"></ion-icon>
|
||||
<ion-label><h2>{{ 'addon.messages.contacts' | translate }}</h2></ion-label>
|
||||
<ion-badge *ngIf="contactRequestsCount > 0" slot="end">{{contactRequestsCount}}</ion-badge>
|
||||
</ion-item>
|
||||
<!-- Favourite conversations. -->
|
||||
<ion-item-divider class="ion-text-wrap core-expandable" (click)="toggle(favourites)" sticky="true">
|
||||
<ion-icon *ngIf="!favourites.expanded" name="fas-caret-right" slot="start"></ion-icon>
|
||||
<ion-icon *ngIf="favourites.expanded" name="fas-caret-down" slot="start"></ion-icon>
|
||||
<ion-label>{{ 'core.favourites' | translate }} ({{ favourites.count }})</ion-label>
|
||||
<ion-badge slot="end" *ngIf="favourites.unread">{{ favourites.unread }}</ion-badge>
|
||||
</ion-item-divider>
|
||||
<div [hidden]="!favourites.conversations || !favourites.expanded || favourites.loading" #favlist>
|
||||
<ng-container *ngTemplateOutlet="conversationsTemplate; context: {conversations: favourites.conversations}">
|
||||
</ng-container>
|
||||
<!-- The infinite loading cannot be inside the ng-template, it fails because it doesn't find ion-content. -->
|
||||
<core-infinite-loading [enabled]="favourites.canLoadMore" (action)="loadMoreConversations(favourites, $event)"
|
||||
[error]="favourites.loadMoreError"></core-infinite-loading>
|
||||
<ion-item class="ion-text-wrap" *ngIf="favourites.conversations && favourites.conversations.length == 0">
|
||||
<ion-label><p>{{ 'addon.messages.nofavourites' | translate }}</p></ion-label>
|
||||
</ion-item>
|
||||
</div>
|
||||
<ion-item class="ion-text-center" *ngIf="favourites.loading">
|
||||
<ion-label><ion-spinner></ion-spinner></ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- Group conversations. -->
|
||||
<ion-item-divider class="ion-text-wrap core-expandable" (click)="toggle(group)" sticky="true">
|
||||
<ion-icon *ngIf="!group.expanded" name="fas-caret-right" slot="start"></ion-icon>
|
||||
<ion-icon *ngIf="group.expanded" name="fas-caret-down" slot="start"></ion-icon>
|
||||
<ion-label>{{ 'addon.messages.groupconversations' | translate }} ({{ group.count }})</ion-label>
|
||||
<ion-badge slot="end" *ngIf="group.unread">{{ group.unread }}</ion-badge>
|
||||
</ion-item-divider>
|
||||
<div [hidden]="!group.conversations || !group.expanded || group.loading" #grouplist>
|
||||
<ng-container *ngTemplateOutlet="conversationsTemplate; context: {conversations: group.conversations}">
|
||||
</ng-container>
|
||||
<!-- The infinite loading cannot be inside the ng-template, it fails because it doesn't find ion-content. -->
|
||||
<core-infinite-loading [enabled]="group.canLoadMore" (action)="loadMoreConversations(group, $event)"
|
||||
[error]="group.loadMoreError"></core-infinite-loading>
|
||||
<ion-item class="ion-text-wrap" *ngIf="group.conversations && group.conversations.length == 0">
|
||||
<ion-label><p>{{ 'addon.messages.nogroupconversations' | translate }}</p></ion-label>
|
||||
</ion-item>
|
||||
</div>
|
||||
<ion-item class="ion-text-center" *ngIf="group.loading">
|
||||
<ion-label><ion-spinner></ion-spinner></ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-divider class="ion-text-wrap core-expandable" (click)="toggle(individual)" sticky="true">
|
||||
<ion-icon *ngIf="!individual.expanded" name="fas-caret-right" slot="start"></ion-icon>
|
||||
<ion-icon *ngIf="individual.expanded" name="fas-caret-down" slot="start"></ion-icon>
|
||||
<ion-label>{{ 'addon.messages.individualconversations' | translate }} ({{ individual.count }})</ion-label>
|
||||
<ion-badge slot="end" *ngIf="individual.unread">{{ individual.unread }}</ion-badge>
|
||||
</ion-item-divider>
|
||||
<div [hidden]="!individual.conversations || !individual.expanded || individual.loading" #indlist>
|
||||
<ng-container *ngTemplateOutlet="conversationsTemplate; context: {conversations: individual.conversations}">
|
||||
</ng-container>
|
||||
<!-- The infinite loading cannot be inside the ng-template, it fails because it doesn't find ion-content. -->
|
||||
<core-infinite-loading [enabled]="individual.canLoadMore" (action)="loadMoreConversations(individual, $event)"
|
||||
[error]="individual.loadMoreError"></core-infinite-loading>
|
||||
<ion-item class="ion-text-wrap" *ngIf="individual.conversations && individual.conversations.length == 0">
|
||||
<ion-label><p>{{ 'addon.messages.noindividualconversations' | translate }}</p></ion-label>
|
||||
</ion-item>
|
||||
</div>
|
||||
<ion-item class="ion-text-center" *ngIf="individual.loading">
|
||||
<ion-label><ion-spinner></ion-spinner></ion-label>
|
||||
</ion-item>
|
||||
|
||||
</ion-list>
|
||||
</core-loading>
|
||||
</core-split-view>
|
||||
</ion-content>
|
||||
|
||||
<!-- Template to render a list of conversations. -->
|
||||
<ng-template #conversationsTemplate let-conversations="conversations">
|
||||
<ion-item class="ion-text-wrap addon-message-discussion" *ngFor="let conversation of conversations" [title]="conversation.name"
|
||||
(click)="gotoConversation(conversation.id, conversation.userid)"
|
||||
[class.core-selected-item]="(conversation.id && conversation.id == selectedConversationId) ||
|
||||
(conversation.userid && conversation.userid == selectedUserId)"
|
||||
id="addon-message-conversation-{{ conversation.id ? conversation.id : 'user-' + conversation.userid }}">
|
||||
<!-- Group conversation image. -->
|
||||
<ion-avatar slot="start" *ngIf="conversation.type == typeGroup">
|
||||
<img [src]="conversation.imageurl" [alt]="conversation.name" core-external-content
|
||||
onError="this.src='assets/img/group-avatar.png'">
|
||||
</ion-avatar>
|
||||
|
||||
<!-- Avatar for individual conversations. -->
|
||||
<core-user-avatar *ngIf="conversation.type != typeGroup" core-user-avatar [user]="conversation.otherUser"
|
||||
[linkProfile]="false" [checkOnline]="conversation.showonlinestatus" slot="start"></core-user-avatar>
|
||||
|
||||
<ion-label>
|
||||
<h2>
|
||||
<core-format-text [text]="conversation.name" contextLevel="system" [contextInstanceId]="0"></core-format-text>
|
||||
<ion-icon name="fas-user-slash" *ngIf="conversation.isblocked"
|
||||
[title]="'addon.messages.contactblocked' | translate"></ion-icon>
|
||||
<ion-icon *ngIf="conversation.ismuted" name="fas-volume-mute"
|
||||
[title]="'addon.messages.mutedconversation' | translate"></ion-icon>
|
||||
</h2>
|
||||
<ion-note *ngIf="conversation.lastmessagedate > 0 || conversation.unreadcount">
|
||||
<ion-badge *ngIf="conversation.unreadcount > 0">{{ conversation.unreadcount }}</ion-badge>
|
||||
<span *ngIf="conversation.lastmessagedate > 0">{{conversation.lastmessagedate | coreDateDayOrTime}}</span>
|
||||
</ion-note>
|
||||
<p *ngIf="conversation.subname"><core-format-text [text]="conversation.subname" contextLevel="system"
|
||||
[contextInstanceId]="0"></core-format-text></p>
|
||||
<p class="addon-message-last-message">
|
||||
<span *ngIf="conversation.sentfromcurrentuser" class="addon-message-last-message-user">
|
||||
{{ 'addon.messages.you' | translate }}
|
||||
</span>
|
||||
<span *ngIf="!conversation.sentfromcurrentuser && conversation.type == typeGroup && conversation.members[0]"
|
||||
class="addon-message-last-message-user">{{ conversation.members[0].fullname + ':' }}</span>
|
||||
<core-format-text clean="true" singleLine="true" [text]="conversation.lastmessage"
|
||||
class="addon-message-last-message-text" contextLevel="system" [contextInstanceId]="0"></core-format-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-template>
|
|
@ -0,0 +1,64 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { conditionalRoutes } from '@/app/app-routing.module';
|
||||
import { AddonMessagesDiscussionRoute } from '@addons/messages/messages-lazy.module';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
|
||||
import { AddonMessagesGroupConversationsPage } from './group-conversations.page';
|
||||
|
||||
const mobileRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AddonMessagesGroupConversationsPage,
|
||||
},
|
||||
AddonMessagesDiscussionRoute,
|
||||
];
|
||||
|
||||
const tabletRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AddonMessagesGroupConversationsPage,
|
||||
children: [
|
||||
AddonMessagesDiscussionRoute,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const routes: Routes = [
|
||||
...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile),
|
||||
...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes),
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
TranslateModule.forChild(),
|
||||
CoreSharedModule,
|
||||
],
|
||||
declarations: [
|
||||
AddonMessagesGroupConversationsPage,
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AddonMessagesGroupConversationsPageModule {}
|
|
@ -0,0 +1,833 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core';
|
||||
import { IonContent, IonRefresher } from '@ionic/angular';
|
||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import {
|
||||
AddonMessagesProvider,
|
||||
AddonMessagesConversationFormatted,
|
||||
AddonMessages,
|
||||
AddonMessagesMemberInfoChangedEventData,
|
||||
AddonMessagesContactRequestCountEventData,
|
||||
AddonMessagesUnreadConversationCountsEventData,
|
||||
AddonMessagesReadChangedEventData,
|
||||
AddonMessagesUpdateConversationListEventData,
|
||||
AddonMessagesNewMessagedEventData,
|
||||
AddonMessagesOpenConversationEventData,
|
||||
} from '../../services/messages';
|
||||
import {
|
||||
AddonMessagesOffline,
|
||||
AddonMessagesOfflineAnyMessagesFormatted,
|
||||
} from '../../services/messages-offline';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreUser } from '@features/user/services/user';
|
||||
import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate';
|
||||
import { Platform, Translate } from '@singletons';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications';
|
||||
import { ActivatedRoute, Params } from '@angular/router';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { AddonMessagesSettingsHandlerService } from '@addons/messages/services/handlers/settings';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
|
||||
/**
|
||||
* Page that displays the list of conversations, including group conversations.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'page-addon-messages-group-conversations',
|
||||
templateUrl: 'group-conversations.html',
|
||||
styleUrls: ['../../messages-common.scss'],
|
||||
})
|
||||
export class AddonMessagesGroupConversationsPage implements OnInit, OnDestroy {
|
||||
|
||||
@ViewChild(IonContent) content?: IonContent;
|
||||
@ViewChild('favlist') favListEl?: ElementRef;
|
||||
@ViewChild('grouplist') groupListEl?: ElementRef;
|
||||
@ViewChild('indlist') indListEl?: ElementRef;
|
||||
|
||||
loaded = false;
|
||||
loadingMessage: string;
|
||||
selectedConversationId?: number;
|
||||
selectedUserId?: number;
|
||||
contactRequestsCount = 0;
|
||||
favourites: AddonMessagesGroupConversationOption = {
|
||||
type: undefined,
|
||||
favourites: true,
|
||||
count: 0,
|
||||
unread: 0,
|
||||
conversations: [],
|
||||
};
|
||||
|
||||
group: AddonMessagesGroupConversationOption = {
|
||||
type: AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP,
|
||||
favourites: false,
|
||||
count: 0,
|
||||
unread: 0,
|
||||
conversations: [],
|
||||
};
|
||||
|
||||
individual: AddonMessagesGroupConversationOption = {
|
||||
type: AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
|
||||
favourites: false,
|
||||
count: 0,
|
||||
unread: 0,
|
||||
conversations: [],
|
||||
};
|
||||
|
||||
typeGroup = AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP;
|
||||
currentListEl?: HTMLElement;
|
||||
|
||||
protected siteId: string;
|
||||
protected currentUserId: number;
|
||||
protected conversationId?: number;
|
||||
protected discussionUserId?: number;
|
||||
protected newMessagesObserver: CoreEventObserver;
|
||||
protected pushObserver: Subscription;
|
||||
protected appResumeSubscription: Subscription;
|
||||
protected readChangedObserver: CoreEventObserver;
|
||||
protected cronObserver: CoreEventObserver;
|
||||
protected openConversationObserver: CoreEventObserver;
|
||||
protected updateConversationListObserver: CoreEventObserver;
|
||||
protected contactRequestsCountObserver: CoreEventObserver;
|
||||
protected memberInfoObserver: CoreEventObserver;
|
||||
|
||||
constructor(
|
||||
protected route: ActivatedRoute,
|
||||
) {
|
||||
this.loadingMessage = Translate.instance.instant('core.loading');
|
||||
this.siteId = CoreSites.instance.getCurrentSiteId();
|
||||
this.currentUserId = CoreSites.instance.getCurrentSiteUserId();
|
||||
|
||||
// Update conversations when new message is received.
|
||||
this.newMessagesObserver = CoreEvents.on<AddonMessagesNewMessagedEventData>(
|
||||
AddonMessagesProvider.NEW_MESSAGE_EVENT,
|
||||
(data) => {
|
||||
// Check if the new message belongs to the option that is currently expanded.
|
||||
const expandedOption = this.getExpandedOption();
|
||||
const messageOption = this.getConversationOption(data);
|
||||
|
||||
if (expandedOption != messageOption) {
|
||||
return; // Message doesn't belong to current list, stop.
|
||||
}
|
||||
|
||||
// Search the conversation to update.
|
||||
const conversation = this.findConversation(data.conversationId, data.userId, expandedOption);
|
||||
|
||||
if (typeof conversation == 'undefined') {
|
||||
// Probably a new conversation, refresh the list.
|
||||
this.loaded = false;
|
||||
this.refreshData().finally(() => {
|
||||
this.loaded = true;
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
if (conversation.lastmessage != data.message || conversation.lastmessagedate != data.timecreated / 1000) {
|
||||
const isNewer = data.timecreated / 1000 > (conversation.lastmessagedate || 0);
|
||||
|
||||
// An existing conversation has a new message, update the last message.
|
||||
conversation.lastmessage = data.message;
|
||||
conversation.lastmessagedate = data.timecreated / 1000;
|
||||
|
||||
// Sort the affected list.
|
||||
const option = this.getConversationOption(conversation);
|
||||
option.conversations = AddonMessages.instance.sortConversations(option.conversations || []);
|
||||
|
||||
if (isNewer) {
|
||||
// The last message is newer than the previous one, scroll to top to keep viewing the conversation.
|
||||
this.content?.scrollToTop();
|
||||
}
|
||||
}
|
||||
},
|
||||
this.siteId,
|
||||
);
|
||||
|
||||
// Update conversations when a message is read.
|
||||
this.readChangedObserver = CoreEvents.on<AddonMessagesReadChangedEventData>(AddonMessagesProvider.READ_CHANGED_EVENT, (
|
||||
data,
|
||||
) => {
|
||||
if (data.conversationId) {
|
||||
const conversation = this.findConversation(data.conversationId);
|
||||
|
||||
if (typeof conversation != 'undefined') {
|
||||
// A conversation has been read reset counter.
|
||||
conversation.unreadcount = 0;
|
||||
|
||||
// Conversations changed, invalidate them and refresh unread counts.
|
||||
AddonMessages.instance.invalidateConversations(this.siteId);
|
||||
AddonMessages.instance.refreshUnreadConversationCounts(this.siteId);
|
||||
}
|
||||
}
|
||||
}, this.siteId);
|
||||
|
||||
// Load a discussion if we receive an event to do so.
|
||||
this.openConversationObserver = CoreEvents.on<AddonMessagesOpenConversationEventData>(
|
||||
AddonMessagesProvider.OPEN_CONVERSATION_EVENT,
|
||||
(data) => {
|
||||
if (data.conversationId || data.userId) {
|
||||
this.gotoConversation(data.conversationId, data.userId);
|
||||
}
|
||||
},
|
||||
this.siteId,
|
||||
);
|
||||
|
||||
// Refresh the view when the app is resumed.
|
||||
this.appResumeSubscription = Platform.instance.resume.subscribe(() => {
|
||||
if (!this.loaded) {
|
||||
return;
|
||||
}
|
||||
this.loaded = false;
|
||||
this.refreshData().finally(() => {
|
||||
this.loaded = true;
|
||||
});
|
||||
});
|
||||
|
||||
// Update conversations if we receive an event to do so.
|
||||
this.updateConversationListObserver = CoreEvents.on<AddonMessagesUpdateConversationListEventData>(
|
||||
AddonMessagesProvider.UPDATE_CONVERSATION_LIST_EVENT,
|
||||
(data) => {
|
||||
if (data && data.action == 'mute') {
|
||||
// If the conversation is displayed, change its muted value.
|
||||
const expandedOption = this.getExpandedOption();
|
||||
|
||||
if (expandedOption && expandedOption.conversations) {
|
||||
const conversation = this.findConversation(data.conversationId, undefined, expandedOption);
|
||||
if (conversation) {
|
||||
conversation.ismuted = !!data.value;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshData();
|
||||
|
||||
},
|
||||
this.siteId,
|
||||
);
|
||||
|
||||
// If a message push notification is received, refresh the view.
|
||||
this.pushObserver = CorePushNotificationsDelegate.instance.on<CorePushNotificationsNotificationBasicData>('receive')
|
||||
.subscribe((notification) => {
|
||||
// New message received. If it's from current site, refresh the data.
|
||||
if (CoreUtils.instance.isFalseOrZero(notification.notif) && notification.site == this.siteId) {
|
||||
// Don't refresh unread counts, it's refreshed from the main menu handler in this case.
|
||||
this.refreshData(undefined, false);
|
||||
}
|
||||
});
|
||||
|
||||
// Update unread conversation counts.
|
||||
this.cronObserver = CoreEvents.on<AddonMessagesUnreadConversationCountsEventData>(
|
||||
AddonMessagesProvider.UNREAD_CONVERSATION_COUNTS_EVENT,
|
||||
(data) => {
|
||||
this.favourites.unread = data.favourites;
|
||||
this.individual.unread = data.individual + data.self; // Self is only returned if it's not favourite.
|
||||
this.group.unread = data.group;
|
||||
},
|
||||
this.siteId,
|
||||
);
|
||||
|
||||
// Update the contact requests badge.
|
||||
this.contactRequestsCountObserver = CoreEvents.on<AddonMessagesContactRequestCountEventData>(
|
||||
AddonMessagesProvider.CONTACT_REQUESTS_COUNT_EVENT,
|
||||
(data) => {
|
||||
this.contactRequestsCount = data.count;
|
||||
},
|
||||
this.siteId,
|
||||
);
|
||||
|
||||
// Update block status of a user.
|
||||
this.memberInfoObserver = CoreEvents.on<AddonMessagesMemberInfoChangedEventData>(
|
||||
AddonMessagesProvider.MEMBER_INFO_CHANGED_EVENT,
|
||||
(data) => {
|
||||
if (!data.userBlocked && !data.userUnblocked) {
|
||||
// The block status has not changed, ignore.
|
||||
return;
|
||||
}
|
||||
|
||||
const expandedOption = this.getExpandedOption();
|
||||
if (expandedOption == this.individual || expandedOption == this.favourites) {
|
||||
if (!expandedOption.conversations || expandedOption.conversations.length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = this.findConversation(undefined, data.userId, expandedOption);
|
||||
if (conversation) {
|
||||
conversation.isblocked = data.userBlocked;
|
||||
}
|
||||
}
|
||||
},
|
||||
this.siteId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component loaded.
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.route.queryParams.subscribe(async (params) => {
|
||||
// Conversation to load.
|
||||
this.conversationId = CoreNavigator.instance.getRouteNumberParam('conversationId', params) || undefined;
|
||||
if (!this.conversationId) {
|
||||
this.discussionUserId = CoreNavigator.instance.getRouteNumberParam('discussionUserId', params) || undefined;
|
||||
}
|
||||
|
||||
if (this.conversationId || this.discussionUserId) {
|
||||
// There is a discussion to load, open the discussion in a new state.
|
||||
this.gotoConversation(this.conversationId, this.discussionUserId);
|
||||
}
|
||||
|
||||
await this.fetchData();
|
||||
if (!this.conversationId && !this.discussionUserId && CoreScreen.instance.isTablet) {
|
||||
// Load the first conversation.
|
||||
let conversation: AddonMessagesConversationForList;
|
||||
const expandedOption = this.getExpandedOption();
|
||||
|
||||
if (expandedOption && expandedOption.conversations.length) {
|
||||
conversation = expandedOption.conversations[0];
|
||||
|
||||
if (conversation) {
|
||||
this.gotoConversation(conversation.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch conversations.
|
||||
*
|
||||
* @param refreshUnreadCounts Whether to refresh unread counts.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async fetchData(refreshUnreadCounts: boolean = true): Promise<void> {
|
||||
// Load the amount of conversations and contact requests.
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
promises.push(this.fetchConversationCounts());
|
||||
|
||||
// View updated by the events observers.
|
||||
promises.push(AddonMessages.instance.getContactRequestsCount(this.siteId));
|
||||
if (refreshUnreadCounts) {
|
||||
promises.push(AddonMessages.instance.refreshUnreadConversationCounts(this.siteId));
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
|
||||
// The expanded status hasn't been initialized. Do it now.
|
||||
if (typeof this.favourites.expanded == 'undefined' && this.conversationId || this.discussionUserId) {
|
||||
// A certain conversation should be opened.
|
||||
// We don't know which option it belongs to, so we need to fetch the data for all of them.
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
promises.push(this.fetchDataForOption(this.favourites, false));
|
||||
promises.push(this.fetchDataForOption(this.group, false));
|
||||
promises.push(this.fetchDataForOption(this.individual, false));
|
||||
|
||||
await Promise.all(promises);
|
||||
// All conversations have been loaded, find the one we need to load and expand its option.
|
||||
const conversation = this.findConversation(this.conversationId, this.discussionUserId);
|
||||
if (conversation) {
|
||||
const option = this.getConversationOption(conversation);
|
||||
|
||||
await this.expandOption(option);
|
||||
|
||||
this.loaded = true;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Load the data for the expanded option.
|
||||
await this.fetchDataForExpandedOption();
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingdiscussions', true);
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch data for the expanded option.
|
||||
*
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async fetchDataForExpandedOption(): Promise<void> {
|
||||
if (typeof this.favourites.expanded == 'undefined') {
|
||||
// Calculate which option should be expanded initially.
|
||||
this.favourites.expanded = this.favourites.count != 0 && !this.group.unread && !this.individual.unread;
|
||||
this.group.expanded = !this.favourites.expanded && this.group.count != 0 && !this.individual.unread;
|
||||
this.individual.expanded = !this.favourites.expanded && !this.group.expanded;
|
||||
}
|
||||
|
||||
this.loadCurrentListElement();
|
||||
|
||||
const expandedOption = this.getExpandedOption();
|
||||
|
||||
if (expandedOption) {
|
||||
await this.fetchDataForOption(expandedOption, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch data for a certain option.
|
||||
*
|
||||
* @param option The option to fetch data for.
|
||||
* @param loadingMore Whether we are loading more data or just the first ones.
|
||||
* @param getCounts Whether to get counts data.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async fetchDataForOption(
|
||||
option: AddonMessagesGroupConversationOption,
|
||||
loadingMore = false,
|
||||
getCounts = false,
|
||||
): Promise<void> {
|
||||
option.loadMoreError = false;
|
||||
|
||||
const limitFrom = loadingMore ? option.conversations.length : 0;
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
let data = {
|
||||
conversations: <AddonMessagesConversationForList[]> [],
|
||||
canLoadMore: false,
|
||||
};
|
||||
let offlineMessages:
|
||||
AddonMessagesOfflineAnyMessagesFormatted[] = [];
|
||||
|
||||
// Get the conversations and, if needed, the offline messages. Always try to get the latest data.
|
||||
promises.push(AddonMessages.instance.invalidateConversations(this.siteId).then(async () => {
|
||||
data = await AddonMessages.instance.getConversations(option.type, option.favourites, limitFrom, this.siteId);
|
||||
|
||||
return;
|
||||
}));
|
||||
|
||||
if (!loadingMore) {
|
||||
promises.push(AddonMessagesOffline.instance.getAllMessages().then((messages) => {
|
||||
offlineMessages = messages;
|
||||
|
||||
return;
|
||||
}));
|
||||
}
|
||||
|
||||
if (getCounts) {
|
||||
promises.push(this.fetchConversationCounts());
|
||||
promises.push(AddonMessages.instance.refreshUnreadConversationCounts(this.siteId));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
if (loadingMore) {
|
||||
option.conversations = option.conversations.concat(data.conversations);
|
||||
option.canLoadMore = data.canLoadMore;
|
||||
} else {
|
||||
option.conversations = data.conversations;
|
||||
option.canLoadMore = data.canLoadMore;
|
||||
|
||||
if (offlineMessages && offlineMessages.length) {
|
||||
await this.loadOfflineMessages(option, offlineMessages);
|
||||
|
||||
// Sort the conversations, the offline messages could affect the order.
|
||||
option.conversations = AddonMessages.instance.sortConversations(option.conversations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch conversation counts.
|
||||
*
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async fetchConversationCounts(): Promise<void> {
|
||||
// Always try to get the latest data.
|
||||
await AddonMessages.instance.invalidateConversationCounts(this.siteId);
|
||||
|
||||
const counts = await AddonMessages.instance.getConversationCounts(this.siteId);
|
||||
this.favourites.count = counts.favourites;
|
||||
this.individual.count = counts.individual + counts.self; // Self is only returned if it's not favourite.
|
||||
this.group.count = counts.group;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a conversation in the list of loaded conversations.
|
||||
*
|
||||
* @param conversationId The conversation ID to search.
|
||||
* @param userId User ID to search (if no conversationId).
|
||||
* @param option The option to search in. If not defined, search in all options.
|
||||
* @return Conversation.
|
||||
*/
|
||||
protected findConversation(
|
||||
conversationId?: number,
|
||||
userId?: number,
|
||||
option?: AddonMessagesGroupConversationOption,
|
||||
): AddonMessagesConversationForList | undefined {
|
||||
|
||||
if (conversationId) {
|
||||
const conversations: AddonMessagesConversationForList[] = option
|
||||
? option.conversations
|
||||
: (this.favourites.conversations.concat(this.group.conversations).concat(this.individual.conversations));
|
||||
|
||||
return conversations.find((conv) => conv.id == conversationId);
|
||||
}
|
||||
|
||||
const conversations = option
|
||||
? option.conversations
|
||||
: this.favourites.conversations.concat(this.individual.conversations);
|
||||
|
||||
return conversations.find((conv) => conv.userid == userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the option that is currently expanded, undefined if they are all collapsed.
|
||||
*
|
||||
* @return Option currently expanded.
|
||||
*/
|
||||
protected getExpandedOption(): AddonMessagesGroupConversationOption | undefined {
|
||||
if (this.favourites.expanded) {
|
||||
return this.favourites;
|
||||
} else if (this.group.expanded) {
|
||||
return this.group;
|
||||
} else if (this.individual.expanded) {
|
||||
return this.individual;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to contacts view.
|
||||
*/
|
||||
gotoContacts(): void {
|
||||
CoreNavigator.instance.navigateToSitePath('contacts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a particular conversation.
|
||||
*
|
||||
* @param conversationId Conversation Id to load.
|
||||
* @param userId User of the conversation. Only if there is no conversationId.
|
||||
* @param messageId Message to scroll after loading the discussion. Used when searching.
|
||||
*/
|
||||
gotoConversation(conversationId?: number, userId?: number, messageId?: number): void {
|
||||
this.selectedConversationId = conversationId;
|
||||
this.selectedUserId = userId;
|
||||
|
||||
const params: Params = {};
|
||||
if (conversationId) {
|
||||
params.conversationId = conversationId;
|
||||
}
|
||||
if (userId) {
|
||||
params.userId = userId;
|
||||
}
|
||||
if (messageId) {
|
||||
params.message = messageId;
|
||||
}
|
||||
|
||||
const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/messages/group-conversations/discussion');
|
||||
const path = (splitViewLoaded ? '../' : '') + 'discussion';
|
||||
CoreNavigator.instance.navigate(path, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to message settings.
|
||||
*/
|
||||
gotoSettings(): void {
|
||||
CoreNavigator.instance.navigateToSitePath(AddonMessagesSettingsHandlerService.PAGE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to load more conversations.
|
||||
*
|
||||
* @param option The option to fetch data for.
|
||||
* @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async loadMoreConversations(option: AddonMessagesGroupConversationOption, infiniteComplete?: () => void): Promise<void> {
|
||||
try {
|
||||
await this.fetchDataForOption(option, true);
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingdiscussions', true);
|
||||
option.loadMoreError = true;
|
||||
}
|
||||
|
||||
infiniteComplete && infiniteComplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load offline messages into the conversations.
|
||||
*
|
||||
* @param option The option where the messages should be loaded.
|
||||
* @param messages Offline messages.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async loadOfflineMessages(
|
||||
option: AddonMessagesGroupConversationOption,
|
||||
messages: AddonMessagesOfflineAnyMessagesFormatted[],
|
||||
): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
messages.forEach((message) => {
|
||||
if ('conversationid' in message) {
|
||||
// It's an existing conversation. Search it in the current option.
|
||||
let conversation = this.findConversation(message.conversationid, undefined, option);
|
||||
|
||||
if (conversation) {
|
||||
// Check if it's the last message. Offline messages are considered more recent than sent messages.
|
||||
if (typeof conversation.lastmessage === 'undefined' || conversation.lastmessage === null ||
|
||||
!conversation.lastmessagepending || (conversation.lastmessagedate || 0) <= message.timecreated / 1000) {
|
||||
|
||||
this.addLastOfflineMessage(conversation, message);
|
||||
}
|
||||
} else {
|
||||
// Conversation not found, it could be an old one or the message could belong to another option.
|
||||
conversation = {
|
||||
id: message.conversationid,
|
||||
type: message.conversation?.type || AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
|
||||
membercount: message.conversation?.membercount || 0,
|
||||
ismuted: message.conversation?.ismuted || false,
|
||||
isfavourite: message.conversation?.isfavourite || false,
|
||||
isread: message.conversation?.isread || false,
|
||||
members: message.conversation?.members || [],
|
||||
messages: message.conversation?.messages || [],
|
||||
candeletemessagesforallusers: message.conversation?.candeletemessagesforallusers || false,
|
||||
userid: 0, // Faked data.
|
||||
name: message.conversation?.name,
|
||||
imageurl: message.conversation?.imageurl || '',
|
||||
};
|
||||
|
||||
if (this.getConversationOption(conversation) == option) {
|
||||
// Message belongs to current option, add the conversation.
|
||||
this.addLastOfflineMessage(conversation, message);
|
||||
this.addOfflineConversation(conversation);
|
||||
}
|
||||
}
|
||||
} else if (option.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL) {
|
||||
// It's a new conversation. Check if we already created it (there is more than one message for the same user).
|
||||
const conversation = this.findConversation(undefined, message.touserid, option);
|
||||
|
||||
message.text = message.smallmessage;
|
||||
|
||||
if (conversation) {
|
||||
// Check if it's the last message. Offline messages are considered more recent than sent messages.
|
||||
if ((conversation.lastmessagedate || 0) <= message.timecreated / 1000) {
|
||||
this.addLastOfflineMessage(conversation, message);
|
||||
}
|
||||
} else {
|
||||
// Get the user data and create a new conversation if it belongs to the current option.
|
||||
promises.push(CoreUser.instance.getProfile(message.touserid, undefined, true).catch(() => {
|
||||
// User not found.
|
||||
}).then((user) => {
|
||||
const conversation: AddonMessagesConversationForList = {
|
||||
id: 0,
|
||||
type: AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_INDIVIDUAL,
|
||||
membercount: 0, // Faked data.
|
||||
ismuted: false, // Faked data.
|
||||
isfavourite: false, // Faked data.
|
||||
isread: false, // Faked data.
|
||||
members: [], // Faked data.
|
||||
messages: [], // Faked data.
|
||||
candeletemessagesforallusers: false,
|
||||
userid: message.touserid,
|
||||
name: user ? user.fullname : String(message.touserid),
|
||||
imageurl: user ? user.profileimageurl : '',
|
||||
};
|
||||
|
||||
this.addLastOfflineMessage(conversation, message);
|
||||
this.addOfflineConversation(conversation);
|
||||
|
||||
return;
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an offline conversation into the right list of conversations.
|
||||
*
|
||||
* @param conversation Offline conversation to add.
|
||||
*/
|
||||
protected addOfflineConversation(conversation: AddonMessagesConversationForList): void {
|
||||
const option = this.getConversationOption(conversation);
|
||||
option.conversations.unshift(conversation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a last offline message into a conversation.
|
||||
*
|
||||
* @param conversation Conversation where to put the last message.
|
||||
* @param message Offline message to add.
|
||||
*/
|
||||
protected addLastOfflineMessage(
|
||||
conversation: AddonMessagesConversationForList,
|
||||
message: AddonMessagesOfflineAnyMessagesFormatted,
|
||||
): void {
|
||||
conversation.lastmessage = message.text;
|
||||
conversation.lastmessagedate = message.timecreated / 1000;
|
||||
conversation.lastmessagepending = true;
|
||||
conversation.sentfromcurrentuser = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a conversation, return its option (favourites, group, individual).
|
||||
*
|
||||
* @param conversation Conversation to check.
|
||||
* @return Option object.
|
||||
*/
|
||||
protected getConversationOption(
|
||||
conversation: AddonMessagesConversationForList | AddonMessagesNewMessagedEventData,
|
||||
): AddonMessagesGroupConversationOption {
|
||||
if (conversation.isfavourite) {
|
||||
return this.favourites;
|
||||
}
|
||||
|
||||
if (conversation.type == AddonMessagesProvider.MESSAGE_CONVERSATION_TYPE_GROUP) {
|
||||
return this.group;
|
||||
}
|
||||
|
||||
return this.individual;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the data.
|
||||
*
|
||||
* @param refresher Refresher.
|
||||
* @param refreshUnreadCounts Whether to refresh unread counts.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async refreshData(refresher?: CustomEvent<IonRefresher>, refreshUnreadCounts: boolean = true): Promise<void> {
|
||||
// Don't invalidate conversations and so, they always try to get latest data.
|
||||
try {
|
||||
await AddonMessages.instance.invalidateContactRequestsCountCache(this.siteId);
|
||||
} finally {
|
||||
try {
|
||||
await this.fetchData(refreshUnreadCounts);
|
||||
} finally {
|
||||
if (refresher) {
|
||||
refresher?.detail.complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toogle the visibility of an option (expand/collapse).
|
||||
*
|
||||
* @param option The option to expand/collapse.
|
||||
*/
|
||||
toggle(option: AddonMessagesGroupConversationOption): void {
|
||||
if (option.expanded) {
|
||||
// Already expanded, close it.
|
||||
option.expanded = false;
|
||||
this.loadCurrentListElement();
|
||||
} else {
|
||||
// Pass getCounts=true to update the counts everytime the user expands an option.
|
||||
this.expandOption(option, true).catch((error) => {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingdiscussions', true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a certain option.
|
||||
*
|
||||
* @param option The option to expand.
|
||||
* @param getCounts Whether to get counts data.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async expandOption(option: AddonMessagesGroupConversationOption, getCounts = false): Promise<void> {
|
||||
// Collapse all and expand the right one.
|
||||
this.favourites.expanded = false;
|
||||
this.group.expanded = false;
|
||||
this.individual.expanded = false;
|
||||
|
||||
option.expanded = true;
|
||||
option.loading = true;
|
||||
|
||||
try {
|
||||
await this.fetchDataForOption(option, false, getCounts);
|
||||
|
||||
this.loadCurrentListElement();
|
||||
} catch (error) {
|
||||
option.expanded = false;
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
option.loading = false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the current list element based on the expanded list.
|
||||
*/
|
||||
protected loadCurrentListElement(): void {
|
||||
if (this.favourites.expanded) {
|
||||
this.currentListEl = this.favListEl && this.favListEl.nativeElement;
|
||||
} else if (this.group.expanded) {
|
||||
this.currentListEl = this.groupListEl && this.groupListEl.nativeElement;
|
||||
} else if (this.individual.expanded) {
|
||||
this.currentListEl = this.indListEl && this.indListEl.nativeElement;
|
||||
} else {
|
||||
this.currentListEl = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the search page.
|
||||
*/
|
||||
gotoSearch(): void {
|
||||
CoreNavigator.instance.navigateToSitePath('search');
|
||||
}
|
||||
|
||||
/**
|
||||
* Page destroyed.
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.newMessagesObserver?.off();
|
||||
this.appResumeSubscription?.unsubscribe();
|
||||
this.pushObserver?.unsubscribe();
|
||||
this.readChangedObserver?.off();
|
||||
this.cronObserver?.off();
|
||||
this.openConversationObserver?.off();
|
||||
this.updateConversationListObserver?.off();
|
||||
this.contactRequestsCountObserver?.off();
|
||||
this.memberInfoObserver?.off();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversation options.
|
||||
*/
|
||||
export type AddonMessagesGroupConversationOption = {
|
||||
type?: number; // Option type.
|
||||
favourites: boolean; // Whether it contains favourites conversations.
|
||||
count: number; // Number of conversations.
|
||||
unread?: number; // Number of unread conversations.
|
||||
expanded?: boolean; // Whether the option is currently expanded.
|
||||
loading?: boolean; // Whether the option is being loaded.
|
||||
canLoadMore?: boolean; // Whether it can load more data.
|
||||
loadMoreError?: boolean; // Whether there was an error loading more conversations.
|
||||
conversations: AddonMessagesConversationForList[]; // List of conversations.
|
||||
};
|
||||
|
||||
/**
|
||||
* Formatted conversation with some calculated data for the list.
|
||||
*/
|
||||
export type AddonMessagesConversationForList = AddonMessagesConversationFormatted & {
|
||||
lastmessagepending?: boolean; // Calculated in the app. Whether last message is pending to be sent.
|
||||
};
|
|
@ -0,0 +1,80 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ 'addon.messages.searchcombined' | translate }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<!-- Add an empty context menu so discussion page can add items in split view,
|
||||
otherwise the menu disappears in some cases. -->
|
||||
<core-context-menu></core-context-menu>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<core-split-view>
|
||||
<core-search-box (onSubmit)="search($event)" (onClear)="clearSearch()" [disabled]="disableSearch" autocorrect="off"
|
||||
[spellcheck]="false" [autoFocus]="true" [lengthCheck]="1" searchArea="AddonMessagesSearch"></core-search-box>
|
||||
|
||||
<core-loading [hideUntil]="!displaySearching" [message]="'core.searching' | translate">
|
||||
<ion-list *ngIf="displayResults">
|
||||
<ng-container *ngTemplateOutlet="resultsTemplate; context: {item: contacts}"></ng-container>
|
||||
<ng-container *ngTemplateOutlet="resultsTemplate; context: {item: nonContacts}"></ng-container>
|
||||
<ng-container *ngTemplateOutlet="resultsTemplate; context: {item: messages}"></ng-container>
|
||||
<!-- The infinite loading cannot be inside the ng-template, it fails because it doesn't find ion-content. -->
|
||||
<core-infinite-loading [enabled]="messages.canLoadMore" (action)="search(query, 'messages', $event)"
|
||||
[error]="messages.loadMoreError"></core-infinite-loading>
|
||||
</ion-list>
|
||||
|
||||
<core-empty-box
|
||||
*ngIf="displayResults && !contacts.results.length && !nonContacts.results.length && !messages.results.length"
|
||||
icon="fas-search" [message]="'core.noresults' | translate">
|
||||
</core-empty-box>
|
||||
</core-loading>
|
||||
</core-split-view>
|
||||
</ion-content>
|
||||
|
||||
<!-- Template to render a list of results -->
|
||||
<ng-template #resultsTemplate let-item="item">
|
||||
<ng-container *ngIf="item.results.length > 0">
|
||||
<ion-item-divider class="ion-text-wrap">
|
||||
<ion-label>{{ item.titleString | translate }}</ion-label>
|
||||
</ion-item-divider>
|
||||
|
||||
<!-- List of results -->
|
||||
<ion-item class="addon-message-discussion ion-text-wrap" *ngFor="let result of item.results" [title]="result.fullname"
|
||||
(click)="openConversation(result)" [class.core-selected-item]="result == selectedResult" detail>
|
||||
<core-user-avatar slot="start" [user]="result" [checkOnline]="true" [linkProfile]="false"></core-user-avatar>
|
||||
<ion-label>
|
||||
<h2>
|
||||
<core-format-text [text]="result.fullname" [highlight]="result.highlightName" [filter]="false"></core-format-text>
|
||||
<ion-icon name="fa-ban" *ngIf="result.isblocked" [title]="'addon.messages.contactblocked' | translate">
|
||||
</ion-icon>
|
||||
</h2>
|
||||
<ion-note *ngIf="result.lastmessagedate > 0">
|
||||
{{result.lastmessagedate | coreDateDayOrTime}}
|
||||
</ion-note>
|
||||
<p class="addon-message-last-message">
|
||||
<span *ngIf="result.sentfromcurrentuser" class="addon-message-last-message-user">
|
||||
{{ 'addon.messages.you' | translate }}
|
||||
</span>
|
||||
<core-format-text clean="true" singleLine="true" [text]="result.lastmessage"
|
||||
[highlight]="result.highlightMessage" contextLevel="system" [contextInstanceId]="0"
|
||||
class="addon-message-last-message-text"></core-format-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- Load more button for contacts and non-contacts -->
|
||||
<ng-container *ngIf="item.type != 'messages'">
|
||||
<div class="ion-padding-horizontal" *ngIf="item.canLoadMore && !item.loadingMore">
|
||||
<ion-button expand="block" color="light" (click)="search(query, item.type)">
|
||||
{{ 'core.loadmore' | translate }}
|
||||
</ion-button>
|
||||
</div>
|
||||
<div *ngIf="item.loadingMore" class="ion-padding ion-text-center">
|
||||
<ion-spinner></ion-spinner>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-template>
|
|
@ -0,0 +1,66 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
import { conditionalRoutes } from '@/app/app-routing.module';
|
||||
import { AddonMessagesDiscussionRoute } from '@addons/messages/messages-lazy.module';
|
||||
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
import { CoreSearchComponentsModule } from '@features/search/components/components.module';
|
||||
|
||||
import { AddonMessagesSearchPage } from './search.page';
|
||||
|
||||
const mobileRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AddonMessagesSearchPage,
|
||||
},
|
||||
AddonMessagesDiscussionRoute,
|
||||
];
|
||||
|
||||
const tabletRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AddonMessagesSearchPage,
|
||||
children: [
|
||||
AddonMessagesDiscussionRoute,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const routes: Routes = [
|
||||
...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile),
|
||||
...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes),
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
TranslateModule.forChild(),
|
||||
CoreSharedModule,
|
||||
CoreSearchComponentsModule,
|
||||
],
|
||||
declarations: [
|
||||
AddonMessagesSearchPage,
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AddonMessagesSearchPageModule {}
|
|
@ -0,0 +1,315 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, OnDestroy } from '@angular/core';
|
||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import {
|
||||
AddonMessagesProvider,
|
||||
AddonMessagesConversationMember,
|
||||
AddonMessagesMessageAreaContact,
|
||||
AddonMessagesMemberInfoChangedEventData,
|
||||
AddonMessages,
|
||||
} from '../../services/messages';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreApp } from '@services/app';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { Params } from '@angular/router';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
|
||||
/**
|
||||
* Page for searching users.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'page-addon-messages-search',
|
||||
templateUrl: 'search.html',
|
||||
})
|
||||
export class AddonMessagesSearchPage implements OnDestroy {
|
||||
|
||||
disableSearch = false;
|
||||
displaySearching = false;
|
||||
displayResults = false;
|
||||
query = '';
|
||||
contacts: AddonMessagesSearchResults = {
|
||||
type: 'contacts',
|
||||
titleString: 'addon.messages.contacts',
|
||||
results: [],
|
||||
canLoadMore: false,
|
||||
loadingMore: false,
|
||||
};
|
||||
|
||||
nonContacts: AddonMessagesSearchResults = {
|
||||
type: 'noncontacts',
|
||||
titleString: 'addon.messages.noncontacts',
|
||||
results: [],
|
||||
canLoadMore: false,
|
||||
loadingMore: false,
|
||||
};
|
||||
|
||||
messages: AddonMessagesSearchMessageResults = {
|
||||
type: 'messages',
|
||||
titleString: 'addon.messages.messages',
|
||||
results: [],
|
||||
canLoadMore: false,
|
||||
loadingMore: false,
|
||||
loadMoreError: false,
|
||||
};
|
||||
|
||||
selectedResult?: AddonMessagesConversationMember | AddonMessagesMessageAreaContact;
|
||||
|
||||
protected memberInfoObserver: CoreEventObserver;
|
||||
|
||||
constructor() {
|
||||
// Update block status of a user.
|
||||
this.memberInfoObserver = CoreEvents.on<AddonMessagesMemberInfoChangedEventData>(
|
||||
AddonMessagesProvider.MEMBER_INFO_CHANGED_EVENT,
|
||||
(data) => {
|
||||
if (!data.userBlocked && !data.userUnblocked) {
|
||||
// The block status has not changed, ignore.
|
||||
return;
|
||||
}
|
||||
|
||||
const contact = this.contacts.results.find((user) => user.id == data.userId);
|
||||
if (contact) {
|
||||
contact.isblocked = !!data.userBlocked;
|
||||
} else {
|
||||
const nonContact = this.nonContacts.results.find((user) => user.id == data.userId);
|
||||
if (nonContact) {
|
||||
nonContact.isblocked = !!data.userBlocked;
|
||||
}
|
||||
}
|
||||
|
||||
this.messages.results.forEach((message: AddonMessagesMessageAreaContact): void => {
|
||||
if (message.userid == data.userId) {
|
||||
message.isblocked = !!data.userBlocked;
|
||||
}
|
||||
});
|
||||
},
|
||||
CoreSites.instance.getCurrentSiteId(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear search.
|
||||
*/
|
||||
clearSearch(): void {
|
||||
this.query = '';
|
||||
this.displayResults = false;
|
||||
|
||||
// Empty details.
|
||||
const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/messages/search/discussion');
|
||||
if (splitViewLoaded) {
|
||||
CoreNavigator.instance.navigate('../');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new search or load more results.
|
||||
*
|
||||
* @param query Text to search for.
|
||||
* @param loadMore Load more contacts, noncontacts or messages. If undefined, start a new search.
|
||||
* @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading.
|
||||
* @return Resolved when done.
|
||||
*/
|
||||
async search(query: string, loadMore?: 'contacts' | 'noncontacts' | 'messages', infiniteComplete?: () => void): Promise<void> {
|
||||
CoreApp.instance.closeKeyboard();
|
||||
|
||||
this.query = query;
|
||||
this.disableSearch = true;
|
||||
this.displaySearching = !loadMore;
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
let newContacts: AddonMessagesConversationMember[] = [];
|
||||
let newNonContacts: AddonMessagesConversationMember[] = [];
|
||||
let newMessages: AddonMessagesMessageAreaContact[] = [];
|
||||
let canLoadMoreContacts = false;
|
||||
let canLoadMoreNonContacts = false;
|
||||
let canLoadMoreMessages = false;
|
||||
|
||||
if (!loadMore || loadMore == 'contacts' || loadMore == 'noncontacts') {
|
||||
const limitNum = loadMore ? AddonMessagesProvider.LIMIT_SEARCH : AddonMessagesProvider.LIMIT_INITIAL_USER_SEARCH;
|
||||
let limitFrom = 0;
|
||||
if (loadMore == 'contacts') {
|
||||
limitFrom = this.contacts.results.length;
|
||||
this.contacts.loadingMore = true;
|
||||
} else if (loadMore == 'noncontacts') {
|
||||
limitFrom = this.nonContacts.results.length;
|
||||
this.nonContacts.loadingMore = true;
|
||||
}
|
||||
|
||||
promises.push(
|
||||
AddonMessages.instance.searchUsers(query, limitFrom, limitNum).then((result) => {
|
||||
if (!loadMore || loadMore == 'contacts') {
|
||||
newContacts = result.contacts;
|
||||
canLoadMoreContacts = result.canLoadMoreContacts;
|
||||
}
|
||||
if (!loadMore || loadMore == 'noncontacts') {
|
||||
newNonContacts = result.nonContacts;
|
||||
canLoadMoreNonContacts = result.canLoadMoreNonContacts;
|
||||
}
|
||||
|
||||
return;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!loadMore || loadMore == 'messages') {
|
||||
let limitFrom = 0;
|
||||
if (loadMore == 'messages') {
|
||||
limitFrom = this.messages.results.length;
|
||||
this.messages.loadingMore = true;
|
||||
}
|
||||
|
||||
promises.push(
|
||||
AddonMessages.instance.searchMessages(query, undefined, limitFrom).then((result) => {
|
||||
newMessages = result.messages;
|
||||
canLoadMoreMessages = result.canLoadMore;
|
||||
|
||||
return;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
if (!loadMore) {
|
||||
this.contacts.results = [];
|
||||
this.nonContacts.results = [];
|
||||
this.messages.results = [];
|
||||
}
|
||||
|
||||
this.displayResults = true;
|
||||
|
||||
if (!loadMore || loadMore == 'contacts') {
|
||||
this.contacts.results.push(...newContacts);
|
||||
this.contacts.canLoadMore = canLoadMoreContacts;
|
||||
this.setHighlight(newContacts, true);
|
||||
}
|
||||
|
||||
if (!loadMore || loadMore == 'noncontacts') {
|
||||
this.nonContacts.results.push(...newNonContacts);
|
||||
this.nonContacts.canLoadMore = canLoadMoreNonContacts;
|
||||
this.setHighlight(newNonContacts, true);
|
||||
}
|
||||
|
||||
if (!loadMore || loadMore == 'messages') {
|
||||
this.messages.results.push(...newMessages);
|
||||
this.messages.canLoadMore = canLoadMoreMessages;
|
||||
this.messages.loadMoreError = false;
|
||||
this.setHighlight(newMessages, false);
|
||||
}
|
||||
|
||||
if (!loadMore) {
|
||||
if (this.contacts.results.length > 0) {
|
||||
this.openConversation(this.contacts.results[0], true);
|
||||
} else if (this.nonContacts.results.length > 0) {
|
||||
this.openConversation(this.nonContacts.results[0], true);
|
||||
} else if (this.messages.results.length > 0) {
|
||||
this.openConversation(this.messages.results[0], true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'addon.messages.errorwhileretrievingusers', true);
|
||||
|
||||
if (loadMore == 'messages') {
|
||||
this.messages.loadMoreError = true;
|
||||
}
|
||||
} finally {
|
||||
this.disableSearch = false;
|
||||
this.displaySearching = false;
|
||||
|
||||
if (loadMore == 'contacts') {
|
||||
this.contacts.loadingMore = false;
|
||||
} else if (loadMore == 'noncontacts') {
|
||||
this.nonContacts.loadingMore = false;
|
||||
} else if (loadMore == 'messages') {
|
||||
this.messages.loadingMore = false;
|
||||
}
|
||||
|
||||
infiniteComplete && infiniteComplete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a conversation in the split view.
|
||||
*
|
||||
* @param result User or message.
|
||||
* @param onInit Whether the tser was selected on initial load.
|
||||
*/
|
||||
openConversation(result: AddonMessagesConversationMember | AddonMessagesMessageAreaContact, onInit: boolean = false): void {
|
||||
if (!onInit || CoreScreen.instance.isTablet) {
|
||||
this.selectedResult = result;
|
||||
|
||||
const params: Params = {};
|
||||
if ('conversationid' in result) {
|
||||
params.conversationId = result.conversationid;
|
||||
} else {
|
||||
params.userId = result.id;
|
||||
}
|
||||
|
||||
const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/messages/search/discussion');
|
||||
const path = (splitViewLoaded ? '../' : '') + 'discussion';
|
||||
CoreNavigator.instance.navigate(path, { params });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the highlight values for each entry.
|
||||
*
|
||||
* @param results Results to highlight.
|
||||
* @param isUser Whether the results are from a user search or from a message search.
|
||||
*/
|
||||
setHighlight(
|
||||
results: (AddonMessagesConversationMemberWithHighlight | AddonMessagesMessageAreaContactWithHighlight)[],
|
||||
isUser = false,
|
||||
): void {
|
||||
results.forEach((result) => {
|
||||
result.highlightName = isUser ? this.query : undefined;
|
||||
result.highlightMessage = !isUser ? this.query : undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Component destroyed.
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.memberInfoObserver?.off();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
type AddonMessagesSearchResults = {
|
||||
type: string;
|
||||
titleString: string;
|
||||
results: AddonMessagesConversationMemberWithHighlight[];
|
||||
canLoadMore: boolean;
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
type AddonMessagesSearchMessageResults = {
|
||||
type: string;
|
||||
titleString: string;
|
||||
results: AddonMessagesMessageAreaContactWithHighlight[];
|
||||
canLoadMore: boolean;
|
||||
loadingMore: boolean;
|
||||
loadMoreError: boolean;
|
||||
};
|
||||
|
||||
type AddonMessagesSearchResultHighlight = {
|
||||
highlightName?: string;
|
||||
highlightMessage?: string;
|
||||
};
|
||||
|
||||
type AddonMessagesConversationMemberWithHighlight = AddonMessagesConversationMember & AddonMessagesSearchResultHighlight;
|
||||
type AddonMessagesMessageAreaContactWithHighlight = AddonMessagesMessageAreaContact & AddonMessagesSearchResultHighlight;
|
|
@ -0,0 +1,145 @@
|
|||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ 'addon.messages.messages' | translate }}</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<ion-refresher slot="fixed" [disabled]="!preferencesLoaded" (ionRefresh)="refreshPreferences($event)">
|
||||
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
<core-loading [hideUntil]="preferencesLoaded">
|
||||
<!-- Contactable privacy. -->
|
||||
<ion-card>
|
||||
<ion-item *ngIf="!advancedContactable">
|
||||
<ion-label>{{ 'addon.messages.blocknoncontacts' | translate }}</ion-label>
|
||||
<ion-toggle [(ngModel)]="contactablePrivacy" (ngModelChange)="saveContactablePrivacy(contactablePrivacy)">
|
||||
</ion-toggle>
|
||||
</ion-item>
|
||||
|
||||
<ion-list *ngIf="advancedContactable" class="ion-text-wrap">
|
||||
<ion-radio-group [(ngModel)]="contactablePrivacy" (ionChange)="saveContactablePrivacy(contactablePrivacy)">
|
||||
<ion-item-divider>
|
||||
<ion-label><h2>{{ 'addon.messages.contactableprivacy' | translate }}</h2></ion-label>
|
||||
</ion-item-divider>
|
||||
<ion-item>
|
||||
<ion-label>{{ 'addon.messages.contactableprivacy_onlycontacts' | translate }}</ion-label>
|
||||
<ion-radio slot="start" [value]="onlyContactsValue"></ion-radio>
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<ion-label>{{ 'addon.messages.contactableprivacy_coursemember' | translate }}</ion-label>
|
||||
<ion-radio slot="start" [value]="courseMemberValue"></ion-radio>
|
||||
</ion-item>
|
||||
<ion-item *ngIf="allowSiteMessaging">
|
||||
<ion-label>{{ 'addon.messages.contactableprivacy_site' | translate }}</ion-label>
|
||||
<ion-radio slot="start" [value]="siteValue"></ion-radio>
|
||||
</ion-item>
|
||||
</ion-radio-group>
|
||||
</ion-list>
|
||||
</ion-card>
|
||||
|
||||
<!-- Notifications. -->
|
||||
<ng-container *ngIf="preferences">
|
||||
<div *ngFor="let component of preferences.components">
|
||||
<ion-card list *ngFor="let notification of component.notifications">
|
||||
<ion-item-divider class="ion-text-wrap">
|
||||
<ion-label>
|
||||
<ion-row class="ion-no-padding" *ngIf="!groupMessagingEnabled">
|
||||
<ion-col class="ion-no-padding">
|
||||
<h2>{{ notification.displayname }}</h2>
|
||||
</ion-col>
|
||||
<ion-col size="2" class="ion-text-center ion-no-padding ion-hide-md-down">
|
||||
<h2>{{ 'core.settings.loggedin' | translate }}</h2>
|
||||
</ion-col>
|
||||
<ion-col *ngIf="!groupMessagingEnabled" size="2" class="ion-text-center ion-no-padding
|
||||
ion-hide-md-down">
|
||||
<h2>{{ 'core.settings.loggedoff' | translate }}</h2>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
<h2 *ngIf="groupMessagingEnabled">{{ 'addon.notifications.notificationpreferences' | translate }}</h2>
|
||||
</ion-label>
|
||||
</ion-item-divider>
|
||||
<ng-container *ngFor="let processor of notification.processors">
|
||||
<!-- If group messaging is enabled, display a simplified view. -->
|
||||
<ng-container *ngIf="groupMessagingEnabled">
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>{{ processor.displayname }}</ion-label>
|
||||
<ion-spinner slot="end" *ngIf="!preferences.disableall && notification.updating"></ion-spinner>
|
||||
<ion-toggle slot="end" *ngIf="!preferences.disableall && !processor.locked"
|
||||
[(ngModel)]="processor.checked" (ngModelChange)="changePreference(notification, '', processor)"
|
||||
[disabled]="notification.updating">
|
||||
</ion-toggle>
|
||||
<ion-note slot="end" *ngIf="!preferences.disableall && processor.locked">
|
||||
{{ processor.lockedmessage }}
|
||||
</ion-note>
|
||||
<ion-note slot="end" *ngIf="preferences.disableall">
|
||||
{{ 'core.settings.disabled' | translate }}
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!groupMessagingEnabled">
|
||||
<!-- Tablet view -->
|
||||
<ion-row class="ion-text-wrap ion-hide-md-down ion-align-items-center">
|
||||
<ion-col class="ion-margin-horizontal">{{ processor.displayname }}</ion-col>
|
||||
<ion-col size="2" class="ion-text-center" *ngFor="let state of ['loggedin', 'loggedoff']">
|
||||
<!-- If notifications not disabled, show toggle. -->
|
||||
<ion-spinner [hidden]="preferences.disableall ||
|
||||
!(notification.updating && notification.updating[state])"></ion-spinner>
|
||||
<ion-toggle *ngIf="!preferences.disableall && !processor.locked"
|
||||
[(ngModel)]="processor[state].checked"
|
||||
(ngModelChange)="changePreference(notification, state, processor)"
|
||||
[disabled]="notification.updating && notification.updating[state]">
|
||||
</ion-toggle>
|
||||
<div class="ion-padding text-gray" *ngIf="!preferences.disableall && processor.locked">
|
||||
{{'core.settings.locked' | translate }}
|
||||
</div>
|
||||
<!-- If notifications are disabled, show "Disabled" instead of toggle. -->
|
||||
<span *ngIf="preferences.disableall">{{ 'core.settings.disabled' | translate }}</span>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
<!-- Phone view -->
|
||||
<ion-list-header class="ion-text-wrap ion-hide-md-up">
|
||||
<ion-label>{{ processor.displayname }}</ion-label>
|
||||
</ion-list-header>
|
||||
<!-- If notifications not disabled, show toggles.
|
||||
If notifications are disabled, show "Disabled" instead of toggle. -->
|
||||
<ion-item *ngFor="let state of ['loggedin', 'loggedoff']" class="ion-text-wrap ion-hide-md-up">
|
||||
<ion-label>{{ 'core.settings.' + state | translate }}</ion-label>
|
||||
<ion-spinner slot="end"
|
||||
*ngIf="!preferences.disableall && (notification.updating && notification.updating[state])">
|
||||
</ion-spinner>
|
||||
<ion-toggle slot="end" *ngIf="!preferences.disableall && !processor.locked"
|
||||
[(ngModel)]="processor[state].checked"
|
||||
(ngModelChange)="changePreference(notification, state, processor)"
|
||||
[disabled]="notification.updating && notification.updating[state]">
|
||||
</ion-toggle>
|
||||
<ion-note slot="end" *ngIf="!preferences.disableall && processor.locked">
|
||||
{{'core.settings.locked' | translate }}
|
||||
</ion-note>
|
||||
<ion-note slot="end" *ngIf="preferences.disableall">
|
||||
{{ 'core.settings.disabled' | translate }}
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ion-card>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- General settings. -->
|
||||
<ion-card>
|
||||
<ion-list class="ion-text-wrap">
|
||||
<ion-item-divider><ion-label><h2>{{ 'core.settings.general' | translate }}</h2></ion-label></ion-item-divider>
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>
|
||||
<h2>{{ 'addon.messages.useentertosend' | translate }}</h2>
|
||||
</ion-label>
|
||||
<ion-toggle [(ngModel)]="sendOnEnter" (ngModelChange)="sendOnEnterChanged()"></ion-toggle>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
</ion-card>
|
||||
</core-loading>
|
||||
</ion-content>
|
|
@ -0,0 +1,47 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
|
||||
import { AddonMessagesSettingsPage } from './settings.page';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AddonMessagesSettingsPage,
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes),
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
FormsModule,
|
||||
TranslateModule.forChild(),
|
||||
CoreSharedModule,
|
||||
],
|
||||
declarations: [
|
||||
AddonMessagesSettingsPage,
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AddonMessagesSettingsPageModule {}
|
|
@ -0,0 +1,300 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import {
|
||||
AddonMessagesProvider, AddonMessagesMessagePreferences,
|
||||
AddonMessagesMessagePreferencesNotification,
|
||||
AddonMessagesMessagePreferencesNotificationProcessor,
|
||||
AddonMessages,
|
||||
} from '../../services/messages';
|
||||
import { CoreUser } from '@features/user/services/user';
|
||||
import { CoreApp } from '@services/app';
|
||||
import { CoreConfig } from '@services/config';
|
||||
import { CoreEvents } from '@singletons/events';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreConstants } from '@/core/constants';
|
||||
import { IonRefresher } from '@ionic/angular';
|
||||
|
||||
/**
|
||||
* Page that displays the messages settings page.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'page-addon-messages-settings',
|
||||
templateUrl: 'settings.html',
|
||||
styleUrls: ['settings.scss'],
|
||||
})
|
||||
export class AddonMessagesSettingsPage implements OnInit, OnDestroy {
|
||||
|
||||
protected updateTimeout?: number;
|
||||
|
||||
preferences?: AddonMessagesMessagePreferences;
|
||||
preferencesLoaded = false;
|
||||
contactablePrivacy?: number | boolean;
|
||||
advancedContactable = false; // Whether the site supports "advanced" contactable privacy.
|
||||
allowSiteMessaging = false;
|
||||
onlyContactsValue = AddonMessagesProvider.MESSAGE_PRIVACY_ONLYCONTACTS;
|
||||
courseMemberValue = AddonMessagesProvider.MESSAGE_PRIVACY_COURSEMEMBER;
|
||||
siteValue = AddonMessagesProvider.MESSAGE_PRIVACY_SITE;
|
||||
groupMessagingEnabled = false;
|
||||
sendOnEnter = false;
|
||||
|
||||
protected previousContactableValue?: number | boolean;
|
||||
|
||||
constructor() {
|
||||
|
||||
const currentSite = CoreSites.instance.getCurrentSite();
|
||||
this.advancedContactable = !!currentSite?.isVersionGreaterEqualThan('3.6');
|
||||
this.allowSiteMessaging = !!currentSite?.canUseAdvancedFeature('messagingallusers');
|
||||
this.groupMessagingEnabled = AddonMessages.instance.isGroupMessagingEnabled();
|
||||
|
||||
this.asyncInit();
|
||||
}
|
||||
|
||||
protected async asyncInit(): Promise<void> {
|
||||
this.sendOnEnter = !!(await CoreConfig.instance.get(CoreConstants.SETTINGS_SEND_ON_ENTER, !CoreApp.instance.isMobile()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs when the page has loaded. This event only happens once per page being created.
|
||||
* If a page leaves but is cached, then this event will not fire again on a subsequent viewing.
|
||||
* Setup code for the page.
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.fetchPreferences();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches preference data.
|
||||
*
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async fetchPreferences(): Promise<void> {
|
||||
try {
|
||||
const preferences = await AddonMessages.instance.getMessagePreferences();
|
||||
if (this.groupMessagingEnabled) {
|
||||
// Simplify the preferences.
|
||||
for (const component of preferences.components) {
|
||||
// Only display get the notification preferences.
|
||||
component.notifications = component.notifications.filter((notification) =>
|
||||
notification.preferencekey == AddonMessagesProvider.NOTIFICATION_PREFERENCES_KEY);
|
||||
|
||||
component.notifications.forEach((notification) => {
|
||||
notification.processors.forEach(
|
||||
(processor: AddonMessagesMessagePreferencesNotificationProcessorFormatted) => {
|
||||
processor.checked = processor.loggedin.checked || processor.loggedoff.checked;
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.preferences = preferences;
|
||||
this.contactablePrivacy = preferences.blocknoncontacts;
|
||||
this.previousContactableValue = this.contactablePrivacy;
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModal(error);
|
||||
} finally {
|
||||
this.preferencesLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update preferences. The purpose is to store the updated data, it won't be reflected in the view.
|
||||
*/
|
||||
protected updatePreferences(): void {
|
||||
AddonMessages.instance.invalidateMessagePreferences().finally(() => {
|
||||
this.fetchPreferences();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update preferences after a certain time. The purpose is to store the updated data, it won't be reflected in the view.
|
||||
*/
|
||||
protected updatePreferencesAfterDelay(): void {
|
||||
// Cancel pending updates.
|
||||
clearTimeout(this.updateTimeout);
|
||||
|
||||
this.updateTimeout = window.setTimeout(() => {
|
||||
this.updateTimeout = undefined;
|
||||
this.updatePreferences();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the contactable privacy setting..
|
||||
*
|
||||
* @param value The value to set.
|
||||
*/
|
||||
async saveContactablePrivacy(value?: number | boolean): Promise<void> {
|
||||
if (this.contactablePrivacy == this.previousContactableValue) {
|
||||
// Value hasn't changed from previous, it probably means that we just fetched the value from the server.
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true);
|
||||
|
||||
if (!this.advancedContactable) {
|
||||
// Convert from boolean to number.
|
||||
value = value ? 1 : 0;
|
||||
}
|
||||
|
||||
try {
|
||||
await CoreUser.instance.updateUserPreference('message_blocknoncontacts', String(value));
|
||||
// Update the preferences since they were modified.
|
||||
this.updatePreferencesAfterDelay();
|
||||
this.previousContactableValue = this.contactablePrivacy;
|
||||
} catch (message) {
|
||||
// Show error and revert change.
|
||||
CoreDomUtils.instance.showErrorModal(message);
|
||||
this.contactablePrivacy = this.previousContactableValue;
|
||||
} finally {
|
||||
modal.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the value of a certain preference.
|
||||
*
|
||||
* @param notification Notification object.
|
||||
* @param state State name, ['loggedin', 'loggedoff'].
|
||||
* @param processor Notification processor.
|
||||
*/
|
||||
async changePreference(
|
||||
notification: AddonMessagesMessagePreferencesNotificationFormatted,
|
||||
state: string,
|
||||
processor: AddonMessagesMessagePreferencesNotificationProcessorFormatted,
|
||||
): Promise<void> {
|
||||
|
||||
const valueArray: string[] = [];
|
||||
let value = 'none';
|
||||
|
||||
if (this.groupMessagingEnabled) {
|
||||
// Update both states at the same time.
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
notification.processors.forEach((processor: AddonMessagesMessagePreferencesNotificationProcessorFormatted) => {
|
||||
if (processor.checked) {
|
||||
valueArray.push(processor.name);
|
||||
}
|
||||
});
|
||||
|
||||
if (value.length > 0) {
|
||||
value = valueArray.join(',');
|
||||
}
|
||||
|
||||
notification.updating = true;
|
||||
|
||||
promises.push(CoreUser.instance.updateUserPreference(notification.preferencekey + '_loggedin', value));
|
||||
promises.push(CoreUser.instance.updateUserPreference(notification.preferencekey + '_loggedoff', value));
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
// Update the preferences since they were modified.
|
||||
this.updatePreferencesAfterDelay();
|
||||
} catch (error) {
|
||||
// Show error and revert change.
|
||||
CoreDomUtils.instance.showErrorModal(error);
|
||||
processor.checked = !processor.checked;
|
||||
} finally {
|
||||
notification.updating = false;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Update only the specified state.
|
||||
const processorState = processor[state];
|
||||
const preferenceName = notification.preferencekey + '_' + processorState.name;
|
||||
|
||||
notification.processors.forEach((processor) => {
|
||||
if (processor[state].checked) {
|
||||
valueArray.push(processor.name);
|
||||
}
|
||||
});
|
||||
|
||||
if (value.length > 0) {
|
||||
value = valueArray.join(',');
|
||||
}
|
||||
|
||||
if (!notification.updating) {
|
||||
notification.updating = {};
|
||||
}
|
||||
|
||||
notification.updating[state] = true;
|
||||
try {
|
||||
await CoreUser.instance.updateUserPreference(preferenceName, value);
|
||||
// Update the preferences since they were modified.
|
||||
this.updatePreferencesAfterDelay();
|
||||
} catch (error) {
|
||||
// Show error and revert change.
|
||||
CoreDomUtils.instance.showErrorModal(error);
|
||||
processorState.checked = !processorState.checked;
|
||||
} finally {
|
||||
notification.updating[state] = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the list of preferences.
|
||||
*
|
||||
* @param refresher Refresher.
|
||||
*/
|
||||
refreshPreferences(refresher?: CustomEvent<IonRefresher>): void {
|
||||
AddonMessages.instance.invalidateMessagePreferences().finally(() => {
|
||||
this.fetchPreferences().finally(() => {
|
||||
refresher?.detail.complete();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
sendOnEnterChanged(): void {
|
||||
// Save the value.
|
||||
CoreConfig.instance.set(CoreConstants.SETTINGS_SEND_ON_ENTER, this.sendOnEnter ? 1 : 0);
|
||||
|
||||
// Notify the app.
|
||||
CoreEvents.trigger(
|
||||
CoreEvents.SEND_ON_ENTER_CHANGED,
|
||||
{ sendOnEnter: !!this.sendOnEnter },
|
||||
CoreSites.instance.getCurrentSiteId(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Page destroyed.
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
// If there is a pending action to update preferences, execute it right now.
|
||||
if (this.updateTimeout) {
|
||||
clearTimeout(this.updateTimeout);
|
||||
this.updatePreferences();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Message preferences notification with some caclulated data.
|
||||
*/
|
||||
type AddonMessagesMessagePreferencesNotificationFormatted = AddonMessagesMessagePreferencesNotification & {
|
||||
updating?: boolean | {[state: string]: boolean}; // Calculated in the app. Whether the notification is being updated.
|
||||
};
|
||||
|
||||
/**
|
||||
* Message preferences notification processor with some caclulated data.
|
||||
*/
|
||||
type AddonMessagesMessagePreferencesNotificationProcessorFormatted = AddonMessagesMessagePreferencesNotificationProcessor & {
|
||||
checked?: boolean; // Calculated in the app. Whether the processor is checked either for loggedin or loggedoff.
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
:host {
|
||||
.list-header {
|
||||
margin-bottom: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
// (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 { CoreSiteSchema } from '@services/sites';
|
||||
|
||||
/**
|
||||
* Database variables for AddonMessagesOffline service.
|
||||
*/
|
||||
export const MESSAGES_TABLE = 'addon_messages_offline_messages'; // When group messaging isn't available or new conversation starts.
|
||||
export const CONVERSATION_MESSAGES_TABLE = 'addon_messages_offline_conversation_messages'; // Conversation messages.
|
||||
export const MESSAGES_OFFLINE_SITE_SCHEMA: CoreSiteSchema = {
|
||||
name: 'AddonMessagesOfflineProvider',
|
||||
version: 1,
|
||||
tables: [
|
||||
{
|
||||
name: MESSAGES_TABLE,
|
||||
columns: [
|
||||
{
|
||||
name: 'touserid',
|
||||
type: 'INTEGER',
|
||||
},
|
||||
{
|
||||
name: 'useridfrom',
|
||||
type: 'INTEGER',
|
||||
},
|
||||
{
|
||||
name: 'smallmessage',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
name: 'timecreated',
|
||||
type: 'INTEGER',
|
||||
},
|
||||
{
|
||||
name: 'deviceoffline', // If message was stored because device was offline.
|
||||
type: 'INTEGER',
|
||||
},
|
||||
],
|
||||
primaryKeys: ['touserid', 'smallmessage', 'timecreated'],
|
||||
},
|
||||
{
|
||||
name: CONVERSATION_MESSAGES_TABLE,
|
||||
columns: [
|
||||
{
|
||||
name: 'conversationid',
|
||||
type: 'INTEGER',
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
type: 'TEXT',
|
||||
},
|
||||
{
|
||||
name: 'timecreated',
|
||||
type: 'INTEGER',
|
||||
},
|
||||
{
|
||||
name: 'deviceoffline', // If message was stored because device was offline.
|
||||
type: 'INTEGER',
|
||||
},
|
||||
{
|
||||
name: 'conversation', // Data about the conversation.
|
||||
type: 'TEXT',
|
||||
},
|
||||
],
|
||||
primaryKeys: ['conversationid', 'text', 'timecreated'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export type AddonMessagesOfflineMessagesDBRecord = {
|
||||
touserid: number;
|
||||
useridfrom: number;
|
||||
smallmessage: string;
|
||||
timecreated: number;
|
||||
deviceoffline: number; // If message was stored because device was offline.
|
||||
};
|
||||
|
||||
export type AddonMessagesOfflineConversationMessagesDBRecord = {
|
||||
conversationid: number;
|
||||
text: string;
|
||||
timecreated: number;
|
||||
deviceoffline: number; // If message was stored because device was offline.
|
||||
conversation: string; // Data about the conversation.
|
||||
};
|
|
@ -0,0 +1,62 @@
|
|||
// (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 { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
||||
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { makeSingleton } from '@singletons';
|
||||
import { AddonMessages } from '../messages';
|
||||
|
||||
/**
|
||||
* Content links handler for a contact requests.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonMessagesContactRequestLinkHandlerService extends CoreContentLinksHandlerBase {
|
||||
|
||||
name = 'AddonMessagesContactRequestLinkHandler';
|
||||
pattern = /\/message\/pendingcontactrequests\.php/;
|
||||
|
||||
/**
|
||||
* Get the list of actions for a link (url).
|
||||
*
|
||||
* @return List of (or promise resolved with list of) actions.
|
||||
*/
|
||||
getActions(): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
|
||||
return [{
|
||||
action: (siteId): void => {
|
||||
CoreNavigator.instance.navigateToSitePath('/messages/contacts', { siteId });
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the handler is enabled for a certain site (site + user) and a URL.
|
||||
* If not defined, defaults to true.
|
||||
*
|
||||
* @param siteId The site ID.
|
||||
* @return Whether the handler is enabled for the URL and site.
|
||||
*/
|
||||
async isEnabled(siteId: string): Promise<boolean> {
|
||||
const enabled = await AddonMessages.instance.isPluginEnabled(siteId);
|
||||
if (!enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return AddonMessages.instance.isGroupMessagingEnabledInSite(siteId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonMessagesContactRequestLinkHandler extends makeSingleton(AddonMessagesContactRequestLinkHandlerService) {}
|
|
@ -0,0 +1,85 @@
|
|||
// (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 { Params } from '@angular/router';
|
||||
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
||||
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { makeSingleton } from '@singletons';
|
||||
import { AddonMessages } from '../messages';
|
||||
|
||||
/**
|
||||
* Content links handler for a discussion.
|
||||
* Match message index URL with params id, user1 or user2.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonMessagesDiscussionLinkHandlerService extends CoreContentLinksHandlerBase {
|
||||
|
||||
name = 'AddonMessagesDiscussionLinkHandler';
|
||||
pattern = /\/message\/index\.php.*([?&](id|user1|user2)=\d+)/;
|
||||
|
||||
/**
|
||||
* Get the list of actions for a link (url).
|
||||
*
|
||||
* @param siteIds List of sites the URL belongs to.
|
||||
* @param url The URL to treat.
|
||||
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
|
||||
* @return List of (or promise resolved with list of) actions.
|
||||
*/
|
||||
getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
|
||||
return [{
|
||||
action: (siteId): void => {
|
||||
const stateParams = {
|
||||
userId: parseInt(params.id || params.user2, 10),
|
||||
};
|
||||
CoreNavigator.instance.navigateToSitePath('/messages/discussion', { params: stateParams, siteId });
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the handler is enabled for a certain site (site + user) and a URL.
|
||||
* If not defined, defaults to true.
|
||||
*
|
||||
* @param siteId The site ID.
|
||||
* @param url The URL to treat.
|
||||
* @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
|
||||
* @return Whether the handler is enabled for the URL and site.
|
||||
*/
|
||||
async isEnabled(siteId: string, url: string, params: Params): Promise<boolean> {
|
||||
const enabled = await AddonMessages.instance.isPluginEnabled(siteId);
|
||||
if (!enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof params.id == 'undefined' && typeof params.user2 == 'undefined') {
|
||||
// Other user not defined, cannot treat the URL.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof params.user1 != 'undefined') {
|
||||
// Check if user1 is the current user, since the app only supports current user.
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
|
||||
return parseInt(params.user1, 10) == site.getUserId();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonMessagesDiscussionLinkHandler extends makeSingleton(AddonMessagesDiscussionLinkHandlerService) {}
|
|
@ -0,0 +1,61 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler';
|
||||
import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { makeSingleton } from '@singletons';
|
||||
import { AddonMessages } from '../messages';
|
||||
|
||||
/**
|
||||
* Content links handler for messaging index.
|
||||
* Match message index URL without params id, user1 or user2.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonMessagesIndexLinkHandlerService extends CoreContentLinksHandlerBase {
|
||||
|
||||
name = 'AddonMessagesIndexLinkHandler';
|
||||
pattern = /\/message\/index\.php((?![?&](id|user1|user2)=\d+).)*$/;
|
||||
|
||||
|
||||
/**
|
||||
* Get the list of actions for a link (url).
|
||||
*
|
||||
* @return List of (or promise resolved with list of) actions.
|
||||
*/
|
||||
getActions(): CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
|
||||
return [{
|
||||
action: async (siteId): Promise<void> => {
|
||||
const pageName = await AddonMessages.instance.getMainMessagesPagePathInSite(siteId);
|
||||
|
||||
CoreNavigator.instance.navigateToSitePath(pageName, { siteId });
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the handler is enabled for a certain site (site + user) and a URL.
|
||||
* If not defined, defaults to true.
|
||||
*
|
||||
* @param siteId The site ID.
|
||||
* @return Whether the handler is enabled for the URL and site.
|
||||
*/
|
||||
isEnabled(siteId: string): Promise<boolean> {
|
||||
return AddonMessages.instance.isPluginEnabled(siteId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonMessagesIndexLinkHandler extends makeSingleton(AddonMessagesIndexLinkHandlerService) {}
|
|
@ -0,0 +1,223 @@
|
|||
// (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 {
|
||||
AddonMessagesProvider,
|
||||
AddonMessages,
|
||||
AddonMessagesUnreadConversationCountsEventData,
|
||||
AddonMessagesContactRequestCountEventData,
|
||||
} from '../messages';
|
||||
import { CoreMainMenuHandler, CoreMainMenuHandlerToDisplay } from '@features/mainmenu/services/mainmenu-delegate';
|
||||
import { CoreCronHandler } from '@services/cron';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreEvents } from '@singletons/events';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import {
|
||||
CorePushNotifications,
|
||||
CorePushNotificationsNotificationBasicData,
|
||||
} from '@features/pushnotifications/services/pushnotifications';
|
||||
import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate';
|
||||
import { makeSingleton } from '@singletons';
|
||||
|
||||
/**
|
||||
* Handler to inject an option into main menu.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonMessagesMainMenuHandlerService implements CoreMainMenuHandler, CoreCronHandler {
|
||||
|
||||
static readonly PAGE_NAME = 'messages';
|
||||
|
||||
name = 'AddonMessages';
|
||||
priority = 800;
|
||||
|
||||
protected handler: CoreMainMenuHandlerToDisplay = {
|
||||
icon: 'fas-comments',
|
||||
title: 'addon.messages.messages',
|
||||
page: AddonMessages.instance.getMainMessagesPagePath(),
|
||||
class: 'addon-messages-handler',
|
||||
showBadge: true, // Do not check isMessageCountEnabled because we'll use fallback it not enabled.
|
||||
badge: '',
|
||||
loading: true,
|
||||
};
|
||||
|
||||
protected unreadCount = 0;
|
||||
protected contactRequestsCount = 0;
|
||||
protected orMore = false;
|
||||
|
||||
constructor() {
|
||||
|
||||
CoreEvents.on<AddonMessagesUnreadConversationCountsEventData>(
|
||||
AddonMessagesProvider.UNREAD_CONVERSATION_COUNTS_EVENT,
|
||||
(data) => {
|
||||
this.unreadCount = data.favourites + data.individual + data.group + data.self;
|
||||
this.orMore = !!data.orMore;
|
||||
this.updateBadge(data.siteId!);
|
||||
},
|
||||
);
|
||||
|
||||
CoreEvents.on<AddonMessagesContactRequestCountEventData>(AddonMessagesProvider.CONTACT_REQUESTS_COUNT_EVENT, (data) => {
|
||||
this.contactRequestsCount = data.count;
|
||||
this.updateBadge(data.siteId!);
|
||||
});
|
||||
|
||||
// Reset info on logout.
|
||||
CoreEvents.on(CoreEvents.LOGOUT, () => {
|
||||
this.unreadCount = 0;
|
||||
this.contactRequestsCount = 0;
|
||||
this.orMore = false;
|
||||
this.handler.badge = '';
|
||||
this.handler.loading = true;
|
||||
});
|
||||
|
||||
// If a message push notification is received, refresh the count.
|
||||
CorePushNotificationsDelegate.instance.on<CorePushNotificationsNotificationBasicData>('receive').subscribe(
|
||||
(notification) => {
|
||||
// New message received. If it's from current site, refresh the data.
|
||||
const isMessage = CoreUtils.instance.isFalseOrZero(notification.notif) ||
|
||||
notification.name == 'messagecontactrequests';
|
||||
if (isMessage && CoreSites.instance.isCurrentSite(notification.site)) {
|
||||
this.refreshBadge(notification.site);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Register Badge counter.
|
||||
CorePushNotificationsDelegate.instance.registerCounterHandler('AddonMessages');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the handler is enabled on a site level.
|
||||
*
|
||||
* @return Whether or not the handler is enabled on a site level.
|
||||
*/
|
||||
isEnabled(): Promise<boolean> {
|
||||
return AddonMessages.instance.isPluginEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data needed to render the handler.
|
||||
*
|
||||
* @return Data needed to render the handler.
|
||||
*/
|
||||
getDisplayData(): CoreMainMenuHandlerToDisplay {
|
||||
this.handler.page = AddonMessages.instance.getMainMessagesPagePath();
|
||||
|
||||
if (this.handler.loading) {
|
||||
this.refreshBadge();
|
||||
}
|
||||
|
||||
return this.handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes badge number.
|
||||
*
|
||||
* @param siteId Site ID or current Site if undefined.
|
||||
* @param unreadOnly If true only the unread conversations count is refreshed.
|
||||
* @return Resolve when done.
|
||||
*/
|
||||
async refreshBadge(siteId?: string, unreadOnly?: boolean): Promise<void> {
|
||||
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||
if (!siteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
promises.push(AddonMessages.instance.refreshUnreadConversationCounts(siteId).catch(() => {
|
||||
this.unreadCount = 0;
|
||||
this.orMore = false;
|
||||
}));
|
||||
|
||||
// Refresh the number of contact requests in 3.6+ sites.
|
||||
if (!unreadOnly && AddonMessages.instance.isGroupMessagingEnabled()) {
|
||||
promises.push(AddonMessages.instance.refreshContactRequestsCount(siteId).catch(() => {
|
||||
this.contactRequestsCount = 0;
|
||||
}));
|
||||
}
|
||||
|
||||
await Promise.all(promises).finally(() => {
|
||||
this.updateBadge(siteId!);
|
||||
this.handler.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update badge number and push notifications counter from loaded data.
|
||||
*
|
||||
* @param siteId Site ID.
|
||||
*/
|
||||
updateBadge(siteId: string): void {
|
||||
const totalCount = this.unreadCount + (this.contactRequestsCount || 0);
|
||||
if (totalCount > 0) {
|
||||
this.handler.badge = totalCount + (this.orMore ? '+' : '');
|
||||
} else {
|
||||
this.handler.badge = '';
|
||||
}
|
||||
|
||||
// Update push notifications badge.
|
||||
CorePushNotifications.instance.updateAddonCounter('AddonMessages', totalCount, siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the process.
|
||||
* Receives the ID of the site affected, undefined for all sites.
|
||||
*
|
||||
* @param siteId ID of the site affected, undefined for all sites.
|
||||
* @return Promise resolved when done, rejected if failure.
|
||||
*/
|
||||
async execute(siteId?: string): Promise<void> {
|
||||
if (!CoreSites.instance.isCurrentSite(siteId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshBadge();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the time between consecutive executions.
|
||||
*
|
||||
* @return Time between consecutive executions (in ms).
|
||||
*/
|
||||
getInterval(): number {
|
||||
if (!this.isSync()) {
|
||||
return 300000; // We have a WS to check the number, check it every 5 minutes.
|
||||
}
|
||||
|
||||
return 600000; // Check it every 10 minutes.
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether it's a synchronization process or not.
|
||||
*
|
||||
* @return True if is a sync process, false otherwise.
|
||||
*/
|
||||
isSync(): boolean {
|
||||
// This is done to use only wifi if using the fallback function.
|
||||
return !AddonMessages.instance.isMessageCountEnabled() && !AddonMessages.instance.isGroupMessagingEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the process should be executed during a manual sync.
|
||||
*
|
||||
* @return True if is a manual sync process, false otherwise.
|
||||
*/
|
||||
canManualSync(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonMessagesMainMenuHandler extends makeSingleton(AddonMessagesMainMenuHandlerService) {}
|
|
@ -0,0 +1,84 @@
|
|||
// (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 { Params } from '@angular/router';
|
||||
import { CorePushNotificationsClickHandler } from '@features/pushnotifications/services/push-delegate';
|
||||
import { CorePushNotificationsNotificationBasicData } from '@features/pushnotifications/services/pushnotifications';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { makeSingleton } from '@singletons';
|
||||
import { AddonMessages } from '../messages';
|
||||
|
||||
/**
|
||||
* Handler for messaging push notifications clicks.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonMessagesPushClickHandlerService implements CorePushNotificationsClickHandler {
|
||||
|
||||
name = 'AddonMessagesPushClickHandler';
|
||||
priority = 200;
|
||||
featureName = 'CoreMainMenuDelegate_AddonMessages';
|
||||
|
||||
/**
|
||||
* Check if a notification click is handled by this handler.
|
||||
*
|
||||
* @param notification The notification to check.
|
||||
* @return Whether the notification click is handled by this handler
|
||||
*/
|
||||
async handles(notification: AddonMessagesPushNotificationData): Promise<boolean> {
|
||||
if (CoreUtils.instance.isTrueOrOne(notification.notif) && notification.name != 'messagecontactrequests') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that messaging is enabled.
|
||||
return AddonMessages.instance.isPluginEnabled(notification.site);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the notification click.
|
||||
*
|
||||
* @param notification The notification to check.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
async handleClick(notification: AddonMessagesPushNotificationData): Promise<void> {
|
||||
try {
|
||||
await AddonMessages.instance.invalidateDiscussionsCache(notification.site);
|
||||
} catch {
|
||||
// Ignore errors.
|
||||
}
|
||||
|
||||
// Check if group messaging is enabled, to determine which page should be loaded.
|
||||
const enabled = await AddonMessages.instance.isGroupMessagingEnabledInSite(notification.site);
|
||||
const pageName = await AddonMessages.instance.getMainMessagesPagePathInSite(notification.site);
|
||||
|
||||
const pageParams: Params = {};
|
||||
|
||||
// Check if we have enough information to open the conversation.
|
||||
if (notification.convid && enabled) {
|
||||
pageParams.conversationId = Number(notification.convid);
|
||||
} else if (notification.userfromid) {
|
||||
pageParams.discussionUserId = Number(notification.userfromid);
|
||||
}
|
||||
|
||||
await CoreNavigator.instance.navigateToSitePath(pageName, { params: pageParams, siteId: notification.site });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonMessagesPushClickHandler extends makeSingleton(AddonMessagesPushClickHandlerService) {}
|
||||
|
||||
type AddonMessagesPushNotificationData = CorePushNotificationsNotificationBasicData & {
|
||||
convid?: number; // Conversation Id.
|
||||
};
|
|
@ -0,0 +1,60 @@
|
|||
// (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 { CoreSettingsHandler, CoreSettingsHandlerData } from '@features/settings/services/settings-delegate';
|
||||
import { makeSingleton } from '@singletons';
|
||||
import { AddonMessages } from '../messages';
|
||||
import { AddonMessagesMainMenuHandlerService } from './mainmenu';
|
||||
|
||||
/**
|
||||
* Message settings handler.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonMessagesSettingsHandlerService implements CoreSettingsHandler {
|
||||
|
||||
static readonly PAGE_NAME = 'settings';
|
||||
|
||||
name = 'AddonMessages';
|
||||
priority = 600;
|
||||
|
||||
/**
|
||||
* Check if the handler is enabled on a site level.
|
||||
*
|
||||
* @return Whether or not the handler is enabled on a site level.
|
||||
*/
|
||||
async isEnabled(): Promise<boolean> {
|
||||
const messagingEnabled = await AddonMessages.instance.isPluginEnabled();
|
||||
|
||||
return messagingEnabled && AddonMessages.instance.isMessagePreferencesEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data needed to render the handler.
|
||||
*
|
||||
* @return Data needed to render the handler.
|
||||
*/
|
||||
getDisplayData(): CoreSettingsHandlerData {
|
||||
return {
|
||||
icon: 'fas-comments',
|
||||
title: 'addon.messages.messages',
|
||||
page: AddonMessagesMainMenuHandlerService.PAGE_NAME + '/' + AddonMessagesSettingsHandlerService.PAGE_NAME,
|
||||
class: 'addon-messages-settings-handler',
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonMessagesSettingsHandler extends makeSingleton(AddonMessagesSettingsHandlerService) {}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
// (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 { CoreCronHandler } from '@services/cron';
|
||||
import { makeSingleton } from '@singletons';
|
||||
import { AddonMessagesSync } from '../messages-sync';
|
||||
|
||||
/**
|
||||
* Synchronization cron handler.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonMessagesSyncCronHandlerService implements CoreCronHandler {
|
||||
|
||||
name = 'AddonMessagesSyncCronHandler';
|
||||
|
||||
/**
|
||||
* Execute the process.
|
||||
* Receives the ID of the site affected, undefined for all sites.
|
||||
*
|
||||
* @param siteId ID of the site affected, undefined for all sites.
|
||||
* @return Promise resolved when done, rejected if failure.
|
||||
*/
|
||||
execute(siteId?: string): Promise<void> {
|
||||
return AddonMessagesSync.instance.syncAllDiscussions(siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the time between consecutive executions.
|
||||
*
|
||||
* @return Time between consecutive executions (in ms).
|
||||
*/
|
||||
getInterval(): number {
|
||||
return 300000; // 5 minutes.
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonMessagesSyncCronHandler extends makeSingleton(AddonMessagesSyncCronHandlerService) {}
|
|
@ -0,0 +1,85 @@
|
|||
// (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 { Params } from '@angular/router';
|
||||
import { CoreUserProfile } from '@features/user/services/user';
|
||||
import { CoreUserDelegateService, CoreUserProfileHandler, CoreUserProfileHandlerData } from '@features/user/services/user-delegate';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { makeSingleton } from '@singletons';
|
||||
import { AddonMessages } from '../messages';
|
||||
|
||||
/**
|
||||
* Profile send message handler.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonMessagesSendMessageUserHandlerService implements CoreUserProfileHandler {
|
||||
|
||||
name = 'AddonMessages:sendMessage';
|
||||
priority = 1000;
|
||||
type = CoreUserDelegateService.TYPE_COMMUNICATION;
|
||||
|
||||
/**
|
||||
* Check if handler is enabled.
|
||||
*
|
||||
* @return Promise resolved with true if enabled, rejected or resolved with false otherwise.
|
||||
*/
|
||||
isEnabled(): Promise<boolean> {
|
||||
return AddonMessages.instance.isPluginEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if handler is enabled for this user in this context.
|
||||
*
|
||||
* @param user User to check.
|
||||
* @return Promise resolved with true if enabled, resolved with false otherwise.
|
||||
*/
|
||||
async isEnabledForUser(user: CoreUserProfile): Promise<boolean> {
|
||||
const currentSite = CoreSites.instance.getCurrentSite();
|
||||
|
||||
if (!currentSite) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// From 3.7 you can send messages to yourself.
|
||||
return user.id != currentSite.getUserId() || currentSite.isVersionGreaterEqualThan('3.7');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data needed to render the handler.
|
||||
*
|
||||
* @return Data needed to render the handler.
|
||||
*/
|
||||
getDisplayData(): CoreUserProfileHandlerData {
|
||||
return {
|
||||
icon: 'fas-paper-plane',
|
||||
title: 'addon.messages.message',
|
||||
class: 'addon-messages-send-message-handler',
|
||||
action: (event: Event, user: CoreUserProfile): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const pageParams: Params = {
|
||||
showKeyboard: true,
|
||||
userId: user.id,
|
||||
};
|
||||
CoreNavigator.instance.navigateToSitePath('/messages/discussion', { params: pageParams });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonMessagesSendMessageUserHandler extends makeSingleton(AddonMessagesSendMessageUserHandlerService) {}
|
|
@ -0,0 +1,384 @@
|
|||
// (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 { CoreSites } from '@services/sites';
|
||||
import { CoreApp } from '@services/app';
|
||||
import { CoreTextUtils } from '@services/utils/text';
|
||||
import {
|
||||
AddonMessagesOfflineConversationMessagesDBRecord,
|
||||
AddonMessagesOfflineMessagesDBRecord,
|
||||
CONVERSATION_MESSAGES_TABLE,
|
||||
MESSAGES_TABLE,
|
||||
} from './database/messages';
|
||||
import { makeSingleton } from '@singletons';
|
||||
import { AddonMessagesConversation } from './messages';
|
||||
|
||||
/**
|
||||
* Service to handle Offline messages.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonMessagesOfflineProvider {
|
||||
|
||||
/**
|
||||
* Delete a message.
|
||||
*
|
||||
* @param conversationId Conversation ID.
|
||||
* @param message The message.
|
||||
* @param timeCreated The time the message was created.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved if stored, rejected if failure.
|
||||
*/
|
||||
async deleteConversationMessage(conversationId: number, message: string, timeCreated: number, siteId?: string): Promise<void> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
|
||||
await site.getDb().deleteRecords(CONVERSATION_MESSAGES_TABLE, {
|
||||
conversationid: conversationId,
|
||||
text: message,
|
||||
timecreated: timeCreated,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all the messages in a conversation.
|
||||
*
|
||||
* @param conversationId Conversation ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved if stored, rejected if failure.
|
||||
*/
|
||||
async deleteConversationMessages(conversationId: number, siteId?: string): Promise<void> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
|
||||
await site.getDb().deleteRecords(CONVERSATION_MESSAGES_TABLE, {
|
||||
conversationid: conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a message.
|
||||
*
|
||||
* @param toUserId User ID to send the message to.
|
||||
* @param message The message.
|
||||
* @param timeCreated The time the message was created.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved if stored, rejected if failure.
|
||||
*/
|
||||
async deleteMessage(toUserId: number, message: string, timeCreated: number, siteId?: string): Promise<void> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
|
||||
await site.getDb().deleteRecords(MESSAGES_TABLE, {
|
||||
touserid: toUserId,
|
||||
smallmessage: message,
|
||||
timecreated: timeCreated,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages where deviceoffline is set to 1.
|
||||
*
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved with messages.
|
||||
*/
|
||||
async getAllDeviceOfflineMessages(
|
||||
siteId?: string,
|
||||
): Promise<AddonMessagesOfflineAnyMessagesFormatted[]> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
|
||||
const [
|
||||
messages,
|
||||
conversations,
|
||||
] = await Promise.all([
|
||||
site.getDb().getRecords<AddonMessagesOfflineMessagesDBRecord>(MESSAGES_TABLE, { deviceoffline: 1 }),
|
||||
site.getDb().getRecords<AddonMessagesOfflineConversationMessagesDBRecord>(
|
||||
CONVERSATION_MESSAGES_TABLE,
|
||||
{ deviceoffline: 1 },
|
||||
),
|
||||
]);
|
||||
|
||||
|
||||
const messageResult:
|
||||
AddonMessagesOfflineAnyMessagesFormatted[] =
|
||||
this.parseMessages(messages);
|
||||
const formattedConv = this.parseConversationMessages(conversations);
|
||||
|
||||
return messageResult.concat(formattedConv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all offline messages.
|
||||
*
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved with messages.
|
||||
*/
|
||||
async getAllMessages(
|
||||
siteId?: string,
|
||||
): Promise<AddonMessagesOfflineAnyMessagesFormatted[]> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
|
||||
const [
|
||||
messages,
|
||||
conversations,
|
||||
] = await Promise.all([
|
||||
site.getDb().getAllRecords<AddonMessagesOfflineMessagesDBRecord>(MESSAGES_TABLE),
|
||||
site.getDb().getAllRecords<AddonMessagesOfflineConversationMessagesDBRecord>(CONVERSATION_MESSAGES_TABLE),
|
||||
]);
|
||||
|
||||
const messageResult:
|
||||
AddonMessagesOfflineAnyMessagesFormatted[] =
|
||||
this.parseMessages(messages);
|
||||
const formattedConv = this.parseConversationMessages(conversations);
|
||||
|
||||
return messageResult.concat(formattedConv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get offline messages to send to a certain user.
|
||||
*
|
||||
* @param conversationId Conversation ID.
|
||||
* @param userIdFrom To add to the conversation messages when parsing.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved with messages.
|
||||
*/
|
||||
async getConversationMessages(
|
||||
conversationId: number,
|
||||
userIdFrom?: number,
|
||||
siteId?: string,
|
||||
): Promise<AddonMessagesOfflineConversationMessagesDBRecordFormatted[]> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
|
||||
const messages: AddonMessagesOfflineConversationMessagesDBRecord[] = await site.getDb().getRecords(
|
||||
CONVERSATION_MESSAGES_TABLE,
|
||||
{ conversationid: conversationId },
|
||||
);
|
||||
|
||||
return this.parseConversationMessages(messages, userIdFrom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get offline messages to send to a certain user.
|
||||
*
|
||||
* @param toUserId User ID to get messages to.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved with messages.
|
||||
*/
|
||||
async getMessages(toUserId: number, siteId?: string): Promise<AddonMessagesOfflineMessagesDBRecordFormatted[]> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
|
||||
const messages: AddonMessagesOfflineMessagesDBRecord[] =
|
||||
await site.getDb().getRecords(MESSAGES_TABLE, { touserid: toUserId });
|
||||
|
||||
return this.parseMessages(messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are offline messages to send to a conversation.
|
||||
*
|
||||
* @param conversationId Conversation ID.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved with boolean: true if has offline messages, false otherwise.
|
||||
*/
|
||||
async hasConversationMessages(conversationId: number, siteId?: string): Promise<boolean> {
|
||||
const messages = await this.getConversationMessages(conversationId, undefined, siteId);
|
||||
|
||||
return !!messages.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are offline messages to send to a certain user.
|
||||
*
|
||||
* @param toUserId User ID to check.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved with boolean: true if has offline messages, false otherwise.
|
||||
*/
|
||||
async hasMessages(toUserId: number, siteId?: string): Promise<boolean> {
|
||||
const messages = await this.getMessages(toUserId, siteId);
|
||||
|
||||
return !!messages.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse some fields of each offline conversation messages.
|
||||
*
|
||||
* @param messages List of messages to parse.
|
||||
* @param userIdFrom To add to the conversation messages when parsin.
|
||||
* @return Parsed messages.
|
||||
*/
|
||||
protected parseConversationMessages(
|
||||
messages: AddonMessagesOfflineConversationMessagesDBRecord[],
|
||||
userIdFrom?: number,
|
||||
): AddonMessagesOfflineConversationMessagesDBRecordFormatted[] {
|
||||
if (!messages) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return messages.map((message) => {
|
||||
const parsedMessage: AddonMessagesOfflineConversationMessagesDBRecordFormatted = {
|
||||
conversationid: message.conversationid,
|
||||
text: message.text,
|
||||
timecreated: message.timecreated,
|
||||
deviceoffline: message.deviceoffline,
|
||||
conversation: message.conversation ? CoreTextUtils.instance.parseJSON(message.conversation, undefined) : undefined,
|
||||
pending: true,
|
||||
useridfrom: userIdFrom,
|
||||
};
|
||||
|
||||
return parsedMessage;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse some fields of each offline messages.
|
||||
*
|
||||
* @param messages List of messages to parse.
|
||||
* @return Parsed messages.
|
||||
*/
|
||||
protected parseMessages(
|
||||
messages: AddonMessagesOfflineMessagesDBRecord[],
|
||||
): AddonMessagesOfflineMessagesDBRecordFormatted[] {
|
||||
if (!messages) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return messages.map((message) => {
|
||||
const parsedMessage: AddonMessagesOfflineMessagesDBRecordFormatted = {
|
||||
touserid: message.touserid,
|
||||
useridfrom: message.useridfrom,
|
||||
smallmessage: message.smallmessage,
|
||||
timecreated: message.timecreated,
|
||||
deviceoffline: message.deviceoffline,
|
||||
pending: true,
|
||||
text: message.smallmessage,
|
||||
};
|
||||
|
||||
return parsedMessage;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a conversation message to be sent later.
|
||||
*
|
||||
* @param conversation Conversation.
|
||||
* @param message The message to send.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved if stored, rejected if failure.
|
||||
*/
|
||||
async saveConversationMessage(
|
||||
conversation: AddonMessagesConversation,
|
||||
message: string,
|
||||
siteId?: string,
|
||||
): Promise<AddonMessagesOfflineConversationMessagesDBRecord> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
|
||||
const entry: AddonMessagesOfflineConversationMessagesDBRecord = {
|
||||
conversationid: conversation.id,
|
||||
text: message,
|
||||
timecreated: Date.now(),
|
||||
deviceoffline: CoreApp.instance.isOnline() ? 0 : 1,
|
||||
conversation: JSON.stringify({
|
||||
name: conversation.name || '',
|
||||
subname: conversation.subname || '',
|
||||
imageurl: conversation.imageurl || '',
|
||||
isfavourite: conversation.isfavourite ? 1 : 0,
|
||||
type: conversation.type,
|
||||
}),
|
||||
};
|
||||
|
||||
await site.getDb().insertRecord(CONVERSATION_MESSAGES_TABLE, entry);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a message to be sent later.
|
||||
*
|
||||
* @param toUserId User ID recipient of the message.
|
||||
* @param message The message to send.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved if stored, rejected if failure.
|
||||
*/
|
||||
async saveMessage(toUserId: number, message: string, siteId?: string): Promise<AddonMessagesOfflineMessagesDBRecord> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
|
||||
const entry: AddonMessagesOfflineMessagesDBRecord = {
|
||||
touserid: toUserId,
|
||||
useridfrom: site.getUserId(),
|
||||
smallmessage: message,
|
||||
timecreated: new Date().getTime(),
|
||||
deviceoffline: CoreApp.instance.isOnline() ? 0 : 1,
|
||||
};
|
||||
|
||||
await site.getDb().insertRecord(MESSAGES_TABLE, entry);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set deviceoffline for a group of messages.
|
||||
*
|
||||
* @param messages Messages to update. Should be the same entry as retrieved from the DB.
|
||||
* @param value Value to set.
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved if stored, rejected if failure.
|
||||
*/
|
||||
async setMessagesDeviceOffline(
|
||||
messages: AddonMessagesOfflineAnyMessagesFormatted[],
|
||||
value: boolean,
|
||||
siteId?: string,
|
||||
): Promise<void> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
|
||||
const db = site.getDb();
|
||||
|
||||
const promises: Promise<number>[] = [];
|
||||
const data = { deviceoffline: value ? 1 : 0 };
|
||||
|
||||
messages.forEach((message) => {
|
||||
if ('conversationid' in message) {
|
||||
promises.push(db.updateRecords(
|
||||
CONVERSATION_MESSAGES_TABLE,
|
||||
data,
|
||||
{ conversationid: message.conversationid, text: message.text, timecreated: message.timecreated },
|
||||
));
|
||||
} else {
|
||||
promises.push(db.updateRecords(
|
||||
MESSAGES_TABLE,
|
||||
data,
|
||||
{ touserid: message.touserid, smallmessage: message.smallmessage, timecreated: message.timecreated },
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonMessagesOffline extends makeSingleton(AddonMessagesOfflineProvider) {}
|
||||
|
||||
export type AddonMessagesOfflineMessagesDBRecordFormatted = AddonMessagesOfflineMessagesDBRecord & {
|
||||
pending?: boolean; // Will be likely true.
|
||||
text?: string; // Copy of smallmessage.
|
||||
};
|
||||
|
||||
export type AddonMessagesOfflineConversationMessagesDBRecordFormatted =
|
||||
Omit<AddonMessagesOfflineConversationMessagesDBRecord, 'conversation'> &
|
||||
{
|
||||
conversation?: AddonMessagesConversation; // Data about the conversation.
|
||||
pending: boolean; // Will be always true.
|
||||
useridfrom?: number; // User Id who send the message, will be likely us.
|
||||
};
|
||||
|
||||
|
||||
export type AddonMessagesOfflineAnyMessagesFormatted =
|
||||
AddonMessagesOfflineConversationMessagesDBRecordFormatted | AddonMessagesOfflineMessagesDBRecordFormatted;
|
|
@ -0,0 +1,407 @@
|
|||
// (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 { CoreSyncBaseProvider } from '@classes/base-sync';
|
||||
import {
|
||||
AddonMessagesOffline, AddonMessagesOfflineAnyMessagesFormatted,
|
||||
} from './messages-offline';
|
||||
import {
|
||||
AddonMessagesProvider,
|
||||
AddonMessages,
|
||||
AddonMessagesGetMessagesWSParams,
|
||||
} from './messages';
|
||||
import { CoreEvents } from '@singletons/events';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { makeSingleton, Translate } from '@singletons';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreApp } from '@services/app';
|
||||
import { CoreConstants } from '@/core/constants';
|
||||
import { CoreUser } from '@features/user/services/user';
|
||||
import { CoreError } from '@classes/errors/error';
|
||||
import { CoreTextErrorObject, CoreTextUtils } from '@services/utils/text';
|
||||
import { CoreSiteWSPreSets } from '@classes/site';
|
||||
|
||||
/**
|
||||
* Service to sync messages.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AddonMessagesSyncProvider extends CoreSyncBaseProvider<AddonMessagesSyncEvents> {
|
||||
|
||||
static readonly AUTO_SYNCED = 'addon_messages_autom_synced';
|
||||
|
||||
constructor() {
|
||||
super('AddonMessagesSync');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID of a discussion sync.
|
||||
*
|
||||
* @param conversationId Conversation ID.
|
||||
* @param userId User ID talking to (if no conversation ID).
|
||||
* @return Sync ID.
|
||||
*/
|
||||
protected getSyncId(conversationId?: number, userId?: number): string {
|
||||
if (conversationId) {
|
||||
return 'conversationid:' + conversationId;
|
||||
} else if (userId) {
|
||||
return 'userid:' + userId;
|
||||
} else {
|
||||
// Should not happen.
|
||||
throw new CoreError('Incorrect messages sync id.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to synchronize all the discussions in a certain site or in all sites.
|
||||
*
|
||||
* @param siteId Site ID to sync. If not defined, sync all sites.
|
||||
* @param onlyDeviceOffline True to only sync discussions that failed because device was offline,
|
||||
* false to sync all.
|
||||
* @return Promise resolved if sync is successful, rejected if sync fails.
|
||||
*/
|
||||
syncAllDiscussions(siteId?: string, onlyDeviceOffline: boolean = false): Promise<void> {
|
||||
const syncFunctionLog = 'all discussions' + (onlyDeviceOffline ? ' (Only offline)' : '');
|
||||
|
||||
return this.syncOnSites(syncFunctionLog, this.syncAllDiscussionsFunc.bind(this, [onlyDeviceOffline]), siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all messages pending to be sent in the site.
|
||||
*
|
||||
* @param siteId Site ID to sync. If not defined, sync all sites.
|
||||
* @param onlyDeviceOffline True to only sync discussions that failed because device was offline.
|
||||
* @param Promise resolved if sync is successful, rejected if sync fails.
|
||||
*/
|
||||
protected async syncAllDiscussionsFunc(siteId: string, onlyDeviceOffline = false): Promise<void> {
|
||||
const userIds: number[] = [];
|
||||
const conversationIds: number[] = [];
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
const messages = onlyDeviceOffline
|
||||
? await AddonMessagesOffline.instance.getAllDeviceOfflineMessages(siteId)
|
||||
: await AddonMessagesOffline.instance.getAllMessages(siteId);
|
||||
|
||||
// Get all the conversations to be synced.
|
||||
messages.forEach((message) => {
|
||||
if ('conversationid' in message) {
|
||||
if (conversationIds.indexOf(message.conversationid) == -1) {
|
||||
conversationIds.push(message.conversationid);
|
||||
}
|
||||
} else if (userIds.indexOf(message.touserid) == -1) {
|
||||
userIds.push(message.touserid);
|
||||
}
|
||||
});
|
||||
|
||||
// Sync all conversations.
|
||||
conversationIds.forEach((conversationId) => {
|
||||
promises.push(this.syncDiscussion(conversationId, undefined, siteId).then((result) => {
|
||||
if (typeof result == 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync successful, send event.
|
||||
CoreEvents.trigger<AddonMessagesSyncEvents>(AddonMessagesSyncProvider.AUTO_SYNCED, result, siteId);
|
||||
|
||||
return;
|
||||
}));
|
||||
});
|
||||
|
||||
userIds.forEach((userId) => {
|
||||
promises.push(this.syncDiscussion(undefined, userId, siteId).then((result) => {
|
||||
if (typeof result == 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync successful, send event.
|
||||
CoreEvents.trigger<AddonMessagesSyncEvents>(AddonMessagesSyncProvider.AUTO_SYNCED, result, siteId);
|
||||
|
||||
return;
|
||||
}));
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize a discussion.
|
||||
*
|
||||
* @param conversationId Conversation ID.
|
||||
* @param userId User ID talking to (if no conversation ID).
|
||||
* @param siteId Site ID.
|
||||
* @return Promise resolved with the list of warnings if sync is successful, rejected otherwise.
|
||||
*/
|
||||
syncDiscussion(conversationId?: number, userId?: number, siteId?: string): Promise<AddonMessagesSyncEvents> {
|
||||
siteId = siteId || CoreSites.instance.getCurrentSiteId();
|
||||
|
||||
const syncId = this.getSyncId(conversationId, userId);
|
||||
|
||||
if (this.isSyncing(syncId, siteId)) {
|
||||
// There's already a sync ongoing for this conversation, return the promise.
|
||||
return this.getOngoingSync(syncId, siteId)!;
|
||||
}
|
||||
|
||||
return this.addOngoingSync(syncId, this.performSyncDiscussion(conversationId, userId, siteId), siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the synchronization of a discussion.
|
||||
*
|
||||
* @param conversationId Conversation ID.
|
||||
* @param userId User ID talking to (if no conversation ID).
|
||||
* @param siteId Site ID.
|
||||
* @return Promise resolved with the list of warnings if sync is successful, rejected otherwise.
|
||||
*/
|
||||
protected async performSyncDiscussion(
|
||||
conversationId: number | undefined,
|
||||
userId: number | undefined,
|
||||
siteId: string,
|
||||
): Promise<AddonMessagesSyncEvents> {
|
||||
const result: AddonMessagesSyncEvents = {
|
||||
warnings: [],
|
||||
userId,
|
||||
conversationId,
|
||||
};
|
||||
|
||||
const groupMessagingEnabled = AddonMessages.instance.isGroupMessagingEnabled();
|
||||
let messages: AddonMessagesOfflineAnyMessagesFormatted[];
|
||||
const errors: (string | CoreError | CoreTextErrorObject)[] = [];
|
||||
|
||||
if (conversationId) {
|
||||
this.logger.debug(`Try to sync conversation '${conversationId}'`);
|
||||
messages = await AddonMessagesOffline.instance.getConversationMessages(conversationId, undefined, siteId);
|
||||
} else if (userId) {
|
||||
this.logger.debug(`Try to sync discussion with user '${userId}'`);
|
||||
messages = await AddonMessagesOffline.instance.getMessages(userId, siteId);
|
||||
} else {
|
||||
// Should not happen.
|
||||
throw new CoreError('Incorrect messages sync.');
|
||||
}
|
||||
|
||||
if (!messages.length) {
|
||||
// Nothing to sync.
|
||||
return result;
|
||||
} else if (!CoreApp.instance.isOnline()) {
|
||||
// Cannot sync in offline. Mark messages as device offline.
|
||||
AddonMessagesOffline.instance.setMessagesDeviceOffline(messages, true);
|
||||
|
||||
throw new CoreError('Cannot sync in offline. Mark messages as device offline.');
|
||||
}
|
||||
|
||||
// Order message by timecreated.
|
||||
messages = AddonMessages.instance.sortMessages(messages);
|
||||
|
||||
// Get messages sent by the user after the first offline message was sent.
|
||||
// We subtract some time because the message could've been saved in server before it was in the app.
|
||||
const timeFrom = Math.floor((messages[0].timecreated - CoreConstants.WS_TIMEOUT - 1000) / 1000);
|
||||
|
||||
const onlineMessages = await this.getMessagesSentAfter(timeFrom, conversationId, userId, siteId);
|
||||
|
||||
// Send the messages. Send them 1 by 1 to simulate web's behaviour and to make sure we know which message has failed.
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
|
||||
const text = ('text' in message ? message.text : message.smallmessage) || '';
|
||||
const textFieldName = conversationId ? 'text' : 'smallmessage';
|
||||
const wrappedText = message[textFieldName][0] != '<' ? '<p>' + text + '</p>' : text;
|
||||
|
||||
try {
|
||||
if (onlineMessages.indexOf(wrappedText) != -1) {
|
||||
// Message already sent, ignore it to prevent duplicates.
|
||||
} else if (conversationId) {
|
||||
await AddonMessages.instance.sendMessageToConversationOnline(conversationId, text, siteId);
|
||||
} else if (userId) {
|
||||
await AddonMessages.instance.sendMessageOnline(userId, text, siteId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!CoreUtils.instance.isWebServiceError(error)) {
|
||||
// Error sending, stop execution.
|
||||
if (CoreApp.instance.isOnline()) {
|
||||
// App is online, unmark deviceoffline if marked.
|
||||
AddonMessagesOffline.instance.setMessagesDeviceOffline(messages, false);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Error returned by WS. Store the error to show a warning but keep sending messages.
|
||||
if (errors.indexOf(error) == -1) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Message was sent, delete it from local DB.
|
||||
if (conversationId) {
|
||||
await AddonMessagesOffline.instance.deleteConversationMessage(conversationId, text, message.timecreated, siteId);
|
||||
} else if (userId) {
|
||||
await AddonMessagesOffline.instance.deleteMessage(userId, text, message.timecreated, siteId);
|
||||
}
|
||||
|
||||
// In some Moodle versions, wait 1 second to make sure timecreated is different.
|
||||
// This is because there was a bug where messages with the same timecreated had a wrong order.
|
||||
if (!groupMessagingEnabled && i < messages.length - 1) {
|
||||
await CoreUtils.instance.wait(1000);
|
||||
}
|
||||
}
|
||||
|
||||
await this.handleSyncErrors(conversationId, userId, errors, result.warnings);
|
||||
|
||||
// All done, return the warnings.
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages sent by current user after a certain time.
|
||||
*
|
||||
* @param time Time in seconds.
|
||||
* @param conversationId Conversation ID.
|
||||
* @param userId User ID talking to (if no conversation ID).
|
||||
* @param siteId Site ID.
|
||||
* @return Promise resolved with the messages texts.
|
||||
*/
|
||||
protected async getMessagesSentAfter(
|
||||
time: number,
|
||||
conversationId?: number,
|
||||
userId?: number,
|
||||
siteId?: string,
|
||||
): Promise<string[]> {
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
|
||||
const siteCurrentUserId = site.getUserId();
|
||||
|
||||
if (conversationId) {
|
||||
try {
|
||||
const result = await AddonMessages.instance.getConversationMessages(conversationId, {
|
||||
excludePending: true,
|
||||
ignoreCache: true,
|
||||
timeFrom: time,
|
||||
});
|
||||
|
||||
const sentMessages = result.messages.filter((message) => message.useridfrom == siteCurrentUserId);
|
||||
|
||||
return sentMessages.map((message) => message.text);
|
||||
} catch (error) {
|
||||
if (error && error.errorcode == 'invalidresponse') {
|
||||
// There's a bug in Moodle that causes this error if there are no new messages. Return empty array.
|
||||
return [];
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
} else if (userId) {
|
||||
const params: AddonMessagesGetMessagesWSParams = {
|
||||
useridto: userId,
|
||||
useridfrom: siteCurrentUserId,
|
||||
limitnum: AddonMessagesProvider.LIMIT_MESSAGES,
|
||||
};
|
||||
const preSets: CoreSiteWSPreSets = {
|
||||
cacheKey: AddonMessages.instance.getCacheKeyForDiscussion(userId),
|
||||
getFromCache: false,
|
||||
emergencyCache: false,
|
||||
};
|
||||
|
||||
const messages = await AddonMessages.instance.getRecentMessages(params, preSets, 0, 0, false, siteId);
|
||||
|
||||
time = time * 1000; // Convert to milliseconds.
|
||||
const messagesAfterTime = messages.filter((message) => message.timecreated >= time);
|
||||
|
||||
return messagesAfterTime.map((message) => message.text);
|
||||
} else {
|
||||
throw new CoreError('Incorrect messages sync identifier');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle sync errors.
|
||||
*
|
||||
* @param conversationId Conversation ID.
|
||||
* @param userId User ID talking to (if no conversation ID).
|
||||
* @param errors List of errors.
|
||||
* @param warnings Array where to place the warnings.
|
||||
* @return Promise resolved when done.
|
||||
*/
|
||||
protected async handleSyncErrors(
|
||||
conversationId?: number,
|
||||
userId?: number,
|
||||
errors: (string | CoreError | CoreTextErrorObject)[] = [],
|
||||
warnings: string[] = [],
|
||||
): Promise<void> {
|
||||
if (!errors || errors.length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (conversationId) {
|
||||
let conversationIdentifier = String(conversationId);
|
||||
try {
|
||||
// Get conversation name and add errors to warnings array.
|
||||
const conversation = await AddonMessages.instance.getConversation(conversationId, false, false);
|
||||
conversationIdentifier = conversation.name || String(conversationId);
|
||||
} catch {
|
||||
// Ignore errors.
|
||||
}
|
||||
|
||||
errors.forEach((error) => {
|
||||
warnings.push(Translate.instance.instant('addon.messages.warningconversationmessagenotsent', {
|
||||
conversation: conversationIdentifier,
|
||||
error: CoreTextUtils.instance.getErrorMessageFromError(error),
|
||||
}));
|
||||
});
|
||||
} else if (userId) {
|
||||
|
||||
// Get user full name and add errors to warnings array.
|
||||
let userIdentifier = String(userId);
|
||||
try {
|
||||
const user = await CoreUser.instance.getProfile(userId, undefined, true);
|
||||
userIdentifier = user.fullname;
|
||||
} catch {
|
||||
// Ignore errors.
|
||||
}
|
||||
|
||||
errors.forEach((error) => {
|
||||
warnings.push(Translate.instance.instant('addon.messages.warningmessagenotsent', {
|
||||
user: userIdentifier,
|
||||
error: CoreTextUtils.instance.getErrorMessageFromError(error),
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If there's an ongoing sync for a certain conversation, wait for it to end.
|
||||
* If there's no sync ongoing the promise will be resolved right away.
|
||||
*
|
||||
* @param conversationId Conversation ID.
|
||||
* @param userId User ID talking to (if no conversation ID).
|
||||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return Promise resolved when there's no sync going on for the identifier.
|
||||
*/
|
||||
waitForSyncConversation(
|
||||
conversationId?: number,
|
||||
userId?: number,
|
||||
siteId?: string,
|
||||
): Promise<AddonMessagesSyncEvents | undefined> {
|
||||
const syncId = this.getSyncId(conversationId, userId);
|
||||
|
||||
return this.waitForSync(syncId, siteId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AddonMessagesSync extends makeSingleton(AddonMessagesSyncProvider) {}
|
||||
|
||||
export type AddonMessagesSyncEvents = {
|
||||
warnings: string[];
|
||||
conversationId?: number;
|
||||
userId?: number;
|
||||
};
|
File diff suppressed because it is too large
Load Diff
|
@ -73,7 +73,8 @@
|
|||
!(notification.processorsByName[currentProcessor!.name][state] &&
|
||||
notification.processorsByName[currentProcessor!.name][state].updating)">
|
||||
</ion-spinner>
|
||||
<ion-toggle *ngIf="preferences!.enableall && !notification.processorsByName[currentProcessor!.name].locked"
|
||||
<ion-toggle
|
||||
*ngIf="preferences!.enableall && !notification.processorsByName[currentProcessor!.name].locked"
|
||||
[(ngModel)]="notification.processorsByName[currentProcessor!.name][state].checked"
|
||||
(ngModelChange)="changePreference(notification, state)"
|
||||
[disabled]="notification.processorsByName[currentProcessor!.name][state].updating">
|
||||
|
@ -95,15 +96,19 @@
|
|||
<!-- If notifications enabled, show toggles. If disabled, show "Disabled" instead of toggle. -->
|
||||
<ion-item *ngFor="let state of ['loggedin', 'loggedoff']" class="ion-text-wrap ion-hide-md-up" lines="none">
|
||||
<ion-label>{{ 'core.settings.' + state | translate }}</ion-label>
|
||||
<ion-spinner slot="end" *ngIf="preferences!.enableall && (notification.processorsByName[currentProcessor!.name][state] &&
|
||||
<ion-spinner slot="end"
|
||||
*ngIf="preferences!.enableall && (notification.processorsByName[currentProcessor!.name][state] &&
|
||||
notification.processorsByName[currentProcessor!.name][state].updating)">
|
||||
</ion-spinner>
|
||||
<ion-toggle slot="end" *ngIf="preferences!.enableall && !notification.processorsByName[currentProcessor!.name].locked"
|
||||
<ion-toggle slot="end"
|
||||
*ngIf="preferences!.enableall && !notification.processorsByName[currentProcessor!.name].locked"
|
||||
[(ngModel)]="notification.processorsByName[currentProcessor!.name][state].checked"
|
||||
(ngModelChange)="changePreference(notification, state)"
|
||||
[disabled]="notification.processorsByName[currentProcessor!.name][state].updating">
|
||||
</ion-toggle>
|
||||
<span slot="end" *ngIf="preferences!.enableall && notification.processorsByName[currentProcessor!.name].locked" class="text-gray">
|
||||
<span slot="end"
|
||||
*ngIf="preferences!.enableall && notification.processorsByName[currentProcessor!.name].locked"
|
||||
class="text-gray">
|
||||
{{'core.settings.locked' | translate }}
|
||||
</span>
|
||||
<ion-note slot="end" *ngIf="!preferences!.enableall">{{ 'core.settings.disabled' | translate }}</ion-note>
|
||||
|
@ -112,4 +117,4 @@
|
|||
</ion-card>
|
||||
</ng-container>
|
||||
</core-loading>
|
||||
</ion-content>
|
||||
</ion-content>
|
||||
|
|
|
@ -22,6 +22,7 @@ import { CoreMainMenuHandler, CoreMainMenuHandlerData } from '@features/mainmenu
|
|||
import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications';
|
||||
import { CorePushNotificationsDelegate } from '@features/pushnotifications/services/push-delegate';
|
||||
import { AddonNotifications, AddonNotificationsProvider } from '../notifications';
|
||||
import { AddonMessagesReadChangedEventData } from '@addons/messages/services/messages';
|
||||
|
||||
/**
|
||||
* Handler to inject an option into main menu.
|
||||
|
@ -48,7 +49,7 @@ export class AddonNotificationsMainMenuHandlerService implements CoreMainMenuHan
|
|||
* Initialize the handler.
|
||||
*/
|
||||
initialize(): void {
|
||||
CoreEvents.on(AddonNotificationsProvider.READ_CHANGED_EVENT, (data: CoreEventSiteData) => {
|
||||
CoreEvents.on<AddonMessagesReadChangedEventData>(AddonNotificationsProvider.READ_CHANGED_EVENT, (data) => {
|
||||
this.updateBadge(data.siteId);
|
||||
});
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@ import { CoreWSExternalWarning } from '@services/ws';
|
|||
import { CoreTextUtils } from '@services/utils/text';
|
||||
import { CoreTimeUtils } from '@services/utils/time';
|
||||
import { CoreUser } from '@features/user/services/user';
|
||||
// @todo import { AddonMessages, AddonMessagesMarkMessageReadResult } from '@addon/messages/services/messages';
|
||||
import { AddonMessages, AddonMessagesMarkMessageReadResult } from '@addons/messages/services/messages';
|
||||
import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
|
||||
import { CoreLogger } from '@singletons/logger';
|
||||
import { makeSingleton } from '@singletons';
|
||||
|
@ -350,7 +350,7 @@ export class AddonNotificationsProvider {
|
|||
async markNotificationRead(
|
||||
notificationId: number,
|
||||
siteId?: string,
|
||||
): Promise<CoreMessageMarkNotificationReadWSResponse | undefined> { // @todo | AddonMessagesMarkMessageReadResult
|
||||
): Promise<CoreMessageMarkNotificationReadWSResponse | AddonMessagesMarkMessageReadResult> {
|
||||
|
||||
const site = await CoreSites.instance.getSite(siteId);
|
||||
|
||||
|
@ -363,7 +363,7 @@ export class AddonNotificationsProvider {
|
|||
return site.write<CoreMessageMarkNotificationReadWSResponse>('core_message_mark_notification_read', params);
|
||||
} else {
|
||||
// Fallback for versions prior to 3.5.
|
||||
// @todo return AddonMessageProvider.instance.markMessageRead(notificationId, site.id);
|
||||
return AddonMessages.instance.markMessageRead(notificationId, site.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@ import { NgModule } from '@angular/core';
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { CoreChronoComponent } from './chrono/chrono';
|
||||
import { CoreDownloadRefreshComponent } from './download-refresh/download-refresh';
|
||||
|
@ -38,10 +39,11 @@ import { CoreContextMenuItemComponent } from './context-menu/context-menu-item';
|
|||
import { CoreContextMenuPopoverComponent } from './context-menu/context-menu-popover';
|
||||
import { CoreUserAvatarComponent } from './user-avatar/user-avatar';
|
||||
import { CoreDynamicComponent } from './dynamic-component/dynamic-component';
|
||||
import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons';
|
||||
import { CoreSendMessageFormComponent } from './send-message-form/send-message-form';
|
||||
|
||||
import { CoreDirectivesModule } from '@directives/directives.module';
|
||||
import { CorePipesModule } from '@pipes/pipes.module';
|
||||
import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
|
@ -67,10 +69,12 @@ import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons';
|
|||
CoreNavBarButtonsComponent,
|
||||
CoreUserAvatarComponent,
|
||||
CoreDynamicComponent,
|
||||
CoreSendMessageFormComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule.forRoot(),
|
||||
FormsModule,
|
||||
TranslateModule.forChild(),
|
||||
CoreDirectivesModule,
|
||||
CorePipesModule,
|
||||
|
@ -98,6 +102,7 @@ import { CoreNavBarButtonsComponent } from './navbar-buttons/navbar-buttons';
|
|||
CoreNavBarButtonsComponent,
|
||||
CoreUserAvatarComponent,
|
||||
CoreDynamicComponent,
|
||||
CoreSendMessageFormComponent,
|
||||
],
|
||||
})
|
||||
export class CoreComponentsModule {}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<form #messageForm>
|
||||
<textarea class="core-send-message-input" [core-auto-focus]="showKeyboard" [placeholder]="placeholder" rows="1" core-auto-rows
|
||||
[(ngModel)]="message" name="message" (onResize)="textareaResized()" (keydown.enter)="enterClicked($event)"
|
||||
(keydown.control.enter)="enterClicked($event, 'control')" (keydown.meta.enter)="enterClicked($event, 'meta')"
|
||||
aria-multiline="true"></textarea>
|
||||
<ion-buttons>
|
||||
<ion-button fill="clear" type="submit" [disabled]="!message || sendDisabled"
|
||||
[attr.aria-label]="'core.send' | translate" [core-suppress-events] (onClick)="submitForm($event)">
|
||||
<ion-icon name="send" color="dark" slot="icon-only"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</form>
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
:host {
|
||||
background: var(--white);
|
||||
|
||||
form {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.core-send-message-input {
|
||||
appearance: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
border: 0;
|
||||
font-family: inherit;
|
||||
background: var(--core-send-message-input-background);
|
||||
color: var(--core-send-message-input-color);
|
||||
border-radius: 5px;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.core-send-message-button {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: none;
|
||||
min-height: 0;
|
||||
align-self: self-end;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { Component, Input, Output, EventEmitter, OnInit, ViewChild, ElementRef } from '@angular/core';
|
||||
import { CoreApp } from '@services/app';
|
||||
import { CoreConfig } from '@services/config';
|
||||
import { CoreEvents } from '@singletons/events';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { CoreTextUtils } from '@services/utils/text';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreConstants } from '@/core/constants';
|
||||
|
||||
/**
|
||||
* Component to display a "send message form".
|
||||
*
|
||||
* @description
|
||||
* This component will display a standalone send message form in order to have a better UX.
|
||||
*
|
||||
* Example usage:
|
||||
* <core-send-message-form (onSubmit)="sendMessage($event)" [placeholder]="'core.messages.newmessage' | translate"
|
||||
* [show-keyboard]="showKeyboard"></core-send-message-form>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'core-send-message-form',
|
||||
templateUrl: 'core-send-message-form.html',
|
||||
styleUrls: ['send-message-form.scss'],
|
||||
})
|
||||
export class CoreSendMessageFormComponent implements OnInit {
|
||||
|
||||
@Input() message = ''; // Input text.
|
||||
@Input() placeholder = ''; // Placeholder for the input area.
|
||||
@Input() showKeyboard = false; // If keyboard is shown or not.
|
||||
@Input() sendDisabled = false; // If send is disabled.
|
||||
@Output() onSubmit: EventEmitter<string>; // Send data when submitting the message form.
|
||||
@Output() onResize: EventEmitter<void>; // Emit when resizing the textarea.
|
||||
|
||||
@ViewChild('messageForm') formElement!: ElementRef;
|
||||
|
||||
protected sendOnEnter = false;
|
||||
|
||||
constructor() {
|
||||
|
||||
this.onSubmit = new EventEmitter();
|
||||
this.onResize = new EventEmitter();
|
||||
|
||||
CoreConfig.instance.get(CoreConstants.SETTINGS_SEND_ON_ENTER, !CoreApp.instance.isMobile()).then((sendOnEnter) => {
|
||||
this.sendOnEnter = !!sendOnEnter;
|
||||
|
||||
return;
|
||||
}).catch(() => {
|
||||
// Nothing to do.
|
||||
});
|
||||
|
||||
CoreEvents.on<boolean>(CoreEvents.SEND_ON_ENTER_CHANGED, (newValue) => {
|
||||
this.sendOnEnter = newValue;
|
||||
}, CoreSites.instance.getCurrentSiteId());
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.showKeyboard = CoreUtils.instance.isTrueOrOne(this.showKeyboard);
|
||||
}
|
||||
|
||||
/**
|
||||
* Form submitted.
|
||||
*
|
||||
* @param $event Mouse event.
|
||||
*/
|
||||
submitForm($event: Event): void {
|
||||
$event.preventDefault();
|
||||
$event.stopPropagation();
|
||||
|
||||
let value = this.message.trim();
|
||||
|
||||
if (!value) {
|
||||
// Silent error.
|
||||
return;
|
||||
}
|
||||
|
||||
this.message = ''; // Reset the form.
|
||||
|
||||
CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, CoreSites.instance.getCurrentSiteId());
|
||||
|
||||
value = CoreTextUtils.instance.replaceNewLines(value, '<br>');
|
||||
this.onSubmit.emit(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Textarea resized.
|
||||
*/
|
||||
textareaResized(): void {
|
||||
this.onResize.emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter key clicked.
|
||||
*
|
||||
* @param e Event.
|
||||
* @param other The name of the other key that was clicked, undefined if no other key.
|
||||
*/
|
||||
enterClicked(e: Event, other?: string): void {
|
||||
if (this.sendDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.sendOnEnter && !other) {
|
||||
// Enter clicked, send the message.
|
||||
this.submitForm(e);
|
||||
} else if (!this.sendOnEnter && !CoreApp.instance.isMobile() && other == 'control') {
|
||||
// Cmd+Enter or Ctrl+Enter, send message.
|
||||
this.submitForm(e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,5 +1,7 @@
|
|||
<ion-content class="menu">
|
||||
<ng-content></ng-content>
|
||||
</ion-content>
|
||||
<ion-router-outlet class="content"></ion-router-outlet>
|
||||
<!-- @todo placeholder -->
|
||||
<ion-router-outlet class="content-outlet"></ion-router-outlet>
|
||||
<core-empty-box class="content-placeholder" icon="fas-arrow-circle-left" [message]="placeholderText | translate"
|
||||
[flipIconRtl]="true">
|
||||
</core-empty-box>
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
// @todo RTL layout
|
||||
|
||||
:host {
|
||||
--menu-min-width: 270px;
|
||||
--menu-max-width: 28%;
|
||||
--menu-box-shadow: var(--core-menu-box-shadow-end);
|
||||
--menu-z: 2;
|
||||
--menu-border-width: 1;
|
||||
--menu-display: flex;
|
||||
--content-display: block;
|
||||
--border-width: 1;
|
||||
--content-outlet-display: none;
|
||||
--content-placeholder-display: var(--content-display);
|
||||
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
@ -17,58 +19,74 @@
|
|||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
contain: strict;
|
||||
|
||||
.menu,
|
||||
.content-outlet {
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: relative;
|
||||
box-shadow: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.menu {
|
||||
box-shadow: var(--menu-box-shadow);
|
||||
z-index: var(--menu-z);
|
||||
display: var(--menu-display);
|
||||
flex-shrink: 0;
|
||||
order: -1;
|
||||
width: 100%;
|
||||
border-inline-start: 0;
|
||||
border-inline-end: var(--border);
|
||||
min-width: var(--menu-min-width);
|
||||
max-width: var(--menu-max-width);
|
||||
}
|
||||
|
||||
.content-outlet {
|
||||
display: var(--content-outlet-display);
|
||||
flex: 1;
|
||||
|
||||
::ng-deep ion-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.content-placeholder {
|
||||
display: var(--content-placeholder-display);
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background-color: var(--ion-background);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
:host(.menu-only) {
|
||||
--menu-min-width: 0;
|
||||
--menu-max-width: 100%;
|
||||
--content-display: none;
|
||||
--border-width: 0;
|
||||
--menu-border-width: 0;
|
||||
--menu-box-shadow: none;
|
||||
--menu-z: 0;
|
||||
--selected-item-border-width: 0;
|
||||
}
|
||||
|
||||
:host(.content-only) {
|
||||
--menu-display: none;
|
||||
--border-width: 0;
|
||||
--menu-border-width: 0;
|
||||
}
|
||||
|
||||
:host(.outlet-activated) {
|
||||
--content-placeholder-display: none;
|
||||
--content-outlet-display: var(--content-display);
|
||||
}
|
||||
|
||||
:host-context(ion-app.md) {
|
||||
--border: calc(var(--border-width) * 1px) solid var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, .13))));
|
||||
--border: calc(var(--menu-border-width) * 1px) solid var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, .13))));
|
||||
}
|
||||
|
||||
:host-context(ion-app.ios) {
|
||||
--border: calc(var(--border-width) * .55px) solid var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-250, #c8c7cc)));
|
||||
}
|
||||
|
||||
.menu,
|
||||
.content {
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: relative;
|
||||
box-shadow: none !important;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: var(--menu-display);
|
||||
flex-shrink: 0;
|
||||
order: -1;
|
||||
border-left: unset;
|
||||
border-right: unset;
|
||||
border-inline-start: 0;
|
||||
border-inline-end: var(--border);
|
||||
min-width: var(--menu-min-width);
|
||||
max-width: var(--menu-max-width);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: var(--content-display);
|
||||
flex: 1;
|
||||
|
||||
::ng-deep ion-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
--border: calc(var(--menu-border-width) * .55px) solid var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-250, #c8c7cc)));
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { AfterViewInit, Component, ElementRef, HostBinding, OnDestroy, ViewChild } from '@angular/core';
|
||||
import { AfterViewInit, Component, ElementRef, HostBinding, Input, OnDestroy, ViewChild } from '@angular/core';
|
||||
import { IonRouterOutlet } from '@ionic/angular';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
@ -32,6 +32,7 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy {
|
|||
|
||||
@ViewChild(IonRouterOutlet) outlet!: IonRouterOutlet;
|
||||
@HostBinding('class') classes = '';
|
||||
@Input() placeholderText = 'core.emptysplit';
|
||||
isNested = false;
|
||||
|
||||
private subscriptions?: Subscription[];
|
||||
|
@ -65,6 +66,10 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy {
|
|||
private updateClasses(): void {
|
||||
const classes: string[] = [this.getCurrentMode()];
|
||||
|
||||
if (this.outlet.isActivated) {
|
||||
classes.push('outlet-activated');
|
||||
}
|
||||
|
||||
if (this.isNested) {
|
||||
classes.push('nested');
|
||||
}
|
||||
|
@ -92,4 +97,13 @@ export class CoreSplitViewComponent implements AfterViewInit, OnDestroy {
|
|||
return CoreSplitViewMode.MenuAndContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if both panels are shown. It depends on screen width.
|
||||
*
|
||||
* @return If split view is enabled.
|
||||
*/
|
||||
isOn(): boolean {
|
||||
return this.outlet.isActivated;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -5,6 +5,33 @@
|
|||
border-radius: 50%;
|
||||
width: var(--core-avatar-size);
|
||||
height: var(--core-avatar-size);
|
||||
max-width: var(--core-avatar-size);
|
||||
max-height: var(--core-avatar-size);
|
||||
}
|
||||
img[core-external-content]:not([src]),
|
||||
img[core-external-content][src=""] {
|
||||
visibility: visible;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
&:after {
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: url('/assets/img/user-avatar.png');
|
||||
background-size: contain;
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
&.core-bar-button-image img {
|
||||
padding: 0;
|
||||
width: var(--core-toolbar-button-image-width);
|
||||
height: var(--core-toolbar-button-image-width);
|
||||
max-width: var(--core-toolbar-button-image-width);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.contact-status {
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
// (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 { Directive, ElementRef, HostListener, Output, EventEmitter, AfterViewInit } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Directive to adapt a textarea rows depending on the input text. It's based on Moodle's data-auto-rows.
|
||||
*
|
||||
* @description
|
||||
* Usage:
|
||||
* <textarea class="core-textarea" [(ngModel)]="message" rows="1" core-auto-rows></textarea>
|
||||
*/
|
||||
@Directive({
|
||||
selector: 'textarea[core-auto-rows], ion-textarea[core-auto-rows]',
|
||||
})
|
||||
export class CoreAutoRowsDirective implements AfterViewInit {
|
||||
|
||||
protected height = 0;
|
||||
|
||||
@Output() onResize: EventEmitter<void>; // Emit when resizing the textarea.
|
||||
|
||||
constructor(protected element: ElementRef) {
|
||||
this.onResize = new EventEmitter();
|
||||
}
|
||||
|
||||
@HostListener('input') onInput(): void {
|
||||
this.resize();
|
||||
}
|
||||
|
||||
@HostListener('change') onChange(): void {
|
||||
// Fired on reset. Wait to the change to be finished.
|
||||
setTimeout(() => {
|
||||
this.resize();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize after content.
|
||||
*/
|
||||
ngAfterViewInit(): void {
|
||||
// Wait for rendering of child views.
|
||||
setTimeout(() => {
|
||||
this.resize();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the textarea.
|
||||
*/
|
||||
protected resize(): void {
|
||||
let nativeElement = this.element.nativeElement;
|
||||
if (nativeElement.tagName == 'ION-TEXTAREA') {
|
||||
// The first child of ion-textarea is the actual textarea element.
|
||||
nativeElement = nativeElement.firstElementChild;
|
||||
}
|
||||
|
||||
// Set height to 1px to force scroll height to calculate correctly.
|
||||
nativeElement.style.height = '1px';
|
||||
nativeElement.style.height = nativeElement.scrollHeight + 'px';
|
||||
|
||||
// Emit event when resizing.
|
||||
if (this.height != nativeElement.scrollHeight) {
|
||||
this.height = nativeElement.scrollHeight;
|
||||
this.onResize.emit();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -23,6 +23,7 @@ import { CoreLongPressDirective } from './long-press';
|
|||
import { CoreSupressEventsDirective } from './supress-events';
|
||||
import { CoreFaIconDirective } from './fa-icon';
|
||||
import { CoreUserLinkDirective } from './user-link';
|
||||
import { CoreAutoRowsDirective } from './auto-rows';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
|
@ -35,6 +36,7 @@ import { CoreUserLinkDirective } from './user-link';
|
|||
CoreFabDirective,
|
||||
CoreFaIconDirective,
|
||||
CoreUserLinkDirective,
|
||||
CoreAutoRowsDirective,
|
||||
],
|
||||
imports: [],
|
||||
exports: [
|
||||
|
@ -47,6 +49,7 @@ import { CoreUserLinkDirective } from './user-link';
|
|||
CoreFabDirective,
|
||||
CoreFaIconDirective,
|
||||
CoreUserLinkDirective,
|
||||
CoreAutoRowsDirective,
|
||||
],
|
||||
})
|
||||
export class CoreDirectivesModule {}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<!-- Course expand="block"s. -->
|
||||
<ng-container *ngFor="let block of blocks">
|
||||
<core-block *ngIf="block.visible" [block]="block" contextLevel="course" [instanceId]="courseId"
|
||||
[extraData]="{'downloadEnabled': downloadEnabled}"></core-block>
|
||||
[extraData]="{'downloadEnabled': downloadEnabled}"></core-block>
|
||||
</ng-container>
|
||||
</ion-list>
|
||||
</core-loading>
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
:host {
|
||||
--side-blocks-box-shadow: var(--core-menu-box-shadow-start);
|
||||
|
||||
&.core-no-blocks .core-course-blocks-content {
|
||||
height: auto;
|
||||
}
|
||||
|
@ -20,7 +22,7 @@
|
|||
div.core-course-blocks-side {
|
||||
max-width: var(--side-blocks-max-width);
|
||||
min-width: var(--side-blocks-min-width);
|
||||
box-shadow: -4px 0px 16px rgba(0, 0, 0, 0.18);
|
||||
box-shadow: var(--side-blocks-box-shadow);
|
||||
z-index: 2;
|
||||
// @todo @include core-split-area-end();
|
||||
}
|
||||
|
@ -53,7 +55,7 @@
|
|||
:host-context([dir="rtl"]).core-has-blocks {
|
||||
@media (min-width: 768px) {
|
||||
div.core-course-blocks-side {
|
||||
box-shadow: 4px 0px 16px rgba(0, 0, 0, 0.18);
|
||||
box-shadow: var(--side-blocks-box-shadow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,8 +22,9 @@
|
|||
</core-course-module-completion>
|
||||
|
||||
<div class="core-module-buttons-more">
|
||||
<!-- @todo <core-download-refresh [status]="downloadStatus" [enabled]="downloadEnabled" [canTrustDownload]="canCheckUpdates"
|
||||
[loading]="spinner || module.handlerData.spinner" (action)="download($event)">
|
||||
<!-- @todo <core-download-refresh [status]="downloadStatus" [enabled]="downloadEnabled"
|
||||
[canTrustDownload]="canCheckUpdates" [loading]="spinner || module.handlerData.spinner"
|
||||
(action)="download($event)">
|
||||
</core-download-refresh> -->
|
||||
|
||||
<!-- Buttons defined by the module handler. -->
|
||||
|
|
|
@ -39,7 +39,8 @@
|
|||
</ion-label>
|
||||
</ion-item-divider>
|
||||
<section *ngFor="let category of categories">
|
||||
<ion-item class="ion-text-wrap" router-direction="forward" [routerLink]="['/main/home/courses/categories', category.id]"
|
||||
<ion-item class="ion-text-wrap" router-direction="forward"
|
||||
[routerLink]="['/main/home/courses/categories', category.id]"
|
||||
[title]="category.name" detail>
|
||||
<ion-icon name="fas-folder" slot="start"></ion-icon>
|
||||
<ion-label>
|
||||
|
@ -61,7 +62,8 @@
|
|||
</ion-item-divider>
|
||||
<core-courses-course-list-item *ngFor="let course of courses" [course]="course"></core-courses-course-list-item>
|
||||
</div>
|
||||
<core-empty-box *ngIf="!categories.length && !courses.length" icon="fas-graduation-cap" [message]="'core.courses.nocoursesyet' | translate">
|
||||
<core-empty-box *ngIf="!categories.length && !courses.length" icon="fas-graduation-cap"
|
||||
[message]="'core.courses.nocoursesyet' | translate">
|
||||
</core-empty-box>
|
||||
</core-loading>
|
||||
</ion-content>
|
||||
|
|
|
@ -3,7 +3,9 @@
|
|||
<ion-buttons slot="start">
|
||||
<ion-back-button [attr.aria-label]="'core.back' | translate"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title><core-format-text [text]="course?.fullname" contextLevel="course" [contextInstanceId]="course?.id"></core-format-text></ion-title>
|
||||
<ion-title>
|
||||
<core-format-text [text]="course?.fullname" contextLevel="course" [contextInstanceId]="course?.id"></core-format-text>
|
||||
</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
|
@ -17,10 +19,14 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="core-course-thumb-parallax-content" *ngIf="course">
|
||||
<ion-item class="ion-text-wrap" (click)="openCourse()" [title]="course.fullname" [attr.details]="!avoidOpenCourse && canAccessCourse">
|
||||
<ion-item class="ion-text-wrap" (click)="openCourse()" [title]="course.fullname"
|
||||
[attr.details]="!avoidOpenCourse && canAccessCourse">
|
||||
<ion-icon name="fas-graduation-cap" fixed-width slot="start"></ion-icon>
|
||||
<ion-label>
|
||||
<h2><core-format-text [text]="course.fullname" contextLevel="course" [contextInstanceId]="course.id"></core-format-text></h2>
|
||||
<h2>
|
||||
<core-format-text [text]="course.fullname" contextLevel="course" [contextInstanceId]="course.id">
|
||||
</core-format-text>
|
||||
</h2>
|
||||
<p *ngIf="course.categoryname"><core-format-text [text]="course.categoryname"
|
||||
contextLevel="coursecat" [contextInstanceId]="course.categoryid"></core-format-text></p>
|
||||
<p *ngIf="course.startdate">
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@import "~theme/breakpoints";
|
||||
@import "~theme/globals";
|
||||
|
||||
// @todo darkmode
|
||||
// @todo RTL layout
|
||||
|
|
|
@ -14,11 +14,13 @@
|
|||
<core-empty-box *ngIf="!grade" icon="stats" [message]="'core.grades.nogradesreturned' | translate"></core-empty-box>
|
||||
|
||||
<ion-list *ngIf="grade">
|
||||
<ion-item *ngIf="grade.itemname && grade.link" class="ion-text-wrap" detail="true" [href]="grade.link" core-link capture="true">
|
||||
<ion-item *ngIf="grade.itemname && grade.link" class="ion-text-wrap" detail="true" [href]="grade.link" core-link
|
||||
capture="true">
|
||||
<ion-icon *ngIf="grade.icon" name="{{grade.icon}}" slot="start"></ion-icon>
|
||||
<img *ngIf="grade.image" [src]="grade.image" slot="start" class="core-module-icon" />
|
||||
<ion-label>
|
||||
<h2><core-format-text [text]="grade.itemname" contextLevel="course" [contextInstanceId]="courseId"></core-format-text></h2>
|
||||
<h2><core-format-text [text]="grade.itemname" contextLevel="course" [contextInstanceId]="courseId">
|
||||
</core-format-text></h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
|
@ -26,7 +28,8 @@
|
|||
<ion-icon *ngIf="grade.icon" name="{{grade.icon}}" slot="start"></ion-icon>
|
||||
<img *ngIf="grade.image" [src]="grade.image" slot="start" class="core-module-icon" />
|
||||
<ion-label>
|
||||
<h2><core-format-text [text]="grade.itemname" contextLevel="course" [contextInstanceId]="courseId"></core-format-text></h2>
|
||||
<h2><core-format-text [text]="grade.itemname" contextLevel="course" [contextInstanceId]="courseId">
|
||||
</core-format-text></h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
|
@ -82,7 +85,8 @@
|
|||
<ion-item class="ion-text-wrap" *ngIf="grade.feedback">
|
||||
<ion-label>
|
||||
<h2>{{ 'core.grades.feedback' | translate}}</h2>
|
||||
<p><core-format-text [fullTitle]="'core.grades.feedback' | translate" maxHeight="60" fullOnClick="true" [text]="grade.feedback" contextLevel="course" [contextInstanceId]="courseId"></core-format-text></p>
|
||||
<p><core-format-text [fullTitle]="'core.grades.feedback' | translate" maxHeight="60" fullOnClick="true"
|
||||
[text]="grade.feedback" contextLevel="course" [contextInstanceId]="courseId"></core-format-text></p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
|
|
|
@ -67,7 +67,7 @@
|
|||
<h2><b>{{ 'core.login.faqwhereisqrcode' | translate }}</b></h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" *ngIf="canScanQR" class="core-login-faqwhereisqrcodeanswer">
|
||||
<ion-item class="ion-text-wrap core-login-faqwhereisqrcodeanswer" *ngIf="canScanQR">
|
||||
<ion-label>
|
||||
<p [innerHTML]="'core.login.faqwhereisqrcodeanswer' | translate: {$image: qrCodeImageHtml}"></p>
|
||||
</ion-label>
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
<ion-item *ngIf="isIOS"
|
||||
(click)="openHandler('CoreSharedFilesListPage', {manage: true, siteId: siteId, hideSitePicker: true})"
|
||||
[title]="'core.sharedfiles.sharedfiles' | translate"
|
||||
[class.core-split-item-selected]="'CoreSharedFilesListPage' == selectedPage" detail>
|
||||
[class.core-selected-item]="'CoreSharedFilesListPage' == selectedPage" detail>
|
||||
<ion-icon name="fas-folder" slot="start"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>{{ 'core.sharedfiles.sharedfiles' | translate }}</h2>
|
||||
|
@ -38,7 +38,7 @@
|
|||
|
||||
<ion-item *ngFor="let handler of handlers" [ngClass]="['core-settings-handler', handler.class]"
|
||||
(click)="openHandler(handler.page, handler.params)" [title]="handler.title | translate" detail
|
||||
[class.core-split-item-selected]="handler.page == selectedPage">
|
||||
[class.core-selected-item]="handler.page == selectedPage">
|
||||
<ion-icon [name]="handler.icon" slot="start" *ngIf="handler.icon">
|
||||
</ion-icon>
|
||||
<ion-label>
|
||||
|
|
|
@ -20,13 +20,13 @@ import { CoreSettingsDelegate, CoreSettingsHandlerData } from '../../services/se
|
|||
import { CoreEventObserver, CoreEvents, CoreEventSiteUpdatedData } from '@singletons/events';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
// import { CoreSplitViewComponent } from '@components/split-view/split-view';
|
||||
// import { CoreSharedFiles } from '@features/sharedfiles/services/sharedfiles';
|
||||
import { CoreSettingsHelper, CoreSiteSpaceUsage } from '../../services/settings-helper';
|
||||
import { CoreApp } from '@services/app';
|
||||
import { CoreSiteInfo } from '@classes/site';
|
||||
import { Translate } from '@singletons';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
|
||||
/**
|
||||
* Page that displays the list of site settings pages.
|
||||
|
@ -37,8 +37,6 @@ import { CoreNavigator } from '@services/navigator';
|
|||
})
|
||||
export class CoreSitePreferencesPage implements OnInit, OnDestroy {
|
||||
|
||||
// @ViewChild(CoreSplitViewComponent) splitviewCtrl?: CoreSplitViewComponent;
|
||||
|
||||
isIOS: boolean;
|
||||
selectedPage?: string;
|
||||
|
||||
|
@ -80,13 +78,14 @@ export class CoreSitePreferencesPage implements OnInit, OnDestroy {
|
|||
|
||||
if (this.selectedPage) {
|
||||
this.openHandler(this.selectedPage);
|
||||
} /* else if (this.splitviewCtrl.isOn()) {
|
||||
} else if (CoreScreen.instance.isTablet) {
|
||||
if (this.isIOS) {
|
||||
this.openHandler('CoreSharedFilesListPage', { manage: true, siteId: this.siteId, hideSitePicker: true });
|
||||
// @todo
|
||||
// this.openHandler('CoreSharedFilesListPage', { manage: true, siteId: this.siteId, hideSitePicker: true });
|
||||
} else if (this.handlers.length > 0) {
|
||||
this.openHandler(this.handlers[0].page, this.handlers[0].params);
|
||||
}
|
||||
}*/
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -22,10 +22,12 @@
|
|||
<!-- Site home main contents. -->
|
||||
<!-- @todo <ng-container *ngIf="section && section.hasContent">
|
||||
<ion-item class="ion-text-wrap" *ngIf="section.summary">
|
||||
<core-format-text [text]="section.summary" contextLevel="course" [contextInstanceId]="siteHomeId"></core-format-text>
|
||||
<core-format-text [text]="section.summary" contextLevel="course" [contextInstanceId]="siteHomeId">
|
||||
</core-format-text>
|
||||
</ion-item>
|
||||
|
||||
<core-course-module *ngFor="let module of section.modules" [module]="module" [courseId]="siteHomeId" [downloadEnabled]="downloadEnabled" [section]="section"></core-course-module>
|
||||
<core-course-module *ngFor="let module of section.modules" [module]="module" [courseId]="siteHomeId"
|
||||
[downloadEnabled]="downloadEnabled" [section]="section"></core-course-module>
|
||||
</ng-container> -->
|
||||
|
||||
<!-- Site home items: news, categories, courses, etc. -->
|
||||
|
@ -72,7 +74,8 @@
|
|||
<ion-item>
|
||||
<ion-label>News (TODO)</ion-label>
|
||||
</ion-item>
|
||||
<!-- @todo <core-course-module class="core-sitehome-news" *ngIf="newsForumModule" [module]="module" [courseId]="siteHomeId"></core-course-module> -->
|
||||
<!-- @todo <core-course-module class="core-sitehome-news" *ngIf="newsForumModule" [module]="module" [courseId]="siteHomeId">
|
||||
</core-course-module> -->
|
||||
</ng-template>
|
||||
|
||||
<ng-template #categories>
|
||||
|
|
|
@ -16,6 +16,7 @@ import { Component, OnInit, Type } from '@angular/core';
|
|||
import { IonInfiniteScroll, IonRefresher } from '@ionic/angular';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreTag } from '@features/tag/services/tag';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { CoreTagAreaDelegate } from '../../services/tag-area-delegate';
|
||||
import { Translate } from '@singletons';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
|
@ -49,35 +50,43 @@ export class CoreTagIndexAreaPage implements OnInit {
|
|||
areaComponent?: Type<unknown>;
|
||||
loadMoreError = false;
|
||||
|
||||
constructor(
|
||||
protected route: ActivatedRoute,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* View loaded.
|
||||
*/
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.tagId = CoreNavigator.instance.getRouteNumberParam('tagId') || this.tagId;
|
||||
this.tagName = CoreNavigator.instance.getRouteParam('tagName') || this.tagName;
|
||||
this.collectionId = CoreNavigator.instance.getRouteNumberParam('collectionId') || this.collectionId;
|
||||
this.areaId = CoreNavigator.instance.getRouteNumberParam('areaId') || this.areaId;
|
||||
this.fromContextId = CoreNavigator.instance.getRouteNumberParam('fromContextId') || this.fromContextId;
|
||||
this.contextId = CoreNavigator.instance.getRouteNumberParam('contextId') || this.contextId;
|
||||
this.recursive = CoreNavigator.instance.getRouteBooleanParam('recursive') ?? true;
|
||||
this.route.queryParams.subscribe(async () => {
|
||||
this.loaded = false;
|
||||
|
||||
this.areaNameKey = CoreNavigator.instance.getRouteParam('areaNameKey') || '';
|
||||
// Pass the the following parameters to avoid fetching the first page.
|
||||
this.componentName = CoreNavigator.instance.getRouteParam('componentName');
|
||||
this.itemType = CoreNavigator.instance.getRouteParam('itemType');
|
||||
this.items = CoreNavigator.instance.getRouteParam<unknown[]>('items') || [];
|
||||
this.nextPage = CoreNavigator.instance.getRouteNumberParam('nextPage') || 0;
|
||||
this.canLoadMore = CoreNavigator.instance.getRouteBooleanParam('canLoadMore') || false;
|
||||
this.tagId = CoreNavigator.instance.getRouteNumberParam('tagId') || this.tagId;
|
||||
this.tagName = CoreNavigator.instance.getRouteParam('tagName') || this.tagName;
|
||||
this.collectionId = CoreNavigator.instance.getRouteNumberParam('collectionId') || this.collectionId;
|
||||
this.areaId = CoreNavigator.instance.getRouteNumberParam('areaId') || this.areaId;
|
||||
this.fromContextId = CoreNavigator.instance.getRouteNumberParam('fromContextId') || this.fromContextId;
|
||||
this.contextId = CoreNavigator.instance.getRouteNumberParam('contextId') || this.contextId;
|
||||
this.recursive = CoreNavigator.instance.getRouteBooleanParam('recursive') ?? true;
|
||||
|
||||
try {
|
||||
if (!this.componentName || !this.itemType || !this.items.length || this.nextPage == 0) {
|
||||
await this.fetchData(true);
|
||||
this.areaNameKey = CoreNavigator.instance.getRouteParam('areaNameKey') || '';
|
||||
// Pass the the following parameters to avoid fetching the first page.
|
||||
this.componentName = CoreNavigator.instance.getRouteParam('componentName');
|
||||
this.itemType = CoreNavigator.instance.getRouteParam('itemType');
|
||||
this.items = CoreNavigator.instance.getRouteParam<unknown[]>('items') || [];
|
||||
this.nextPage = CoreNavigator.instance.getRouteNumberParam('nextPage') || 0;
|
||||
this.canLoadMore = CoreNavigator.instance.getRouteBooleanParam('canLoadMore') || false;
|
||||
|
||||
try {
|
||||
if (!this.componentName || !this.itemType || !this.items.length || this.nextPage == 0) {
|
||||
await this.fetchData(true);
|
||||
}
|
||||
|
||||
this.areaComponent = await CoreTagAreaDelegate.instance.getComponent(this.componentName!, this.itemType!);
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
this.areaComponent = await CoreTagAreaDelegate.instance.getComponent(this.componentName!, this.itemType!);
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -6,26 +6,28 @@
|
|||
<ion-title>{{ 'core.tag.tag' | translate }}: {{ tagName }}</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<!--@todo <core-split-view>-->
|
||||
|
||||
<ion-content>
|
||||
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshData($event)">
|
||||
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
<core-loading [hideUntil]="loaded">
|
||||
<ion-list *ngIf="hasUnsupportedAreas || areas.length">
|
||||
<ion-item *ngIf="hasUnsupportedAreas" class="core-warning-item">
|
||||
<ion-icon slot="start" name="fas-exclamation-triangle" color="warning"></ion-icon>
|
||||
<ion-label class="ion-text-wrap">{{ 'core.tag.warningareasnotsupported' | translate }}</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" *ngFor="let area of areas" [title]="area.nameKey | translate"
|
||||
(click)="openArea(area)" [class.core-split-item-selected]="area!.id == selectedAreaId">
|
||||
<ion-label>
|
||||
<h2>{{ area!.nameKey | translate }}</h2>
|
||||
</ion-label>
|
||||
<ion-badge slot="end" *ngIf="area!.badge">{{ area!.badge }}</ion-badge>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
<core-empty-box icon="fa-tag" *ngIf="!hasUnsupportedAreas && (!areas || !areas.length)"
|
||||
[message]="'core.tag.noresultsfor' | translate: { $a: tagName }"></core-empty-box>
|
||||
</core-loading>
|
||||
<core-split-view>
|
||||
<ion-refresher slot="fixed" [disabled]="!loaded" (ionRefresh)="refreshData($event)">
|
||||
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
<core-loading [hideUntil]="loaded">
|
||||
<ion-list *ngIf="hasUnsupportedAreas || areas.length">
|
||||
<ion-item *ngIf="hasUnsupportedAreas" class="core-warning-item">
|
||||
<ion-icon slot="start" name="fas-exclamation-triangle" color="warning"></ion-icon>
|
||||
<ion-label class="ion-text-wrap">{{ 'core.tag.warningareasnotsupported' | translate }}</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" *ngFor="let area of areas" [title]="area.nameKey | translate"
|
||||
(click)="openArea(area)" [class.core-selected-item]="area!.id == selectedAreaId">
|
||||
<ion-label>
|
||||
<h2>{{ area!.nameKey | translate }}</h2>
|
||||
</ion-label>
|
||||
<ion-badge slot="end" *ngIf="area!.badge">{{ area!.badge }}</ion-badge>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
<core-empty-box icon="fa-tag" *ngIf="!hasUnsupportedAreas && (!areas || !areas.length)"
|
||||
[message]="'core.tag.noresultsfor' | translate: { $a: tagName }"></core-empty-box>
|
||||
</core-loading>
|
||||
</core-split-view>
|
||||
</ion-content>
|
||||
|
|
|
@ -17,15 +17,34 @@ import { IonicModule } from '@ionic/angular';
|
|||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { conditionalRoutes } from '@/app/app-routing.module';
|
||||
import { CoreTagIndexAreaRoute } from '@features/tag/tag-lazy.module';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
import { CoreTagIndexPage } from './index.page';
|
||||
|
||||
const routes: Routes = [
|
||||
const mobileRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: CoreTagIndexPage,
|
||||
},
|
||||
CoreTagIndexAreaRoute,
|
||||
];
|
||||
|
||||
const tabletRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: CoreTagIndexPage,
|
||||
children: [
|
||||
CoreTagIndexAreaRoute,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const routes: Routes = [
|
||||
...conditionalRoutes(mobileRoutes, () => CoreScreen.instance.isMobile),
|
||||
...conditionalRoutes(tabletRoutes, () => CoreScreen.instance.isTablet),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
|
|
@ -15,9 +15,9 @@
|
|||
import { Component, OnInit } from '@angular/core';
|
||||
import { IonRefresher } from '@ionic/angular';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
// import { CoreSplitViewComponent } from '@components/split-view/split-view';
|
||||
import { CoreTag } from '@features/tag/services/tag';
|
||||
import { CoreTagAreaDelegate } from '@features/tag/services/tag-area-delegate';
|
||||
import { CoreScreen } from '@services/screen';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
|
||||
/**
|
||||
|
@ -29,8 +29,6 @@ import { CoreNavigator } from '@services/navigator';
|
|||
})
|
||||
export class CoreTagIndexPage implements OnInit {
|
||||
|
||||
// @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent;
|
||||
|
||||
tagId = 0;
|
||||
tagName = '';
|
||||
collectionId = 0;
|
||||
|
@ -42,7 +40,7 @@ export class CoreTagIndexPage implements OnInit {
|
|||
selectedAreaId?: number;
|
||||
hasUnsupportedAreas = false;
|
||||
|
||||
areas: (CoreTagAreaDisplay | null)[] = [];
|
||||
areas: CoreTagAreaDisplay[] = [];
|
||||
|
||||
/**
|
||||
* View loaded.
|
||||
|
@ -58,10 +56,10 @@ export class CoreTagIndexPage implements OnInit {
|
|||
|
||||
try {
|
||||
await this.fetchData();
|
||||
/* if (this.splitviewCtrl.isOn() && this.areas && this.areas.length > 0) {
|
||||
if (CoreScreen.instance.isTablet && this.areas && this.areas.length > 0) {
|
||||
const area = this.areas.find((area) => area.id == this.areaId);
|
||||
this.openArea(area || this.areas[0]);
|
||||
}*/
|
||||
}
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
}
|
||||
|
@ -88,17 +86,19 @@ export class CoreTagIndexPage implements OnInit {
|
|||
this.areas = [];
|
||||
this.hasUnsupportedAreas = false;
|
||||
|
||||
const areasDisplay: (CoreTagAreaDisplay | null)[] = await Promise.all(areas.map(async (area) => {
|
||||
const areasDisplay: CoreTagAreaDisplay[] = [];
|
||||
|
||||
await Promise.all(areas.map(async (area) => {
|
||||
const items = await CoreTagAreaDelegate.instance.parseContent(area.component, area.itemtype, area.content);
|
||||
|
||||
if (!items || !items.length) {
|
||||
// Tag area not supported, skip.
|
||||
this.hasUnsupportedAreas = true;
|
||||
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
areasDisplay.push({
|
||||
id: area.ta,
|
||||
componentName: area.component,
|
||||
itemType: area.itemtype,
|
||||
|
@ -106,10 +106,10 @@ export class CoreTagIndexPage implements OnInit {
|
|||
items,
|
||||
canLoadMore: !!area.nextpageurl,
|
||||
badge: items && items.length ? items.length + (area.nextpageurl ? '+' : '') : '',
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||
this.areas = areasDisplay.filter((area) => area != null);
|
||||
this.areas = areasDisplay;
|
||||
|
||||
} catch (error) {
|
||||
CoreDomUtils.instance.showErrorModalDefault(error, 'Error loading tag index');
|
||||
|
@ -160,8 +160,11 @@ export class CoreTagIndexPage implements OnInit {
|
|||
canLoadMore: area.canLoadMore,
|
||||
nextPage: 1,
|
||||
};
|
||||
// this.splitviewCtrl.push('index-area', params);
|
||||
CoreNavigator.instance.navigate('../index-area', { params });
|
||||
|
||||
const splitViewLoaded = CoreNavigator.instance.isCurrentPathInTablet('**/tag/index/index-area');
|
||||
const path = (splitViewLoaded ? '../' : '') + 'index-area';
|
||||
|
||||
CoreNavigator.instance.navigate(path, { params });
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -18,8 +18,8 @@
|
|||
searchArea="CoreTag"></core-search-box>
|
||||
</ion-col>
|
||||
<ion-col size="12" size-sm="6" *ngIf="collections && collections.length > 1">
|
||||
<ion-select class="ion-text-start" [(ngModel)]="collectionId" (ngModelChange)="searchTags(query)"
|
||||
[disabled]="searching" interface="popover" class="core-button-select">
|
||||
<ion-select class="core-button-select ion-text-start" [(ngModel)]="collectionId" (ngModelChange)="searchTags(query)"
|
||||
[disabled]="searching" interface="popover">
|
||||
<ion-select-option [value]="0">{{ 'core.tag.inalltagcoll' | translate }}</ion-select-option>
|
||||
<ion-select-option *ngFor="let collection of collections" [value]="collection.id">
|
||||
{{ collection.name }}</ion-select-option>
|
||||
|
|
|
@ -17,6 +17,7 @@ import { CoreSites } from '@services/sites';
|
|||
import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
|
||||
import { CoreWSExternalWarning } from '@services/ws';
|
||||
import { makeSingleton, Translate } from '@singletons';
|
||||
import { CoreError } from '@classes/errors/error';
|
||||
|
||||
const ROOT_CACHE_KEY = 'CoreTag:';
|
||||
|
||||
|
@ -121,7 +122,7 @@ export class CoreTagProvider {
|
|||
const response: CoreTagCollections = await site.read('core_tag_get_tag_collections', null, preSets);
|
||||
|
||||
if (!response || !response.collections) {
|
||||
throw null;
|
||||
throw new CoreError('Cannot fetch tag collections');
|
||||
}
|
||||
|
||||
return response.collections;
|
||||
|
@ -185,7 +186,7 @@ export class CoreTagProvider {
|
|||
}
|
||||
|
||||
if (!response) {
|
||||
throw null;
|
||||
throw new CoreError('Cannot fetch tag index per area');
|
||||
}
|
||||
|
||||
return response;
|
||||
|
|
|
@ -13,10 +13,16 @@
|
|||
// limitations under the License.
|
||||
|
||||
import { Injector, NgModule } from '@angular/core';
|
||||
import { RouterModule, ROUTES, Routes } from '@angular/router';
|
||||
import { Route, RouterModule, ROUTES, Routes } from '@angular/router';
|
||||
|
||||
import { buildTabMainRoutes } from '@features/mainmenu/mainmenu-tab-routing.module';
|
||||
|
||||
export const CoreTagIndexAreaRoute: Route = {
|
||||
path: 'index-area',
|
||||
loadChildren: () =>
|
||||
import('@features/tag/pages/index-area/index-area.page.module').then(m => m.CoreTagIndexAreaPageModule),
|
||||
};
|
||||
|
||||
function buildRoutes(injector: Injector): Routes {
|
||||
return [
|
||||
{
|
||||
|
@ -27,11 +33,7 @@ function buildRoutes(injector: Injector): Routes {
|
|||
path: 'search',
|
||||
loadChildren: () => import('@features/tag//pages/search/search.page.module').then(m => m.CoreTagSearchPageModule),
|
||||
},
|
||||
{
|
||||
path: 'index-area',
|
||||
loadChildren: () =>
|
||||
import('@features/tag/pages/index-area/index-area.page.module').then(m => m.CoreTagIndexAreaPageModule),
|
||||
},
|
||||
CoreTagIndexAreaRoute,
|
||||
...buildTabMainRoutes(injector, {
|
||||
redirectTo: 'search',
|
||||
pathMatch: 'full',
|
||||
|
|
|
@ -14,8 +14,8 @@
|
|||
<ion-list *ngIf="user && !isDeleted && isEnrolled">
|
||||
<ion-item class="ion-text-center core-user-profile-maininfo">
|
||||
<core-user-avatar [user]="user" [userId]="user.id" [linkProfile]="false" [checkOnline]="true">
|
||||
<div class="core-icon-foreground">
|
||||
<ion-icon *ngIf="canChangeProfilePicture" name="fa-pen" (click)="changeProfilePicture()">
|
||||
<div class="core-icon-foreground" *ngIf="canChangeProfilePicture">
|
||||
<ion-icon name="fa-pen" (click)="changeProfilePicture()">
|
||||
</ion-icon>
|
||||
</div>
|
||||
</core-user-avatar>
|
||||
|
@ -29,24 +29,23 @@
|
|||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-grid class="core-user-communication-handlers"
|
||||
<div class="core-user-communication-handlers"
|
||||
*ngIf="(communicationHandlers && communicationHandlers.length) || isLoadingHandlers">
|
||||
<ion-row class="ion-no-padding justify-content-between"
|
||||
*ngIf="communicationHandlers && communicationHandlers.length">
|
||||
<ion-col *ngFor="let handler of communicationHandlers" class="ion-align-self-center ion-text-center">
|
||||
<a (click)="handlerClicked($event, handler)" [ngClass]="['core-user-profile-handler', handler.class || '']"
|
||||
title="{{handler.title | translate}}">
|
||||
<ion-icon [name]="handler.icon" slot="start"></ion-icon>
|
||||
<p>{{handler.title | translate}}</p>
|
||||
</a>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
<ion-row class="ion-no-padding">
|
||||
<ion-col class="ion-text-center core-loading-handlers" *ngIf="isLoadingHandlers">
|
||||
<ion-spinner></ion-spinner>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
<ion-item *ngIf="communicationHandlers && communicationHandlers.length">
|
||||
<ion-label>
|
||||
<ion-button *ngFor="let handler of communicationHandlers" expand="block" size="default"
|
||||
[ngClass]="['core-user-profile-handler', handler.class || '']" (click)="handlerClicked($event, handler)"
|
||||
[hidden]="handler.hidden" title="{{ handler.title | translate }}" [disabled]="handler.spinner">
|
||||
<ion-icon *ngIf="handler.icon" [name]="handler.icon" slot="start"></ion-icon>
|
||||
{{ handler.title | translate }}
|
||||
</ion-button>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<div *ngIf="isLoadingHandlers" class="ion-text-center core-loading-handlers">
|
||||
<ion-spinner></ion-spinner>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<ion-item button class="ion-text-wrap core-user-profile-handler" (click)="openUserDetails()"
|
||||
title="{{ 'core.user.details' | translate }}" detail>
|
||||
|
@ -70,14 +69,12 @@
|
|||
|
||||
<ion-item *ngIf="actionHandlers && actionHandlers.length">
|
||||
<ion-label>
|
||||
<ion-button *ngFor="let handler of actionHandlers" expand="block" fill="outline"
|
||||
<ion-button *ngFor="let handler of actionHandlers" expand="block" fill="outline" size="default"
|
||||
[ngClass]="['core-user-profile-handler', handler.class || '']" (click)="handlerClicked($event, handler)"
|
||||
[hidden]="handler.hidden" title="{{ handler.title | translate }}" [disabled]="handler.spinner">
|
||||
<ion-icon *ngIf="handler.icon" [name]="handler.icon" slot="start"></ion-icon>
|
||||
<ion-label>
|
||||
<span>{{ handler.title | translate }}</span>
|
||||
</ion-label>
|
||||
<ion-spinner *ngIf="handler.spinner"></ion-spinner>
|
||||
{{ handler.title | translate }}
|
||||
<ion-spinner *ngIf="handler.spinner" slot="end"></ion-spinner>
|
||||
</ion-button>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue