From b34adcaa428e89c61718468c88640a3ac40da625 Mon Sep 17 00:00:00 2001
From: Albert Gasset <albertgasset@fsfe.org>
Date: Fri, 11 May 2018 19:22:22 +0200
Subject: [PATCH 1/3] MOBILE-2341 core: Support split views in course formats

---
 .../course/classes/main-activity-component.ts | 19 ----------
 .../course/classes/main-resource-component.ts | 35 ++++++++++++++-----
 .../singleactivity/providers/handler.ts       | 19 +++++++++-
 src/core/course/pages/section/section.html    |  2 +-
 src/core/course/pages/section/section.ts      | 26 ++++++++++----
 src/core/course/providers/default-format.ts   | 12 +++++++
 src/core/course/providers/default-module.ts   | 10 ++++++
 src/core/course/providers/format-delegate.ts  | 22 ++++++++++++
 src/core/course/providers/module-delegate.ts  | 19 ++++++++++
 9 files changed, 129 insertions(+), 35 deletions(-)

diff --git a/src/core/course/classes/main-activity-component.ts b/src/core/course/classes/main-activity-component.ts
index f1fe1bf30..d0d8489f0 100644
--- a/src/core/course/classes/main-activity-component.ts
+++ b/src/core/course/classes/main-activity-component.ts
@@ -84,25 +84,6 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR
         }
     }
 
-    /**
-     * Refresh the data.
-     *
-     * @param {any}       [refresher] Refresher.
-     * @param {Function}  [done] Function to call when done.
-     * @param {boolean}   [showErrors=false] If show errors to the user of hide them.
-     * @return {Promise<any>} Promise resolved when done.
-     */
-    doRefresh(refresher?: any, done?: () => void, showErrors: boolean = false): Promise<any> {
-        if (this.loaded) {
-            return this.refreshContent(true, showErrors).finally(() => {
-                refresher && refresher.complete();
-                done && done();
-            });
-        }
-
-        return Promise.resolve();
-    }
-
     /**
      * Compares sync event data with current data to check if refresh content is needed.
      *
diff --git a/src/core/course/classes/main-resource-component.ts b/src/core/course/classes/main-resource-component.ts
index 2023b1175..5d753824f 100644
--- a/src/core/course/classes/main-resource-component.ts
+++ b/src/core/course/classes/main-resource-component.ts
@@ -17,7 +17,8 @@ import { TranslateService } from '@ngx-translate/core';
 import { CoreDomUtilsProvider } from '@providers/utils/dom';
 import { CoreTextUtilsProvider } from '@providers/utils/text';
 import { CoreCourseHelperProvider } from '@core/course/providers/helper';
-import { CoreCourseModuleMainComponent } from '@core/course/providers/module-delegate';
+import { CoreCourseModuleMainComponent, CoreCourseModuleDelegate } from '@core/course/providers/module-delegate';
+import { CoreCourseSectionPage } from '@core/course/pages/section/section.ts';
 
 /**
  * Template class to easily create CoreCourseModuleMainComponent of resources (or activities without syncing).
@@ -50,12 +51,16 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy,
     protected courseHelper: CoreCourseHelperProvider;
     protected translate: TranslateService;
     protected domUtils: CoreDomUtilsProvider;
+    protected moduleDelegate: CoreCourseModuleDelegate;
+    protected courseSectionPage: CoreCourseSectionPage;
 
     constructor(injector: Injector) {
         this.textUtils = injector.get(CoreTextUtilsProvider);
         this.courseHelper = injector.get(CoreCourseHelperProvider);
         this.translate = injector.get(TranslateService);
         this.domUtils = injector.get(CoreDomUtilsProvider);
+        this.moduleDelegate = injector.get(CoreCourseModuleDelegate);
+        this.courseSectionPage = injector.get(CoreCourseSectionPage, null);
         this.dataRetrieved = new EventEmitter();
     }
 
@@ -73,15 +78,27 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy,
     /**
      * Refresh the data.
      *
-     * @param {any} [refresher] Refresher.
-     * @param {Function} [done] Function to call when done.
+     * @param {any}       [refresher] Refresher.
+     * @param {Function}  [done] Function to call when done.
+     * @param {boolean}   [showErrors=false] If show errors to the user of hide them.
      * @return {Promise<any>} Promise resolved when done.
      */
-    doRefresh(refresher?: any, done?: () => void): Promise<any> {
+    doRefresh(refresher?: any, done?: () => void, showErrors: boolean = false): Promise<any> {
         if (this.loaded) {
-            return this.refreshContent().finally(() => {
-                refresher && refresher.complete();
-                done && done();
+            /* If it's a single activity course and the refresher is displayed within the component,
+               call doRefresh on the section page to refresh the course data. */
+            let promise;
+            if (this.courseSectionPage && !this.moduleDelegate.displayRefresherInSingleActivity(this.module.modname)) {
+                promise = this.courseSectionPage.doRefresh();
+            } else {
+                promise = Promise.resolve();
+            }
+
+            return promise.finally(() => {
+                return this.refreshContent(true, showErrors).finally(() => {
+                    refresher && refresher.complete();
+                    done && done();
+                });
             });
         }
 
@@ -91,9 +108,11 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy,
     /**
      * Perform the refresh content function.
      *
+     * @param  {boolean}      [sync=false]       If the refresh needs syncing.
+     * @param  {boolean}      [showErrors=false] Wether to show errors to the user or hide them.
      * @return {Promise<any>} Resolved when done.
      */
-    protected refreshContent(): Promise<any> {
+     protected refreshContent(sync: boolean = false, showErrors: boolean = false): Promise<any> {
         this.refreshIcon = 'spinner';
 
         return this.invalidateContent().catch(() => {
diff --git a/src/core/course/formats/singleactivity/providers/handler.ts b/src/core/course/formats/singleactivity/providers/handler.ts
index 67090c9ee..31a967606 100644
--- a/src/core/course/formats/singleactivity/providers/handler.ts
+++ b/src/core/course/formats/singleactivity/providers/handler.ts
@@ -14,6 +14,7 @@
 
 import { Injectable, Injector } from '@angular/core';
 import { CoreCourseFormatHandler } from '../../../providers/format-delegate';
+import { CoreCourseModuleDelegate } from '../../../providers/module-delegate';
 import { CoreCourseFormatSingleActivityComponent } from '../components/singleactivity';
 
 /**
@@ -24,7 +25,7 @@ export class CoreCourseFormatSingleActivityHandler implements CoreCourseFormatHa
     name = 'CoreCourseFormatSingleActivity';
     format = 'singleactivity';
 
-    constructor() {
+    constructor(private moduleDelegate: CoreCourseModuleDelegate) {
         // Nothing to do.
     }
 
@@ -83,6 +84,22 @@ export class CoreCourseFormatSingleActivityHandler implements CoreCourseFormatHa
         return false;
     }
 
+    /**
+     * Whether the course refresher should be displayed. If it returns false, a refresher must be included in the course format,
+     * and the doRefresh method of CoreCourseSectionPage must be called on refresh. Defaults to true.
+     *
+     * @param {any} course The course to check.
+     * @param {any[]} sections List of course sections.
+     * @return {boolean} Whether the refresher should be displayed.
+     */
+    displayRefresher(course: any, sections: any[]): boolean {
+        if (sections && sections[0] && sections[0].modules) {
+            return this.moduleDelegate.displayRefresherInSingleActivity(sections[0].modules[0].modname);
+        } else {
+            return true;
+        }
+    }
+
     /**
      * Return the Component to use to display the course format instead of using the default one.
      * Use it if you want to display a format completely different from the default one.
diff --git a/src/core/course/pages/section/section.html b/src/core/course/pages/section/section.html
index cb9f66456..16a95b37b 100644
--- a/src/core/course/pages/section/section.html
+++ b/src/core/course/pages/section/section.html
@@ -17,7 +17,7 @@
                     </core-context-menu>
                 </core-navbar-buttons>
                 <ion-content>
-                    <ion-refresher [enabled]="dataLoaded" (ionRefresh)="doRefresh($event)">
+                    <ion-refresher [enabled]="dataLoaded && displayRefresher" (ionRefresh)="doRefresh($event)">
                         <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
                     </ion-refresher>
 
diff --git a/src/core/course/pages/section/section.ts b/src/core/course/pages/section/section.ts
index 3c5f55f39..2d31e9a50 100644
--- a/src/core/course/pages/section/section.ts
+++ b/src/core/course/pages/section/section.ts
@@ -54,6 +54,7 @@ export class CoreCourseSectionPage implements OnDestroy {
     };
     moduleId: number;
     displayEnableDownload: boolean;
+    displayRefresher: boolean;
 
     protected module: any;
     protected completionObserver;
@@ -188,6 +189,9 @@ export class CoreCourseSectionPage implements OnDestroy {
 
                     // Get the title again now that we have sections.
                     this.title = this.courseFormatDelegate.getCourseTitle(this.course, this.sections);
+
+                    // Get whether to show the refresher now that we have sections.
+                    this.displayRefresher = this.courseFormatDelegate.displayRefresher(this.course, this.sections);
                 });
             }));
 
@@ -212,13 +216,23 @@ export class CoreCourseSectionPage implements OnDestroy {
     /**
      * Refresh the data.
      *
-     * @param {any} refresher Refresher.
+     * @param  {any} [refresher] Refresher.
+     * @return {Promise<any>} Promise resolved when done.
      */
-    doRefresh(refresher: any): void {
-        this.invalidateData().finally(() => {
-            this.loadData(true).finally(() => {
-                this.formatComponent.doRefresh(refresher).finally(() => {
-                    refresher.complete();
+    doRefresh(refresher?: any): Promise<any> {
+        return this.invalidateData().finally(() => {
+            return this.loadData(true).finally(() => {
+                /* Do not call doRefresh on the format component if the refresher is defined in the format component
+                   to prevent an inifinite loop. */
+                 let promise;
+                 if (this.displayRefresher) {
+                     promise = this.formatComponent.doRefresh(refresher);
+                 } else {
+                     promise = Promise.resolve();
+                 }
+
+                return promise.finally(() => {
+                    refresher && refresher.complete();
                 });
             });
         });
diff --git a/src/core/course/providers/default-format.ts b/src/core/course/providers/default-format.ts
index 7c9ad4648..10919e155 100644
--- a/src/core/course/providers/default-format.ts
+++ b/src/core/course/providers/default-format.ts
@@ -77,6 +77,18 @@ export class CoreCourseFormatDefaultHandler implements CoreCourseFormatHandler {
         return true;
     }
 
+    /**
+     * Whether the course refresher should be displayed. If it returns false, a refresher must be included in the course format,
+     * and the doRefresh method of CoreCourseSectionPage must be called on refresh. Defaults to true.
+     *
+     * @param {any} course The course to check.
+     * @param {any[]} sections List of course sections.
+     * @return {boolean} Whether the refresher should be displayed.
+     */
+    displayRefresher?(course: any, sections: any[]): boolean {
+        return true;
+    }
+
     /**
      * Given a list of sections, get the "current" section that should be displayed first.
      *
diff --git a/src/core/course/providers/default-module.ts b/src/core/course/providers/default-module.ts
index 65b52a9df..04596a46e 100644
--- a/src/core/course/providers/default-module.ts
+++ b/src/core/course/providers/default-module.ts
@@ -89,4 +89,14 @@ export class CoreCourseModuleDefaultHandler implements CoreCourseModuleHandler {
         // We can't inject CoreCourseUnsupportedModuleComponent here due to circular dependencies.
         // Don't return anything, by default it will use CoreCourseUnsupportedModuleComponent.
     }
+
+    /**
+     * Whether to display the course refresher in single activity course format. If it returns false, a refresher must be
+     * included in the template that calls the doRefresh method of the component. Defaults to true.
+     *
+     * @return {boolean} Whether the refresher should be displayed.
+     */
+    displayRefresherInSingleActivity(): boolean {
+        return true;
+    }
 }
diff --git a/src/core/course/providers/format-delegate.ts b/src/core/course/providers/format-delegate.ts
index e79841ace..218a9bfdf 100644
--- a/src/core/course/providers/format-delegate.ts
+++ b/src/core/course/providers/format-delegate.ts
@@ -65,6 +65,16 @@ export interface CoreCourseFormatHandler extends CoreDelegateHandler {
      */
     displaySectionSelector?(course: any): boolean;
 
+    /**
+     * Whether the course refresher should be displayed. If it returns false, a refresher must be included in the course format,
+     * and the doRefresh method of CoreCourseSectionPage must be called on refresh. Defaults to true.
+     *
+     * @param {any} course The course to check.
+     * @param {any[]} sections List of course sections.
+     * @type {boolean} Whether the refresher should be displayed.
+     */
+    displayRefresher?(course: any, sections: any[]): boolean;
+
     /**
      * Given a list of sections, get the "current" section that should be displayed first. Defaults to first section.
      *
@@ -183,6 +193,18 @@ export class CoreCourseFormatDelegate extends CoreDelegate {
         return this.executeFunctionOnEnabled(course.format, 'displayEnableDownload', [course]);
     }
 
+    /**
+     * Whether the course refresher should be displayed. If it returns false, a refresher must be included in the course format,
+     * and the doRefresh method of CoreCourseSectionPage must be called on refresh. Defaults to true.
+     *
+     * @param {any} course The course to check.
+     * @param {any[]} sections List of course sections.
+     * @return {boolean} Whether the refresher should be displayed.
+     */
+    displayRefresher(course: any, sections: any[]): boolean {
+        return this.executeFunctionOnEnabled(course.format, 'displayRefresher', [course, sections]);
+    }
+
     /**
      * Whether the default section selector should be displayed. Defaults to true.
      *
diff --git a/src/core/course/providers/module-delegate.ts b/src/core/course/providers/module-delegate.ts
index 530fd0ec1..b4981020e 100644
--- a/src/core/course/providers/module-delegate.ts
+++ b/src/core/course/providers/module-delegate.ts
@@ -53,6 +53,14 @@ export interface CoreCourseModuleHandler extends CoreDelegateHandler {
      * @return {any|Promise<any>} The component (or promise resolved with component) to use, undefined if not found.
      */
     getMainComponent(injector: Injector, course: any, module: any): any | Promise<any>;
+
+    /**
+     * Whether to display the course refresher in single activity course format. If it returns false, a refresher must be
+     * included in the template that calls the doRefresh method of the component. Defaults to true.
+     *
+     * @return {boolean} Whether the refresher should be displayed.
+     */
+    displayRefresherInSingleActivity?(): boolean;
 }
 
 /**
@@ -247,4 +255,15 @@ export class CoreCourseModuleDelegate extends CoreDelegate {
 
         return false;
     }
+
+    /**
+     * Whether to display the course refresher in single activity course format. If it returns false, a refresher must be
+     * included in the template that calls the doRefresh method of the component. Defaults to true.
+     *
+     * @param {any} modname The name of the module type.
+     * @return {boolean} Whether the refresher should be displayed.
+     */
+    displayRefresherInSingleActivity(modname: string): boolean {
+        return this.executeFunctionOnEnabled(modname, 'displayRefresherInSingleActivity');
+    }
 }

From 401b407f1f9173ddaff86490b890c7e3d7e5a601 Mon Sep 17 00:00:00 2001
From: Albert Gasset <albertgasset@fsfe.org>
Date: Mon, 14 May 2018 10:18:21 +0200
Subject: [PATCH 2/3] MOBILE-2341 forum: Migrate Forum

---
 .../mod/forum/components/components.module.ts |  50 ++
 .../mod/forum/components/index/index.html     | 101 +++
 .../mod/forum/components/index/index.scss     |   5 +
 src/addon/mod/forum/components/index/index.ts | 421 ++++++++++
 src/addon/mod/forum/components/post/post.html |  61 ++
 src/addon/mod/forum/components/post/post.ts   | 307 ++++++++
 src/addon/mod/forum/forum.module.ts           |  60 ++
 src/addon/mod/forum/lang/en.json              |  32 +
 .../forum/pages/discussion/discussion.html    |  61 ++
 .../pages/discussion/discussion.module.ts     |  35 +
 .../mod/forum/pages/discussion/discussion.ts  | 411 ++++++++++
 src/addon/mod/forum/pages/index/index.html    |  11 +
 .../mod/forum/pages/index/index.module.ts     |  33 +
 src/addon/mod/forum/pages/index/index.ts      |  48 ++
 .../pages/new-discussion/new-discussion.html  |  53 ++
 .../new-discussion/new-discussion.module.ts   |  33 +
 .../pages/new-discussion/new-discussion.ts    | 535 +++++++++++++
 .../providers/discussion-link-handler.ts      |  69 ++
 src/addon/mod/forum/providers/forum.ts        | 729 ++++++++++++++++++
 src/addon/mod/forum/providers/helper.ts       | 243 ++++++
 .../mod/forum/providers/index-link-handler.ts |  44 ++
 .../mod/forum/providers/module-handler.ts     |  81 ++
 src/addon/mod/forum/providers/offline.ts      | 454 +++++++++++
 .../mod/forum/providers/prefetch-handler.ts   | 264 +++++++
 .../mod/forum/providers/sync-cron-handler.ts  |  47 ++
 src/addon/mod/forum/providers/sync.ts         | 547 +++++++++++++
 src/app/app.module.ts                         |   2 +
 src/providers/sync.ts                         |  20 +-
 28 files changed, 4747 insertions(+), 10 deletions(-)
 create mode 100644 src/addon/mod/forum/components/components.module.ts
 create mode 100644 src/addon/mod/forum/components/index/index.html
 create mode 100644 src/addon/mod/forum/components/index/index.scss
 create mode 100644 src/addon/mod/forum/components/index/index.ts
 create mode 100644 src/addon/mod/forum/components/post/post.html
 create mode 100644 src/addon/mod/forum/components/post/post.ts
 create mode 100644 src/addon/mod/forum/forum.module.ts
 create mode 100644 src/addon/mod/forum/lang/en.json
 create mode 100644 src/addon/mod/forum/pages/discussion/discussion.html
 create mode 100644 src/addon/mod/forum/pages/discussion/discussion.module.ts
 create mode 100644 src/addon/mod/forum/pages/discussion/discussion.ts
 create mode 100644 src/addon/mod/forum/pages/index/index.html
 create mode 100644 src/addon/mod/forum/pages/index/index.module.ts
 create mode 100644 src/addon/mod/forum/pages/index/index.ts
 create mode 100644 src/addon/mod/forum/pages/new-discussion/new-discussion.html
 create mode 100644 src/addon/mod/forum/pages/new-discussion/new-discussion.module.ts
 create mode 100644 src/addon/mod/forum/pages/new-discussion/new-discussion.ts
 create mode 100644 src/addon/mod/forum/providers/discussion-link-handler.ts
 create mode 100644 src/addon/mod/forum/providers/forum.ts
 create mode 100644 src/addon/mod/forum/providers/helper.ts
 create mode 100644 src/addon/mod/forum/providers/index-link-handler.ts
 create mode 100644 src/addon/mod/forum/providers/module-handler.ts
 create mode 100644 src/addon/mod/forum/providers/offline.ts
 create mode 100644 src/addon/mod/forum/providers/prefetch-handler.ts
 create mode 100644 src/addon/mod/forum/providers/sync-cron-handler.ts
 create mode 100644 src/addon/mod/forum/providers/sync.ts

diff --git a/src/addon/mod/forum/components/components.module.ts b/src/addon/mod/forum/components/components.module.ts
new file mode 100644
index 000000000..e9b656899
--- /dev/null
+++ b/src/addon/mod/forum/components/components.module.ts
@@ -0,0 +1,50 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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 { CoreComponentsModule } from '@components/components.module';
+import { CoreDirectivesModule } from '@directives/directives.module';
+import { CorePipesModule } from '@pipes/pipes.module';
+import { CoreCourseComponentsModule } from '@core/course/components/components.module';
+import { AddonModForumIndexComponent } from './index/index';
+import { AddonModForumPostComponent } from './post/post';
+
+@NgModule({
+    declarations: [
+        AddonModForumIndexComponent,
+        AddonModForumPostComponent
+    ],
+    imports: [
+        CommonModule,
+        IonicModule,
+        TranslateModule.forChild(),
+        CoreComponentsModule,
+        CoreDirectivesModule,
+        CorePipesModule,
+        CoreCourseComponentsModule
+    ],
+    providers: [
+    ],
+    exports: [
+        AddonModForumIndexComponent,
+        AddonModForumPostComponent
+    ],
+    entryComponents: [
+        AddonModForumIndexComponent
+    ]
+})
+export class AddonModForumComponentsModule {}
diff --git a/src/addon/mod/forum/components/index/index.html b/src/addon/mod/forum/components/index/index.html
new file mode 100644
index 000000000..33b90c5d6
--- /dev/null
+++ b/src/addon/mod/forum/components/index/index.html
@@ -0,0 +1,101 @@
+<!-- Buttons to add to the header. -->
+<core-navbar-buttons end>
+    <core-context-menu>
+        <core-context-menu-item *ngIf="externalUrl" [priority]="900" [content]="'core.openinbrowser' | translate" [href]="externalUrl" [iconAction]="'open'"></core-context-menu-item>
+        <core-context-menu-item *ngIf="description" [priority]="800" [content]="'core.moduleintro' | translate" (action)="expandDescription()" [iconAction]="'arrow-forward'"></core-context-menu-item>
+        <core-context-menu-item *ngIf="loaded && !hasOffline && isOnline" [priority]="700" [content]="'addon.mod_forum.refreshdiscussions' | translate" (action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false"></core-context-menu-item>
+        <core-context-menu-item *ngIf="loaded && hasOffline && isOnline"  [priority]="600" [content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(null, $event, true)" [iconAction]="syncIcon" [closeOnClick]="false"></core-context-menu-item>
+        <core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="500" [content]="prefetchText" (action)="prefetch()" [iconAction]="prefetchStatusIcon" [closeOnClick]="false"></core-context-menu-item>
+        <core-context-menu-item *ngIf="size" [priority]="400" [content]="size" [iconDescription]="'cube'" (action)="removeFiles()" [iconAction]="'trash'"></core-context-menu-item>
+    </core-context-menu>
+</core-navbar-buttons>
+
+<!-- Content. -->
+<core-split-view>
+    <ion-content>
+        <ion-refresher [enabled]="loaded" (ionRefresh)="doRefresh($event)">
+            <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
+        </ion-refresher>
+        <core-loading [hideUntil]="loaded" class="core-loading-center">
+            <core-course-module-description [description]="description" [component]="component" [componentId]="componentId" [note]="descriptionNote"></core-course-module-description>
+
+            <!-- Forum discussions found to be synchronized -->
+            <ion-card class="core-warning-card" icon-start *ngIf="hasOffline">
+                <ion-icon name="warning"></ion-icon> {{ 'core.hasdatatosync' | translate:{$a: moduleName} }}
+            </ion-card>
+
+            <ng-container *ngIf="forum && discussions.length > 0">
+                <div padding-horizontal margin-vertical *ngIf="forum.cancreatediscussions">
+                    <button ion-button block (click)="openNewDiscussion()">
+                        {{ 'addon.mod_forum.addanewdiscussion' | translate }}
+                    </button>
+                </div>
+                <ion-card *ngFor="let discussion of offlineDiscussions" (click)="openNewDiscussion(discussion.timecreated)" [class.addon-forum-discussion-selected]="discussion.timecreated == -selectedDiscussion">
+                    <ion-item text-wrap>
+                        <ion-avatar item-start core-user-link [userId]="discussion.userid" [courseId]="courseId">
+                            <img [src]="discussion.userpictureurl" onError="this.src='assets/img/user-avatar.png'" core-external-content [alt]="'core.pictureof' | translate:{$a: discussion.userfullname}" role="presentation">
+                        </ion-avatar>
+                        <h2>{{discussion.subject}}</h2>
+                        <p *ngIf="discussion.userfullname">
+                            <ion-note float-right padding-left><ion-icon name="time"></ion-icon> {{ 'core.notsent' | translate }}</ion-note>
+                            {{discussion.userfullname}}
+                        </p>
+                    </ion-item>
+                    <ion-card-content>
+                        <ion-note text-right *ngIf="discussion.groupname">
+                            <ion-icon name="people"></ion-icon> {{ discussion.groupname }}
+                        </ion-note>
+                        <p><core-format-text [clean]="true" [maxHeight]="60" [component]="component" [componentId]="componentId" [text]="discussion.message"></core-format-text></p>
+                    </ion-card-content>
+                </ion-card>
+                <ion-card *ngFor="let discussion of discussions" (click)="openDiscussion(discussion)" [class.addon-forum-discussion-selected]="discussion.discussion == selectedDiscussion">
+                    <ion-item text-wrap>
+                        <ion-avatar item-start core-user-link [userId]="discussion.userid" [courseId]="courseId">
+                            <img [src]="discussion.userpictureurl" onError="this.src='assets/img/user-avatar.png'" core-external-content [alt]="'core.pictureof' | translate:{$a: discussion.userfullname}">
+                        </ion-avatar>
+                        <h2><ion-icon name="pin" *ngIf="discussion.pinned"></ion-icon> {{discussion.subject}}</h2>
+                        <p>
+                            <ion-note float-right padding-left>
+                                {{discussion.created | coreDateDayOrTime}}
+                                <div *ngIf="discussion.numunread"><ion-icon name="record"></ion-icon> {{ 'addon.mod_forum.unreadpostsnumber' | translate:{ '$a' : discussion.numunread} }}</div>
+                            </ion-note>
+                            {{discussion.userfullname}}
+                        </p>
+                    </ion-item>
+                    <ion-card-content>
+                        <core-format-text [clean]="true" [maxHeight]="60" [component]="component" [componentId]="componentId" [text]="discussion.message"></core-format-text>
+                    </ion-card-content>
+                    <ion-row text-center>
+                        <ion-col *ngIf="discussion.groupname">
+                            <ion-note>
+                                <ion-icon name="people"></ion-icon> {{ discussion.groupname }}
+                            </ion-note>
+                        </ion-col>
+                        <ion-col>
+                            <ion-note>
+                                <ion-icon name="chatboxes"></ion-icon> {{ 'addon.mod_forum.numreplies' | translate:{numreplies: discussion.numreplies} }}
+                            </ion-note>
+                        </ion-col>
+                        <ion-col *ngIf="discussion.timemodified > discussion.created">
+                            <ion-note>
+                                <ion-icon name="time"></ion-icon> {{discussion.timemodified | coreTimeAgo}}
+                            </ion-note>
+                        </ion-col>
+                    </ion-row>
+                </ion-card>
+            </ng-container>
+
+            <core-empty-box *ngIf="forum && discussions.length == 0" icon="chatbubbles" [message]="'addon.mod_forum.forumnodiscussionsyet' | translate">
+                <div padding *ngIf="forum.cancreatediscussions">
+                    <button ion-button block (click)="addNewDiscussion()">
+                        {{ 'addon.mod_forum.addanewdiscussion' | translate }}
+                    </button>
+                </div>
+            </core-empty-box>
+
+            <ion-infinite-scroll [enabled]="canLoadMore" (ionInfinite)="$event.waitFor(fetchContent())" position="top">
+                <ion-infinite-scroll-content></ion-infinite-scroll-content>
+            </ion-infinite-scroll>
+        </core-loading>
+    </ion-content>
+</core-split-view>
diff --git a/src/addon/mod/forum/components/index/index.scss b/src/addon/mod/forum/components/index/index.scss
new file mode 100644
index 000000000..62b648d10
--- /dev/null
+++ b/src/addon/mod/forum/components/index/index.scss
@@ -0,0 +1,5 @@
+addon-mod-forum-index {
+    .addon-forum-discussion-selected {
+        border-top: 5px solid $core-color-light;
+    }
+}
diff --git a/src/addon/mod/forum/components/index/index.ts b/src/addon/mod/forum/components/index/index.ts
new file mode 100644
index 000000000..6ff264e86
--- /dev/null
+++ b/src/addon/mod/forum/components/index/index.ts
@@ -0,0 +1,421 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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, Optional, Injector, ViewChild } from '@angular/core';
+import { Content, NavController } from 'ionic-angular';
+import { CoreSplitViewComponent } from '@components/split-view/split-view';
+import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component';
+import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate';
+import { CoreUserProvider } from '@core/user/providers/user';
+import { CoreGroupsProvider } from '@providers/groups';
+import { AddonModForumProvider } from '../../providers/forum';
+import { AddonModForumHelperProvider } from '../../providers/helper';
+import { AddonModForumOfflineProvider } from '../../providers/offline';
+import { AddonModForumSyncProvider } from '../../providers/sync';
+import { AddonModForumPrefetchHandler } from '../../providers/prefetch-handler';
+
+/**
+ * Component that displays a forum entry page.
+ */
+@Component({
+    selector: 'addon-mod-forum-index',
+    templateUrl: 'index.html',
+})
+export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityComponent {
+    @ViewChild(CoreSplitViewComponent) splitviewCtrl: CoreSplitViewComponent;
+
+    component = AddonModForumProvider.COMPONENT;
+    moduleName = 'forum';
+
+    descriptionNote: string;
+    forum: any;
+    trackPosts = false;
+    usesGroups = false;
+    canLoadMore = false;
+    discussions = [];
+    offlineDiscussions = [];
+    count = 0;
+    selectedDiscussion = 0; // Disucssion ID or negative timecreated if it's an offline discussion.
+
+    protected syncEventName = AddonModForumSyncProvider.AUTO_SYNCED;
+    protected page = 0;
+    protected syncManualObserver: any; // It will observe the sync manual event.
+    protected replyObserver: any;
+    protected newDiscObserver: any;
+    protected viewDiscObserver: any;
+
+    constructor(injector: Injector,
+            @Optional() protected content: Content,
+            protected navCtrl: NavController,
+            protected groupsProvider: CoreGroupsProvider,
+            protected userProvider: CoreUserProvider,
+            protected forumProvider: AddonModForumProvider,
+            protected forumHelper: AddonModForumHelperProvider,
+            protected forumOffline: AddonModForumOfflineProvider,
+            protected forumSync: AddonModForumSyncProvider,
+            protected prefetchDelegate: CoreCourseModulePrefetchDelegate,
+            protected prefetchHandler: AddonModForumPrefetchHandler) {
+        super(injector);
+    }
+
+    /**
+     * Component being initialized.
+     */
+    ngOnInit(): void {
+        super.ngOnInit();
+
+        // Refresh data if this forum discussion is synchronized from discussions list.
+        this.syncManualObserver = this.eventsProvider.on(AddonModForumSyncProvider.MANUAL_SYNCED, (data) => {
+            this.autoSyncEventReceived(data);
+        }, this.siteId);
+
+        // Listen for discussions added. When a discussion is added, we reload the data.
+        this.newDiscObserver = this.eventsProvider.on(AddonModForumProvider.NEW_DISCUSSION_EVENT, this.eventReceived.bind(this));
+        this.replyObserver = this.eventsProvider.on(AddonModForumProvider.REPLY_DISCUSSION_EVENT, this.eventReceived.bind(this));
+
+        // Select the curren opened discussion.
+        this.viewDiscObserver = this.eventsProvider.on(AddonModForumProvider.VIEW_DISCUSSION_EVENT, (data) => {
+            if (this.forum && this.forum.id == data.forumId) {
+                this.selectedDiscussion = this.splitviewCtrl.isOn() ? data.discussion : 0;
+            }
+        }, this.sitesProvider.getCurrentSiteId());
+
+        this.loadContent(false, true).then(() => {
+            if (!this.forum) {
+                return;
+            }
+
+            if (this.splitviewCtrl.isOn()) {
+                // Load the first discussion.
+                if (this.offlineDiscussions.length > 0) {
+                    this.openNewDiscussion(this.offlineDiscussions[0].timecreated);
+                } else if (this.discussions.length > 0) {
+                    this.openDiscussion(this.discussions[0]);
+                }
+            }
+
+            this.forumProvider.logView(this.forum.id).then(() => {
+                this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus);
+            }).catch((error) => {
+                // Ignore errors.
+            });
+        });
+    }
+
+    /**
+     * Download the component contents.
+     *
+     * @param  {boolean} [refresh=false]    Whether we're refreshing data.
+     * @param  {boolean} [sync=false]       If the refresh needs syncing.
+     * @param  {boolean} [showErrors=false] Wether to show errors to the user or hide them.
+     * @return {Promise<any>} Promise resolved when done.
+     */
+    protected fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<any> {
+        return this.forumProvider.getForum(this.courseId, this.module.id).then((forum) => {
+            this.forum = forum;
+
+            this.description = forum.intro || this.description;
+            this.descriptionNote = this.translate.instant('addon.mod_forum.numdiscussions', {numdiscussions: forum.numdiscussions});
+            if (typeof forum.istracked != 'undefined') {
+                this.trackPosts = forum.istracked;
+            }
+
+            this.dataRetrieved.emit(forum);
+
+            if (sync) {
+                // Try to synchronize the forum.
+                return this.syncActivity(showErrors).then((updated) => {
+                    if (updated) {
+                        // Sync successful, send event.
+                        this.eventsProvider.trigger(AddonModForumSyncProvider.MANUAL_SYNCED, {
+                            forumId: forum.id,
+                            userId: this.sitesProvider.getCurrentSiteUserId(),
+                        }, this.sitesProvider.getCurrentSiteId());
+                    }
+                });
+            }
+        }).then(() => {
+            // Check if the activity uses groups.
+            return this.groupsProvider.getActivityGroupMode(this.forum.cmid).then((mode) => {
+                this.usesGroups = (mode === CoreGroupsProvider.SEPARATEGROUPS || mode === CoreGroupsProvider.VISIBLEGROUPS);
+            });
+        }).then(() => {
+            return Promise.all([
+                this.fetchOfflineDiscussion(),
+                this.fetchDiscussions(refresh),
+            ]);
+        }).catch((message) => {
+            if (!refresh) {
+                // Get forum failed, retry without using cache since it might be a new activity.
+                return this.refreshContent(sync);
+            }
+
+            this.domUtils.showErrorModalDefault(message, 'addon.mod_forum.errorgetforum', true);
+
+            this.canLoadMore = false; // Set to false to prevent infinite calls with infinite-loading.
+
+            return Promise.reject(null);
+        });
+    }
+
+    /**
+     * Convenience function to fetch offline discussions.
+     *
+     * @return {Promise<any>} Promise resolved when done.
+     */
+    protected fetchOfflineDiscussion(): Promise<any> {
+        return this.forumOffline.getNewDiscussions(this.forum.id).then((offlineDiscussions) => {
+            this.hasOffline = !!offlineDiscussions.length;
+
+            if (this.hasOffline) {
+                let promise;
+                if (this.usesGroups) {
+                    promise = this.forumProvider.formatDiscussionsGroups(this.forum.cmid, offlineDiscussions);
+                } else {
+                    promise = Promise.resolve(offlineDiscussions);
+                }
+
+                return promise.then((offlineDiscussions) => {
+                    // Fill user data for Offline discussions (should be already cached).
+                    const userPromises = [];
+                    offlineDiscussions.forEach((discussion) => {
+                        if (discussion.parent != 0 || this.forum.type != 'single') {
+                            // Do not show author for first post and type single.
+                            userPromises.push(this.userProvider.getProfile(discussion.userid, this.courseId, true)
+                                    .then((user) => {
+                                discussion.userfullname = user.fullname;
+                                discussion.userpictureurl = user.profileimageurl;
+                            }).catch(() => {
+                                // Ignore errors.
+                            }));
+                        }
+                    });
+
+                    return Promise.all(userPromises).then(() => {
+                        // Sort discussion by time (newer first).
+                        offlineDiscussions.sort((a, b) => b.timecreated - a.timecreated);
+
+                        this.offlineDiscussions = offlineDiscussions;
+                    });
+                });
+            } else {
+                this.offlineDiscussions = [];
+            }
+        });
+    }
+
+    /**
+     * Convenience function to get forum discussions.
+     *
+     * @param  {boolean} refresh Whether we're refreshing data.
+     * @return {Promise<any>} Promise resolved when done.
+     */
+    protected fetchDiscussions(refresh: boolean): Promise<any> {
+        if (refresh) {
+            this.page = 0;
+        }
+
+        return this.forumProvider.getDiscussions(this.forum.id, this.page).then((response) => {
+            let promise;
+            if (this.usesGroups) {
+                promise = this.forumProvider.formatDiscussionsGroups(this.forum.cmid, response.discussions);
+            } else {
+                promise = Promise.resolve(response.discussions);
+            }
+
+            return promise.then((discussions) => {
+                if (this.forum.type == 'single') {
+                    // Hide author for first post and type single.
+                    for (const x in discussions) {
+                        if (discussions[x].userfullname && discussions[x].parent == 0) {
+                            discussions[x].userfullname = false;
+                            break;
+                        }
+                    }
+                }
+
+                if (typeof this.forum.istracked == 'undefined' && !this.trackPosts) {
+                    // If any discussion has unread posts, the whole forum is being tracked.
+                    for (const y in discussions) {
+                        if (discussions[y].numunread > 0) {
+                            this.trackPosts = true;
+                            break;
+                        }
+                    }
+                }
+
+                if (this.page == 0) {
+                    this.discussions = discussions;
+                } else {
+                    this.discussions = this.discussions.concat(discussions);
+                }
+
+                this.canLoadMore = response.canLoadMore;
+                this.page++;
+
+                // Check if there are replies for discussions stored in offline.
+                return this.forumOffline.hasForumReplies(this.forum.id).then((hasOffline) => {
+                    const offlinePromises = [];
+                    this.hasOffline = this.hasOffline || hasOffline;
+
+                    if (hasOffline) {
+                        // Only update new fetched discussions.
+                        discussions.forEach((discussion) => {
+                            // Get offline discussions.
+                            offlinePromises.push(this.forumOffline.getDiscussionReplies(discussion.discussion).then((replies) => {
+                                discussion.numreplies = parseInt(discussion.numreplies, 10) + replies.length;
+                            }));
+                        });
+                    }
+
+                    return Promise.all(offlinePromises);
+                });
+            });
+        }).catch((message) => {
+            this.domUtils.showErrorModal(message);
+            this.canLoadMore = false; // Set to false to prevent infinite calls with infinite-loading.
+
+            return Promise.reject(null);
+        });
+    }
+
+    /**
+     * Perform the invalidate content function.
+     *
+     * @return {Promise<any>} Resolved when done.
+     */
+    protected invalidateContent(): Promise<any> {
+        const promises = [];
+
+        promises.push(this.forumProvider.invalidateForumData(this.courseId));
+
+        if (this.forum) {
+            promises.push(this.forumProvider.invalidateDiscussionsList(this.forum.id));
+            promises.push(this.groupsProvider.invalidateActivityGroupMode(this.forum.cmid));
+        }
+
+        return Promise.all(promises);
+    }
+
+    /**
+     * Performs the sync of the activity.
+     *
+     * @return {Promise<any>} Promise resolved when done.
+     */
+    protected sync(): Promise<boolean> {
+        const promises = [];
+
+        promises.push(this.forumSync.syncForumDiscussions(this.forum.id).then((result) => {
+            if (result.warnings && result.warnings.length) {
+                this.domUtils.showErrorModal(result.warnings[0]);
+            }
+
+            return result;
+        }));
+
+        promises.push(this.forumSync.syncForumReplies(this.forum.id).then((result) => {
+            if (result.warnings && result.warnings.length) {
+                this.domUtils.showErrorModal(result.warnings[0]);
+            }
+
+            return result;
+        }));
+
+        return Promise.all(promises).then((results) => {
+            return results.reduce((a, b) => ({
+                updated: a.updated || b.updated,
+                warnings: (a.warnings || []).concat(b.warnings || []),
+            }), {updated: false});
+        });
+    }
+
+    /**
+     * Checks if sync has succeed from result sync data.
+     *
+     * @param  {any} result Data returned on the sync function.
+     * @return {boolean} Whether it succeed or not.
+     */
+    protected hasSyncSucceed(result: any): boolean {
+        return result.updated;
+    }
+
+    /**
+     * Compares sync event data with current data to check if refresh content is needed.
+     *
+     * @param  {any} syncEventData Data receiven on sync observer.
+     * @return {boolean} True if refresh is needed, false otherwise.
+     */
+    protected isRefreshSyncNeeded(syncEventData: any): boolean {
+        return this.forum && syncEventData.forumId == this.forum.id &&
+            syncEventData.userId == this.sitesProvider.getCurrentSiteUserId();
+    }
+
+    /**
+     * Function called when we receive an event of new discussion or reply to discussion.
+     *
+     * @param {any} data Event data.
+     */
+    protected eventReceived(data: any): void {
+        if ((this.forum && this.forum.id === data.forumId) || data.cmId === this.module.id) {
+            this.showLoadingAndRefresh(false);
+
+            // Check completion since it could be configured to complete once the user adds a new discussion or replies.
+            this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus);
+        }
+    }
+
+    /**
+     * Opens a discussion.
+     *
+     * @param {any} discussion Discussion object.
+     */
+    openDiscussion(discussion: any): void {
+        const params = {
+            courseId: this.courseId,
+            cmId: this.module.id,
+            forumId: this.forum.id,
+            discussionId: discussion.discussion,
+            trackPosts: this.trackPosts,
+            locked: discussion.locked && !discussion.canreply
+        };
+        this.splitviewCtrl.push('AddonModForumDiscussionPage', params);
+    }
+
+    /**
+     * Opens the new discussion form.
+     *
+     * @param {number} [timeCreated=0] Creation time of the offline discussion.
+     */
+    openNewDiscussion(timeCreated: number = 0): void {
+        const params = {
+            courseId: this.courseId,
+            cmId: this.module.id,
+            forumId: this.forum.id,
+            timeCreated: timeCreated,
+        };
+        this.splitviewCtrl.push('AddonModForumNewDiscussionPage', params);
+    }
+
+    /**
+     * Component being destroyed.
+     */
+    ngOnDestroy(): void {
+        super.ngOnDestroy();
+
+        this.syncManualObserver && this.syncManualObserver.off();
+        this.newDiscObserver && this.newDiscObserver.off();
+        this.replyObserver && this.replyObserver.off();
+        this.viewDiscObserver && this.viewDiscObserver.off();
+    }
+}
diff --git a/src/addon/mod/forum/components/post/post.html b/src/addon/mod/forum/components/post/post.html
new file mode 100644
index 000000000..3e89f3252
--- /dev/null
+++ b/src/addon/mod/forum/components/post/post.html
@@ -0,0 +1,61 @@
+<ion-item text-wrap>
+    <ion-avatar item-start (click)="openUserProfile(post.userid)">
+        <img [src]="post.userpictureurl" onError="this.src='assets/img/user-avatar.png'" core-external-content [alt]="'core.pictureof' | translate:{$a: post.userfullname}" role="presentation">
+    </ion-avatar>
+    <h2><span [class.core-bold]="post.parent == 0">{{post.subject}}</span></h2>
+    <p>
+        <ion-note float-right padding-left *ngIf="!post.modified"><ion-icon name="time"></ion-icon> {{ 'core.notsent' | translate }}</ion-note>
+        <ion-note float-right padding-left *ngIf="post.modified">
+            {{post.modified | coreDateDayOrTime}}
+            <div *ngIf="unread"><ion-icon name="record"></ion-icon> {{ 'addon.mod_forum.unread' | translate }}</div>
+        </ion-note>
+        {{post.userfullname}}
+    </p>
+</ion-item>
+<ion-card-content>
+    <core-format-text [component]="component" [componentId]="componentId" [text]="post.message"></core-format-text>
+    <div *ngFor="let attachment of post.attachments">
+        <!-- Files already attached to the submission. -->
+        <core-file *ngIf="!attachment.name" [file]="attachment" [component]="component" [componentId]="componentId"></core-file>
+        <!-- Files stored in offline to be sent later. -->
+        <core-local-file *ngIf="attachment.name" [file]="attachment"></core-local-file>
+    </div>
+</ion-card-content>
+<ion-item text-right *ngIf="post.id && post.canreply">
+    <button ion-button icon-left clear small (click)="showReply()" [attr.aria-controls]="'addon-forum-reply-edit-form-' + uniqueId" [attr.aria-expanded]="replyData.replyingTo === post.id">
+        <ion-icon name="undo"></ion-icon> {{ 'addon.mod_forum.reply' | translate }}
+    </button>
+</ion-item>
+<ion-item text-right *ngIf="!post.id && (!replyData.isEditing || replyData.replyingTo != post.parent)">
+    <button ion-button icon-left clear small (click)="editReply()" [attr.aria-controls]="'addon-forum-reply-edit-form-' + uniqueId" [attr.aria-expanded]="replyData.replyingTo === post.parent">
+        <ion-icon name="create"></ion-icon> {{ 'addon.mod_forum.edit' | translate }}
+    </button>
+</ion-item>
+<ion-list [id]="'addon-forum-reply-edit-form-' + uniqueId" *ngIf="(post.id && !replyData.isEditing && replyData.replyingTo == post.id) || (!post.id && replyData.isEditing && replyData.replyingTo == post.parent)">
+    <ion-item>
+        <ion-label stacked>{{ 'addon.mod_forum.subject' | translate }}</ion-label>
+        <ion-input type="text" [placeholder]="'addon.mod_forum.subject' | translate" [(ngModel)]="replyData.subject"></ion-input>
+    </ion-item>
+    <ion-item>
+        <ion-label stacked>{{ 'addon.mod_forum.message' | translate }}</ion-label>
+        <core-rich-text-editor item-content [control]="messageControl" (contentChanged)="onMessageChange($event)" [placeholder]="'addon.mod_forum.message' | translate" [name]="'mod_forum_reply_' + post.id"></core-rich-text-editor>
+        <!-- @todo: Attributes that were passed to RTE in Ionic 1 but now they aren't supported yet:
+            [component]="component" [componentId]="componentId" -->
+    </ion-item>
+    <core-attachments *ngIf="forum.id && forum.maxattachments > 0" [files]="replyData.files" [maxSize]="forum.maxbytes" [maxSubmissions]="forum.maxattachments" [component]="component" [componentId]="forum.cmid" [allowOffline]="true"></core-attachments>
+    <ion-grid>
+        <ion-row>
+            <ion-col>
+                <button ion-button block (click)="reply()" [disabled]="replyData.subject == '' || replyData.message == null">{{ 'addon.mod_forum.posttoforum' | translate }}</button>
+            </ion-col>
+            <ion-col>
+                <button ion-button block color="light" (click)="cancel()">{{ 'core.cancel' | translate }}</button>
+            </ion-col>
+        </ion-row>
+        <ion-row *ngIf="replyData.isEditing">
+            <ion-col>
+                <button ion-button block color="light" (click)="discard()">{{ 'core.discard' | translate }}</button>
+            </ion-col>
+        </ion-row>
+    </ion-grid>
+</ion-list>
diff --git a/src/addon/mod/forum/components/post/post.ts b/src/addon/mod/forum/components/post/post.ts
new file mode 100644
index 000000000..04dda2b06
--- /dev/null
+++ b/src/addon/mod/forum/components/post/post.ts
@@ -0,0 +1,307 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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, Optional, EventEmitter, OnInit, OnDestroy } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { NavController } from 'ionic-angular';
+import { TranslateService } from '@ngx-translate/core';
+import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader';
+import { CoreSplitViewComponent } from '@components/split-view/split-view';
+import { CoreSyncProvider } from '@providers/sync';
+import { CoreDomUtilsProvider } from '@providers/utils/dom';
+import { CoreTextUtilsProvider } from '@providers/utils/text';
+import { AddonModForumProvider } from '../../providers/forum';
+import { AddonModForumHelperProvider } from '../../providers/helper';
+import { AddonModForumOfflineProvider } from '../../providers/offline';
+import { AddonModForumSyncProvider } from '../../providers/sync';
+
+/**
+ * Components that shows a discussion post, its attachments and the action buttons allowed (reply, etc.).
+ */
+@Component({
+    selector: 'addon-mod-forum-post',
+    templateUrl: 'post.html',
+})
+export class AddonModForumPostComponent implements OnInit, OnDestroy {
+
+    @Input() post: any; // Post.
+    @Input() courseId: number; // Post's course ID.
+    @Input() discussionId: number; // Post's' discussion ID.
+    @Input() component: string; // Component this post belong to.
+    @Input() componentId: number; // Component ID.
+    @Input() replyData: any; // Object with the new post data. Usually shared between posts.
+    @Input() originalData: any; // Object with the original post data. Usually shared between posts.
+    @Input() trackPosts: boolean; // True if post is being tracked.
+    @Input() forum: any; // The forum the post belongs to. Required for attachments and offline posts.
+    @Input() defaultSubject: string; // Default subject to set to new posts.
+    @Output() onPostChange: EventEmitter<void>; // Event emitted when a reply is posted or modified.
+
+    messageControl = new FormControl();
+
+    uniqueId: string;
+
+    protected syncId: string;
+
+    constructor(
+            private navCtrl: NavController,
+            private uploaderProvider: CoreFileUploaderProvider,
+            private syncProvider: CoreSyncProvider,
+            private domUtils: CoreDomUtilsProvider,
+            private textUtils: CoreTextUtilsProvider,
+            private translate: TranslateService,
+            private forumProvider: AddonModForumProvider,
+            private forumHelper: AddonModForumHelperProvider,
+            private forumOffline: AddonModForumOfflineProvider,
+            private forumSync: AddonModForumSyncProvider,
+            @Optional() private svComponent: CoreSplitViewComponent) {
+        this.onPostChange = new EventEmitter<void>();
+    }
+
+    /**
+     * Component being initialized.
+     */
+    ngOnInit(): void {
+        this.uniqueId = this.post.id ? 'reply' + this.post.id : 'edit' + this.post.parent;
+    }
+
+    /**
+     * Opens the profile of a user.
+     *
+     * @param {number} userId
+     */
+    openUserProfile(userId: number): void {
+        // Decide which navCtrl to use. If this page is inside a split view, use the split view's master nav.
+        const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl;
+        navCtrl.push('CoreUserProfilePage', {userId, courseId: this.courseId});
+    }
+
+    /**
+     * Set data to new post, clearing tmp files and updating original data.
+     */
+    protected setReplyData(replyingTo?: number, isEditing?: boolean, subject?: string, message?: string, files?: any[]): void {
+        // Delete the local files from the tmp folder if any.
+        this.uploaderProvider.clearTmpFiles(this.replyData.files);
+
+        this.replyData.replyingTo = replyingTo || 0;
+        this.replyData.isEditing = !!isEditing;
+        this.replyData.subject = subject || this.defaultSubject || '';
+        this.replyData.message = message || null;
+        this.replyData.files = files || [];
+
+        // Update rich text editor.
+        this.messageControl.setValue(this.replyData.message);
+
+        // Update original data.
+        this.originalData.subject = this.replyData.subject;
+        this.originalData.message = this.replyData.message;
+        this.originalData.files = this.replyData.files.slice();
+    }
+
+    /**
+     * Set this post as being replied to.
+     */
+    showReply(): void {
+        if (this.replyData.isEditing) {
+            // User is editing a post, data needs to be resetted. Ask confirm if there is unsaved data.
+            this.confirmDiscard().then(() => {
+                this.setReplyData(this.post.id);
+            }).catch(() => {
+                // Cancelled.
+            });
+        } else if (!this.replyData.replyingTo) {
+            // User isn't replying, it's a brand new reply. Initialize the data.
+            this.setReplyData(this.post.id);
+        } else {
+            // The post being replied has changed but the data will be kept.
+            this.replyData.replyingTo = this.post.id;
+            this.messageControl.setValue(this.replyData.message);
+        }
+    }
+
+    /**
+     * Set this post as being edited to.
+     */
+    editReply(): void {
+        // Ask confirm if there is unsaved data.
+        this.confirmDiscard().then(() => {
+            this.syncId = this.forumSync.getDiscussionSyncId(this.discussionId);
+            this.syncProvider.blockOperation(AddonModForumProvider.COMPONENT, this.syncId);
+
+            this.setReplyData(this.post.parent, true, this.post.subject, this.post.message, this.post.attachments);
+        }).catch(() => {
+            // Cancelled.
+        });
+    }
+
+    /**
+     * Message changed.
+     *
+     * @param {string} text The new text.
+     */
+    onMessageChange(text: string): void {
+        this.replyData.message = text;
+    }
+
+    /**
+     * Reply to this post.
+     */
+    reply(): void {
+        if (!this.replyData.subject) {
+            this.domUtils.showErrorModal('addon.mod_forum.erroremptysubject', true);
+
+            return;
+        }
+
+        if (!this.replyData.message) {
+            this.domUtils.showErrorModal('addon.mod_forum.erroremptymessage', true);
+
+            return;
+        }
+
+        let saveOffline = false;
+        let message = this.replyData.message;
+        const subject = this.replyData.subject;
+        const replyingTo = this.replyData.replyingTo;
+        const files = this.replyData.files || [];
+        const options: any = {};
+        const modal = this.domUtils.showModalLoading('core.sending', true);
+
+        // Check if rich text editor is enabled or not.
+        this.domUtils.isRichTextEditorEnabled().then((enabled) => {
+            if (!enabled) {
+                // Rich text editor not enabled, add some HTML to the message if needed.
+                message = this.textUtils.formatHtmlLines(message);
+            }
+
+            // Upload attachments first if any.
+            if (files.length) {
+                return this.forumHelper.uploadOrStoreReplyFiles(this.forum.id, replyingTo, files, false).catch((error) => {
+                    // Cannot upload them in online, save them in offline.
+                    if (!this.forum.id) {
+                        // Cannot store them in offline without the forum ID. Reject.
+                        return Promise.reject(error);
+                    }
+
+                    saveOffline = true;
+
+                    return this.forumHelper.uploadOrStoreReplyFiles(this.forum.id, replyingTo, files, true);
+                });
+            }
+        }).then((attach) => {
+            if (attach) {
+                options.attachmentsid = attach;
+            }
+
+            if (saveOffline) {
+                // Save post in offline.
+                return this.forumOffline.replyPost(replyingTo, this.discussionId, this.forum.id, this.forum.name,
+                        this.courseId, subject, message, options).then(() => {
+                    // Return false since it wasn't sent to server.
+                    return false;
+                });
+            } else {
+                // Try to send it to server.
+                // Don't allow offline if there are attachments since they were uploaded fine.
+                return this.forumProvider.replyPost(replyingTo, this.discussionId, this.forum.id, this.forum.name,
+                        this.courseId, subject, message, options, undefined, !files.length);
+            }
+        }).then((sent) => {
+            if (sent && this.forum.id) {
+                // Data sent to server, delete stored files (if any).
+                this.forumHelper.deleteReplyStoredFiles(this.forum.id, replyingTo);
+            }
+
+            // Reset data.
+            this.setReplyData();
+
+            this.onPostChange.emit();
+
+            if (this.syncId) {
+                this.syncProvider.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId);
+            }
+        }).catch((message) => {
+            this.domUtils.showErrorModalDefault(message, 'addon.mod_forum.couldnotadd', true);
+        }).finally(() => {
+            modal.dismiss();
+        });
+    }
+
+    /**
+     * Cancel reply.
+     */
+    cancel(): void {
+        this.confirmDiscard().then(() => {
+            // Reset data.
+            this.setReplyData();
+
+            if (this.syncId) {
+                this.syncProvider.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId);
+            }
+        }).catch(() => {
+            // Cancelled.
+        });
+    }
+
+    /**
+     * Discard offline reply.
+     */
+    discard(): void {
+        this.domUtils.showConfirm(this.translate.instant('core.areyousure')).then(() => {
+            const promises = [];
+
+            promises.push(this.forumOffline.deleteReply(this.post.parent));
+            if (this.forum.id) {
+                promises.push(this.forumHelper.deleteReplyStoredFiles(this.forum.id, this.post.parent).catch(() => {
+                    // Ignore errors, maybe there are no files.
+                }));
+            }
+
+            return Promise.all(promises).finally(() => {
+                // Reset data.
+                this.setReplyData();
+
+                this.onPostChange.emit();
+
+                if (this.syncId) {
+                    this.syncProvider.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId);
+                }
+            });
+        }).catch(() => {
+            // Cancelled.
+        });
+    }
+
+    /**
+     * Component being destroyed.
+     */
+    ngOnDestroy(): void {
+        if (this.syncId) {
+            this.syncProvider.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId);
+        }
+    }
+
+    /**
+     * Confirm discard changes if any.
+     *
+     * @return {Promise<void>} Promise resolved if the user confirms or data was not changed and rejected otherwise.
+     */
+    protected confirmDiscard(): Promise<void> {
+        if (this.forumHelper.hasPostDataChanged(this.replyData, this.originalData)) {
+            // Show confirmation if some data has been modified.
+            return this.domUtils.showConfirm(this.translate.instant('core.confirmloss'));
+        } else {
+            return Promise.resolve();
+        }
+    }
+}
diff --git a/src/addon/mod/forum/forum.module.ts b/src/addon/mod/forum/forum.module.ts
new file mode 100644
index 000000000..8c4717f54
--- /dev/null
+++ b/src/addon/mod/forum/forum.module.ts
@@ -0,0 +1,60 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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 { CoreCronDelegate } from '@providers/cron';
+import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate';
+import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate';
+import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate';
+import { AddonModForumProvider } from './providers/forum';
+import { AddonModForumOfflineProvider } from './providers/offline';
+import { AddonModForumHelperProvider } from './providers/helper';
+import { AddonModForumSyncProvider } from './providers/sync';
+import { AddonModForumModuleHandler } from './providers/module-handler';
+import { AddonModForumPrefetchHandler } from './providers/prefetch-handler';
+import { AddonModForumSyncCronHandler } from './providers/sync-cron-handler';
+import { AddonModForumIndexLinkHandler } from './providers/index-link-handler';
+import { AddonModForumDiscussionLinkHandler } from './providers/discussion-link-handler';
+import { AddonModForumComponentsModule } from './components/components.module';
+
+@NgModule({
+    declarations: [
+    ],
+    imports: [
+        AddonModForumComponentsModule,
+    ],
+    providers: [
+        AddonModForumProvider,
+        AddonModForumOfflineProvider,
+        AddonModForumHelperProvider,
+        AddonModForumSyncProvider,
+        AddonModForumModuleHandler,
+        AddonModForumPrefetchHandler,
+        AddonModForumSyncCronHandler,
+        AddonModForumIndexLinkHandler,
+        AddonModForumDiscussionLinkHandler,
+    ]
+})
+export class AddonModForumModule {
+    constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModForumModuleHandler,
+            prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModForumPrefetchHandler,
+            cronDelegate: CoreCronDelegate, syncHandler: AddonModForumSyncCronHandler, linksDelegate: CoreContentLinksDelegate,
+            indexHandler: AddonModForumIndexLinkHandler, discussionHandler: AddonModForumDiscussionLinkHandler) {
+        moduleDelegate.registerHandler(moduleHandler);
+        prefetchDelegate.registerHandler(prefetchHandler);
+        cronDelegate.register(syncHandler);
+        linksDelegate.registerHandler(indexHandler);
+        linksDelegate.registerHandler(discussionHandler);
+    }
+}
diff --git a/src/addon/mod/forum/lang/en.json b/src/addon/mod/forum/lang/en.json
new file mode 100644
index 000000000..0b3ffb881
--- /dev/null
+++ b/src/addon/mod/forum/lang/en.json
@@ -0,0 +1,32 @@
+{
+    "addanewdiscussion": "Add a new discussion topic",
+    "cannotadddiscussion": "Adding discussions to this forum requires group membership.",
+    "cannotadddiscussionall": "You do not have permission to add a new discussion topic for all participants.",
+    "cannotcreatediscussion": "Could not create new discussion",
+    "couldnotadd": "Could not add your post due to an unknown error",
+    "discussion": "Discussion",
+    "discussionlocked": "This discussion has been locked so you can no longer reply to it.",
+    "discussionpinned": "Pinned",
+    "discussionsubscription": "Discussion subscription",
+    "edit": "Edit",
+    "erroremptymessage": "Post message cannot be empty",
+    "erroremptysubject": "Post subject cannot be empty.",
+    "errorgetforum": "Error getting forum data.",
+    "errorgetgroups": "Error getting group settings.",
+    "forumnodiscussionsyet": "There are no discussions yet in this forum.",
+    "group": "Group",
+    "message": "Message",
+    "modeflatnewestfirst": "Display replies flat, with newest first",
+    "modeflatoldestfirst": "Display replies flat, with oldest first",
+    "modenested": "Display replies in nested form",
+    "numdiscussions": "{{numdiscussions}} discussions",
+    "numreplies": "{{numreplies}} replies",
+    "posttoforum": "Post to forum",
+    "re": "Re:",
+    "refreshdiscussions": "Refresh discussions",
+    "refreshposts": "Refresh posts",
+    "reply": "Reply",
+    "subject": "Subject",
+    "unread": "Unread",
+    "unreadpostsnumber": "{{$a}} unread posts"
+}
\ No newline at end of file
diff --git a/src/addon/mod/forum/pages/discussion/discussion.html b/src/addon/mod/forum/pages/discussion/discussion.html
new file mode 100644
index 000000000..08b5a387e
--- /dev/null
+++ b/src/addon/mod/forum/pages/discussion/discussion.html
@@ -0,0 +1,61 @@
+<ion-header>
+    <ion-navbar>
+        <ion-title *ngIf="discussion"><core-format-text [text]="discussion.subject"></core-format-text></ion-title>
+        <ion-buttons end>
+            <!-- The context menu will be added in here. -->
+        </ion-buttons>
+    </ion-navbar>
+</ion-header>
+<core-navbar-buttons end>
+    <core-context-menu>
+        <core-context-menu-item [priority]="650" *ngIf="discussionLoaded && !postHasOffline && isOnline" [content]="'addon.mod_forum.refreshposts' | translate" (action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false"></core-context-menu-item>
+        <core-context-menu-item [priority]="550" *ngIf="discussionLoaded && !isSplitViewOn && postHasOffline && isOnline" [content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(null, $event, true)" [iconAction]="syncIcon" [closeOnClick]="false"></core-context-menu-item>
+        <core-context-menu-item [hidden]="sort == 'flat-oldest'" [priority]="500" [content]="'addon.mod_forum.modeflatoldestfirst' | translate" (action)="changeSort('flat-oldest')" iconAction="arrow-round-down"></core-context-menu-item>
+        <core-context-menu-item [hidden]="sort == 'flat-newest'" [priority]="450" [content]="'addon.mod_forum.modeflatnewestfirst' | translate" (action)="changeSort('flat-newest')" iconAction="arrow-round-up"></core-context-menu-item>
+        <core-context-menu-item [hidden]="sort == 'nested'" [priority]="400" [content]="'addon.mod_forum.modenested' | translate" (action)="changeSort('nested')" iconAction="swap"></core-context-menu-item>
+    </core-context-menu>
+</core-navbar-buttons>
+<ion-content>
+    <ion-refresher [enabled]="discussionLoaded" (ionRefresh)="doRefresh($event)">
+        <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
+    </ion-refresher>
+
+    <core-loading [hideUntil]="discussionLoaded">
+        <!-- Discussion replies found to be synchronized -->
+        <ion-card class="core-warning-card" *ngIf="postHasOffline">
+            <ion-icon name="warning"></ion-icon> {{ 'core.hasdatatosync' | translate:{$a: discussionStr} }}
+        </ion-card>
+
+        <ion-card class="core-warning-card" *ngIf="locked">
+            <ion-icon name="warning"></ion-icon> {{ 'addon.mod_forum.discussionlocked' | translate }}
+        </ion-card>
+
+        <ion-card *ngIf="discussion" margin-bottom>
+            <addon-mod-forum-post [post]="discussion" [courseId]="courseId" [discussionId]="discussionId" [component]="component" [componentId]="componentId" [replyData]="replyData" [originalData]="originalData" [defaultSubject]="defaultSubject" [forum]="forum" [trackPosts]="trackPosts" (onPostChange)="postListChanged()"></addon-mod-forum-post>
+        </ion-card>
+
+        <ion-card *ngIf="sort != 'nested'">
+            <ng-container *ngFor="let post of posts; first as first">
+                <ion-item-divider color="light" *ngIf="!first"></ion-item-divider>
+                <addon-mod-forum-post [post]="post" [courseId]="courseId" [discussionId]="discussionId" [component]="component" [componentId]="componentId" [replyData]="replyData" [originalData]="originalData" [defaultSubject]="defaultSubject" [forum]="forum" [trackPosts]="trackPosts" (onPostChange)="postListChanged()"></addon-mod-forum-post>
+            </ng-container>
+        </ion-card>
+
+        <ng-container *ngIf="sort == 'nested'">
+            <ng-container *ngFor="let post of posts">
+                <ng-container *ngTemplateOutlet="nestedPosts; context: {post: post}"></ng-container>
+            </ng-container>
+        </ng-container>
+
+        <ng-template #nestedPosts let-post="post">
+            <ion-card>
+                <addon-mod-forum-post [post]="post" [courseId]="courseId" [discussionId]="discussionId" [component]="component" [componentId]="componentId" [replyData]="replyData" [originalData]="originalData" [defaultSubject]="defaultSubject" [forum]="forum" [trackPosts]="trackPosts" (onPostChange)="postListChanged()"></addon-mod-forum-post>
+            </ion-card>
+            <div padding-left *ngIf="post.children.length && post.children[0].subject">
+                <ng-container *ngFor="let child of post.children">
+                    <ng-container *ngTemplateOutlet="nestedPosts; context: {post: child}"></ng-container>
+                </ng-container>
+            </div>
+        </ng-template>
+    </core-loading>
+</ion-content>
diff --git a/src/addon/mod/forum/pages/discussion/discussion.module.ts b/src/addon/mod/forum/pages/discussion/discussion.module.ts
new file mode 100644
index 000000000..9877a3397
--- /dev/null
+++ b/src/addon/mod/forum/pages/discussion/discussion.module.ts
@@ -0,0 +1,35 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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 { IonicPageModule } from 'ionic-angular';
+import { TranslateModule } from '@ngx-translate/core';
+import { CoreComponentsModule } from '@components/components.module';
+import { CoreDirectivesModule } from '@directives/directives.module';
+import { AddonModForumComponentsModule } from '../../components/components.module';
+import { AddonModForumDiscussionPage } from './discussion';
+
+@NgModule({
+    declarations: [
+        AddonModForumDiscussionPage,
+    ],
+    imports: [
+        CoreComponentsModule,
+        CoreDirectivesModule,
+        AddonModForumComponentsModule,
+        IonicPageModule.forChild(AddonModForumDiscussionPage),
+        TranslateModule.forChild()
+    ],
+})
+export class AddonModForumDiscussionPageModule {}
diff --git a/src/addon/mod/forum/pages/discussion/discussion.ts b/src/addon/mod/forum/pages/discussion/discussion.ts
new file mode 100644
index 000000000..95f37419d
--- /dev/null
+++ b/src/addon/mod/forum/pages/discussion/discussion.ts
@@ -0,0 +1,411 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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, Optional, OnDestroy, ViewChild } from '@angular/core';
+import { IonicPage, NavParams, Content } from 'ionic-angular';
+import { Network } from '@ionic-native/network';
+import { TranslateService } from '@ngx-translate/core';
+import { CoreAppProvider } from '@providers/app';
+import { CoreEventsProvider } from '@providers/events';
+import { CoreSitesProvider } from '@providers/sites';
+import { CoreDomUtilsProvider } from '@providers/utils/dom';
+import { CoreUtilsProvider } from '@providers/utils/utils';
+import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader';
+import { CoreSplitViewComponent } from '@components/split-view/split-view';
+import { AddonModForumProvider } from '../../providers/forum';
+import { AddonModForumOfflineProvider } from '../../providers/offline';
+import { AddonModForumHelperProvider } from '../../providers/helper';
+import { AddonModForumSyncProvider } from '../../providers/sync';
+
+type SortType = 'flat-newest' | 'flat-oldest' | 'nested';
+
+/**
+ * Page that displays a forum discussion.
+ */
+@IonicPage({ segment: 'addon-mod-forum-discussion' })
+@Component({
+    selector: 'page-addon-mod-forum-discussion',
+    templateUrl: 'discussion.html',
+})
+export class AddonModForumDiscussionPage implements OnDestroy {
+    @ViewChild(Content) content: Content;
+
+    courseId: number;
+    cmId: number;
+    forumId: number;
+    discussionId: number;
+    forum: any;
+    discussion: any;
+    posts: any[];
+    discussionLoaded = false;
+    defaultSubject: string;
+    isOnline: boolean;
+    isSplitViewOn: boolean;
+    locked: boolean;
+    postHasOffline: boolean;
+    sort: SortType = 'flat-oldest';
+    trackPosts: boolean;
+    replyData = {
+        replyingTo: 0,
+        isEditing: false,
+        subject: '',
+        message: null, // Null means empty or just white space.
+        files: [],
+    };
+    originalData = {
+        subject: null, // Null means original data is not set.
+        message: null, // Null means empty or just white space.
+        files: [],
+    };
+    refreshIcon = 'spinner';
+    syncIcon = 'spinner';
+
+    protected onlineObserver: any;
+    protected syncObserver: any;
+    protected syncManualObserver: any;
+
+    constructor(navParams: NavParams,
+            network: Network,
+            private appProvider: CoreAppProvider,
+            private eventsProvider: CoreEventsProvider,
+            private sitesProvider: CoreSitesProvider,
+            private domUtils: CoreDomUtilsProvider,
+            private utils: CoreUtilsProvider,
+            private translate: TranslateService,
+            private uploaderProvider: CoreFileUploaderProvider,
+            private forumProvider: AddonModForumProvider,
+            private forumOffline: AddonModForumOfflineProvider,
+            private forumHelper: AddonModForumHelperProvider,
+            private forumSync: AddonModForumSyncProvider,
+            @Optional() private svComponent: CoreSplitViewComponent) {
+        this.courseId = navParams.get('courseId');
+        this.cmId = navParams.get('cmId');
+        this.forumId = navParams.get('forumId');
+        this.discussionId = navParams.get('discussionId');
+        this.trackPosts = navParams.get('trackPosts');
+        this.locked = navParams.get('locked');
+
+        this.isOnline = this.appProvider.isOnline();
+        this.onlineObserver = network.onchange().subscribe((online) => {
+            this.isOnline = this.appProvider.isOnline();
+        });
+        this.isSplitViewOn = this.svComponent && this.svComponent.isOn();
+    }
+
+    /**
+     * View loaded.
+     */
+    ionViewDidLoad(): void {
+        this.fetchPosts(true, false, true);
+    }
+
+    /**
+     * User entered the page that contains the component.
+     */
+    ionViewDidEnter(): void {
+        // Refresh data if this discussion is synchronized automatically.
+        this.syncObserver = this.eventsProvider.on(AddonModForumSyncProvider.AUTO_SYNCED, (data) => {
+            if (data.forumId == this.forumId && this.discussionId == data.discussionId
+                    && data.userId == this.sitesProvider.getCurrentSiteUserId()) {
+                // Refresh the data.
+                this.discussionLoaded = false;
+                this.refreshPosts();
+            }
+        }, this.sitesProvider.getCurrentSiteId());
+
+        // Refresh data if this forum discussion is synchronized from discussions list.
+        this.syncManualObserver = this.eventsProvider.on(AddonModForumSyncProvider.MANUAL_SYNCED, (data) => {
+            if (data.forumId == this.forumId && data.userId == this.sitesProvider.getCurrentSiteUserId()) {
+                // Refresh the data.
+                this.discussionLoaded = false;
+                this.refreshPosts();
+            }
+        }, this.sitesProvider.getCurrentSiteId());
+
+        // Trigger view event, to highlight the current opened discussion in the split view.
+        this.eventsProvider.trigger(AddonModForumProvider.VIEW_DISCUSSION_EVENT, {
+            forumId: this.forumId,
+            discussion: this.discussionId,
+        }, this.sitesProvider.getCurrentSiteId());
+    }
+
+    /**
+     * Check if we can leave the page or not.
+     *
+     * @return {boolean|Promise<void>} Resolved if we can leave it, rejected if not.
+     */
+    ionViewCanLeave(): boolean | Promise<void> {
+        let promise: any;
+
+        if (this.forumHelper.hasPostDataChanged(this.replyData, this.originalData)) {
+            // Show confirmation if some data has been modified.
+            promise = this.domUtils.showConfirm(this.translate.instant('core.confirmcanceledit'));
+        } else {
+            promise = Promise.resolve();
+        }
+
+        return promise.then(() => {
+            // Delete the local files from the tmp folder.
+            this.uploaderProvider.clearTmpFiles(this.replyData.files);
+        });
+    }
+
+    /**
+     * Convenience function to get the forum.
+     *
+     * @return {Promise<any>} Promise resolved with the forum.
+     */
+    protected fetchForum(): Promise<any> {
+        if (this.courseId && this.cmId) {
+            return this.forumProvider.getForum(this.courseId, this.cmId);
+        } else if (this.courseId && this.forumId) {
+            return this.forumProvider.getForumById(this.courseId, this.forumId);
+        } else {
+            // Cannot get the forum.
+            return Promise.reject(null);
+        }
+    }
+
+    /**
+     * Convenience function to get forum discussions.
+     *
+     * @param  {boolean} [sync]            Whether to try to synchronize the discussion.
+     * @param  {boolean} [showErrors]      Whether to show errors in a modal.
+     * @param  {boolean} [forceMarkAsRead] Whether to mark all posts as read.
+     * @return {Promise<any>} Promise resolved when done.
+     */
+    protected fetchPosts(sync?: boolean, showErrors?: boolean, forceMarkAsRead?: boolean): Promise<any> {
+        let syncPromise;
+        if (sync) {
+            // Try to synchronize the forum.
+            syncPromise = this.syncDiscussion(showErrors).catch(() => {
+                // Ignore errors.
+            });
+        } else {
+            syncPromise = Promise.resolve();
+        }
+
+        let onlinePosts = [];
+        const offlineReplies = [];
+        let hasUnreadPosts = false;
+
+        return syncPromise.then(() => {
+            return this.forumProvider.getDiscussionPosts(this.discussionId).then((posts) => {
+                onlinePosts = posts;
+
+            }).then(() => {
+                // Check if there are responses stored in offline.
+                return this.forumOffline.getDiscussionReplies(this.discussionId).then((replies) => {
+                    this.postHasOffline = !!replies.length;
+                    const convertPromises = [];
+
+                    // Index posts to allow quick access. Also check unread field.
+                    const posts = {};
+                    onlinePosts.forEach((post) => {
+                        posts[post.id] = post;
+                        hasUnreadPosts = hasUnreadPosts || !post.postread;
+                    });
+
+                    replies.forEach((offlineReply) => {
+                        // If we don't have forumId and courseId, get it from the post.
+                        if (!this.forumId) {
+                            this.forumId = offlineReply.forumid;
+                        }
+                        if (!this.courseId) {
+                            this.courseId = offlineReply.courseid;
+                        }
+
+                        convertPromises.push(this.forumHelper.convertOfflineReplyToOnline(offlineReply).then((reply) => {
+                            offlineReplies.push(reply);
+
+                            // Disable reply of the parent. Reply in offline to the same post is not allowed, edit instead.
+                            posts[reply.parent].canreply = false;
+                        }));
+                    });
+
+                    return Promise.all(convertPromises).then(() => {
+                        // Convert back to array.
+                        onlinePosts = this.utils.objectToArray(posts);
+                    });
+                });
+            });
+        }).then(() => {
+            const posts = offlineReplies.concat(onlinePosts);
+            this.discussion = this.forumProvider.extractStartingPost(posts);
+
+            // If sort type is nested, normal sorting is disabled and nested posts will be displayed.
+            if (this.sort == 'nested') {
+                // Sort first by creation date to make format tree work.
+                this.forumProvider.sortDiscussionPosts(posts, 'ASC');
+                this.posts = this.utils.formatTree(posts, 'parent', 'id', this.discussion.id);
+            } else {
+                // Set default reply subject.
+                const direction = this.sort == 'flat-newest' ? 'DESC' : 'ASC';
+                this.forumProvider.sortDiscussionPosts(posts, direction);
+                this.posts = posts;
+            }
+            this.defaultSubject = this.translate.instant('addon.mod_forum.re') + ' ' + this.discussion.subject;
+            this.replyData.subject = this.defaultSubject;
+
+            // Now try to get the forum.
+            return this.fetchForum().then((forum) => {
+                if (this.discussion.userfullname && this.discussion.parent == 0 && forum.type == 'single') {
+                    // Hide author for first post and type single.
+                    this.discussion.userfullname = null;
+                }
+
+                // "forum.istracked" is more reliable than "trackPosts".
+                if (typeof forum.istracked != 'undefined') {
+                    this.trackPosts = forum.istracked;
+                }
+
+                this.forumId = forum.id;
+                this.cmId = forum.cmid;
+                this.forum = forum;
+            }).catch(() => {
+                // Ignore errors.
+                this.forum = {};
+            });
+        }).catch((message) => {
+            this.domUtils.showErrorModal(message);
+        }).finally(() => {
+            this.discussionLoaded = true;
+            this.refreshIcon = 'refresh';
+            this.syncIcon = 'sync';
+
+            if (forceMarkAsRead || (hasUnreadPosts && this.trackPosts)) {
+                // // Add log in Moodle and mark unread posts as readed.
+                this.forumProvider.logDiscussionView(this.discussionId).catch(() => {
+                    // Ignore errors.
+                });
+            }
+        });
+    }
+
+    /**
+     * Tries to synchronize the posts discussion.
+     *
+     * @param  {boolean} showErrors Whether to show errors in a modal.
+     * @return {Promise<any>} Promise resolved when done.
+     */
+    protected syncDiscussion(showErrors: boolean): Promise<any> {
+        return this.forumSync.syncDiscussionReplies(this.discussionId).then((result) => {
+            if (result.warnings && result.warnings.length) {
+                this.domUtils.showErrorModal(result.warnings[0]);
+            }
+
+            if (result && result.updated) {
+                // Sync successful, send event.
+                this.eventsProvider.trigger(AddonModForumSyncProvider.MANUAL_SYNCED, {
+                    forumId: this.forumId,
+                    userId: this.sitesProvider.getCurrentSiteUserId(),
+                    warnings: result.warnings
+                }, this.sitesProvider.getCurrentSiteId());
+            }
+
+            return result.updated;
+        }).catch((error) => {
+            if (showErrors) {
+                this.domUtils.showErrorModalDefault(error, 'core.errorsync', true);
+            }
+
+            return Promise.reject(null);
+        });
+    }
+
+    /**
+     * Refresh the data.
+     *
+     * @param {any}       [refresher] Refresher.
+     * @param {Function}  [done] Function to call when done.
+     * @param {boolean}   [showErrors=false] If show errors to the user of hide them.
+     * @return {Promise<any>} Promise resolved when done.
+     */
+    doRefresh(refresher?: any, done?: () => void, showErrors: boolean = false): Promise<any> {
+        if (this.discussionLoaded) {
+            return this.refreshPosts(true, showErrors).finally(() => {
+                refresher && refresher.complete();
+                done && done();
+            });
+        }
+
+        return Promise.resolve();
+    }
+
+    /**
+     * Refresh posts.
+     *
+     * @param  {boolean} [sync]       Whether to try to synchronize the discussion.
+     * @param  {boolean} [showErrors] Whether to show errors in a modal.
+     * @return {Promise<any>} Promise resolved when done.
+     */
+    refreshPosts(sync?: boolean, showErrors?: boolean): Promise<any> {
+        this.content && this.content.scrollToTop();
+        this.refreshIcon = 'spinner';
+        this.syncIcon = 'spinner';
+
+        return this.forumProvider.invalidateDiscussionPosts(this.discussionId).catch(() => {
+            // Ignore errors.
+        }).then(() => {
+            return this.fetchPosts(sync, showErrors);
+        });
+    }
+
+    /**
+     * Function to change posts sorting
+     *
+     * @param  {SortType} type Sort type.
+     * @return {Promise<any>} Promised resolved when done.
+     */
+    changeSort(type: SortType): Promise<any> {
+        this.discussionLoaded = false;
+        this.sort = type;
+        this.content && this.content.scrollToTop();
+
+        return this.fetchPosts();
+    }
+
+    /**
+     * New post added.
+     */
+    postListChanged(): void {
+        // Trigger an event to notify a new reply.
+        const data = {
+            forumId: this.forumId,
+            discussionId: this.discussionId,
+            cmId: this.cmId
+        };
+        this.eventsProvider.trigger(AddonModForumProvider.REPLY_DISCUSSION_EVENT, data, this.sitesProvider.getCurrentSiteId());
+
+        this.discussionLoaded = false;
+        this.refreshPosts().finally(() => {
+            this.discussionLoaded = true;
+        });
+    }
+
+    /**
+     * Runs when the page is about to leave and no longer be the active page.
+     */
+    ionViewWillLeave(): void {
+        this.syncObserver && this.syncObserver.off();
+        this.syncManualObserver && this.syncManualObserver.off();
+    }
+
+    /**
+     * Page destroyed.
+     */
+    ngOnDestroy(): void {
+        this.onlineObserver && this.onlineObserver.unsubscribe();
+    }
+}
diff --git a/src/addon/mod/forum/pages/index/index.html b/src/addon/mod/forum/pages/index/index.html
new file mode 100644
index 000000000..6153680ea
--- /dev/null
+++ b/src/addon/mod/forum/pages/index/index.html
@@ -0,0 +1,11 @@
+<ion-header>
+    <ion-navbar>
+        <ion-title><core-format-text [text]="title"></core-format-text></ion-title>
+
+        <ion-buttons end>
+            <!-- The buttons defined by the component will be added in here. -->
+        </ion-buttons>
+    </ion-navbar>
+</ion-header>
+
+<addon-mod-forum-index [module]="module" [courseId]="courseId" (dataRetrieved)="updateData($event)"></addon-mod-forum-index>
diff --git a/src/addon/mod/forum/pages/index/index.module.ts b/src/addon/mod/forum/pages/index/index.module.ts
new file mode 100644
index 000000000..c677d8d2e
--- /dev/null
+++ b/src/addon/mod/forum/pages/index/index.module.ts
@@ -0,0 +1,33 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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 { IonicPageModule } from 'ionic-angular';
+import { TranslateModule } from '@ngx-translate/core';
+import { CoreDirectivesModule } from '@directives/directives.module';
+import { AddonModForumComponentsModule } from '../../components/components.module';
+import { AddonModForumIndexPage } from './index';
+
+@NgModule({
+    declarations: [
+        AddonModForumIndexPage,
+    ],
+    imports: [
+        CoreDirectivesModule,
+        AddonModForumComponentsModule,
+        IonicPageModule.forChild(AddonModForumIndexPage),
+        TranslateModule.forChild()
+    ],
+})
+export class AddonModForumIndexPageModule {}
diff --git a/src/addon/mod/forum/pages/index/index.ts b/src/addon/mod/forum/pages/index/index.ts
new file mode 100644
index 000000000..102e0b88f
--- /dev/null
+++ b/src/addon/mod/forum/pages/index/index.ts
@@ -0,0 +1,48 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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, ViewChild } from '@angular/core';
+import { IonicPage, NavParams } from 'ionic-angular';
+import { AddonModForumIndexComponent } from '../../components/index/index';
+
+/**
+ * Page that displays a forum.
+ */
+@IonicPage({ segment: 'addon-mod-forum-index' })
+@Component({
+    selector: 'page-addon-mod-forum-index',
+    templateUrl: 'index.html',
+})
+export class AddonModForumIndexPage {
+    @ViewChild(AddonModForumIndexComponent) forumComponent: AddonModForumIndexComponent;
+
+    title: string;
+    module: any;
+    courseId: number;
+
+    constructor(navParams: NavParams) {
+        this.module = navParams.get('module') || {};
+        this.courseId = navParams.get('courseId');
+        this.title = this.module.name;
+    }
+
+    /**
+     * Update some data based on the forum instance.
+     *
+     * @param {any} forum Forum instance.
+     */
+    updateData(forum: any): void {
+        this.title = forum.name || this.title;
+    }
+}
diff --git a/src/addon/mod/forum/pages/new-discussion/new-discussion.html b/src/addon/mod/forum/pages/new-discussion/new-discussion.html
new file mode 100644
index 000000000..096edd01d
--- /dev/null
+++ b/src/addon/mod/forum/pages/new-discussion/new-discussion.html
@@ -0,0 +1,53 @@
+<ion-header>
+    <ion-navbar>
+        <ion-title>{{ 'addon.mod_forum.addanewdiscussion' | translate }}</ion-title>
+        <ion-buttons end>
+            <!-- The context menu will be added in here. -->
+        </ion-buttons>
+    </ion-navbar>
+</ion-header>
+<ion-content>
+    <ion-refresher [enabled]="groupsLoaded" (ionRefresh)="refreshGroups($event)">
+        <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content>
+    </ion-refresher>
+
+    <core-loading [hideUntil]="groupsLoaded">
+        <ion-list *ngIf="showForm">
+            <ion-item>
+                <ion-label stacked>{{ 'addon.mod_forum.subject' | translate }}</ion-label>
+                <ion-input type="text" [placeholder]="'addon.mod_forum.subject' | translate" [(ngModel)]="newDiscussion.subject"></ion-input>
+            </ion-item>
+            <ion-item>
+                <ion-label stacked>{{ 'addon.mod_forum.message' | translate }}</ion-label>
+                <core-rich-text-editor item-content [control]="messageControl" (contentChanged)="onMessageChange($event)" [placeholder]="'addon.mod_forum.message' | translate" name="addon_mod_forum_new_discussion"></core-rich-text-editor>
+                <!-- @todo: Attributes that were passed to RTE in Ionic 1 but now they aren't supported yet:
+                    [component]="component" [componentId]="forum.cmid" -->
+            </ion-item>
+            <ion-item *ngIf="showGroups">
+                <ion-label id="addon-mod-forum-groupslabel">{{ 'addon.mod_forum.group' | translate }}</ion-label>
+                <ion-select [(ngModel)]="newDiscussion.groupId" aria-labelledby="addon-mod-forum-groupslabel" interface="popover">
+                    <ion-option *ngFor="let group of groups" [value]="group.id">{{ group.name }}</ion-option>
+                </ion-select>
+            </ion-item>
+            <ion-item>
+                <ion-label>{{ 'addon.mod_forum.discussionsubscription' | translate }}</ion-label>
+                <ion-toggle [(ngModel)]="newDiscussion.subscribe"></ion-toggle>
+            </ion-item>
+            <ion-item *ngIf="canPin">
+                <ion-label>{{ 'addon.mod_forum.discussionpinned' | translate }}</ion-label>
+                <ion-toggle [(ngModel)]="newDiscussion.pin"></ion-toggle>
+            </ion-item>
+            <core-attachments *ngIf="canCreateAttachments && forum && forum.maxattachments > 0" [files]="newDiscussion.files" [maxSize]="forum.maxbytes" [maxSubmissions]="forum.maxattachments" [component]="component" [componentId]="forum.cmid" [allowOffline]="true"></core-attachments>
+            <ion-item>
+                <ion-row>
+                    <ion-col>
+                        <button ion-button block (click)="add()" [disabled]="newDiscussion.subject == '' || newDiscussion.message == null">{{ 'addon.mod_forum.posttoforum' | translate }}</button>
+                    </ion-col>
+                    <ion-col *ngIf="hasOffline">
+                        <button ion-button block color="light" (click)="discard()">{{ 'core.discard' | translate }}</button>
+                    </ion-col>
+                </ion-row>
+            </ion-item>
+        </ion-list>
+    </core-loading>
+</ion-content>
diff --git a/src/addon/mod/forum/pages/new-discussion/new-discussion.module.ts b/src/addon/mod/forum/pages/new-discussion/new-discussion.module.ts
new file mode 100644
index 000000000..b94e61e1b
--- /dev/null
+++ b/src/addon/mod/forum/pages/new-discussion/new-discussion.module.ts
@@ -0,0 +1,33 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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 { IonicPageModule } from 'ionic-angular';
+import { TranslateModule } from '@ngx-translate/core';
+import { CoreComponentsModule } from '@components/components.module';
+import { CoreDirectivesModule } from '@directives/directives.module';
+import { AddonModForumNewDiscussionPage } from './new-discussion';
+
+@NgModule({
+    declarations: [
+        AddonModForumNewDiscussionPage,
+    ],
+    imports: [
+        CoreComponentsModule,
+        CoreDirectivesModule,
+        IonicPageModule.forChild(AddonModForumNewDiscussionPage),
+        TranslateModule.forChild()
+    ],
+})
+export class AddonModForumNewDiscussionPageModule {}
diff --git a/src/addon/mod/forum/pages/new-discussion/new-discussion.ts b/src/addon/mod/forum/pages/new-discussion/new-discussion.ts
new file mode 100644
index 000000000..2ab73fc2b
--- /dev/null
+++ b/src/addon/mod/forum/pages/new-discussion/new-discussion.ts
@@ -0,0 +1,535 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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, Optional, ViewChild } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { IonicPage, NavController, NavParams } from 'ionic-angular';
+import { TranslateService } from '@ngx-translate/core';
+import { CoreEventsProvider } from '@providers/events';
+import { CoreGroupsProvider } from '@providers/groups';
+import { CoreSitesProvider } from '@providers/sites';
+import { CoreSyncProvider } from '@providers/sync';
+import { CoreDomUtilsProvider } from '@providers/utils/dom';
+import { CoreTextUtilsProvider } from '@providers/utils/text';
+import { CoreUtilsProvider } from '@providers/utils/utils';
+import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader';
+import { CoreSplitViewComponent } from '@components/split-view/split-view';
+import { CoreRichTextEditorComponent } from '@components/rich-text-editor/rich-text-editor.ts';
+import { AddonModForumProvider } from '../../providers/forum';
+import { AddonModForumOfflineProvider } from '../../providers/offline';
+import { AddonModForumHelperProvider } from '../../providers/helper';
+import { AddonModForumSyncProvider } from '../../providers/sync';
+
+/**
+ * Page that displays the new discussion form.
+ */
+@IonicPage({ segment: 'addon-mod-forum-new-discussion' })
+@Component({
+    selector: 'page-addon-mod-forum-new-discussion',
+    templateUrl: 'new-discussion.html',
+})
+export class AddonModForumNewDiscussionPage implements OnDestroy {
+
+    @ViewChild(CoreRichTextEditorComponent) messageEditor: CoreRichTextEditorComponent;
+
+    component = AddonModForumProvider.COMPONENT;
+    messageControl = new FormControl();
+    groupsLoaded = false;
+    showGroups = false;
+    hasOffline = false;
+    canCreateAttachments = true; // Assume we can by default.
+    canPin = false;
+    forum: any;
+    showForm = false;
+    groups = [];
+    newDiscussion = {
+        subject: '',
+        message: null, // Null means empty or just white space.
+        groupId: 0,
+        subscribe: true,
+        pin: false,
+        files: []
+    };
+
+    protected courseId: number;
+    protected cmId: number;
+    protected forumId: number;
+    protected timeCreated: number;
+    protected syncId: string;
+    protected syncObserver: any;
+    protected isDestroyed = false;
+    protected originalData: any;
+
+    constructor(navParams: NavParams,
+            private navCtrl: NavController,
+            private translate: TranslateService,
+            private domUtils: CoreDomUtilsProvider,
+            private eventsProvider: CoreEventsProvider,
+            private groupsProvider: CoreGroupsProvider,
+            private sitesProvider: CoreSitesProvider,
+            private syncProvider: CoreSyncProvider,
+            private uploaderProvider: CoreFileUploaderProvider,
+            private textUtils: CoreTextUtilsProvider,
+            private utils: CoreUtilsProvider,
+            private forumProvider: AddonModForumProvider,
+            private forumOffline: AddonModForumOfflineProvider,
+            private forumSync: AddonModForumSyncProvider,
+            private forumHelper: AddonModForumHelperProvider,
+            @Optional() private svComponent: CoreSplitViewComponent) {
+        this.courseId = navParams.get('courseId');
+        this.cmId = navParams.get('cmId');
+        this.forumId = navParams.get('forumId');
+        this.timeCreated = navParams.get('timeCreated');
+    }
+
+    /**
+     * Component being initialized.
+     */
+    ngOnInit(): void {
+        this.fetchDiscussionData().finally(() => {
+            this.groupsLoaded = true;
+        });
+    }
+
+    /**
+     * User entered the page that contains the component.
+     */
+    ionViewDidEnter(): void {
+        // Refresh data if this discussion is synchronized automatically.
+        this.syncObserver = this.eventsProvider.on(AddonModForumSyncProvider.AUTO_SYNCED, (data) => {
+            if (data.forumId == this.forumId && data.userId == this.sitesProvider.getCurrentSiteUserId()) {
+                this.domUtils.showAlertTranslated('core.notice', 'core.contenteditingsynced');
+                this.returnToDiscussions();
+            }
+        }, this.sitesProvider.getCurrentSiteId());
+
+        // Trigger view event, to highlight the current opened discussion in the split view.
+        this.eventsProvider.trigger(AddonModForumProvider.VIEW_DISCUSSION_EVENT, {
+            forumId: this.forumId,
+            discussion: -this.timeCreated,
+        }, this.sitesProvider.getCurrentSiteId());
+    }
+
+    /**
+     * Fetch if forum uses groups and the groups it uses.
+     *
+     * @param  {boolean} [refresh] Whether we're refreshing data.
+     * @return {Promise<any>} Promise resolved when done.
+     */
+    protected fetchDiscussionData(refresh?: boolean): Promise<any> {
+        return this.groupsProvider.getActivityGroupMode(this.cmId).then((mode) => {
+            const promises = [];
+
+            if (mode === CoreGroupsProvider.SEPARATEGROUPS || mode === CoreGroupsProvider.VISIBLEGROUPS) {
+                promises.push(this.groupsProvider.getActivityAllowedGroups(this.cmId).then((forumGroups) => {
+                    let promise;
+                    if (mode === CoreGroupsProvider.VISIBLEGROUPS) {
+                        // We need to check which of the returned groups the user can post to.
+                        promise = this.validateVisibleGroups(forumGroups);
+                    } else {
+                        // WS already filters groups, no need to do it ourselves. Add "All participants" if needed.
+                        promise = this.addAllParticipantsOption(forumGroups, true);
+                    }
+
+                    return promise.then((forumGroups) => {
+                        if (forumGroups.length > 0) {
+                            this.groups = forumGroups;
+                            // Do not override group id.
+                            this.newDiscussion.groupId = this.newDiscussion.groupId || forumGroups[0].id;
+                            this.showGroups = true;
+                        } else {
+                            const message = mode === CoreGroupsProvider.SEPARATEGROUPS ?
+                                    'addon.mod_forum.cannotadddiscussionall' : 'addon.mod_forum.cannotadddiscussion';
+
+                            return Promise.reject(this.translate.instant(message));
+                        }
+                    });
+                }));
+            } else {
+                this.showGroups = false;
+
+                // Use the canAddDiscussion WS to check if the user can add attachments and pin discussions.
+                promises.push(this.forumProvider.canAddDiscussionToAll(this.forumId).then((response) => {
+                    this.canPin = !!response.canpindiscussions;
+                    this.canCreateAttachments = !!response.cancreateattachment;
+                }).catch(() => {
+                    // Ignore errors, use default values.
+                }));
+            }
+
+            // Get forum.
+            promises.push(this.forumProvider.getForum(this.courseId, this.cmId).then((forum) => {
+                this.forum = forum;
+            }));
+
+            // If editing a discussion, get offline data.
+            if (this.timeCreated && !refresh) {
+                this.syncId = this.forumSync.getForumSyncId(this.forumId);
+                promises.push(this.forumSync.waitForSync(this.syncId).then(() => {
+                    // Do not block if the scope is already destroyed.
+                    if (!this.isDestroyed) {
+                        this.syncProvider.blockOperation(AddonModForumProvider.COMPONENT, this.syncId);
+                    }
+
+                    return this.forumOffline.getNewDiscussion(this.forumId, this.timeCreated).then((discussion) => {
+                        this.hasOffline = true;
+                        discussion.options = discussion.options || {};
+                        this.newDiscussion.groupId = discussion.groupid ? discussion.groupid : this.newDiscussion.groupId;
+                        this.newDiscussion.subject = discussion.subject;
+                        this.newDiscussion.message = discussion.message;
+                        this.newDiscussion.subscribe = discussion.options.discussionsubscribe;
+                        this.newDiscussion.pin = discussion.options.discussionpinned;
+                        this.messageControl.setValue(discussion.message);
+
+                        // Treat offline attachments if any.
+                        if (discussion.options.attachmentsid && discussion.options.attachmentsid.offline) {
+                            return this.forumHelper.getNewDiscussionStoredFiles(this.forumId, this.timeCreated).then((files) => {
+                                this.newDiscussion.files = files;
+                            });
+                        }
+                    });
+                }));
+            }
+
+            return Promise.all(promises);
+        }).then(() => {
+            if (!this.originalData) {
+                // Initialize original data.
+                this.originalData = {
+                    subject: this.newDiscussion.subject,
+                    message: this.newDiscussion.message,
+                    files: this.newDiscussion.files.slice(),
+                };
+            }
+            this.showForm = true;
+        }).catch((message) => {
+            this.domUtils.showErrorModalDefault(message, 'addon.mod_forum.errorgetgroups', true);
+            this.showForm = false;
+        });
+    }
+
+    /**
+     * Validate which of the groups returned by getActivityAllowedGroups in visible groups should be shown to post to.
+     *
+     * @param  {any[]} forumGroups Forum groups.
+     * @return {Promise<any>} Promise resolved when done.
+     */
+    protected validateVisibleGroups(forumGroups: any[]): Promise<any> {
+        // We first check if the user can post to all the groups.
+        return this.forumProvider.canAddDiscussionToAll(this.forumId).catch(() => {
+            // The call failed, let's assume he can't.
+            return {
+                status: false,
+                canpindiscussions: false,
+                cancreateattachment: true
+            };
+        }).then((response) => {
+            this.canPin = !!response.canpindiscussions;
+            this.canCreateAttachments = !!response.cancreateattachment;
+
+            if (response.status) {
+                // The user can post to all groups, add the "All participants" option and return them all.
+                return this.addAllParticipantsOption(forumGroups, false);
+            } else {
+                // The user can't post to all groups, let's check which groups he can post to.
+                const promises = [];
+                const filtered = [];
+
+                forumGroups.forEach((group) => {
+                    promises.push(this.forumProvider.canAddDiscussion(this.forumId, group.id).catch(() => {
+                        /* The call failed, let's return true so the group is shown. If the user can't post to
+                           it an error will be shown when he tries to add the discussion. */
+                        return {
+                            status: true
+                        };
+                    }).then((response) => {
+                        if (response.status) {
+                            filtered.push(group);
+                        }
+                    }));
+                });
+
+                return Promise.all(promises).then(() => {
+                    return filtered;
+                });
+            }
+        });
+    }
+
+    /**
+     * Filter forum groups, returning only those that are inside user groups.
+     *
+     * @param  {any[]} forumGroups Forum groups.
+     * @param  {any[]} userGroups User groups.
+     * @return {any[]} Filtered groups.
+     */
+    protected filterGroups(forumGroups: any[], userGroups: any[]): any[] {
+        const filtered = [];
+        const userGroupsIds = userGroups.map((g) => g.id);
+
+        forumGroups.forEach((fg) => {
+            if (userGroupsIds.indexOf(fg.id) > -1) {
+                filtered.push(fg);
+            }
+        });
+
+        return filtered;
+    }
+
+    /**
+     * Add the "All participants" option to a list of groups if the user can add a discussion to all participants.
+     *
+     * @param  {any[]}   groups Groups.
+     * @param  {boolean} check  True to check if the user can add a discussion to all participants.
+     * @return {Promise<any[]>} Promise resolved with the list of groups.
+     */
+    protected addAllParticipantsOption(groups: any[], check: boolean): Promise<any[]> {
+        if (!this.forumProvider.isAllParticipantsFixed()) {
+            // All participants has a bug, don't add it.
+            return Promise.resolve(groups);
+        }
+
+        let promise;
+
+        if (check) {
+            // We need to check if the user can add a discussion to all participants.
+            promise = this.forumProvider.canAddDiscussionToAll(this.forumId).then((response) => {
+                this.canPin = !!response.canpindiscussions;
+                this.canCreateAttachments = !!response.cancreateattachment;
+
+                return response.status;
+            }).catch(() => {
+                // The call failed, let's assume he can't.
+                return false;
+            });
+        } else {
+            // No need to check, assume the user can.
+            promise = Promise.resolve(true);
+        }
+
+        return promise.then((canAdd) => {
+            if (canAdd) {
+                groups.unshift({
+                    courseid: this.courseId,
+                    id: -1,
+                    name: this.translate.instant('core.allparticipants')
+                });
+            }
+
+            return groups;
+        });
+    }
+
+    /**
+     * Pull to refresh.
+     *
+     * @param {any} refresher Refresher.
+     */
+    refreshGroups(refresher: any): void {
+        const promises = [
+            this.groupsProvider.invalidateActivityGroupMode(this.cmId),
+            this.groupsProvider.invalidateActivityAllowedGroups(this.cmId),
+            this.forumProvider.invalidateCanAddDiscussion(this.forumId),
+        ];
+
+        Promise.all(promises).finally(() => {
+            this.fetchDiscussionData(true).finally(() => {
+                refresher.complete();
+            });
+        });
+    }
+
+    /**
+     * Convenience function to update or return to discussions depending on device.
+     *
+     * @param {number} [discussionId] Id of the new discussion.
+     */
+    protected returnToDiscussions(discussionId?: number): void {
+        const data: any = {
+            forumId: this.forumId,
+            cmId: this.cmId,
+            discussionId: discussionId,
+        };
+        this.eventsProvider.trigger(AddonModForumProvider.NEW_DISCUSSION_EVENT, data, this.sitesProvider.getCurrentSiteId());
+
+        // Delete the local files from the tmp folder.
+        this.uploaderProvider.clearTmpFiles(this.newDiscussion.files);
+
+        if (this.svComponent && this.svComponent.isOn()) {
+            // Empty form.
+            this.hasOffline = false;
+            this.newDiscussion.subject = '';
+            this.newDiscussion.message = null;
+            this.newDiscussion.files = [];
+            this.messageEditor.clearText();
+            this.originalData = this.utils.clone(this.newDiscussion);
+
+            // Trigger view event, to highlight the current opened discussion in the split view.
+            this.eventsProvider.trigger(AddonModForumProvider.VIEW_DISCUSSION_EVENT, {
+                forumId: this.forumId,
+                discussion: 0,
+            }, this.sitesProvider.getCurrentSiteId());
+        } else {
+            this.originalData = null; // Avoid asking for confirmation.
+            this.navCtrl.pop();
+        }
+    }
+
+    /**
+     * Message changed.
+     *
+     * @param {string} text The new text.
+     */
+    onMessageChange(text: string): void {
+        this.newDiscussion.message = text;
+    }
+
+    /**
+     * Add a new discussion.
+     */
+    add(): void {
+        const forumName = this.forum.name;
+        const subject = this.newDiscussion.subject;
+        let  message = this.newDiscussion.message;
+        const pin = this.newDiscussion.pin;
+        const groupId = this.newDiscussion.groupId;
+        const attachments = this.newDiscussion.files;
+        const discTimecreated = this.timeCreated || Date.now();
+        const options: any = {
+            discussionsubscribe: !!this.newDiscussion.subscribe
+        };
+        let saveOffline = false;
+
+        if (!subject) {
+            this.domUtils.showErrorModal('addon.mod_forum.erroremptysubject', true);
+
+            return;
+        }
+        if (!message) {
+            this.domUtils.showErrorModal('addon.mod_forum.erroremptymessage', true);
+
+            return;
+        }
+
+        const modal = this.domUtils.showModalLoading('core.sending', true);
+
+        // Check if rich text editor is enabled or not.
+        this.domUtils.isRichTextEditorEnabled().then((enabled) => {
+            if (!enabled) {
+                // Rich text editor not enabled, add some HTML to the message if needed.
+                message = this.textUtils.formatHtmlLines(message);
+            }
+
+            // Upload attachments first if any.
+            if (attachments.length) {
+                return this.forumHelper.uploadOrStoreNewDiscussionFiles(this.forumId, discTimecreated, attachments, false)
+                        .catch(() => {
+                    // Cannot upload them in online, save them in offline.
+                    saveOffline = true;
+
+                    return this.forumHelper.uploadOrStoreNewDiscussionFiles(this.forumId, discTimecreated, attachments, true);
+                });
+            }
+        }).then((attach) => {
+            if (attach) {
+                options.attachmentsid = attach;
+            }
+            if (pin) {
+                options.discussionpinned = true;
+            }
+
+            if (saveOffline) {
+                // Save discussion in offline.
+                return this.forumOffline.addNewDiscussion(this.forumId, forumName, this.courseId, subject,
+                        message, options, groupId, discTimecreated).then(() => {
+                    // Don't return anything.
+                });
+            } else {
+                // Try to send it to server.
+                // Don't allow offline if there are attachments since they were uploaded fine.
+                return this.forumProvider.addNewDiscussion(this.forumId, forumName, this.courseId, subject, message, options,
+                        groupId, undefined, discTimecreated, !attachments.length);
+            }
+        }).then((discussionId) => {
+            if (discussionId) {
+                // Data sent to server, delete stored files (if any).
+                this.forumHelper.deleteNewDiscussionStoredFiles(this.forumId, discTimecreated);
+            }
+
+            this.returnToDiscussions(discussionId);
+        }).catch((message) => {
+            this.domUtils.showErrorModalDefault(message, 'addon.mod_forum.cannotcreatediscussion', true);
+        }).finally(() => {
+            modal.dismiss();
+        });
+    }
+
+    /**
+     * Discard an offline saved discussion.
+     */
+    discard(): void {
+        this.domUtils.showConfirm(this.translate.instant('core.areyousure')).then(() => {
+            const promises = [];
+
+            promises.push(this.forumOffline.deleteNewDiscussion(this.forumId, this.timeCreated));
+            promises.push(this.forumHelper.deleteNewDiscussionStoredFiles(this.forumId, this.timeCreated).catch(() => {
+                // Ignore errors, maybe there are no files.
+            }));
+
+            return Promise.all(promises).then(() => {
+                this.returnToDiscussions();
+            });
+        }).catch(() => {
+            // Cancelled.
+        });
+    }
+
+    /**
+     * Check if we can leave the page or not.
+     *
+     * @return {boolean|Promise<void>} Resolved if we can leave it, rejected if not.
+     */
+    ionViewCanLeave(): boolean | Promise<void> {
+        let promise: any;
+
+        if (this.forumHelper.hasPostDataChanged(this.newDiscussion, this.originalData)) {
+            // Show confirmation if some data has been modified.
+            promise = this.domUtils.showConfirm(this.translate.instant('core.confirmcanceledit'));
+        } else {
+            promise = Promise.resolve();
+        }
+
+        return promise.then(() => {
+            // Delete the local files from the tmp folder.
+            this.uploaderProvider.clearTmpFiles(this.newDiscussion.files);
+        });
+    }
+
+    /**
+     * Runs when the page is about to leave and no longer be the active page.
+     */
+    ionViewWillLeave(): void {
+        this.syncObserver && this.syncObserver.off();
+    }
+
+    /**
+     * Page destroyed.
+     */
+    ngOnDestroy(): void {
+        if (this.syncId) {
+            this.syncProvider.unblockOperation(AddonModForumProvider.COMPONENT, this.syncId);
+        }
+        this.isDestroyed = true;
+    }
+}
diff --git a/src/addon/mod/forum/providers/discussion-link-handler.ts b/src/addon/mod/forum/providers/discussion-link-handler.ts
new file mode 100644
index 000000000..f4962baf0
--- /dev/null
+++ b/src/addon/mod/forum/providers/discussion-link-handler.ts
@@ -0,0 +1,69 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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 { CoreDomUtilsProvider } from '@providers/utils/dom';
+import { CoreContentLinksHandlerBase } from '@core/contentlinks/classes/base-handler';
+import { CoreContentLinksAction } from '@core/contentlinks/providers/delegate';
+import { CoreContentLinksHelperProvider } from '@core/contentlinks/providers/helper';
+
+/**
+ * Handler to treat links to forum review.
+ */
+@Injectable()
+export class AddonModForumDiscussionLinkHandler extends CoreContentLinksHandlerBase {
+    name = 'AddonModForumDiscussionLinkHandler';
+    featureName = 'CoreCourseModuleDelegate_AddonModForum';
+    pattern = /\/mod\/forum\/discuss\.php.*([\&\?]d=\d+)/;
+
+    constructor(protected domUtils: CoreDomUtilsProvider, protected linkHelper: CoreContentLinksHelperProvider) {
+        super();
+    }
+
+    /**
+     * Get the list of actions for a link (url).
+     *
+     * @param {string[]} siteIds List of sites the URL belongs to.
+     * @param {string} url The URL to treat.
+     * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
+     * @param {number} [courseId] Course ID related to the URL. Optional but recommended.
+     * @return {CoreContentLinksAction[]|Promise<CoreContentLinksAction[]>} List of (or promise resolved with list of) actions.
+     */
+    getActions(siteIds: string[], url: string, params: any, courseId?: number):
+            CoreContentLinksAction[] | Promise<CoreContentLinksAction[]> {
+        return [{
+            action: (siteId, navCtrl?): void => {
+                const pageParams = {
+                    courseId: courseId || parseInt(params.courseid, 10) || parseInt(params.cid, 10),
+                    discussionId: parseInt(params.d, 10),
+                };
+                this.linkHelper.goInSite(navCtrl, 'AddonModForumDiscussionPage', pageParams, siteId);
+            }
+        }];
+    }
+
+    /**
+     * Check if the handler is enabled for a certain site (site + user) and a URL.
+     * If not defined, defaults to true.
+     *
+     * @param {string} siteId The site ID.
+     * @param {string} url The URL to treat.
+     * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
+     * @param {number} [courseId] Course ID related to the URL. Optional but recommended.
+     * @return {boolean|Promise<boolean>} Whether the handler is enabled for the URL and site.
+     */
+    isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise<boolean> {
+        return true;
+    }
+}
diff --git a/src/addon/mod/forum/providers/forum.ts b/src/addon/mod/forum/providers/forum.ts
new file mode 100644
index 000000000..4b41b2b21
--- /dev/null
+++ b/src/addon/mod/forum/providers/forum.ts
@@ -0,0 +1,729 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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 { TranslateService } from '@ngx-translate/core';
+import { CoreAppProvider } from '@providers/app';
+import { CoreFilepoolProvider } from '@providers/filepool';
+import { CoreGroupsProvider } from '@providers/groups';
+import { CoreSitesProvider } from '@providers/sites';
+import { CoreUserProvider } from '@core/user/providers/user';
+import { CoreUtilsProvider } from '@providers/utils/utils';
+import { AddonModForumOfflineProvider } from './offline';
+
+/**
+ * Service that provides some features for forums.
+ */
+@Injectable()
+export class AddonModForumProvider {
+    static COMPONENT = 'mmaModForum';
+    static DISCUSSIONS_PER_PAGE = 10; // Max of discussions per page.
+    static NEW_DISCUSSION_EVENT = 'addon_mod_forum_new_discussion';
+    static REPLY_DISCUSSION_EVENT = 'addon_mod_forum_reply_discussion';
+    static VIEW_DISCUSSION_EVENT = 'addon_mod_forum_view_discussion';
+
+    protected ROOT_CACHE_KEY = 'mmaModForum:';
+
+    constructor(private appProvider: CoreAppProvider,
+            private sitesProvider: CoreSitesProvider,
+            private groupsProvider: CoreGroupsProvider,
+            private filepoolProvider: CoreFilepoolProvider,
+            private userProvider: CoreUserProvider,
+            private translate: TranslateService,
+            private utils: CoreUtilsProvider,
+            private forumOffline: AddonModForumOfflineProvider) {}
+
+    /**
+     * Get cache key for can add discussion WS calls.
+     *
+     * @param  {number} forumId Forum ID.
+     * @param  {number} groupId Group ID.
+     * @return {string}         Cache key.
+     */
+    protected getCanAddDiscussionCacheKey(forumId: number, groupId: number): string {
+        return this.getCommonCanAddDiscussionCacheKey(forumId) + ':' + groupId;
+    }
+
+    /**
+     * Get common part of cache key for can add discussion WS calls.
+     *
+     * @param  {number} forumId Forum ID.
+     * @return {string}         Cache key.
+     */
+    protected getCommonCanAddDiscussionCacheKey(forumId: number): string {
+        return this.ROOT_CACHE_KEY + 'canadddiscussion:' + forumId;
+    }
+
+    /**
+     * Get cache key for forum data WS calls.
+     *
+     * @param  {number} courseId Course ID.
+     * @return {string}          Cache key.
+     */
+    protected getForumDataCacheKey(courseId: number): string {
+        return this.ROOT_CACHE_KEY + 'forum:' + courseId;
+    }
+
+    /**
+     * Get cache key for forum discussion posts WS calls.
+     *
+     * @param  {number} discussionId Discussion ID.
+     * @return {string}              Cache key.
+     */
+    protected getDiscussionPostsCacheKey(discussionId: number): string {
+        return this.ROOT_CACHE_KEY + 'discussion:' + discussionId;
+    }
+
+    /**
+     * Get cache key for forum discussions list WS calls.
+     *
+     * @param  {number} forumId Forum ID.
+     * @return {string}         Cache key.
+     */
+    protected getDiscussionsListCacheKey(forumId: number): string {
+        return this.ROOT_CACHE_KEY + 'discussions:' + forumId;
+    }
+
+    /**
+     * Add a new discussion.
+     *
+     * @param  {number}  forumId       Forum ID.
+     * @param  {string}  name          Forum name.
+     * @param  {number}  courseId      Course ID the forum belongs to.
+     * @param  {string}  subject       New discussion's subject.
+     * @param  {string}  message       New discussion's message.
+     * @param  {any}     [options]     Options (subscribe, pin, ...).
+     * @param  {string}  [groupId]     Group this discussion belongs to.
+     * @param  {string}  [siteId]      Site ID. If not defined, current site.
+     * @param  {number}  [timeCreated] The time the discussion was created. Only used when editing discussion.
+     * @param  {boolean} allowOffline  True if it can be stored in offline, false otherwise.
+     * @return {Promise<any>}          Promise resolved with discussion ID if sent online, resolved with false if stored offline.
+     */
+    addNewDiscussion(forumId: number, name: string, courseId: number, subject: string, message: string, options?: any,
+            groupId?: number, siteId?: string, timeCreated?: number, allowOffline?: boolean): Promise<any> {
+        siteId = siteId || this.sitesProvider.getCurrentSiteId();
+
+        // Convenience function to store a message to be synchronized later.
+        const storeOffline = (): Promise<any> => {
+            return this.forumOffline.addNewDiscussion(forumId, name, courseId, subject, message, options,
+                    groupId, timeCreated, siteId).then(() => {
+                return false;
+            });
+        };
+
+        // If we are editing an offline discussion, discard previous first.
+        let discardPromise;
+        if (timeCreated) {
+            discardPromise = this.forumOffline.deleteNewDiscussion(forumId, timeCreated, siteId);
+        } else {
+            discardPromise = Promise.resolve();
+        }
+
+        return discardPromise.then(() => {
+            if (!this.appProvider.isOnline() && allowOffline) {
+                // App is offline, store the action.
+                return storeOffline();
+            }
+
+            return this.addNewDiscussionOnline(forumId, subject, message, options, groupId, siteId).then((id) => {
+                // Success, return the discussion ID.
+                return id;
+            }).catch((error) => {
+                if (!allowOffline || this.utils.isWebServiceError(error)) {
+                    // The WebService has thrown an error or offline not supported, reject.
+                    return Promise.reject(error);
+                }
+
+                // Couldn't connect to server, store in offline.
+                return storeOffline();
+            });
+        });
+    }
+
+    /**
+     * Add a new discussion. It will fail if offline or cannot connect.
+     *
+     * @param  {number} forumId   Forum ID.
+     * @param  {string} subject   New discussion's subject.
+     * @param  {string} message   New discussion's message.
+     * @param  {any}    [options] Options (subscribe, pin, ...).
+     * @param  {string} [groupId] Group this discussion belongs to.
+     * @param  {string} [siteId]  Site ID. If not defined, current site.
+     * @return {Promise<any>}     Promise resolved when the discussion is created.
+     */
+    addNewDiscussionOnline(forumId: number, subject: string, message: string, options?: any, groupId?: number, siteId?: string)
+            : Promise<any> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            const params: any = {
+                forumid: forumId,
+                subject: subject,
+                message: message,
+                options: this.utils.objectToArrayOfObjects(options, 'name', 'value')
+            };
+
+            if (groupId) {
+                params.groupid = groupId;
+            }
+
+            return site.write('mod_forum_add_discussion', params).then((response) => {
+                // Other errors ocurring.
+                if (!response || !response.discussionid) {
+                    return this.utils.createFakeWSError('');
+                } else {
+                    return response.discussionid;
+                }
+            });
+        });
+    }
+
+    /**
+     * Check if a user can post to a certain group.
+     *
+     * @param  {number} forumId Forum ID.
+     * @param  {number} groupId Group ID.
+     * @return {Promise<any>}   Promise resolved with an object with the following properties:
+     *                           - status (boolean)
+     *                           - canpindiscussions (boolean)
+     *                           - cancreateattachment (boolean)
+     */
+    canAddDiscussion(forumId: number, groupId: number): Promise<any> {
+        const params = {
+            forumid: forumId,
+            groupid: groupId
+        };
+        const preSets = {
+            cacheKey: this.getCanAddDiscussionCacheKey(forumId, groupId)
+        };
+
+        return this.sitesProvider.getCurrentSite().read('mod_forum_can_add_discussion', params, preSets).then((result) => {
+            if (result) {
+                if (typeof result.canpindiscussions == 'undefined') {
+                    // WS doesn't support it yet, default it to false to prevent students from seing the option.
+                    result.canpindiscussions = false;
+                }
+                if (typeof result.cancreateattachment == 'undefined') {
+                    // WS doesn't support it yet, default it to true since usually the users will be able to create them.
+                    result.cancreateattachment = true;
+                }
+
+                return result;
+            }
+
+            return Promise.reject(null);
+        });
+    }
+
+    /**
+     * Check if a user can post to all groups.
+     *
+     * @param  {number} forumId Forum ID.
+     * @return {Promise<any>}   Promise resolved with an object with the following properties:
+     *                           - status (boolean)
+     *                           - canpindiscussions (boolean)
+     *                           - cancreateattachment (boolean)
+     */
+    canAddDiscussionToAll(forumId: number): Promise<any> {
+        return this.canAddDiscussion(forumId, -1);
+    }
+
+    /**
+     * Extract the starting post of a discussion from a list of posts. The post is removed from the array passed as a parameter.
+     *
+     * @param  {any[]} posts Posts to search.
+     * @return {any}         Starting post or undefined if not found.
+     */
+    extractStartingPost(posts: any[]): any {
+        // Check the last post first, since they'll usually be ordered by create time.
+        for (let i = posts.length - 1; i >= 0; i--) {
+            if (posts[i].parent == 0) {
+                return posts.splice(i, 1).pop(); // Remove it from the array.
+            }
+        }
+
+        return undefined;
+    }
+
+    /**
+     * There was a bug adding new discussions to All Participants (see MDL-57962). Check if it's fixed.
+     *
+     * @return {boolean} True if fixed, false otherwise.
+     */
+    isAllParticipantsFixed(): boolean {
+        return this.sitesProvider.getCurrentSite().isVersionGreaterEqualThan(['3.1.5', '3.2.2']);
+    }
+
+    /**
+     * Format discussions, setting groupname if the discussion group is valid.
+     *
+     * @param  {number} cmId        Forum cmid.
+     * @param  {any[]}  discussions List of discussions to format.
+     * @return {Promise<any[]>}     Promise resolved with the formatted discussions.
+     */
+    formatDiscussionsGroups(cmId: number, discussions: any[]): Promise<any[]> {
+        discussions = this.utils.clone(discussions);
+
+        return this.groupsProvider.getActivityAllowedGroups(cmId).then((forumGroups) => {
+            const strAllParts = this.translate.instant('core.allparticipants');
+
+            // Turn groups into an object where each group is identified by id.
+            const groups = {};
+            forumGroups.forEach((fg) => {
+                groups[fg.id] = fg;
+            });
+
+            // Format discussions.
+            discussions.forEach((disc) => {
+                if (disc.groupid === -1) {
+                    disc.groupname = strAllParts;
+                } else {
+                    const group = groups[disc.groupid];
+                    if (group) {
+                        disc.groupname = group.name;
+                    }
+                }
+            });
+
+            return discussions;
+        }).catch(() => {
+            return discussions;
+        });
+    }
+
+    /**
+     * Get all course forums.
+     *
+     * @param  {number} courseId Course ID.
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @return {Promise<any[]>}  Promise resolved when the forums are retrieved.
+     */
+    getCourseForums(courseId: number, siteId?: string): Promise<any[]> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            const params = {
+                courseids: [courseId]
+            };
+            const preSets = {
+                cacheKey: this.getForumDataCacheKey(courseId)
+            };
+
+            return site.read('mod_forum_get_forums_by_courses', params, preSets);
+        });
+    }
+
+    /**
+     * Get a forum by course module ID.
+     *
+     * @param  {number} courseId Course ID.
+     * @param  {number} cmId     Course module ID.
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @return {Promise<any>}    Promise resolved when the forum is retrieved.
+     */
+    getForum(courseId: number, cmId: number, siteId?: string): Promise<any> {
+        return this.getCourseForums(courseId, siteId).then((forums) => {
+            const forum = forums.find((forum) => forum.cmid == cmId);
+            if (forum) {
+                return forum;
+            }
+
+            return Promise.reject(null);
+        });
+    }
+
+    /**
+     * Get a forum by forum ID.
+     *
+     * @param  {number} courseId Course ID.
+     * @param  {number} forumId  Forum ID.
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @return {Promise<any>}    Promise resolved when the forum is retrieved.
+     */
+    getForumById(courseId: number, forumId: number, siteId?: string): Promise<any> {
+        return this.getCourseForums(courseId, siteId).then((forums) => {
+            const forum = forums.find((forum) => forum.id == forumId);
+            if (forum) {
+                return forum;
+            }
+
+            return Promise.reject(null);
+        });
+    }
+
+    /**
+     * Get forum discussion posts.
+     *
+     * @param  {number} discussionid Discussion ID.
+     * @return {Promise<any[]>}      Promise resolved with forum posts.
+     */
+    getDiscussionPosts(discussionid: number): Promise<any> {
+        const site = this.sitesProvider.getCurrentSite();
+        const params = {
+            discussionid: discussionid
+        };
+        const preSets = {
+            cacheKey: this.getDiscussionPostsCacheKey(discussionid)
+        };
+
+        return site.read('mod_forum_get_forum_discussion_posts', params, preSets).then((response) => {
+            if (response) {
+                this.storeUserData(response.posts);
+
+                return response.posts;
+            } else {
+                return Promise.reject(null);
+            }
+        });
+    }
+
+    /**
+     * Sort forum discussion posts by an specified field.
+     *
+     * @param {any[]}  posts     Discussion posts to be sorted in place.
+     * @param {string} direction Direction of the sorting (ASC / DESC).
+     */
+    sortDiscussionPosts(posts: any[], direction: string): void {
+        // @todo: Check children when sorting.
+        posts.sort((a, b) => {
+            a = parseInt(a.created, 10);
+            b = parseInt(b.created, 10);
+            if (direction == 'ASC') {
+                return a - b;
+            } else {
+                return b - a;
+            }
+        });
+    }
+
+    /**
+     * Get forum discussions.
+     *
+     * @param  {number}  forumId      Forum ID.
+     * @param  {number}  [page=0]     Page.
+     * @param  {boolean} [forceCache] True to always get the value from cache. false otherwise.
+     * @param  {string}  [siteId]     Site ID. If not defined, current site.
+     * @return {Promise<any>}         Promise resolved with an object with:
+     *                                 - discussions: List of discussions.
+     *                                 - canLoadMore: True if there may be more discussions to load.
+     */
+    getDiscussions(forumId: number, page: number = 0, forceCache?: boolean, siteId?: string): Promise<any> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            const params = {
+                forumid: forumId,
+                sortby:  'timemodified',
+                sortdirection:  'DESC',
+                page: page,
+                perpage: AddonModForumProvider.DISCUSSIONS_PER_PAGE
+            };
+            const preSets: any = {
+                cacheKey: this.getDiscussionsListCacheKey(forumId)
+            };
+            if (forceCache) {
+                preSets.omitExpires = true;
+            }
+
+            return site.read('mod_forum_get_forum_discussions_paginated', params, preSets).then((response) => {
+                if (response) {
+                    this.storeUserData(response.discussions);
+
+                    return Promise.resolve({
+                        discussions: response.discussions,
+                        canLoadMore: response.discussions.length >= AddonModForumProvider.DISCUSSIONS_PER_PAGE,
+                    });
+                } else {
+                    return Promise.reject(null);
+                }
+            });
+        });
+    }
+
+    /**
+     * Get forum discussions in several pages.
+     * If a page fails, the discussions until that page will be returned along with a flag indicating an error occurred.
+     *
+     * @param  {number}  forumId     Forum ID.
+     * @param  {boolean} forceCache  True to always get the value from cache, false otherwise.
+     * @param  {number}  [numPages]  Number of pages to get. If not defined, all pages.
+     * @param  {number}  [startPage] Page to start. If not defined, first page.
+     * @param  {string}  [siteId]    Site ID. If not defined, current site.
+     * @return {Promise<any>}        Promise resolved with an object with:
+     *                                - discussions: List of discussions.
+     *                                - error: True if an error occurred, false otherwise.
+     */
+    getDiscussionsInPages(forumId: number, forceCache?: boolean, numPages?: number, startPage?: number, siteId?: string)
+            : Promise<any> {
+        if (typeof numPages == 'undefined') {
+            numPages = -1;
+        }
+        startPage = startPage || 0;
+
+        const result = {
+            discussions: [],
+            error: false
+        };
+
+        if (!numPages) {
+            return Promise.resolve(result);
+        }
+
+        const getPage = (page: number): Promise<any> => {
+            // Get page discussions.
+            return this.getDiscussions(forumId, page, forceCache, siteId).then((response) => {
+                result.discussions = result.discussions.concat(response.discussions);
+                numPages--;
+
+                if (response.canLoadMore && numPages !== 0) {
+                    return getPage(page + 1); // Get next page.
+                } else {
+                    return result;
+                }
+            }).catch(() => {
+                // Error getting a page.
+                result.error = true;
+
+                return result;
+            });
+        };
+
+        return getPage(startPage);
+    }
+
+    /**
+     * Invalidates can add discussion WS calls.
+     *
+     * @param  {number} forumId  Forum ID.
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @return {Promise<any>}    Promise resolved when the data is invalidated.
+     */
+    invalidateCanAddDiscussion(forumId: number, siteId?: string): Promise<any> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            return site.invalidateWsCacheForKeyStartingWith(this.getCommonCanAddDiscussionCacheKey(forumId));
+        });
+    }
+
+    /**
+     * Invalidate the prefetched content except files.
+     * To invalidate files, use AddonModForum#invalidateFiles.
+     *
+     * @param  {number} moduleId The module ID.
+     * @param  {number} courseId Course ID.
+     * @return {Promise<any>}    Promise resolved when data is invalidated.
+     */
+    invalidateContent(moduleId: number, courseId: number): Promise<any> {
+        // Get the forum first, we need the forum ID.
+        return this.getForum(courseId, moduleId).then((forum) => {
+            // We need to get the list of discussions to be able to invalidate their posts.
+            return this.getDiscussionsInPages(forum.id, true).then((response) => {
+                // Now invalidate the WS calls.
+                const promises = [];
+
+                promises.push(this.invalidateForumData(courseId));
+                promises.push(this.invalidateDiscussionsList(forum.id));
+                promises.push(this.invalidateCanAddDiscussion(forum.id));
+
+                response.discussions.forEach((discussion) => {
+                    promises.push(this.invalidateDiscussionPosts(discussion.discussion));
+                });
+
+                return this.utils.allPromises(promises);
+            });
+        });
+    }
+
+    /**
+     * Invalidates forum discussion posts.
+     *
+     * @param  {number} discussionId Discussion ID.
+     * @param  {string} [siteId]     Site ID. If not defined, current site.
+     * @return {Promise<any>}        Promise resolved when the data is invalidated.
+     */
+    invalidateDiscussionPosts(discussionId: number, siteId?: string): Promise<any> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            return site.invalidateWsCacheForKey(this.getDiscussionPostsCacheKey(discussionId));
+        });
+    }
+
+    /**
+     * Invalidates discussion list.
+     *
+     * @param  {number} forumId  Forum ID.
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @return {Promise<any>}    Promise resolved when the data is invalidated.
+     */
+    invalidateDiscussionsList(forumId: number, siteId?: string): Promise<any> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            return site.invalidateWsCacheForKey(this.getDiscussionsListCacheKey(forumId));
+        });
+    }
+
+    /**
+     * Invalidate the prefetched files.
+     *
+     * @param  {number} moduleId The module ID.
+     * @return {Promise<any>}   Promise resolved when the files are invalidated.
+     */
+    invalidateFiles(moduleId: number): Promise<any> {
+        const siteId = this.sitesProvider.getCurrentSiteId();
+
+        return this.filepoolProvider.invalidateFilesByComponent(siteId, AddonModForumProvider.COMPONENT, moduleId);
+    }
+
+    /**
+     * Invalidates forum data.
+     *
+     * @param  {number} courseId Course ID.
+     * @return {Promise<any>}    Promise resolved when the data is invalidated.
+     */
+    invalidateForumData(courseId: number): Promise<any> {
+        return this.sitesProvider.getCurrentSite().invalidateWsCacheForKey(this.getForumDataCacheKey(courseId));
+    }
+
+    /**
+     * Report a forum as being viewed.
+     *
+     * @param  {number} id    Module ID.
+     * @return {Promise<any>} Promise resolved when the WS call is successful.
+     */
+    logView(id: number): Promise<any> {
+        const params = {
+            forumid: id
+        };
+
+        return this.sitesProvider.getCurrentSite().write('mod_forum_view_forum', params);
+    }
+
+    /**
+     * Report a forum discussion as being viewed.
+     *
+     * @param  {number} id    Discussion ID.
+     * @return {Promise<any>} Promise resolved when the WS call is successful.
+     */
+    logDiscussionView(id: number): Promise<any> {
+        const params = {
+            discussionid: id
+        };
+
+        return this.sitesProvider.getCurrentSite().write('mod_forum_view_forum_discussion', params);
+    }
+
+    /**
+     * Reply to a certain post.
+     *
+     * @param  {number}  postId         ID of the post being replied.
+     * @param  {number}  discussionId   ID of the discussion the user is replying to.
+     * @param  {number}  forumId        ID of the forum the user is replying to.
+     * @param  {string}  name           Forum name.
+     * @param  {number}  courseId       Course ID the forum belongs to.
+     * @param  {string}  subject        New post's subject.
+     * @param  {string}  message        New post's message.
+     * @param  {any}     [options]      Options (subscribe, attachments, ...).
+     * @param  {string}  [siteId]       Site ID. If not defined, current site.
+     * @param  {boolean} [allowOffline] True if it can be stored in offline, false otherwise.
+     * @return {Promise<any>}           Promise resolved with post ID if sent online, resolved with false if stored offline.
+     */
+    replyPost(postId: number, discussionId: number, forumId: number, name: string, courseId: number, subject: string,
+            message: string, options?: any, siteId?: string, allowOffline?: boolean): Promise<any> {
+        siteId = siteId || this.sitesProvider.getCurrentSiteId();
+
+        // Convenience function to store a message to be synchronized later.
+        const storeOffline = (): Promise<boolean> => {
+            if (!forumId) {
+                // Not enough data to store in offline, reject.
+                return Promise.reject(this.translate.instant('core.networkerrormsg'));
+            }
+
+            return this.forumOffline.replyPost(postId, discussionId, forumId, name, courseId, subject, message, options, siteId)
+                    .then(() => {
+                return false;
+            });
+        };
+
+        if (!this.appProvider.isOnline() && allowOffline) {
+            // App is offline, store the action.
+            return storeOffline();
+        }
+
+        // If there's already a reply to be sent to the server, discard it first.
+        return this.forumOffline.deleteReply(postId, siteId).then(() => {
+
+            return this.replyPostOnline(postId, subject, message, options, siteId).then(() => {
+                return true;
+            }).catch((error) => {
+                if (allowOffline && !this.utils.isWebServiceError(error)) {
+                    // Couldn't connect to server, store in offline.
+                    return storeOffline();
+                } else {
+                    // The WebService has thrown an error or offline not supported, reject.
+                    return Promise.reject(error);
+                }
+            });
+        });
+    }
+
+    /**
+     * Reply to a certain post. It will fail if offline or cannot connect.
+     *
+     * @param  {number} postId    ID of the post being replied.
+     * @param  {string} subject   New post's subject.
+     * @param  {string} message   New post's message.
+     * @param  {any}    [options] Options (subscribe, attachments, ...).
+     * @param  {string} [siteId]  Site ID. If not defined, current site.
+     * @return {Promise<number>}  Promise resolved with the created post id.
+     */
+    replyPostOnline(postId: number, subject: string, message: string, options?: any, siteId?: string): Promise<number> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            const params = {
+                postid: postId,
+                subject: subject,
+                message: message,
+                options: this.utils.objectToArrayOfObjects(options, 'name', 'value')
+            };
+
+            return site.write('mod_forum_add_discussion_post', params).then((response) => {
+                if (!response || !response.postid) {
+                    return this.utils.createFakeWSError('');
+                } else {
+                    return response.postid;
+                }
+            });
+        });
+    }
+
+    /**
+     * Store the users data from a discussions/posts list.
+     *
+     * @param {any[]} list Array of posts or discussions.
+     */
+    protected storeUserData(list: any[]): void {
+        const users = {};
+
+        list.forEach((entry) => {
+            const userId = parseInt(entry.userid);
+            if (!isNaN(userId) && !users[userId]) {
+                users[userId] = {
+                    id: userId,
+                    fullname: entry.userfullname,
+                    profileimageurl: entry.userpictureurl
+                };
+            }
+            const userModified = parseInt(entry.usermodified);
+            if (!isNaN(userModified) && !users[userModified]) {
+                users[userModified] = {
+                    id: userModified,
+                    fullname: entry.usermodifiedfullname,
+                    profileimageurl: entry.usermodifiedpictureurl
+                };
+            }
+        });
+
+        this.userProvider.storeUsers(this.utils.objectToArray(users));
+    }
+}
diff --git a/src/addon/mod/forum/providers/helper.ts b/src/addon/mod/forum/providers/helper.ts
new file mode 100644
index 000000000..e039cdf7b
--- /dev/null
+++ b/src/addon/mod/forum/providers/helper.ts
@@ -0,0 +1,243 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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 { CoreFileProvider } from '@providers/file';
+import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader';
+import { CoreUserProvider } from '@core/user/providers/user';
+import { AddonModForumProvider } from './forum';
+import { AddonModForumOfflineProvider } from './offline';
+
+/**
+ * Service that provides some features for forums.
+ */
+@Injectable()
+export class AddonModForumHelperProvider {
+    constructor(private fileProvider: CoreFileProvider,
+            private uploaderProvider: CoreFileUploaderProvider,
+            private userProvider: CoreUserProvider,
+            private forumOffline: AddonModForumOfflineProvider) {}
+
+    /**
+     * Convert offline reply to online format in order to be compatible with them.
+     *
+     * @param  {any}    offlineReply Offline version of the reply.
+     * @param  {string} [siteId]     Site ID. If not defined, current site.
+     * @return {Promise<any>}        Promise resolved with the object converted to Online.
+     */
+    convertOfflineReplyToOnline(offlineReply: any, siteId?: string): Promise<any> {
+        const reply: any = {
+                attachments: [],
+                canreply: false,
+                children: [],
+                created: offlineReply.timecreated,
+                discussion: offlineReply.discussionid,
+                id: false,
+                mailed: 0,
+                mailnow: 0,
+                message: offlineReply.message,
+                messageformat: 1,
+                messagetrust: 0,
+                modified: false,
+                parent: offlineReply.postid,
+                postread: false,
+                subject: offlineReply.subject,
+                totalscore: 0,
+                userid: offlineReply.userid
+            },
+            promises = [];
+
+        // Treat attachments if any.
+        if (offlineReply.options && offlineReply.options.attachmentsid) {
+            reply.attachments = offlineReply.options.attachmentsid.online || [];
+
+            if (offlineReply.options.attachmentsid.offline) {
+                promises.push(this.getReplyStoredFiles(offlineReply.forumid, reply.parent, siteId, reply.userid)
+                            .then((files) => {
+                    reply.attachments = reply.attachments.concat(files);
+                }));
+            }
+        }
+
+        // Get user data.
+        promises.push(this.userProvider.getProfile(offlineReply.userid, offlineReply.courseid, true).then((user) => {
+            reply.userfullname = user.fullname;
+            reply.userpictureurl = user.profileimageurl;
+        }).catch(() => {
+            // Ignore errors.
+        }));
+
+        return Promise.all(promises).then(() => {
+            reply.attachment = reply.attachments.length > 0 ? 1 : 0;
+
+            return reply;
+        });
+    }
+
+    /**
+     * Delete stored attachment files for a new discussion.
+     *
+     * @param  {number} forumId     Forum ID.
+     * @param  {number} timecreated The time the discussion was created.
+     * @param  {string} [siteId]    Site ID. If not defined, current site.
+     * @return {Promise<any>}       Promise resolved when deleted.
+     */
+    deleteNewDiscussionStoredFiles(forumId: number, timecreated: number, siteId?: string): Promise<any> {
+        return this.forumOffline.getNewDiscussionFolder(forumId, timecreated, siteId).then((folderPath) => {
+            return this.fileProvider.removeDir(folderPath).catch(() => {
+                // Ignore any errors, CoreFileProvider.removeDir fails if folder doesn't exists.
+            });
+        });
+    }
+
+    /**
+     * Delete stored attachment files for a reply.
+     *
+     * @param  {number} forumId  Forum ID.
+     * @param  {number} postId   ID of the post being replied.
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @param  {number} [userId] User the reply belongs to. If not defined, current user in site.
+     * @return {Promise<any>}    Promise resolved when deleted.
+     */
+    deleteReplyStoredFiles(forumId: number, postId: number, siteId?: string, userId?: number): Promise<any> {
+        return this.forumOffline.getReplyFolder(forumId, postId, siteId, userId).then((folderPath) => {
+            return this.fileProvider.removeDir(folderPath).catch(() => {
+                // Ignore any errors, CoreFileProvider.removeDir fails if folder doesn't exists.
+            });
+        });
+    }
+
+    /**
+     * Get a list of stored attachment files for a new discussion. See AddonModForumHelper#storeNewDiscussionFiles.
+     *
+     * @param  {number} forumId     Forum ID.
+     * @param  {number} timecreated The time the discussion was created.
+     * @param  {string} [siteId]    Site ID. If not defined, current site.
+     * @return {Promise<any[]>}     Promise resolved with the files.
+     */
+    getNewDiscussionStoredFiles(forumId: number, timecreated: number, siteId?: string): Promise<any[]> {
+        return this.forumOffline.getNewDiscussionFolder(forumId, timecreated, siteId).then((folderPath) => {
+            return this.uploaderProvider.getStoredFiles(folderPath);
+        });
+    }
+
+    /**
+     * Get a list of stored attachment files for a reply. See AddonModForumHelper#storeReplyFiles.
+     *
+     * @param  {number} forumId  Forum ID.
+     * @param  {number} postId   ID of the post being replied.
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @param  {number} [userId] User the reply belongs to. If not defined, current user in site.
+     * @return {Promise<any[]>}  Promise resolved with the files.
+     */
+    getReplyStoredFiles(forumId: number, postId: number, siteId?: string, userId?: number): Promise<any[]> {
+        return this.forumOffline.getReplyFolder(forumId, postId, siteId, userId).then((folderPath) => {
+            return this.uploaderProvider.getStoredFiles(folderPath);
+        });
+    }
+
+    /**
+     * Check if the data of a post/discussion has changed.
+     *
+     * @param  {any} post       Current data.
+     * @param  {any} [original] Original ata.
+     * @return {boolean} True if data has changed, false otherwise.
+     */
+    hasPostDataChanged(post: any, original?: any): boolean {
+        if (!original || original.subject == null) {
+            // There is no original data, assume it hasn't changed.
+            return false;
+        }
+
+        if (post.subject != original.subject || post.message != original.message) {
+            return true;
+        }
+
+        return this.uploaderProvider.areFileListDifferent(post.files, original.files);
+    }
+
+    /**
+     * Given a list of files (either online files or local files), store the local files in a local folder
+     * to be submitted later.
+     *
+     * @param  {number} forumId     Forum ID.
+     * @param  {number} timecreated The time the discussion was created.
+     * @param  {any[]}  files       List of files.
+     * @param  {string} [siteId]    Site ID. If not defined, current site.
+     * @return {Promise<any>}       Promise resolved if success, rejected otherwise.
+     */
+    storeNewDiscussionFiles(forumId: number, timecreated: number, files: any[], siteId?: string): Promise<any> {
+        // Get the folder where to store the files.
+        return this.forumOffline.getNewDiscussionFolder(forumId, timecreated, siteId).then((folderPath) => {
+            return this.uploaderProvider.storeFilesToUpload(folderPath, files);
+        });
+    }
+
+    /**
+     * Given a list of files (either online files or local files), store the local files in a local folder
+     * to be submitted later.
+     *
+     * @param  {number} forumId  Forum ID.
+     * @param  {number} postId   ID of the post being replied.
+     * @param  {any[]}  files    List of files.
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @param  {number} [userId] User the reply belongs to. If not defined, current user in site.
+     * @return {Promise<any>}    Promise resolved if success, rejected otherwise.
+     */
+    storeReplyFiles(forumId: number, postId: number, files: any[], siteId?: string, userId?: number): Promise<any> {
+        // Get the folder where to store the files.
+        return this.forumOffline.getReplyFolder(forumId, postId, siteId, userId).then((folderPath) => {
+            return this.uploaderProvider.storeFilesToUpload(folderPath, files);
+        });
+    }
+
+    /**
+     * Upload or store some files for a new discussion, depending if the user is offline or not.
+     *
+     * @param  {number}  forumId     Forum ID.
+     * @param  {number}  timecreated The time the discussion was created.
+     * @param  {any[]}   files       List of files.
+     * @param  {boolean} offline     True if files sould be stored for offline, false to upload them.
+     * @param  {string}  [siteId]    Site ID. If not defined, current site.
+     * @return {Promise<any>}        Promise resolved if success.
+     */
+    uploadOrStoreNewDiscussionFiles(forumId: number, timecreated: number, files: any[], offline: boolean, siteId?: string)
+            : Promise<any> {
+        if (offline) {
+            return this.storeNewDiscussionFiles(forumId, timecreated, files, siteId);
+        } else {
+            return this.uploaderProvider.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId);
+        }
+    }
+
+    /**
+     * Upload or store some files for a reply, depending if the user is offline or not.
+     *
+     * @param  {number}  forumId  Forum ID.
+     * @param  {number}  postId   ID of the post being replied.
+     * @param  {any[]}   files    List of files.
+     * @param  {boolean} offline  True if files sould be stored for offline, false to upload them.
+     * @param  {string}  [siteId] Site ID. If not defined, current site.
+     * @param  {number}  [userId] User the reply belongs to. If not defined, current user in site.
+     * @return {Promise<any>}     Promise resolved if success.
+     */
+    uploadOrStoreReplyFiles(forumId: number, postId: number, files: any[], offline: boolean, siteId?: string, userId?: number)
+            : Promise<any> {
+        if (offline) {
+            return this.storeReplyFiles(forumId, postId, files, siteId, userId);
+        } else {
+            return this.uploaderProvider.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId);
+        }
+    }
+}
diff --git a/src/addon/mod/forum/providers/index-link-handler.ts b/src/addon/mod/forum/providers/index-link-handler.ts
new file mode 100644
index 000000000..4beb1226f
--- /dev/null
+++ b/src/addon/mod/forum/providers/index-link-handler.ts
@@ -0,0 +1,44 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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 { CoreContentLinksModuleIndexHandler } from '@core/contentlinks/classes/module-index-handler';
+import { CoreCourseHelperProvider } from '@core/course/providers/helper';
+import { AddonModForumProvider } from './forum';
+
+/**
+ * Handler to treat links to forum index.
+ */
+@Injectable()
+export class AddonModForumIndexLinkHandler extends CoreContentLinksModuleIndexHandler {
+    name = 'AddonModForumIndexLinkHandler';
+
+    constructor(courseHelper: CoreCourseHelperProvider, protected forumProvider: AddonModForumProvider) {
+        super(courseHelper, 'AddonModForum', 'forum');
+    }
+
+    /**
+     * Check if the handler is enabled for a certain site (site + user) and a URL.
+     * If not defined, defaults to true.
+     *
+     * @param {string} siteId The site ID.
+     * @param {string} url The URL to treat.
+     * @param {any} params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
+     * @param {number} [courseId] Course ID related to the URL. Optional but recommended.
+     * @return {boolean|Promise<boolean>} Whether the handler is enabled for the URL and site.
+     */
+    isEnabled(siteId: string, url: string, params: any, courseId?: number): boolean | Promise<boolean> {
+        return true;
+    }
+}
diff --git a/src/addon/mod/forum/providers/module-handler.ts b/src/addon/mod/forum/providers/module-handler.ts
new file mode 100644
index 000000000..be911f52d
--- /dev/null
+++ b/src/addon/mod/forum/providers/module-handler.ts
@@ -0,0 +1,81 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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 { NavController, NavOptions } from 'ionic-angular';
+import { AddonModForumIndexComponent } from '../components/index/index';
+import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@core/course/providers/module-delegate';
+import { CoreCourseProvider } from '@core/course/providers/course';
+
+/**
+ * Handler to support forum modules.
+ */
+@Injectable()
+export class AddonModForumModuleHandler implements CoreCourseModuleHandler {
+    name = 'AddonModForum';
+    modName = 'forum';
+
+    constructor(private courseProvider: CoreCourseProvider) { }
+
+    /**
+     * Check if the handler is enabled on a site level.
+     *
+     * @return {boolean} Whether or not the handler is enabled on a site level.
+     */
+    isEnabled(): boolean {
+        return true;
+    }
+
+    /**
+     * Get the data required to display the module in the course contents view.
+     *
+     * @param {any} module The module object.
+     * @param {number} courseId The course ID.
+     * @param {number} sectionId The section ID.
+     * @return {CoreCourseModuleHandlerData} Data to render the module.
+     */
+    getData(module: any, courseId: number, sectionId: number): CoreCourseModuleHandlerData {
+        return {
+            icon: this.courseProvider.getModuleIconSrc('forum'),
+            title: module.name,
+            class: 'addon-mod_forum-handler',
+            showDownloadButton: true,
+            action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void {
+                navCtrl.push('AddonModForumIndexPage', {module: module, courseId: courseId}, options);
+            }
+        };
+    }
+
+    /**
+     * Get the component to render the module. This is needed to support singleactivity course format.
+     * The component returned must implement CoreCourseModuleMainComponent.
+     *
+     * @param {any} course The course object.
+     * @param {any} module The module object.
+     * @return {any} The component to use, undefined if not found.
+     */
+    getMainComponent(course: any, module: any): any {
+        return AddonModForumIndexComponent;
+    }
+
+    /**
+     * Whether to display the course refresher in single activity course format. If it returns false, a refresher must be
+     * included in the template that calls the doRefresh method of the component. Defaults to true.
+     *
+     * @return {boolean} Whether the refresher should be displayed.
+     */
+    displayRefresherInSingleActivity(): boolean {
+        return false;
+    }
+}
diff --git a/src/addon/mod/forum/providers/offline.ts b/src/addon/mod/forum/providers/offline.ts
new file mode 100644
index 000000000..5c024bd42
--- /dev/null
+++ b/src/addon/mod/forum/providers/offline.ts
@@ -0,0 +1,454 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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 { CoreFileProvider } from '@providers/file';
+import { CoreSitesProvider } from '@providers/sites';
+import { CoreTextUtilsProvider } from '@providers/utils/text';
+
+/**
+ * Service to handle offline forum.
+ */
+@Injectable()
+export class AddonModForumOfflineProvider {
+
+    // Variables for database.
+    protected DISCUSSIONS_TABLE = 'addon_mod_forum_discussions';
+    protected REPLIES_TABLE = 'addon_mod_forum_replies';
+
+    protected tablesSchema = [
+        {
+            name: this.DISCUSSIONS_TABLE,
+            columns: [
+                {
+                    name: 'forumid',
+                    type: 'INTEGER',
+                },
+                {
+                    name: 'name',
+                    type: 'TEXT',
+                },
+                {
+                    name: 'courseid',
+                    type: 'INTEGER',
+                },
+                {
+                    name: 'subject',
+                    type: 'TEXT',
+                },
+                {
+                    name: 'message',
+                    type: 'TEXT',
+                },
+                {
+                    name: 'options',
+                    type: 'TEXT',
+                },
+                {
+                    name: 'groupid',
+                    type: 'INTEGER',
+                },
+                {
+                    name: 'userid',
+                    type: 'INTEGER',
+                },
+                {
+                    name: 'timecreated',
+                    type: 'INTEGER',
+                }
+            ],
+            primaryKeys: ['forumid', 'userid', 'timecreated']
+        },
+        {
+            name: this.REPLIES_TABLE,
+            columns: [
+                {
+                    name: 'postid',
+                    type: 'INTEGER',
+                },
+                {
+                    name: 'discussionid',
+                    type: 'INTEGER',
+                },
+                {
+                    name: 'forumid',
+                    type: 'INTEGER',
+                },
+                {
+                    name: 'name',
+                    type: 'TEXT',
+                },
+                {
+                    name: 'courseid',
+                    type: 'INTEGER',
+                },
+                {
+                    name: 'subject',
+                    type: 'TEXT',
+                },
+                {
+                    name: 'message',
+                    type: 'TEXT',
+                },
+                {
+                    name: 'options',
+                    type: 'TEXT',
+                },
+                {
+                    name: 'userid',
+                    type: 'INTEGER',
+                },
+                {
+                    name: 'timecreated',
+                    type: 'INTEGER',
+                }
+            ],
+            primaryKeys: ['postid', 'userid']
+        }
+    ];
+
+    constructor(private fileProvider: CoreFileProvider,
+            private sitesProvider: CoreSitesProvider,
+            private textUtils: CoreTextUtilsProvider) {
+        this.sitesProvider.createTablesFromSchema(this.tablesSchema);
+    }
+
+    /**
+     * Delete a forum offline discussion.
+     *
+     * @param  {number} forumId     Forum ID.
+     * @param  {number} timeCreated The time the discussion was created.
+     * @param  {string} [siteId]    Site ID. If not defined, current site.
+     * @param  {number} [userId]    User the discussion belongs to. If not defined, current user in site.
+     * @return {Promise<any>}       Promise resolved if stored, rejected if failure.
+     */
+    deleteNewDiscussion(forumId: number, timeCreated: number, siteId?: string, userId?: number): Promise<any> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            const conditions = {
+                forumid: forumId,
+                userid: userId || site.getUserId(),
+                timecreated: timeCreated,
+            };
+
+            return site.getDb().deleteRecords(this.DISCUSSIONS_TABLE, conditions);
+        });
+    }
+
+    /**
+     * Get a forum offline discussion.
+     *
+     * @param  {number} forumId     Forum ID.
+     * @param  {number} timeCreated The time the discussion was created.
+     * @param  {string} [siteId]    Site ID. If not defined, current site.
+     * @param  {number} [userId]    User the discussion belongs to. If not defined, current user in site.
+     * @return {Promise<any>}       Promise resolved if stored, rejected if failure.
+     */
+    getNewDiscussion(forumId: number, timeCreated: number, siteId?: string, userId?: number): Promise<any> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            const conditions = {
+                forumid: forumId,
+                userid: userId || site.getUserId(),
+                timecreated: timeCreated,
+            };
+
+            return site.getDb().getRecord(this.DISCUSSIONS_TABLE, conditions).then((record) => {
+                record.options = JSON.parse(record.options);
+
+                return record;
+            });
+        });
+    }
+
+    /**
+     * Get all offline new discussions.
+     *
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @return {Promise<any[]>}  Promise resolved with discussions.
+     */
+    getAllNewDiscussions(siteId?: string): Promise<any[]> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            return site.getDb().getRecords(this.DISCUSSIONS_TABLE).then(this.parseRecordOptions);
+        });
+    }
+
+    /**
+     * Check if there are offline new discussions to send.
+     *
+     * @param  {number} forumId   Forum ID.
+     * @param  {string} [siteId]  Site ID. If not defined, current site.
+     * @param  {number} [userId]  User the discussions belong to. If not defined, current user in site.
+     * @return {Promise<boolean>} Promise resolved with boolean: true if has offline answers, false otherwise.
+     */
+    hasNewDiscussions(forumId: number, siteId?: string, userId?: number): Promise<boolean> {
+        return this.getNewDiscussions(forumId, siteId, userId).then((discussions) => {
+            return !!discussions.length;
+        }).catch(() => {
+            // No offline data found, return false.
+            return false;
+        });
+    }
+
+    /**
+     * Get new discussions to be synced.
+     *
+     * @param  {number} forumId  Forum ID to get.
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @param  {number} [userId] User the discussions belong to. If not defined, current user in site.
+     * @return {Promise<any[]>}  Promise resolved with the object to be synced.
+     */
+    getNewDiscussions(forumId: number, siteId?: string, userId?: number): Promise<any[]> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            const conditions = {
+                forumid: forumId,
+                userid: userId || site.getUserId(),
+            };
+
+            return site.getDb().getRecords(this.DISCUSSIONS_TABLE, conditions).then(this.parseRecordOptions);
+        });
+    }
+
+    /**
+     * Offline version for adding a new discussion to a forum.
+     *
+     * @param  {number} forumId       Forum ID.
+     * @param  {string} name          Forum name.
+     * @param  {number} courseId      Course ID the forum belongs to.
+     * @param  {string} subject       New discussion's subject.
+     * @param  {string} message       New discussion's message.
+     * @param  {any}    [options]     Options (subscribe, pin, ...).
+     * @param  {string} [groupId]     Group this discussion belongs to.
+     * @param  {number} [timeCreated] The time the discussion was created. If not defined, current time.
+     * @param  {string} [siteId]      Site ID. If not defined, current site.
+     * @param  {number} [userId]      User the discussion belong to. If not defined, current user in site.
+     * @return {Promise<any>}         Promise resolved when new discussion is successfully saved.
+     */
+    addNewDiscussion(forumId: number, name: string, courseId: number, subject: string, message: string, options?: any,
+            groupId?: number, timeCreated?: number, siteId?: string, userId?: number): Promise<any> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            const data = {
+                forumid: forumId,
+                name: name,
+                courseid: courseId,
+                subject: subject,
+                message: message,
+                options: JSON.stringify(options || {}),
+                groupid: groupId || -1,
+                userid: userId || site.getUserId(),
+                timecreated: timeCreated || new Date().getTime()
+            };
+
+            return site.getDb().insertRecord(this.DISCUSSIONS_TABLE, data);
+        });
+    }
+
+    /**
+     * Delete forum offline replies.
+     *
+     * @param  {number} postId   ID of the post being replied.
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @param  {number} [userId] User the reply belongs to. If not defined, current user in site.
+     * @return {Promise<any>}    Promise resolved if stored, rejected if failure.
+     */
+    deleteReply(postId: number, siteId?: string, userId?: number): Promise<any> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            const conditions = {
+                postid: postId,
+                userid: userId || site.getUserId(),
+            };
+
+            return site.getDb().deleteRecords(this.REPLIES_TABLE, conditions);
+        });
+    }
+
+    /**
+     * Get all offline replies.
+     *
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @return {Promise<any[]>}  Promise resolved with replies.
+     */
+    getAllReplies(siteId?: string): Promise<any[]> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            return site.getDb().getRecords(this.REPLIES_TABLE).then(this.parseRecordOptions);
+        });
+    }
+
+    /**
+     * Check if there is an offline reply for a forum to be synced.
+     *
+     * @param  {number} forumId   ID of the forum being replied.
+     * @param  {string} [siteId]  Site ID. If not defined, current site.
+     * @param  {number} [userId]  User the replies belong to. If not defined, current user in site.
+     * @return {Promise<boolean>} Promise resolved with boolean: true if has offline answers, false otherwise.
+     */
+    hasForumReplies(forumId: number, siteId?: string, userId?: number): Promise<boolean> {
+        return this.getForumReplies(forumId, siteId, userId).then((replies) => {
+            return !!replies.length;
+        }).catch(() => {
+            // No offline data found, return false.
+            return false;
+        });
+    }
+
+    /**
+     * Get the replies of a forum to be synced.
+     *
+     * @param  {number} forumId  ID of the forum being replied.
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @param  {number} [userId] User the replies belong to. If not defined, current user in site.
+     * @return {Promise<any[]>}  Promise resolved with replies.
+     */
+    getForumReplies(forumId: number, siteId?: string, userId?: number): Promise<any[]> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            const conditions = {
+                forumid: forumId,
+                userid: userId || site.getUserId(),
+            };
+
+            return site.getDb().getRecords(this.REPLIES_TABLE, conditions).then(this.parseRecordOptions);
+        });
+    }
+
+    /**
+     * Check if there is an offline reply to be synced.
+     *
+     * @param  {number} discussionId ID of the discussion the user is replying to.
+     * @param  {string} [siteId]     Site ID. If not defined, current site.
+     * @param  {number} [userId]     User the replies belong to. If not defined, current user in site.
+     * @return {Promise<boolean>}    Promise resolved with boolean: true if has offline answers, false otherwise.
+     */
+    hasDiscussionReplies(discussionId: number, siteId?: string, userId?: number): Promise<boolean> {
+        return this.getDiscussionReplies(discussionId, siteId, userId).then((replies) => {
+            return !!replies.length;
+        }).catch(() => {
+            // No offline data found, return false.
+            return false;
+        });
+    }
+
+    /**
+     * Get the replies of a discussion to be synced.
+     *
+     * @param  {number} discussionId ID of the discussion the user is replying to.
+     * @param  {string} [siteId]     Site ID. If not defined, current site.
+     * @param  {number} [userId]     User the replies belong to. If not defined, current user in site.
+     * @return {Promise<any[]>}      Promise resolved with discussions.
+     */
+    getDiscussionReplies(discussionId: number, siteId?: string, userId?: number): Promise<any[]> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            const conditions = {
+                discussionid: discussionId,
+                userid: userId || site.getUserId(),
+            };
+
+            return site.getDb().getRecords(this.REPLIES_TABLE, conditions).then(this.parseRecordOptions);
+        });
+    }
+
+    /**
+     * Offline version for replying to a certain post.
+     *
+     * @param  {number}  postId       ID of the post being replied.
+     * @param  {number}  discussionId ID of the discussion the user is replying to.
+     * @param  {number}  forumId      ID of the forum the user is replying to.
+     * @param  {string}  name         Forum name.
+     * @param  {number}  courseId     Course ID the forum belongs to.
+     * @param  {string}  subject      New post's subject.
+     * @param  {string}  message      New post's message.
+     * @param  {any}     [options]    Options (subscribe, attachments, ...).
+     * @param  {string}  [siteId]     Site ID. If not defined, current site.
+     * @param  {number}  [userId]     User the post belong to. If not defined, current user in site.
+     * @return {Promise<any>}         Promise resolved when the post is created.
+     */
+    replyPost(postId: number, discussionId: number, forumId: number, name: string, courseId: number, subject: string,
+            message: string, options?: any, siteId?: string, userId?: number): Promise<any> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            const data = {
+                postid: postId,
+                discussionid: discussionId,
+                forumid: forumId,
+                name: name,
+                courseid: courseId,
+                subject: subject,
+                message: message,
+                options: JSON.stringify(options || {}),
+                userid: userId || site.getUserId(),
+                timecreated: new Date().getTime()
+            };
+
+            return site.getDb().insertRecord(this.REPLIES_TABLE, data);
+        });
+    }
+
+    /**
+     * Get the path to the folder where to store files for offline attachments in a forum.
+     *
+     * @param  {number} forumId  Forum ID.
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @return {Promise<string>} Promise resolved with the path.
+     */
+    getForumFolder(forumId: number, siteId?: string): Promise<string> {
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            const siteFolderPath = this.fileProvider.getSiteFolder(site.getId());
+
+            return this.textUtils.concatenatePaths(siteFolderPath, 'offlineforum/' + forumId);
+        });
+    }
+
+    /**
+     * Get the path to the folder where to store files for a new offline discussion.
+     *
+     * @param  {number} forumId     Forum ID.
+     * @param  {number} timeCreated The time the discussion was created.
+     * @param  {string} [siteId]    Site ID. If not defined, current site.
+     * @return {Promise<string>}    Promise resolved with the path.
+     */
+    getNewDiscussionFolder(forumId: number, timeCreated: number, siteId?: string): Promise<string> {
+        return this.getForumFolder(forumId, siteId).then((folderPath) => {
+            return this.textUtils.concatenatePaths(folderPath, 'newdisc_' + timeCreated);
+        });
+    }
+
+    /**
+     * Get the path to the folder where to store files for a new offline reply.
+     *
+     * @param  {number} forumId  Forum ID.
+     * @param  {number} postId   ID of the post being replied.
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @param  {number} [userId] User the replies belong to. If not defined, current user in site.
+     * @return {Promise<string>} Promise resolved with the path.
+     */
+    getReplyFolder(forumId: number, postId: number, siteId?: string, userId?: number): Promise<string> {
+        return this.getForumFolder(forumId, siteId).then((folderPath) => {
+            return this.sitesProvider.getSite(siteId).then((site) => {
+                userId = userId || site.getUserId();
+
+                return this.textUtils.concatenatePaths(folderPath, 'reply_' + postId + '_' + userId);
+            });
+        });
+    }
+
+    /**
+     * Parse "options" column of fetched records.
+     *
+     * @param  {any[]} records List of records.
+     * @return {any[]}         List of records with options parsed.
+     */
+    protected parseRecordOptions(records: any[]): any[] {
+        records.forEach((record) => {
+            record.options = JSON.parse(record.options);
+        });
+
+        return records;
+    }
+}
diff --git a/src/addon/mod/forum/providers/prefetch-handler.ts b/src/addon/mod/forum/providers/prefetch-handler.ts
new file mode 100644
index 000000000..5ac82167b
--- /dev/null
+++ b/src/addon/mod/forum/providers/prefetch-handler.ts
@@ -0,0 +1,264 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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, Injector } from '@angular/core';
+import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate';
+import { CoreCourseModulePrefetchHandlerBase } from '@core/course/classes/module-prefetch-handler';
+import { CoreGroupsProvider } from '@providers/groups';
+import { CoreUserProvider } from '@core/user/providers/user';
+import { AddonModForumProvider } from './forum';
+
+/**
+ * Handler to prefetch forums.
+ */
+@Injectable()
+export class AddonModForumPrefetchHandler extends CoreCourseModulePrefetchHandlerBase {
+    name = 'AddonModForum';
+    modName = 'forum';
+    component = AddonModForumProvider.COMPONENT;
+    updatesNames = /^configuration$|^.*files$|^discussions$/;
+
+    constructor(injector: Injector,
+            private groupsProvider: CoreGroupsProvider,
+            private userProvider: CoreUserProvider,
+            private prefetchDelegate: CoreCourseModulePrefetchDelegate,
+            private forumProvider: AddonModForumProvider) {
+        super(injector);
+    }
+
+    /**
+     * Download the module.
+     *
+     * @param {any} module The module object returned by WS.
+     * @param {number} courseId Course ID.
+     * @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch.
+     * @return {Promise<any>} Promise resolved when all content is downloaded.
+     */
+    download(module: any, courseId: number, dirPath?: string): Promise<any> {
+        // Same implementation for download or prefetch.
+        return this.prefetch(module, courseId, false, dirPath);
+    }
+
+    /**
+     * Get list of files. If not defined, we'll assume they're in module.contents.
+     *
+     * @param {any} module Module.
+     * @param {Number} courseId Course ID the module belongs to.
+     * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section.
+     * @return {Promise<any[]>} Promise resolved with the list of files.
+     */
+    getFiles(module: any, courseId: number, single?: boolean): Promise<any[]> {
+        return this.forumProvider.getForum(courseId, module.id).then((forum) => {
+            const files = this.getIntroFilesFromInstance(module, forum);
+
+            // Get posts.
+            return this.getPostsForPrefetch(forum.id).then((posts) => {
+                // Add posts attachments and embedded files.
+                return files.concat(this.getPostsFiles(posts));
+            });
+        }).catch(() => {
+            // Forum not found, return empty list.
+            return [];
+        });
+    }
+
+    /**
+     * Given a list of forum posts, return a list with all the files (attachments and embedded files).
+     *
+     * @param {any[]} posts Forum posts.
+     * @return {any[]} Files.
+     */
+    protected getPostsFiles(posts: any[]): any[] {
+        let files = [];
+
+        posts.forEach((post) => {
+            if (post.attachments && post.attachments.length) {
+                files = files.concat(post.attachments);
+            }
+            if (post.message) {
+                files = files.concat(this.domUtils.extractDownloadableFilesFromHtmlAsFakeFileObjects(post.message));
+            }
+        });
+
+        return files;
+    }
+
+    /**
+     * Get the posts to be prefetched.
+     *
+     * @param {number} forumId Forum ID
+     * @return {Promise<any[]>} Promise resolved with array of posts.
+     */
+    protected getPostsForPrefetch(forumId: number): Promise<any[]> {
+        // Get discussions in first 2 pages.
+        return this.forumProvider.getDiscussionsInPages(forumId, false, 2).then((response) => {
+            if (response.error) {
+                return Promise.reject(null);
+            }
+
+            const promises = [];
+            let posts = [];
+
+            response.discussions.forEach((discussion) => {
+                promises.push(this.forumProvider.getDiscussionPosts(discussion.discussion).then((ps) => {
+                    posts = posts.concat(ps);
+                }));
+            });
+
+            return Promise.all(promises).then(() => {
+                return posts;
+            });
+        });
+    }
+
+    /**
+     * Invalidate the prefetched content.
+     *
+     * @param {number} moduleId The module ID.
+     * @param {number} courseId The course ID the module belongs to.
+     * @return {Promise<any>} Promise resolved when the data is invalidated.
+     */
+    invalidateContent(moduleId: number, courseId: number): Promise<any> {
+        return this.forumProvider.invalidateContent(moduleId, courseId);
+    }
+
+    /**
+     * Invalidate WS calls needed to determine module status.
+     *
+     * @param {any} module Module.
+     * @param {number} courseId Course ID the module belongs to.
+     * @return {Promise<any>} Promise resolved when invalidated.
+     */
+    invalidateModule(module: any, courseId: number): Promise<any> {
+        if (this.prefetchDelegate.canCheckUpdates()) {
+            // If can check updates only get forum by course is needed.
+            return this.forumProvider.invalidateForumData(courseId);
+        }
+
+        // Get the forum since we need its ID.
+        return this.forumProvider.getForum(courseId, module.id).then((forum) => {
+            return Promise.all([
+                this.forumProvider.invalidateForumData(courseId),
+                this.forumProvider.invalidateDiscussionsList(forum.id),
+            ]);
+        });
+    }
+
+    /**
+     * Prefetch a module.
+     *
+     * @param {any} module Module.
+     * @param {number} courseId Course ID the module belongs to.
+     * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section.
+     * @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch.
+     * @return {Promise<any>} Promise resolved when done.
+     */
+    prefetch(module: any, courseId?: number, single?: boolean, dirPath?: string): Promise<any> {
+        return this.prefetchPackage(module, courseId, single, this.prefetchForum.bind(this));
+    }
+
+    /**
+     * Prefetch a forum.
+     *
+     * @param {any} module The module object returned by WS.
+     * @param {number} courseId Course ID the module belongs to.
+     * @param {boolean} single True if we're downloading a single module, false if we're downloading a whole section.
+     * @param {string} siteId Site ID.
+     * @return {Promise<any>} Promise resolved when done.
+     */
+    protected prefetchForum(module: any, courseId: number, single: boolean, siteId: string): Promise<any> {
+        // Get the forum data.
+        return this.forumProvider.getForum(courseId, module.id).then((forum) => {
+            // Prefetch the posts.
+            return this.getPostsForPrefetch(forum.id).then((posts) => {
+                const promises = [];
+
+                // Prefetch user profiles.
+                const userIds = posts.map((post) => post.userid).filter((userId) => !!userId);
+                promises.push(this.userProvider.prefetchProfiles(userIds).catch(() => {
+                    // Ignore failures.
+                }));
+
+                // Prefetch intro files, attachments and embedded files.
+                const files = this.getIntroFilesFromInstance(module, forum).concat(this.getPostsFiles(posts));
+                promises.push(this.filepoolProvider.addFilesToQueue(siteId, files, this.component, module.id));
+
+                // Prefetch groups data.
+                promises.push(this.prefetchGroupsInfo(forum, courseId, forum.cancreatediscussions));
+
+                return Promise.all(promises);
+            });
+        });
+    }
+
+    /**
+     * Prefetch groups info for a forum.
+     *
+     * @param {any} module The module object returned by WS.
+     * @param {number} courseI Course ID the module belongs to.
+     * @param {boolean} canCreateDiscussions Whether the user can create discussions in the forum.
+     * @return {Promise<any>} Promise resolved when group data has been prefetched.
+     */
+    protected prefetchGroupsInfo(forum: any, courseId: number, canCreateDiscussions: boolean): any {
+        // Check group mode.
+        return this.groupsProvider.getActivityGroupMode(forum.cmid).then((mode) => {
+            if (mode !== CoreGroupsProvider.SEPARATEGROUPS && mode !== CoreGroupsProvider.VISIBLEGROUPS) {
+                // Activity doesn't use groups. Prefetch canAddDiscussionToAll to determine if user can pin/attach.
+                return this.forumProvider.canAddDiscussionToAll(forum.id).catch(() => {
+                        // Ignore errors.
+                });
+            }
+
+            // Activity uses groups, prefetch allowed groups.
+            return this.groupsProvider.getActivityAllowedGroups(forum.cmid).then((groups) => {
+                if (mode === CoreGroupsProvider.SEPARATEGROUPS) {
+                    // Groups are already filtered by WS. Prefetch canAddDiscussionToAll to determine if user can pin/attach.
+                    return this.forumProvider.canAddDiscussionToAll(forum.id).catch(() => {
+                        // Ignore errors.
+                    });
+                }
+
+                if (canCreateDiscussions) {
+                    // Prefetch data to check the visible groups when creating discussions.
+                    return this.forumProvider.canAddDiscussionToAll(forum.id).catch(() => {
+                        // The call failed, let's assume he can't.
+                        return {
+                            status: false
+                        };
+                    }).then((response) => {
+                        if (response.status) {
+                            // User can post to all groups, nothing else to prefetch.
+                            return;
+                        }
+
+                        // The user can't post to all groups, let's check which groups he can post to.
+                        const groupPromises = [];
+                        groups.forEach((group) => {
+                            groupPromises.push(this.forumProvider.canAddDiscussion(forum.id, group.id).catch(() => {
+                                // Ignore errors.
+                            }));
+                        });
+
+                        return Promise.all(groupPromises);
+                    });
+                }
+            });
+        }).catch((error) => {
+            // Ignore errors if cannot create discussions.
+            if (canCreateDiscussions) {
+                return Promise.reject(error);
+            }
+        });
+    }
+}
diff --git a/src/addon/mod/forum/providers/sync-cron-handler.ts b/src/addon/mod/forum/providers/sync-cron-handler.ts
new file mode 100644
index 000000000..3ce2fcbc5
--- /dev/null
+++ b/src/addon/mod/forum/providers/sync-cron-handler.ts
@@ -0,0 +1,47 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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 '@providers/cron';
+import { AddonModForumSyncProvider } from './sync';
+
+/**
+ * Synchronization cron handler.
+ */
+@Injectable()
+export class AddonModForumSyncCronHandler implements CoreCronHandler {
+    name = 'AddonModForumSyncCronHandler';
+
+    constructor(private forumSync: AddonModForumSyncProvider) {}
+
+    /**
+     * Execute the process.
+     * Receives the ID of the site affected, undefined for all sites.
+     *
+     * @param  {string} [siteId] ID of the site affected, undefined for all sites.
+     * @return {Promise<any>}         Promise resolved when done, rejected if failure.
+     */
+    execute(siteId?: string): Promise<any> {
+        return this.forumSync.syncAllForums(siteId);
+    }
+
+    /**
+     * Get the time between consecutive executions.
+     *
+     * @return {number} Time between consecutive executions (in ms).
+     */
+    getInterval(): number {
+        return AddonModForumSyncProvider.SYNC_TIME;
+    }
+}
diff --git a/src/addon/mod/forum/providers/sync.ts b/src/addon/mod/forum/providers/sync.ts
new file mode 100644
index 000000000..0a201a5f1
--- /dev/null
+++ b/src/addon/mod/forum/providers/sync.ts
@@ -0,0 +1,547 @@
+// (C) Copyright 2015 Martin Dougiamas
+//
+// 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 { TranslateService } from '@ngx-translate/core';
+import { CoreSyncBaseProvider } from '@classes/base-sync';
+import { CoreCourseProvider } from '@core/course/providers/course';
+import { CoreFileUploaderProvider } from '@core/fileuploader/providers/fileuploader';
+import { CoreAppProvider } from '@providers/app';
+import { CoreLoggerProvider } from '@providers/logger';
+import { CoreEventsProvider } from '@providers/events';
+import { CoreSitesProvider } from '@providers/sites';
+import { CoreSyncProvider } from '@providers/sync';
+import { CoreTextUtilsProvider } from '@providers/utils/text';
+import { CoreUtilsProvider } from '@providers/utils/utils';
+import { AddonModForumProvider } from './forum';
+import { AddonModForumHelperProvider } from './helper';
+import { AddonModForumOfflineProvider } from './offline';
+
+/**
+ * Service to sync forums.
+ */
+@Injectable()
+export class AddonModForumSyncProvider extends CoreSyncBaseProvider {
+
+    static AUTO_SYNCED = 'addon_mod_forum_autom_synced';
+    static MANUAL_SYNCED = 'addon_mod_forum_manual_synced';
+    static SYNC_TIME = 600000;
+
+    protected componentTranslate: string;
+
+    constructor(translate: TranslateService,
+            appProvider: CoreAppProvider,
+            courseProvider: CoreCourseProvider,
+            private eventsProvider: CoreEventsProvider,
+            loggerProvider: CoreLoggerProvider,
+            sitesProvider: CoreSitesProvider,
+            syncProvider: CoreSyncProvider,
+            textUtils: CoreTextUtilsProvider,
+            private uploaderProvider: CoreFileUploaderProvider,
+            private utils: CoreUtilsProvider,
+            private forumProvider: AddonModForumProvider,
+            private forumHelper: AddonModForumHelperProvider,
+            private forumOffline: AddonModForumOfflineProvider) {
+
+        super('AddonModForumSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate);
+
+        this.componentTranslate = courseProvider.translateModuleName('forum');
+    }
+
+    /**
+     * Try to synchronize all the forums in a certain site or in all sites.
+     *
+     * @param  {string} [siteId] Site ID to sync. If not defined, sync all sites.
+     * @return {Promise<any>}    Promise resolved if sync is successful, rejected if sync fails.
+     */
+    syncAllForums(siteId?: string): Promise<any> {
+        return this.syncOnSites('all forums', this.syncAllForumsFunc.bind(this), [], siteId);
+    }
+
+    /**
+     * Sync all forums on a site.
+     *
+     * @param  {string}       [siteId] Site ID to sync. If not defined, sync all sites.
+     * @return {Promise<any>}          Promise resolved if sync is successful, rejected if sync fails.
+     */
+    protected syncAllForumsFunc(siteId?: string): Promise<any> {
+        const sitePromises = [];
+
+        // Sync all new discussions.
+        sitePromises.push(this.forumOffline.getAllNewDiscussions(siteId).then((discussions) => {
+            const promises = {};
+
+            // Do not sync same forum twice.
+            discussions.forEach((discussion) => {
+                if (typeof promises[discussion.forumid] != 'undefined') {
+                    return;
+                }
+
+                promises[discussion.forumid] = this.syncForumDiscussionsIfNeeded(discussion.forumid, discussion.userid, siteId)
+                        .then((result) => {
+                    if (result && result.updated) {
+                        // Sync successful, send event.
+                        this.eventsProvider.trigger(AddonModForumSyncProvider.AUTO_SYNCED, {
+                            forumId: discussion.forumid,
+                            userId: discussion.userid,
+                            warnings: result.warnings
+                        }, siteId);
+                    }
+                });
+            });
+
+            return Promise.all(this.utils.objectToArray(promises));
+        }));
+
+        // Sync all discussion replies.
+        sitePromises.push(this.forumOffline.getAllReplies(siteId).then((replies) => {
+            const promises = {};
+
+            // Do not sync same discussion twice.
+            replies.forEach((reply) => {
+                if (typeof promises[reply.discussionid] != 'undefined') {
+                    return;
+                }
+
+                promises[reply.discussionid] = this.syncDiscussionRepliesIfNeeded(reply.discussionid, reply.userid, siteId)
+                        .then((result) => {
+                    if (result && result.updated) {
+                        // Sync successful, send event.
+                        this.eventsProvider.trigger(AddonModForumSyncProvider.AUTO_SYNCED, {
+                            forumId: reply.forumid,
+                            discussionId: reply.discussionid,
+                            userId: reply.userid,
+                            warnings: result.warnings
+                        }, siteId);
+                    }
+                });
+            });
+
+            return Promise.all(this.utils.objectToArray(promises));
+        }));
+
+        return Promise.all(sitePromises);
+    }
+
+    /**
+     * Sync a forum only if a certain time has passed since the last time.
+     *
+     * @param  {number} forumId  Forum ID.
+     * @param  {number} userId   User the discussion belong to.
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @return {Promise<any>}    Promise resolved when the forum is synced or if it doesn't need to be synced.
+     */
+    syncForumDiscussionsIfNeeded(forumId: number, userId: number, siteId?: string): Promise<any> {
+        siteId = siteId || this.sitesProvider.getCurrentSiteId();
+
+        const syncId = this.getForumSyncId(forumId, userId);
+
+        return this.isSyncNeeded(syncId, siteId).then((needed) => {
+            if (needed) {
+                return this.syncForumDiscussions(forumId, userId, siteId);
+            }
+        });
+    }
+
+    /**
+     * Synchronize all offline discussions of a forum.
+     *
+     * @param  {number} forumId  Forum ID to be synced.
+     * @param  {number} [userId] User the discussions belong to.
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @return {Promise<any>}    Promise resolved if sync is successful, rejected otherwise.
+     */
+    syncForumDiscussions(forumId: number, userId?: number, siteId?: string): Promise<any> {
+        userId = userId || this.sitesProvider.getCurrentSiteUserId();
+        siteId = siteId || this.sitesProvider.getCurrentSiteId();
+
+        const syncId = this.getForumSyncId(forumId, userId);
+
+        if (this.isSyncing(syncId, siteId)) {
+            // There's already a sync ongoing for this discussion, return the promise.
+            return this.getOngoingSync(syncId, siteId);
+        }
+
+        // Verify that forum isn't blocked.
+        if (this.syncProvider.isBlocked(AddonModForumProvider.COMPONENT, syncId, siteId)) {
+            this.logger.debug('Cannot sync forum ' + forumId + ' because it is blocked.');
+
+            return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate}));
+        }
+
+        this.logger.debug('Try to sync forum ' + forumId + ' for user ' + userId);
+
+        const result = {
+            warnings: [],
+            updated: false
+        };
+
+        // Get offline responses to be sent.
+        const syncPromise = this.forumOffline.getNewDiscussions(forumId, siteId, userId).catch(() => {
+            // No offline data found, return empty object.
+            return [];
+        }).then((discussions) => {
+            if (!discussions.length) {
+                // Nothing to sync.
+                return;
+            } else if (!this.appProvider.isOnline()) {
+                // Cannot sync in offline.
+                return Promise.reject(null);
+            }
+
+            const promises = [];
+
+            discussions.forEach((data) => {
+                data.options = data.options || {};
+
+                // First of all upload the attachments (if any).
+                const promise = this.uploadAttachments(forumId, data, true, siteId, userId).then((itemId) => {
+                    // Now try to add the discussion.
+                    data.options.attachmentsid = itemId;
+
+                    return this.forumProvider.addNewDiscussionOnline(forumId, data.subject, data.message,
+                            data.options, data.groupid, siteId);
+                });
+
+                promises.push(promise.then(() => {
+                    result.updated = true;
+
+                    return this.deleteNewDiscussion(forumId, data.timecreated, siteId, userId);
+                }).catch((error) => {
+                    if (this.utils.isWebServiceError(error)) {
+                        // The WebService has thrown an error, this means that responses cannot be submitted. Delete them.
+                        result.updated = true;
+
+                        return this.deleteNewDiscussion(forumId, data.timecreated, siteId, userId).then(() => {
+                            // Responses deleted, add a warning.
+                            result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', {
+                                component: this.componentTranslate,
+                                name: data.name,
+                                error: error.error
+                            }));
+                        });
+                    } else {
+                        // Couldn't connect to server, reject.
+                        return Promise.reject(error);
+                    }
+                }));
+            });
+
+            return Promise.all(promises);
+        }).then(() => {
+            if (result.updated) {
+                // Data has been sent to server. Now invalidate the WS calls.
+                const promises = [
+                    this.forumProvider.invalidateDiscussionsList(forumId, siteId),
+                    this.forumProvider.invalidateCanAddDiscussion(forumId, siteId),
+                ];
+
+                return Promise.all(promises).catch(() => {
+                    // Ignore errors.
+                });
+            }
+        }).then(() => {
+            // Sync finished, set sync time.
+            return this.setSyncTime(syncId, siteId).catch(() => {
+                // Ignore errors.
+            });
+        }).then(() => {
+            // All done, return the warnings.
+            return result;
+        });
+
+        return this.addOngoingSync(syncId, syncPromise, siteId);
+    }
+
+    /**
+     * Synchronize all offline discussion replies of a forum.
+     *
+     * @param  {number} forumId  Forum ID to be synced.
+     * @param  {number} [userId] User the discussions belong to.
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @return {Promise<any>}    Promise resolved if sync is successful, rejected otherwise.
+     */
+    syncForumReplies(forumId: number, userId?: number, siteId?: string): Promise<any> {
+        // Get offline forum replies to be sent.
+        return this.forumOffline.getForumReplies(forumId, siteId, userId).catch(() => {
+            // No offline data found, return empty list.
+            return [];
+        }).then((replies) => {
+            if (!replies.length) {
+                // Nothing to sync.
+                return { warnings: [], updated: false };
+            } else if (!this.appProvider.isOnline()) {
+                // Cannot sync in offline.
+                return Promise.reject(null);
+            }
+
+            const promises = {};
+
+            // Do not sync same discussion twice.
+            replies.forEach((reply) => {
+                if (typeof promises[reply.discussionid] != 'undefined') {
+                    return;
+                }
+                promises[reply.discussionid] = this.syncDiscussionReplies(reply.discussionid, userId, siteId);
+            });
+
+            return Promise.all(this.utils.objectToArray(promises)).then((results) => {
+                return results.reduce((a, b) => ({
+                        warnings: a.warnings.concat(b.warnings),
+                        updated: a.updated || b.updated,
+                    }), { warnings: [], updated: false });
+            });
+        });
+    }
+
+    /**
+     * Sync a forum discussion replies only if a certain time has passed since the last time.
+     *
+     * @param  {number} discussionId Discussion ID to be synced.
+     * @param  {number} [userId]     User the posts belong to.
+     * @param  {string} [siteId]     Site ID. If not defined, current site.
+     * @return {Promise<any>}        Promise resolved when the forum discussion is synced or if it doesn't need to be synced.
+     */
+    syncDiscussionRepliesIfNeeded(discussionId: number, userId?: number, siteId?: string): Promise<any> {
+        siteId = siteId || this.sitesProvider.getCurrentSiteId();
+
+        const syncId = this.getDiscussionSyncId(discussionId, userId);
+
+        return this.isSyncNeeded(syncId, siteId).then((needed) => {
+            if (needed) {
+                return this.syncDiscussionReplies(discussionId, userId, siteId);
+            }
+        });
+    }
+
+    /**
+     * Synchronize all offline replies from a discussion.
+     *
+     * @param  {number} discussionId Discussion ID to be synced.
+     * @param  {number} [userId]     User the posts belong to.
+     * @param  {string} [siteId]     Site ID. If not defined, current site.
+     * @return {Promise<any>}        Promise resolved if sync is successful, rejected otherwise.
+     */
+    syncDiscussionReplies(discussionId: number, userId?: number, siteId?: string): Promise<any> {
+        userId = userId || this.sitesProvider.getCurrentSiteUserId();
+        siteId = siteId || this.sitesProvider.getCurrentSiteId();
+
+        const syncId = this.getDiscussionSyncId(discussionId, userId);
+
+        if (this.isSyncing(syncId, siteId)) {
+            // There's already a sync ongoing for this discussion, return the promise.
+            return this.getOngoingSync(syncId, siteId);
+        }
+
+        // Verify that forum isn't blocked.
+        if (this.syncProvider.isBlocked(AddonModForumProvider.COMPONENT, syncId, siteId)) {
+            this.logger.debug('Cannot sync forum discussion ' + discussionId + ' because it is blocked.');
+
+            return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate}));
+        }
+
+        this.logger.debug('Try to sync forum discussion ' + discussionId + ' for user ' + userId);
+
+        let forumId;
+        const result = {
+            warnings: [],
+            updated: false
+        };
+
+        // Get offline responses to be sent.
+        const syncPromise = this.forumOffline.getDiscussionReplies(discussionId, siteId, userId).catch(() => {
+            // No offline data found, return empty object.
+            return [];
+        }).then((replies) => {
+            if (!replies.length) {
+                // Nothing to sync.
+                return;
+            } else if (!this.appProvider.isOnline()) {
+                // Cannot sync in offline.
+                return Promise.reject(null);
+            }
+
+            const promises = [];
+
+            replies.forEach((data) => {
+                forumId = data.forumid;
+                data.options = data.options || {};
+
+                // First of all upload the attachments (if any).
+                const promise = this.uploadAttachments(forumId, data, false, siteId, userId).then((itemId) => {
+                    // Now try to send the reply.
+                    data.options.attachmentsid = itemId;
+
+                    return this.forumProvider.replyPostOnline(data.postid, data.subject, data.message, data.options, siteId);
+                });
+
+                promises.push(promise.then(() => {
+                    result.updated = true;
+
+                    return this.deleteReply(forumId, data.postid, siteId, userId);
+                }).catch((error) => {
+                    if (this.utils.isWebServiceError(error)) {
+                        // The WebService has thrown an error, this means that responses cannot be submitted. Delete them.
+                        result.updated = true;
+
+                        return this.deleteReply(forumId, data.postid, siteId, userId).then(() => {
+                            // Responses deleted, add a warning.
+                            result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', {
+                                component: this.componentTranslate,
+                                name: data.name,
+                                error: error.error
+                            }));
+                        });
+                    } else {
+                        // Couldn't connect to server, reject.
+                        return Promise.reject(error);
+                    }
+                }));
+            });
+
+            return Promise.all(promises);
+        }).then(() => {
+            // Data has been sent to server. Now invalidate the WS calls.
+            const promises = [];
+            if (forumId) {
+                promises.push(this.forumProvider.invalidateDiscussionsList(forumId, siteId));
+            }
+            promises.push(this.forumProvider.invalidateDiscussionPosts(discussionId, siteId));
+
+            return this.utils.allPromises(promises).catch(() => {
+                // Ignore errors.
+            });
+        }).then(() => {
+            // Sync finished, set sync time.
+            return this.setSyncTime(syncId, siteId).catch(() => {
+                // Ignore errors.
+            });
+        }).then(() => {
+            // All done, return the warnings.
+            return result;
+        });
+
+        return this.addOngoingSync(syncId, syncPromise, siteId);
+    }
+
+    /**
+     * Delete a new discussion.
+     *
+     * @param  {number} forumId     Forum ID the discussion belongs to.
+     * @param  {number} timecreated The timecreated of the discussion.
+     * @param  {string} [siteId]    Site ID. If not defined, current site.
+     * @param  {number} [userId]    User the discussion belongs to. If not defined, current user in site.
+     * @return {Promise<any>}       Promise resolved when deleted.
+     */
+    protected deleteNewDiscussion(forumId: number, timecreated: number, siteId?: string, userId?: number): Promise<any> {
+        const promises = [];
+
+        promises.push(this.forumOffline.deleteNewDiscussion(forumId, timecreated, siteId, userId));
+        promises.push(this.forumHelper.deleteNewDiscussionStoredFiles(forumId, timecreated, siteId).catch(() => {
+            // Ignore errors, maybe there are no files.
+        }));
+
+        return Promise.all(promises);
+    }
+
+    /**
+     * Delete a new discussion.
+     *
+     * @param  {number} forumId  Forum ID the discussion belongs to.
+     * @param  {number} postId   ID of the post being replied.
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @param  {number} [userId] User the discussion belongs to. If not defined, current user in site.
+     * @return {Promise<any>}    Promise resolved when deleted.
+     */
+    protected deleteReply(forumId: number, postId: number, siteId?: string, userId?: number): Promise<any> {
+        const promises = [];
+
+        promises.push(this.forumOffline.deleteReply(postId, siteId, userId));
+        promises.push(this.forumHelper.deleteReplyStoredFiles(forumId, postId, siteId, userId).catch(() => {
+            // Ignore errors, maybe there are no files.
+        }));
+
+        return Promise.all(promises);
+    }
+
+    /**
+     * Upload attachments of an offline post/discussion.
+     *
+     * @param  {number}  forumId  Forum ID the post belongs to.
+     * @param  {any}     post     Offline post or discussion.
+     * @param  {boolean} isDisc   True if it's a new discussion, false if it's a reply.
+     * @param  {string}  [siteId] Site ID. If not defined, current site.
+     * @param  {number}  [userId] User the reply belongs to. If not defined, current user in site.
+     * @return {Promise<any>}     Promise resolved with draftid if uploaded, resolved with undefined if nothing to upload.
+     */
+    protected uploadAttachments(forumId: number, post: any, isDisc: boolean, siteId?: string, userId?: number): Promise<any> {
+        const attachments = post && post.options && post.options.attachmentsid;
+
+        if (attachments) {
+            // Has some attachments to sync.
+            let files = attachments.online || [];
+            let promise;
+
+            if (attachments.offline) {
+                // Has offline files.
+                if (isDisc) {
+                    promise = this.forumHelper.getNewDiscussionStoredFiles(forumId, post.timecreated, siteId);
+                } else {
+                    promise = this.forumHelper.getReplyStoredFiles(forumId, post.postid, siteId, userId);
+                }
+
+                promise.then((atts) => {
+                    files = files.concat(atts);
+                }).catch(() => {
+                    // Folder not found, no files to add.
+                });
+            } else {
+                promise = Promise.resolve();
+            }
+
+            return promise.then(() => {
+                return this.uploaderProvider.uploadOrReuploadFiles(files, AddonModForumProvider.COMPONENT, forumId, siteId);
+            });
+        }
+
+        // No attachments, resolve.
+        return Promise.resolve();
+    }
+
+    /**
+     * Get the ID of a forum sync.
+     *
+     * @param  {number} forumId  Forum ID.
+     * @param  {number} [userId] User the responses belong to.. If not defined, current user.
+     * @return {string}          Sync ID.
+     */
+    getForumSyncId(forumId: number, userId?: number): string {
+        userId = userId || this.sitesProvider.getCurrentSiteUserId();
+
+        return 'forum#' + forumId + '#' + userId;
+    }
+
+    /**
+     * Get the ID of a discussion sync.
+     *
+     * @param  {number} discussionId Discussion ID.
+     * @param  {number} [userId]     User the responses belong to.. If not defined, current user.
+     * @return {string}              Sync ID.
+     */
+    getDiscussionSyncId(discussionId: number, userId?: number): string {
+        userId = userId || this.sitesProvider.getCurrentSiteUserId();
+
+        return 'discussion#' + discussionId + '#' + userId;
+    }
+}
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index 3f85cb6bf..cec2dc5da 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -84,6 +84,7 @@ import { AddonModLabelModule } from '@addon/mod/label/label.module';
 import { AddonModResourceModule } from '@addon/mod/resource/resource.module';
 import { AddonModFeedbackModule } from '@addon/mod/feedback/feedback.module';
 import { AddonModFolderModule } from '@addon/mod/folder/folder.module';
+import { AddonModForumModule } from '@addon/mod/forum/forum.module';
 import { AddonModPageModule } from '@addon/mod/page/page.module';
 import { AddonModQuizModule } from '@addon/mod/quiz/quiz.module';
 import { AddonModScormModule } from '@addon/mod/scorm/scorm.module';
@@ -184,6 +185,7 @@ export const CORE_PROVIDERS: any[] = [
         AddonModResourceModule,
         AddonModFeedbackModule,
         AddonModFolderModule,
+        AddonModForumModule,
         AddonModPageModule,
         AddonModQuizModule,
         AddonModScormModule,
diff --git a/src/providers/sync.ts b/src/providers/sync.ts
index 9137e997f..510fa7be5 100644
--- a/src/providers/sync.ts
+++ b/src/providers/sync.ts
@@ -65,11 +65,11 @@ export class CoreSyncProvider {
      * Block a component and ID so it cannot be synchronized.
      *
      * @param {string} component Component name.
-     * @param {number} id Unique ID per component.
+     * @param {string | number} id Unique ID per component.
      * @param {string} [operation] Operation name. If not defined, a default text is used.
      * @param {string} [siteId] Site ID. If not defined, current site.
      */
-    blockOperation(component: string, id: number, operation?: string, siteId?: string): void {
+    blockOperation(component: string, id: string | number, operation?: string, siteId?: string): void {
         siteId = siteId || this.sitesProvider.getCurrentSiteId();
 
         const uniqueId = this.getUniqueSyncBlockId(component, id);
@@ -104,10 +104,10 @@ export class CoreSyncProvider {
      * Clear all blocks for a certain component.
      *
      * @param {string} component Component name.
-     * @param {number} id Unique ID per component.
+     * @param {string | number} id Unique ID per component.
      * @param {string} [siteId] Site ID. If not defined, current site.
      */
-    clearBlocks(component: string, id: number, siteId?: string): void {
+    clearBlocks(component: string, id: string | number, siteId?: string): void {
         siteId = siteId || this.sitesProvider.getCurrentSiteId();
 
         const uniqueId = this.getUniqueSyncBlockId(component, id);
@@ -150,10 +150,10 @@ export class CoreSyncProvider {
      * Convenience function to create unique identifiers for a component and id.
      *
      * @param {string} component Component name.
-     * @param {number} id Unique ID per component.
+     * @param {string | number} id Unique ID per component.
      * @return {string} Unique sync id.
      */
-    protected getUniqueSyncBlockId(component: string, id: number): string {
+    protected getUniqueSyncBlockId(component: string, id: string | number): string {
         return component + '#' + id;
     }
 
@@ -162,11 +162,11 @@ export class CoreSyncProvider {
      * One block can have different operations. Here we check how many operations are being blocking the object.
      *
      * @param {string} component Component name.
-     * @param {number} id Unique ID per component.
+     * @param {string | number} id Unique ID per component.
      * @param {string} [siteId] Site ID. If not defined, current site.
      * @return {boolean} Whether it's blocked.
      */
-    isBlocked(component: string, id: number, siteId?: string): boolean {
+    isBlocked(component: string, id: string | number, siteId?: string): boolean {
         siteId = siteId || this.sitesProvider.getCurrentSiteId();
 
         if (!this.blockedItems[siteId]) {
@@ -185,11 +185,11 @@ export class CoreSyncProvider {
      * Unblock an operation on a component and ID.
      *
      * @param {string} component Component name.
-     * @param {number} id Unique ID per component.
+     * @param {string | number} id Unique ID per component.
      * @param {string} [operation] Operation name. If not defined, a default text is used.
      * @param {string} [siteId] Site ID. If not defined, current site.
      */
-    unblockOperation(component: string, id: number, operation?: string, siteId?: string): void {
+    unblockOperation(component: string, id: string | number, operation?: string, siteId?: string): void {
         operation = operation || '-';
         siteId = siteId || this.sitesProvider.getCurrentSiteId();
 

From 7126bdf4742875e1aa3c8018581a61e6fc01f5f5 Mon Sep 17 00:00:00 2001
From: Albert Gasset <albertgasset@fsfe.org>
Date: Wed, 16 May 2018 16:10:14 +0200
Subject: [PATCH 3/3] MOBILE-2341 forum: PR fixes

---
 .../mod/forum/components/index/index.html     |  8 +--
 src/addon/mod/forum/components/index/index.ts | 39 ++++++++---
 src/addon/mod/forum/components/post/post.ts   |  8 ++-
 src/addon/mod/forum/lang/en.json              |  4 +-
 .../mod/forum/pages/discussion/discussion.ts  |  9 +--
 src/addon/mod/forum/providers/forum.ts        | 69 ++++++++++---------
 src/addon/mod/forum/providers/offline.ts      | 14 ++--
 .../mod/forum/providers/prefetch-handler.ts   | 24 -------
 .../singleactivity/providers/handler.ts       |  2 +-
 9 files changed, 93 insertions(+), 84 deletions(-)

diff --git a/src/addon/mod/forum/components/index/index.html b/src/addon/mod/forum/components/index/index.html
index 33b90c5d6..f7eb42ee1 100644
--- a/src/addon/mod/forum/components/index/index.html
+++ b/src/addon/mod/forum/components/index/index.html
@@ -27,7 +27,7 @@
             <ng-container *ngIf="forum && discussions.length > 0">
                 <div padding-horizontal margin-vertical *ngIf="forum.cancreatediscussions">
                     <button ion-button block (click)="openNewDiscussion()">
-                        {{ 'addon.mod_forum.addanewdiscussion' | translate }}
+                        {{addDiscussionText}}
                     </button>
                 </div>
                 <ion-card *ngFor="let discussion of offlineDiscussions" (click)="openNewDiscussion(discussion.timecreated)" [class.addon-forum-discussion-selected]="discussion.timecreated == -selectedDiscussion">
@@ -51,7 +51,7 @@
                 <ion-card *ngFor="let discussion of discussions" (click)="openDiscussion(discussion)" [class.addon-forum-discussion-selected]="discussion.discussion == selectedDiscussion">
                     <ion-item text-wrap>
                         <ion-avatar item-start core-user-link [userId]="discussion.userid" [courseId]="courseId">
-                            <img [src]="discussion.userpictureurl" onError="this.src='assets/img/user-avatar.png'" core-external-content [alt]="'core.pictureof' | translate:{$a: discussion.userfullname}">
+                            <img [src]="discussion.userpictureurl" onError="this.src='assets/img/user-avatar.png'" core-external-content [alt]="'core.pictureof' | translate:{$a: discussion.userfullname}" role="presentation">
                         </ion-avatar>
                         <h2><ion-icon name="pin" *ngIf="discussion.pinned"></ion-icon> {{discussion.subject}}</h2>
                         <p>
@@ -87,13 +87,13 @@
 
             <core-empty-box *ngIf="forum && discussions.length == 0" icon="chatbubbles" [message]="'addon.mod_forum.forumnodiscussionsyet' | translate">
                 <div padding *ngIf="forum.cancreatediscussions">
-                    <button ion-button block (click)="addNewDiscussion()">
+                    <button ion-button block (click)="openNewDiscussion()">
                         {{ 'addon.mod_forum.addanewdiscussion' | translate }}
                     </button>
                 </div>
             </core-empty-box>
 
-            <ion-infinite-scroll [enabled]="canLoadMore" (ionInfinite)="$event.waitFor(fetchContent())" position="top">
+            <ion-infinite-scroll [enabled]="canLoadMore" (ionInfinite)="$event.waitFor(fetchMoreDiscussions())">
                 <ion-infinite-scroll-content></ion-infinite-scroll-content>
             </ion-infinite-scroll>
         </core-loading>
diff --git a/src/addon/mod/forum/components/index/index.ts b/src/addon/mod/forum/components/index/index.ts
index 6ff264e86..caae5c8d7 100644
--- a/src/addon/mod/forum/components/index/index.ts
+++ b/src/addon/mod/forum/components/index/index.ts
@@ -40,16 +40,16 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
 
     descriptionNote: string;
     forum: any;
-    trackPosts = false;
-    usesGroups = false;
     canLoadMore = false;
     discussions = [];
     offlineDiscussions = [];
-    count = 0;
     selectedDiscussion = 0; // Disucssion ID or negative timecreated if it's an offline discussion.
+    addDiscussionText = this.translate.instant('addon.mod_forum.addanewdiscussion');
 
     protected syncEventName = AddonModForumSyncProvider.AUTO_SYNCED;
     protected page = 0;
+    protected trackPosts = false;
+    protected usesGroups = false;
     protected syncManualObserver: any; // It will observe the sync manual event.
     protected replyObserver: any;
     protected newDiscObserver: any;
@@ -133,6 +133,18 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
 
             this.dataRetrieved.emit(forum);
 
+            switch (forum.type) {
+                case 'news':
+                case 'blog':
+                    this.addDiscussionText = this.translate.instant('addon.mod_forum.addanewtopic');
+                    break;
+                case 'qanda':
+                    this.addDiscussionText = this.translate.instant('addon.mod_forum.addanewquestion');
+                    break;
+                default:
+                    this.addDiscussionText = this.translate.instant('addon.mod_forum.addanewdiscussion');
+            }
+
             if (sync) {
                 // Try to synchronize the forum.
                 return this.syncActivity(showErrors).then((updated) => {
@@ -141,6 +153,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
                         this.eventsProvider.trigger(AddonModForumSyncProvider.MANUAL_SYNCED, {
                             forumId: forum.id,
                             userId: this.sitesProvider.getCurrentSiteUserId(),
+                            source: 'index',
                         }, this.sitesProvider.getCurrentSiteId());
                     }
                 });
@@ -164,8 +177,6 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
             this.domUtils.showErrorModalDefault(message, 'addon.mod_forum.errorgetforum', true);
 
             this.canLoadMore = false; // Set to false to prevent infinite calls with infinite-loading.
-
-            return Promise.reject(null);
         });
     }
 
@@ -282,11 +293,19 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
                     return Promise.all(offlinePromises);
                 });
             });
-        }).catch((message) => {
-            this.domUtils.showErrorModal(message);
-            this.canLoadMore = false; // Set to false to prevent infinite calls with infinite-loading.
+        });
+    }
 
-            return Promise.reject(null);
+    /**
+     * Convenience function to load more forum discussions.
+     *
+     * @return {Promise<any>} Promise resolved when done.
+     */
+    protected fetchMoreDiscussions(): Promise<any> {
+        return this.fetchDiscussions(false).catch((message) => {
+            this.domUtils.showErrorModalDefault(message, 'addon.mod_forum.errorgetforum', true);
+
+            this.canLoadMore = false; // Set to false to prevent infinite calls with infinite-loading.
         });
     }
 
@@ -357,7 +376,7 @@ export class AddonModForumIndexComponent extends CoreCourseModuleMainActivityCom
      * @return {boolean} True if refresh is needed, false otherwise.
      */
     protected isRefreshSyncNeeded(syncEventData: any): boolean {
-        return this.forum && syncEventData.forumId == this.forum.id &&
+        return this.forum && syncEventData.source != 'index' && syncEventData.forumId == this.forum.id &&
             syncEventData.userId == this.sitesProvider.getCurrentSiteUserId();
     }
 
diff --git a/src/addon/mod/forum/components/post/post.ts b/src/addon/mod/forum/components/post/post.ts
index 04dda2b06..528b10974 100644
--- a/src/addon/mod/forum/components/post/post.ts
+++ b/src/addon/mod/forum/components/post/post.ts
@@ -87,7 +87,13 @@ export class AddonModForumPostComponent implements OnInit, OnDestroy {
     }
 
     /**
-     * Set data to new post, clearing tmp files and updating original data.
+     * Set data to new post, clearing temporary files and updating original data.
+     *
+     * @param {number} [replyingTo] Id of post beeing replied.
+     * @param {boolean} [isEditing] True it's an offline reply beeing edited, false otherwise.
+     * @param {string} [subject] Subject of the reply.
+     * @param {string} [message] Message of the reply.
+     * @param {any[]} [files] Reply attachments.
      */
     protected setReplyData(replyingTo?: number, isEditing?: boolean, subject?: string, message?: string, files?: any[]): void {
         // Delete the local files from the tmp folder if any.
diff --git a/src/addon/mod/forum/lang/en.json b/src/addon/mod/forum/lang/en.json
index 0b3ffb881..5a461dcfd 100644
--- a/src/addon/mod/forum/lang/en.json
+++ b/src/addon/mod/forum/lang/en.json
@@ -1,5 +1,7 @@
 {
     "addanewdiscussion": "Add a new discussion topic",
+    "addanewquestion": "Add a new question",
+    "addanewtopic": "Add a new topic",
     "cannotadddiscussion": "Adding discussions to this forum requires group membership.",
     "cannotadddiscussionall": "You do not have permission to add a new discussion topic for all participants.",
     "cannotcreatediscussion": "Could not create new discussion",
@@ -29,4 +31,4 @@
     "subject": "Subject",
     "unread": "Unread",
     "unreadpostsnumber": "{{$a}} unread posts"
-}
\ No newline at end of file
+}
diff --git a/src/addon/mod/forum/pages/discussion/discussion.ts b/src/addon/mod/forum/pages/discussion/discussion.ts
index 95f37419d..ae1dab47b 100644
--- a/src/addon/mod/forum/pages/discussion/discussion.ts
+++ b/src/addon/mod/forum/pages/discussion/discussion.ts
@@ -42,8 +42,6 @@ export class AddonModForumDiscussionPage implements OnDestroy {
     @ViewChild(Content) content: Content;
 
     courseId: number;
-    cmId: number;
-    forumId: number;
     discussionId: number;
     forum: any;
     discussion: any;
@@ -71,6 +69,8 @@ export class AddonModForumDiscussionPage implements OnDestroy {
     refreshIcon = 'spinner';
     syncIcon = 'spinner';
 
+    protected cmId: number;
+    protected forumId: number;
     protected onlineObserver: any;
     protected syncObserver: any;
     protected syncManualObserver: any;
@@ -126,7 +126,8 @@ export class AddonModForumDiscussionPage implements OnDestroy {
 
         // Refresh data if this forum discussion is synchronized from discussions list.
         this.syncManualObserver = this.eventsProvider.on(AddonModForumSyncProvider.MANUAL_SYNCED, (data) => {
-            if (data.forumId == this.forumId && data.userId == this.sitesProvider.getCurrentSiteUserId()) {
+            if (data.source != 'discussion' && data.forumId == this.forumId &&
+                    data.userId == this.sitesProvider.getCurrentSiteUserId()) {
                 // Refresh the data.
                 this.discussionLoaded = false;
                 this.refreshPosts();
@@ -310,7 +311,7 @@ export class AddonModForumDiscussionPage implements OnDestroy {
                 this.eventsProvider.trigger(AddonModForumSyncProvider.MANUAL_SYNCED, {
                     forumId: this.forumId,
                     userId: this.sitesProvider.getCurrentSiteUserId(),
-                    warnings: result.warnings
+                    source: 'discussion'
                 }, this.sitesProvider.getCurrentSiteId());
             }
 
diff --git a/src/addon/mod/forum/providers/forum.ts b/src/addon/mod/forum/providers/forum.ts
index 4b41b2b21..37f36b139 100644
--- a/src/addon/mod/forum/providers/forum.ts
+++ b/src/addon/mod/forum/providers/forum.ts
@@ -190,14 +190,15 @@ export class AddonModForumProvider {
     /**
      * Check if a user can post to a certain group.
      *
-     * @param  {number} forumId Forum ID.
-     * @param  {number} groupId Group ID.
-     * @return {Promise<any>}   Promise resolved with an object with the following properties:
-     *                           - status (boolean)
-     *                           - canpindiscussions (boolean)
-     *                           - cancreateattachment (boolean)
+     * @param  {number} forumId  Forum ID.
+     * @param  {number} groupId  Group ID.
+     * @param  {string} [siteId] Site ID. If not defined, current site.
+     * @return {Promise<any>}    Promise resolved with an object with the following properties:
+     *                            - status (boolean)
+     *                            - canpindiscussions (boolean)
+     *                            - cancreateattachment (boolean)
      */
-    canAddDiscussion(forumId: number, groupId: number): Promise<any> {
+    canAddDiscussion(forumId: number, groupId: number, siteId?: string): Promise<any> {
         const params = {
             forumid: forumId,
             groupid: groupId
@@ -206,21 +207,23 @@ export class AddonModForumProvider {
             cacheKey: this.getCanAddDiscussionCacheKey(forumId, groupId)
         };
 
-        return this.sitesProvider.getCurrentSite().read('mod_forum_can_add_discussion', params, preSets).then((result) => {
-            if (result) {
-                if (typeof result.canpindiscussions == 'undefined') {
-                    // WS doesn't support it yet, default it to false to prevent students from seing the option.
-                    result.canpindiscussions = false;
-                }
-                if (typeof result.cancreateattachment == 'undefined') {
-                    // WS doesn't support it yet, default it to true since usually the users will be able to create them.
-                    result.cancreateattachment = true;
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            return site.read('mod_forum_can_add_discussion', params, preSets).then((result) => {
+                if (result) {
+                    if (typeof result.canpindiscussions == 'undefined') {
+                        // WS doesn't support it yet, default it to false to prevent students from seing the option.
+                        result.canpindiscussions = false;
+                    }
+                    if (typeof result.cancreateattachment == 'undefined') {
+                        // WS doesn't support it yet, default it to true since usually the users will be able to create them.
+                        result.cancreateattachment = true;
+                    }
+
+                    return result;
                 }
 
-                return result;
-            }
-
-            return Promise.reject(null);
+                return Promise.reject(null);
+            });
         });
     }
 
@@ -361,26 +364,28 @@ export class AddonModForumProvider {
     /**
      * Get forum discussion posts.
      *
-     * @param  {number} discussionid Discussion ID.
+     * @param  {number} discussionId Discussion ID.
+     * @param  {string} [siteId]     Site ID. If not defined, current site.
      * @return {Promise<any[]>}      Promise resolved with forum posts.
      */
-    getDiscussionPosts(discussionid: number): Promise<any> {
-        const site = this.sitesProvider.getCurrentSite();
+    getDiscussionPosts(discussionId: number, siteId?: string): Promise<any> {
         const params = {
-            discussionid: discussionid
+            discussionid: discussionId
         };
         const preSets = {
-            cacheKey: this.getDiscussionPostsCacheKey(discussionid)
+            cacheKey: this.getDiscussionPostsCacheKey(discussionId)
         };
 
-        return site.read('mod_forum_get_forum_discussion_posts', params, preSets).then((response) => {
-            if (response) {
-                this.storeUserData(response.posts);
+        return this.sitesProvider.getSite(siteId).then((site) => {
+            return site.read('mod_forum_get_forum_discussion_posts', params, preSets).then((response) => {
+                if (response) {
+                    this.storeUserData(response.posts);
 
-                return response.posts;
-            } else {
-                return Promise.reject(null);
-            }
+                    return response.posts;
+                } else {
+                    return Promise.reject(null);
+                }
+            });
         });
     }
 
diff --git a/src/addon/mod/forum/providers/offline.ts b/src/addon/mod/forum/providers/offline.ts
index 5c024bd42..ac3a44261 100644
--- a/src/addon/mod/forum/providers/offline.ts
+++ b/src/addon/mod/forum/providers/offline.ts
@@ -163,7 +163,7 @@ export class AddonModForumOfflineProvider {
             };
 
             return site.getDb().getRecord(this.DISCUSSIONS_TABLE, conditions).then((record) => {
-                record.options = JSON.parse(record.options);
+                record.options = this.textUtils.parseJSON(record.options);
 
                 return record;
             });
@@ -178,7 +178,7 @@ export class AddonModForumOfflineProvider {
      */
     getAllNewDiscussions(siteId?: string): Promise<any[]> {
         return this.sitesProvider.getSite(siteId).then((site) => {
-            return site.getDb().getRecords(this.DISCUSSIONS_TABLE).then(this.parseRecordOptions);
+            return site.getDb().getRecords(this.DISCUSSIONS_TABLE).then(this.parseRecordOptions.bind(this));
         });
     }
 
@@ -214,7 +214,7 @@ export class AddonModForumOfflineProvider {
                 userid: userId || site.getUserId(),
             };
 
-            return site.getDb().getRecords(this.DISCUSSIONS_TABLE, conditions).then(this.parseRecordOptions);
+            return site.getDb().getRecords(this.DISCUSSIONS_TABLE, conditions).then(this.parseRecordOptions.bind(this));
         });
     }
 
@@ -279,7 +279,7 @@ export class AddonModForumOfflineProvider {
      */
     getAllReplies(siteId?: string): Promise<any[]> {
         return this.sitesProvider.getSite(siteId).then((site) => {
-            return site.getDb().getRecords(this.REPLIES_TABLE).then(this.parseRecordOptions);
+            return site.getDb().getRecords(this.REPLIES_TABLE).then(this.parseRecordOptions.bind(this));
         });
     }
 
@@ -315,7 +315,7 @@ export class AddonModForumOfflineProvider {
                 userid: userId || site.getUserId(),
             };
 
-            return site.getDb().getRecords(this.REPLIES_TABLE, conditions).then(this.parseRecordOptions);
+            return site.getDb().getRecords(this.REPLIES_TABLE, conditions).then(this.parseRecordOptions.bind(this));
         });
     }
 
@@ -351,7 +351,7 @@ export class AddonModForumOfflineProvider {
                 userid: userId || site.getUserId(),
             };
 
-            return site.getDb().getRecords(this.REPLIES_TABLE, conditions).then(this.parseRecordOptions);
+            return site.getDb().getRecords(this.REPLIES_TABLE, conditions).then(this.parseRecordOptions.bind(this));
         });
     }
 
@@ -446,7 +446,7 @@ export class AddonModForumOfflineProvider {
      */
     protected parseRecordOptions(records: any[]): any[] {
         records.forEach((record) => {
-            record.options = JSON.parse(record.options);
+            record.options = this.textUtils.parseJSON(record.options);
         });
 
         return records;
diff --git a/src/addon/mod/forum/providers/prefetch-handler.ts b/src/addon/mod/forum/providers/prefetch-handler.ts
index 5ac82167b..89da20694 100644
--- a/src/addon/mod/forum/providers/prefetch-handler.ts
+++ b/src/addon/mod/forum/providers/prefetch-handler.ts
@@ -13,7 +13,6 @@
 // limitations under the License.
 
 import { Injectable, Injector } from '@angular/core';
-import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate';
 import { CoreCourseModulePrefetchHandlerBase } from '@core/course/classes/module-prefetch-handler';
 import { CoreGroupsProvider } from '@providers/groups';
 import { CoreUserProvider } from '@core/user/providers/user';
@@ -32,7 +31,6 @@ export class AddonModForumPrefetchHandler extends CoreCourseModulePrefetchHandle
     constructor(injector: Injector,
             private groupsProvider: CoreGroupsProvider,
             private userProvider: CoreUserProvider,
-            private prefetchDelegate: CoreCourseModulePrefetchDelegate,
             private forumProvider: AddonModForumProvider) {
         super(injector);
     }
@@ -133,28 +131,6 @@ export class AddonModForumPrefetchHandler extends CoreCourseModulePrefetchHandle
         return this.forumProvider.invalidateContent(moduleId, courseId);
     }
 
-    /**
-     * Invalidate WS calls needed to determine module status.
-     *
-     * @param {any} module Module.
-     * @param {number} courseId Course ID the module belongs to.
-     * @return {Promise<any>} Promise resolved when invalidated.
-     */
-    invalidateModule(module: any, courseId: number): Promise<any> {
-        if (this.prefetchDelegate.canCheckUpdates()) {
-            // If can check updates only get forum by course is needed.
-            return this.forumProvider.invalidateForumData(courseId);
-        }
-
-        // Get the forum since we need its ID.
-        return this.forumProvider.getForum(courseId, module.id).then((forum) => {
-            return Promise.all([
-                this.forumProvider.invalidateForumData(courseId),
-                this.forumProvider.invalidateDiscussionsList(forum.id),
-            ]);
-        });
-    }
-
     /**
      * Prefetch a module.
      *
diff --git a/src/core/course/formats/singleactivity/providers/handler.ts b/src/core/course/formats/singleactivity/providers/handler.ts
index 31a967606..977ada030 100644
--- a/src/core/course/formats/singleactivity/providers/handler.ts
+++ b/src/core/course/formats/singleactivity/providers/handler.ts
@@ -93,7 +93,7 @@ export class CoreCourseFormatSingleActivityHandler implements CoreCourseFormatHa
      * @return {boolean} Whether the refresher should be displayed.
      */
     displayRefresher(course: any, sections: any[]): boolean {
-        if (sections && sections[0] && sections[0].modules) {
+        if (sections && sections[0] && sections[0].modules && sections[0].modules[0]) {
             return this.moduleDelegate.displayRefresherInSingleActivity(sections[0].modules[0].modname);
         } else {
             return true;