#61 Response caching for Magazine, Social, Calendar and Ask
Merged 5 years ago by a2batic. Opened 5 years ago by amitosh.
amitosh/Fedora-app offline  into  master

file modified
+32 -64
@@ -3673,38 +3673,6 @@ 

        "requires": {

          "falafel": "1.2.0",

          "through2": "0.6.5"

-       },

-       "dependencies": {

-         "isarray": {

-           "version": "0.0.1",

-           "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",

-           "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="

-         },

-         "readable-stream": {

-           "version": "1.0.34",

-           "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz",

-           "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=",

-           "requires": {

-             "core-util-is": "1.0.2",

-             "inherits": "2.0.3",

-             "isarray": "0.0.1",

-             "string_decoder": "0.10.31"

-           }

-         },

-         "string_decoder": {

-           "version": "0.10.31",

-           "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",

-           "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="

-         },

-         "through2": {

-           "version": "0.6.5",

-           "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz",

-           "integrity": "sha1-QaucZ7KdVyCQcUEOHXp6lozTrUg=",

-           "requires": {

-             "readable-stream": "1.0.34",

-             "xtend": "4.0.1"

-           }

-         }

        }

      },

      "interpret": {
@@ -5801,6 +5769,38 @@ 

        "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",

        "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU="

      },

+     "through2": {

+       "version": "0.6.5",

+       "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz",

+       "integrity": "sha1-QaucZ7KdVyCQcUEOHXp6lozTrUg=",

+       "requires": {

+         "readable-stream": "1.0.34",

+         "xtend": "4.0.1"

+       },

+       "dependencies": {

+         "isarray": {

+           "version": "0.0.1",

+           "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",

+           "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="

+         },

+         "readable-stream": {

+           "version": "1.0.34",

+           "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz",

+           "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=",

+           "requires": {

+             "core-util-is": "1.0.2",

+             "inherits": "2.0.3",

+             "isarray": "0.0.1",

+             "string_decoder": "0.10.31"

+           }

+         },

+         "string_decoder": {

+           "version": "0.10.31",

+           "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",

+           "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="

+         }

+       }

+     },

      "timers-browserify": {

        "version": "2.0.6",

        "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.6.tgz",
@@ -6078,38 +6078,6 @@ 

          "esmangle-evaluator": "1.0.1",

          "recast": "0.10.43",

          "through2": "0.6.5"

-       },

-       "dependencies": {

-         "isarray": {

-           "version": "0.0.1",

-           "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",

-           "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="

-         },

-         "readable-stream": {

-           "version": "1.0.34",

-           "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz",

-           "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=",

-           "requires": {

-             "core-util-is": "1.0.2",

-             "inherits": "2.0.3",

-             "isarray": "0.0.1",

-             "string_decoder": "0.10.31"

-           }

-         },

-         "string_decoder": {

-           "version": "0.10.31",

-           "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",

-           "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="

-         },

-         "through2": {

-           "version": "0.6.5",

-           "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz",

-           "integrity": "sha1-QaucZ7KdVyCQcUEOHXp6lozTrUg=",

-           "requires": {

-             "readable-stream": "1.0.34",

-             "xtend": "4.0.1"

-           }

-         }

        }

      },

      "url": {

file modified
+1 -1
@@ -38,7 +38,7 @@ 

      "@ionic-native/splash-screen": "4.4.0",

      "@ionic-native/status-bar": "4.4.0",

      "@ionic-native/toast": "^4.5.3",

-     "@ionic/storage": "2.1.3",

+     "@ionic/storage": "^2.1.3",

      "cordova-android": "7.0.0",

      "cordova-browser": "5.0.3",

      "cordova-plugin-calendar": "^4.5.5",

file modified
+3 -1
@@ -1,6 +1,7 @@ 

  import { BrowserModule } from '@angular/platform-browser';

  import { ErrorHandler, NgModule } from '@angular/core';

  import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';

+ import { IonicStorageModule } from '@ionic/storage';

  import { HttpClientModule } from '@angular/common/http';

  import { InAppBrowser } from '@ionic-native/in-app-browser';

  import { SplashScreen } from '@ionic-native/splash-screen';
@@ -32,7 +33,8 @@ 

    imports: [

      BrowserModule,

      HttpClientModule,

-     IonicModule.forRoot(App)

+     IonicModule.forRoot(App),

+     IonicStorageModule.forRoot()

    ],

    bootstrap: [IonicApp],

    entryComponents: [

file modified
+1 -1
@@ -36,7 +36,7 @@ 

      this.askFedora

        .getQuestions()

        .subscribe(questions => {

-         this.questions = questions;

+         this.questions = questions || [];

        });

    }

  

@@ -15,8 +15,8 @@ 

    <ion-list>

      <ion-item>

  

-       <ion-select [(ngModel)]="selectedCalendar" (ngModelChange)="updateMeetings()">

-         <ion-option *ngFor="let calendar of calendars" [value]="calendar.realName" [selected]="calendar.realName == selectedCalendar">

+       <ion-select [(ngModel)]="selectedCalendar" [compareWith]="compareCalendar" (ngModelChange)="updateMeetings()">

+         <ion-option *ngFor="let calendar of calendars" [value]="calendar" [selected]="calendar.realName == selectedCalendar.realName">

            {{ calendar.displayName }}

          </ion-option>

        </ion-select>

@@ -7,7 +7,7 @@ 

  /**

   * We default to the QA calendar

   */

- const DEFAULT_CALENDAR = 'QA';

+ const DEFAULT_CALENDAR = { realName:'QA', displayName: 'QA' };

  

  /**

   * The FedoCal interface
@@ -34,13 +34,13 @@ 

    /**

     * ID of the selected calendar

     */

-   private selectedCalendar: string;

+   private selectedCalendar: CalendarType;

  

    constructor(private fedoCal: FedoCalService, private calendar: Calendar) {

      this.calendars = [];

      this.meetings = [];

  

-     this.selectedCalendar = DEFAULT_CALENDAR;

+     this.selectedCalendar = DEFAULT_CALENDAR as CalendarType;

    }

  

    ngOnInit() {
@@ -84,4 +84,15 @@ 

        meeting.timeEnd

      );

    }

+ 

+   /**

+    * Compares two calendars for similarity

+    *

+    * @param a Calendar

+    * @param b Calendar

+    * @returns boolean value indicating if both calendars are equal

+    */

+   compareCalendar(a:CalendarType, b:CalendarType) {

+     return a.realName === b.realName;

+   }

  }

@@ -35,7 +35,7 @@ 

  <img id="maglogo" src="assets/img/mag_logo.svg">

      <img id="mag-title" src="assets/img/mag_title.svg">

  

-   <ion-list *ngIf="posts.length !== 0">

+   <ion-list>

      <ion-card *ngFor="let post of posts">

        <!--<ion-row id="featured-image">

        <img src="https://fedoramagazine.org/wp-json/wp/v2/media/{{post.id}}" />

@@ -20,7 +20,6 @@ 

  

    constructor(private browser: Browser,

      private fedoraMag: FedoraMagazineService, private socialSharing: SocialSharing) {

-     this.posts = [];

    }

  

    ngOnInit() {

file modified
+2 -2
@@ -5,7 +5,7 @@ 

  import { FacebookProvider } from '../../providers/social/facebook';

  import { TwitterProvider } from '../../providers/social/twitter';

  import { Post } from '../../providers/social/social';

- import { forkJoin } from 'rxjs/observable/forkJoin';

+ import { combineLatest } from 'rxjs/observable/combineLatest';

  

  const HANDLE = {

    FB: 'fedoraqa',
@@ -43,7 +43,7 @@ 

     * Currently, we fetch posts from Facebook and Twitter

     */

    private updatePosts(): void {

-     forkJoin(this.fb.getPosts(HANDLE.FB), this.twitter.getPosts(HANDLE.TWITTER))

+     combineLatest(this.fb.getPosts(HANDLE.FB), this.twitter.getPosts(HANDLE.TWITTER))

        .subscribe(values => {

          this.posts = [...values[0], ...values[1]] as Post[];

        });

@@ -1,8 +1,12 @@ 

- import 'rxjs/add/operator/map';

  import { Injectable } from '@angular/core';

  import { HttpClient } from '@angular/common/http';

+ import { Storage } from '@ionic/storage';

  import { chooseEndpoint } from '../../utils';

  import { Observable } from 'rxjs/Observable';

+ import { fromPromise } from 'rxjs/observable/fromPromise';

+ import { merge } from 'rxjs/observable/merge';

+ import { map, tap } from 'rxjs/operators';

+ import { defaultValue } from '../../utils';

  

  /**

   * Ask Fedora API endpoint
@@ -12,6 +16,12 @@ 

  const ENDPOINT = chooseEndpoint('/ask-fedora', 'https://ask.fedoraproject.org/en/api/v1');

  

  /**

+  * The key used for storing offline content

+  */

+ const STORAGE_KEY = 'fedora-ask__questions';

+ 

+ 

+ /**

   * A question on Ask Fedora

   */

  export interface Question {
@@ -63,7 +73,6 @@ 

    score: number,

  }

  

- 

  /**

   * Service for Ask Fedora API

   *
@@ -71,7 +80,17 @@ 

   */

  @Injectable()

  export class AskFedoraService {

-   constructor(private http: HttpClient) {

+ 

+   constructor(private http: HttpClient, private storage: Storage) {

+   }

+ 

+   /**

+    * Fetch a list of questions on Ask Fedora from offline cache

+    *

+    * @returns Observable which emits an array of questions

+    */

+   private loadCachedQuestions(): Observable<Question[]> {

+     return fromPromise(this.storage.get(STORAGE_KEY)).pipe(defaultValue([]));

    }

  

    /**
@@ -79,18 +98,33 @@ 

     *

     * @returns Observable which emits an array of questions

     */

-   getQuestions(): Observable<Question[]> {

+   fetchQuestions(): Observable<Question[]> {

      return this.http.get(`${ENDPOINT}/questions/`)

-       .map((data: any) => (data.questions as any[]).map(q => ({

-         id: q.id,

-         title: q.title,

-         link: q.url,

-         answerCount: q.answer_count,

-         content: q.summary,

-         addedAt: new Date(parseInt(q.added_at, 10)),

-         tags: q.tags,

-         viewCount: q.view_count,

-         score: q.score,

-       })));

+       .pipe(

+         map((data: any) => (data.questions as any[]).map(q => ({

+           id: q.id,

+           title: q.title,

+           link: q.url,

+           answerCount: q.answer_count,

+           content: q.summary,

+           addedAt: new Date(parseInt(q.added_at, 10)),

+           tags: q.tags,

+           viewCount: q.view_count,

+           score: q.score,

+         })))

+       );

+   }

+ 

+   /**

+    * Fetch a list of questions on Ask Fedora

+    * Loads from offline cache first and then from HTTP. The response of the HTTP

+    * request is persisted into the disk cache for further requests.

+    *

+    * @returns Observable which emits an array of questions

+    */

+   getQuestions(): Observable<Question[]> {

+     return merge(this.loadCachedQuestions(), this.fetchQuestions().pipe(

+       tap(x => this.storage.set(STORAGE_KEY, x)))

+     );

    }

  }

@@ -1,12 +1,15 @@ 

  import { Injectable } from '@angular/core';

- import 'rxjs/add/operator/map';

  

  import * as _ from 'lodash';

  import * as moment from 'moment-timezone';

  

  import { HttpClient } from '@angular/common/http';

+ import { Storage } from '@ionic/storage';

  import { Observable } from 'rxjs/Observable';

- import { chooseEndpoint } from '../../utils';

+ import { fromPromise } from 'rxjs/observable/fromPromise';

+ import { merge } from 'rxjs/observable/merge';

+ import { tap, map } from 'rxjs/operators';

+ import { chooseEndpoint, defaultValue } from '../../utils';

  

  /**

   * FedoCal API endpoint
@@ -16,6 +19,11 @@ 

  const ENDPOINT = chooseEndpoint('/fedocal', 'https://apps.fedoraproject.org/calendar/api');

  

  /**

+  * The key used for storing offline content

+  */

+ const CALENDAR_STORAGE_KEY = 'fedocal__calendars';

+ 

+ /**

   * Date format for display

   */

  const DATE_FORMAT = 'dddd, MMMM Do YYYY';
@@ -45,6 +53,29 @@ 

  }

  

  /**

+  * Get the key for offline storage which stores the meetings for the given Calendar

+  *

+  * @param calendar Calendar

+  * @returns key to use while accessing offline content

+  */

+ function getCalendarStorageKey(calendar: Calendar): string {

+   return `fedocal__meetings-${calendar.realName}`;

+ }

+ 

+ /**

+  * Convert a date consisting of date, time and timezone as different strings to

+  * a single momentjs date

+  *

+  * @param date     Date string

+  * @param time     Time string

+  * @param timezone Timezone identifier

+  */

+ function dateToMoment(date: string, time: string, timezone: string) {

+   const m = moment.tz(`${date} ${time}`, timezone).tz('UTC');

+   return m;

+ }

+ 

+ /**

   * A calendar on FedoCal

   *

   * Calendars are assoicated with a group / SIG / or event. They contain a number
@@ -144,7 +175,17 @@ 

   */

  @Injectable()

  export class FedoCalService {

-   constructor(private http: HttpClient) {

+ 

+   constructor(private http: HttpClient, private storage: Storage) {

+   }

+ 

+   /**

+    * Fetch the list of calendars on FedoCal from offline cache

+    *

+    * @returns Observable which emits an array of posts

+    */

+   private loadCachedCalendars(): Observable<Calendar[]> {

+     return fromPromise(this.storage.get(CALENDAR_STORAGE_KEY)).pipe(defaultValue([]));

    }

  

    /**
@@ -152,63 +193,94 @@ 

     *

     * @returns Observable which emits an array of calendars

     */

-   getCalendars(): Observable<Calendar[]> {

+   fetchCalendars(): Observable<Calendar[]> {

      return this.http.get(`${ENDPOINT}/calendars/`)

-       .map((data: any) =>

-         data.calendars.map((c: any) => ({

-           realName: c.calendar_name,

-           displayName: calendarNameToDisplayName(c.calendar_name),

-           description: c.calendar_description,

-           contact: c.calendar_contact,

-           adminGroup: c.calendar_admin_group,

-           editorGroup: c.calendar_editor_group,

-           enabled: c.calendar_status === 'Enabled'

-         })));

+       .pipe(

+         map((data: any) =>

+           data.calendars.map((c: any) => ({

+             realName: c.calendar_name,

+             displayName: calendarNameToDisplayName(c.calendar_name),

+             description: c.calendar_description,

+             contact: c.calendar_contact,

+             adminGroup: c.calendar_admin_group,

+             editorGroup: c.calendar_editor_group,

+             enabled: c.calendar_status === 'Enabled'

+           })))

+       );

    }

  

    /**

-    * Fetch the list of meetings for a given FedoCal calendar name

+    * Fetch the list of calendars from FedoCal

+    *

+    * Loads from offline cache first and then from API. The response of the API

+    * request is persisted into the disk cache for further requests.

+    *

+    * @returns Observable which emits an array of calendars

+    */

+   getCalendars(): Observable<Calendar[]> {

+     return merge(this.loadCachedCalendars(), this.fetchCalendars().pipe(

+       tap(x => this.storage.set(CALENDAR_STORAGE_KEY, x))

+     ));

+   }

+ 

+   /**

+    * Fetch the list of meetings for a given FedoCal calendar name from offline

+    * cache

     *

     * @param calendar FedoCal calendar name

     * @returns Observable which emits an array of meetings

     */

-   getMeetings(calendar:string): Observable<Meeting[]> {

- 

-     return this.http.get(`${ENDPOINT}/meetings/`, { params: { calendar: calendar } })

-       .map((data: any) => data.meetings.map(m => {

-         const mTime = dateToMoment(m.meeting_date, m.meeting_time_start, m.meeting_timezone);

-         const mTimeEnd = dateToMoment(m.meeting_date_end, m.meeting_time_stop, m.meeting_timezone);

- 

-         return {

-           name: m.meeting_name,

-           description: m.meeting_information,

-           time: mTime.toDate(),

-           timeEnd: mTimeEnd.toDate(),

-           displayTime: {

-             // Format momentjs object to the defined format for display

-             dateString: mTime.format(DATE_FORMAT),

-             timeString: mTime.format(TIME_FORMAT)

-           },

-           displayTimeEnd: {

-             dateString: mTimeEnd.format(DATE_FORMAT),

-             timeString: mTimeEnd.format(TIME_FORMAT),

-           },

-           location: m.meeting_location,

-         };

-       }));

+   private loadCachedMeetings(calendar: Calendar): Observable<Meeting[]> {

+     return fromPromise(this.storage.get(getCalendarStorageKey(calendar))).pipe(defaultValue([]));

    }

- }

  

- /**

-  * Convert a date consisting of date, time and timezone as different strings to

-  * a single momentjs date

-  *

-  * @param date     Date string

-  * @param time     Time string

-  * @param timezone Timezone identifier

-  */

- function dateToMoment(date:string, time:string, timezone:string) {

-   const m = moment.tz(`${date} ${time}`, timezone).tz('UTC');

-   return m;

+   /**

+    * Fetch the list of meetings for a given FedoCal calendar name from FedoCal API

+    *

+    * @param calendar FedoCal calendar name

+    * @returns Observable which emits an array of meetings

+    */

+   fetchMeetings(calendar: Calendar): Observable<Meeting[]> {

+ 

+     return this.http.get(`${ENDPOINT}/meetings/`, { params: { calendar: calendar.realName } })

+       .pipe(

+         map((data: any) => data.meetings.map(m => {

+           const mTime = dateToMoment(m.meeting_date, m.meeting_time_start, m.meeting_timezone);

+           const mTimeEnd = dateToMoment(m.meeting_date_end, m.meeting_time_stop, m.meeting_timezone);

+ 

+           return {

+             name: m.meeting_name,

+             description: m.meeting_information,

+             time: mTime.toDate(),

+             timeEnd: mTimeEnd.toDate(),

+             displayTime: {

+               // Format momentjs object to the defined format for display

+               dateString: mTime.format(DATE_FORMAT),

+               timeString: mTime.format(TIME_FORMAT)

+             },

+             displayTimeEnd: {

+               dateString: mTimeEnd.format(DATE_FORMAT),

+               timeString: mTimeEnd.format(TIME_FORMAT),

+             },

+             location: m.meeting_location,

+           };

+         }))

+       );

+   }

+ 

+   /**

+    * Fetch the list of meetings for a given FedoCal calendar name

+    *

+    * Loads from offline cache first and then from API. The response of the API

+    * request is persisted into the disk cache for further requests.

+    *

+    * @param calendar FedoCal calendar name

+    * @returns Observable which emits an array of meetings

+    */

+   getMeetings(calendar: Calendar): Observable<Meeting[]> {

+     return merge(this.loadCachedMeetings(calendar), this.fetchMeetings(calendar).pipe(

+       tap(x => this.storage.set(getCalendarStorageKey(calendar), x))

+     ));

+   }

  }

  

@@ -1,12 +1,26 @@ 

- import 'rxjs/add/operator/map';

+ import { Storage } from '@ionic/storage';

  import { Injectable } from '@angular/core';

  import { HttpClient } from '@angular/common/http';

  import { Observable } from 'rxjs/Observable';

- import { chooseEndpoint } from '../../utils';

+ import { map, tap } from 'rxjs/operators';

+ import { fromPromise } from 'rxjs/observable/fromPromise';

+ import { merge } from 'rxjs/observable/merge';

+ import { chooseEndpoint, defaultValue } from '../../utils';

  

+ /**

+  * Endpoint for this service.

+  *

+  * Since Fedora Magazine API does not support CORS, we need to use a proxy when

+  * running on browser platforms.

+  */

  const ENDPOINT = chooseEndpoint('/fedora-magazine', 'https://fedoramagazine.org/wp-json/wp/v2');

  

  /**

+  * The key used for storing offline content

+  */

+ const STORAGE_KEY = 'fedora-magazine__posts';

+ 

+ /**

   * Represents a post on Fedora Magazine

   */

  export interface Post {
@@ -23,7 +37,7 @@ 

    /**

     * Permalink to the post

     */

-   permalink:string,

+   permalink: string,

  

    /**

     * Post title
@@ -56,25 +70,51 @@ 

   */

  @Injectable()

  export class FedoraMagazineService {

-   constructor(private http: HttpClient) {

+ 

+   constructor(private http: HttpClient, private storage: Storage) {

    }

  

    /**

-    * Fetch the list of latest posts on Fedora Magazine

+    * Fetch the list of posts on Fedora Magazine from offline cache

     *

     * @returns Observable which emits an array of posts

     */

-   getPosts(): Observable<Post[]> {

+   private loadCachedPosts() {

+     return fromPromise(this.storage.get(STORAGE_KEY)).pipe(defaultValue([]));

+   }

+ 

+   /**

+    * Fetch the list of latest posts on Fedora Magazine from Fedora Magazine API

+    *

+    * @returns Observable which emits an array of posts

+    */

+   fetchPosts(): Observable<Post[]> {

      return this.http.get(`${ENDPOINT}/posts`)

-       .map((data: any[]) => data.map((post: any) => ({

-         id: post.id,

-         link: post.link,

-         permalink: post.guid.rendered,

-         title: post.title.rendered,

-         image: post.featured_media,

-         excerpt: post.excerpt.rendered,

-         content: post.content.rendered,

-         publishedAt: new Date(post.date_gmt+'Z'),

-       })));

+       .pipe(

+         map((data: any[]) => data.map((post: any) => ({

+           id: post.id,

+           link: post.link,

+           permalink: post.guid.rendered,

+           title: post.title.rendered,

+           image: post.featured_media,

+           excerpt: post.excerpt.rendered,

+           content: post.content.rendered,

+           publishedAt: new Date(post.date_gmt + 'Z'),

+         })))

+       );

+   }

+ 

+   /**

+    * Fetch the list of posts on Fedora Magazine

+    *

+    * Loads from offline cache first and then from HTTP. The response of the HTTP

+    * request is persisted into the disk cache for further requests.

+    *

+    * @returns Observable which emits an array of posts

+    */

+   getPosts() {

+     return merge(this.loadCachedPosts(), this.fetchPosts().pipe(

+       tap(x => this.storage.set(STORAGE_KEY, x)))

+     );

    }

  }

@@ -1,11 +1,30 @@ 

  import { Injectable } from '@angular/core';

- import 'rxjs/add/operator/map';

  import ENV from '@environment';

+ import { Storage } from '@ionic/storage';

  import { Facebook } from 'fb';

  import { compact } from 'lodash-es';

  import { SocialProvider, Post, FACEBOOK } from './social';

  import { Observable } from 'rxjs/Observable';

  import { Observer } from 'rxjs/Observer';

+ import { fromPromise } from 'rxjs/observable/fromPromise';

+ import { merge } from 'rxjs/observable/merge';

+ import { tap, map } from 'rxjs/operators';

+ import { defaultValue } from '../../utils';

+ 

+ /**

+  * The prefix of the key used for storing offline content

+  */

+ const STORAGE_KEY_PREFIX = 'fedora-social__facebook';

+ 

+ /**

+  * Get the key for offline storage which stores the posts for the given page

+  *

+  * @param page facebook page ID

+  * @returns key to use while accessing offline content

+  */

+ function getStorageKey(page: string): string {

+   return `${STORAGE_KEY_PREFIX}-${page}`;

+ }

  

  /**

   * Service for Facebook API
@@ -19,18 +38,29 @@ 

     * Facebook API instance

     */

    private fb: Facebook;

-   constructor() {

+ 

+   constructor(private storage: Storage) {

      this.fb = new Facebook(ENV.FB_CONFIG);

    }

  

    /**

+    * Fetch the list of posts for a given Facebook page from the offline cache

+    *

+    * @param page ID of the Facebook page

+    * @returns Observable which emits an array of Facebook posts

+    */

+   private loadCachedPosts(page: string): Observable<Post[]> {

+     return fromPromise(this.storage.get(getStorageKey(page))).pipe(defaultValue([]));

+   }

+ 

+   /**

     * Perform a call to a Facebook API using the library

     *

     * Converts callback-style error handling to observables.

     *

     * @param uri Facebook API URI

     */

-   private api(uri:string) {

+   private api(uri: string) {

      return Observable.create((emitter: Observer<any>) => {

        this.fb.api(uri, res => {

          if (!res || res.error) {
@@ -50,21 +80,36 @@ 

     * @param args Extra args for pagination etc.

     * @returns Observable which emits an array of Facebook posts

     */

-   public getPosts(page: string, args?): Observable<Post[]> {

+   fetchPosts(page: string, args?): Observable<Post[]> {

      return this.api(`${page}/posts`)

-       .map(res => {

-         const posts = compact<Post>(res.data.map(p => {

-           const post = {

-             id: p.id,

-             link: 'https://facebook.com/' + p.id,

-             content: p.message,

-             origin: FACEBOOK,

-           };

+       .pipe(

+         map((res: any) => {

+           const posts = compact<Post>(res.data.map(p => {

+             const post = {

+               id: p.id,

+               link: 'https://facebook.com/' + p.id,

+               content: p.message,

+               origin: FACEBOOK,

+             };

  

-           return post.content ? post : null;

-         }));

+             return post.content ? post : null;

+           }));

  

-         return posts;

-       });

+           return posts;

+         })

+       );

+   }

+ 

+   /**

+    * Fetch the list of posts for a given Facebook page

+    *

+    * This returns a list of cached values followed by values from the API

+    * @param page ID of the Facebook page

+    * @returns Observable which emits an array of Facebook posts

+    */

+   public getPosts(page: string): Observable<Post[]> {

+     return merge(this.loadCachedPosts(page), this.fetchPosts(page).pipe(

+       tap(x => this.storage.set(getStorageKey(page), x))

+     ));

    }

  }

@@ -43,9 +43,22 @@ 

    /**

     * Fetch the list of posts for a given service specific resource ID

     *

-    * @param resID service specific resource IS

+    * This may return a cached response

+    *

+    * @param resID service specific resource ID

+    * @returns Observable which emits an array of posts

+    */

+   getPosts(resID: string): Observable<Post[]>

+ 

+   /**

+    * Fetch the list of posts for a given service specific resource ID, usually

+    * from an API call

+    *

+    * This always returns a value from the underlying API

+    *

+    * @param resID service specific resource ID

     * @param args Extra args for pagination etc.

     * @returns Observable which emits an array of posts

     */

-   getPosts(resID: string, args?:{ offset?: string }): Observable<Post[]>

+   fetchPosts(resID: string, args?:{ offset?: string }): Observable<Post[]>

  }

file modified
+63 -13
@@ -1,39 +1,89 @@ 

- import 'rxjs/add/operator/map';

- import 'rxjs/add/operator/do';

+ import ENV from '@environment';

  import { Injectable } from '@angular/core';

  import { SocialProvider, Post } from './social';

  import { HttpClient } from '@angular/common/http';

- import { chooseEndpoint } from '../../utils';

+ import { Storage } from '@ionic/storage';

  import { Observable } from 'rxjs/Observable';

- import ENV from '@environment';

+ import { map, tap } from 'rxjs/operators';

+ import { fromPromise } from 'rxjs/observable/fromPromise';

+ import { merge } from 'rxjs/observable/merge';

+ import { chooseEndpoint, defaultValue } from '../../utils';

  

+ /**

+  * Endpoint for this service.

+  *

+  * Since Twitter API does not support CORS, we need to use a proxy when running

+  * on browser platforms.

+  */

  const ENDPOINT = chooseEndpoint('/twitter', 'https://api.twitter.com/1.1');

  

  /**

+  * The prefix of the key used for storing offline content

+  */

+ const STORAGE_KEY_PREFIX = 'fedora-social__twitter';

+ 

+ /**

+  * Get the key for offline storage which stores the tweets for the given handle

+  *

+  * @param handle twitter handle

+  * @returns key to use while accessing offline content

+  */

+ function getStorageKey(handle: string): string {

+   return `${STORAGE_KEY_PREFIX}-${handle}`;

+ }

+ 

+ /**

   * Service for Twitter API

   */

  @Injectable()

  export class TwitterProvider implements SocialProvider {

-   constructor(private http: HttpClient) {

+ 

+   constructor(private http: HttpClient, private storage: Storage) {

    }

  

    /**

-    * Fetch the list of tweets for a given Twitter handle

+    * Fetch tweets for a handle from offline cache

+    *

+    * @param handle twitter handle

+    * @returns Observable which emits an array of tweets

+    */

+   private loadCachedPosts(handle: string): Observable<Post[]> {

+     return fromPromise(this.storage.get(getStorageKey(handle))).pipe(defaultValue([]));

+   }

+ 

+   /**

+    * Fetch the list of tweets for a given Twitter handle from Twitter API

     *

     * @param handle Twitter handle whose tweets to load

     * @param args   Extra args for pagination etc.

     * @returns Observable which emits an array of tweets

     */

-   public getPosts(handle: string, args?): Observable<Post[]> {

+   fetchPosts(handle: string, args?): Observable<Post[]> {

      return this.http.get(`${ENDPOINT}/statuses/user_timeline.json`, {

        params: { screen_name: handle },

        headers: { 'Authorization': 'Bearer ' + ENV.TWITTER_CONFIG.BEARER_TOKEN }

-     }).map((tweetsResponse: any) => tweetsResponse.map(t => ({

-       id: t.id,

-       link: 'https://twitter.com/statuses/' + t.id_str,

-       content: t.text,

-       origin: 'twitter',

-     })));

+     }).pipe(

+       map((tweetsResponse: any) => tweetsResponse.map(t => ({

+         id: t.id,

+         link: 'https://twitter.com/statuses/' + t.id_str,

+         content: t.text,

+         origin: 'twitter',

+       })))

+     );

+   }

+ 

+   /**

+    * Fetch the list of tweets for a given Twitter handle

+    *

+    * This returns a list of cached values followed by values from the API

+    *

+    * @param handle Twitter handle whose tweets to load

+    * @returns Observable which emits an array of tweets

+    */

+   public getPosts(handle: string): Observable<Post[]> {

+     return merge(this.loadCachedPosts(handle), this.fetchPosts(handle).pipe(

+       tap(x => this.storage.set(getStorageKey(handle), x))

+     ));

    }

  }

  

file modified
+10
@@ -1,3 +1,5 @@ 

+ import { map } from 'rxjs/operators';

+ import { OperatorFunction } from 'rxjs/interfaces';

  

  /**

   * Choose appropriate API end point or the proxy to avoid CORS in Ionic dev server
@@ -9,3 +11,11 @@ 

    // courtesy: https://forum.ionicframework.com/t/check-if-run-on-emulator-dev-production-or-livereload/71845/9

    return window.hasOwnProperty('IonicDevServer') ? devServerEndpoint : appEndpoint;

  }

+ 

+ /**

+  * Substitutes a falsy value in a RxJS stream with a default value

+  * @param value the default value

+  */

+ export function defaultValue<T>(value:T): OperatorFunction<T, T> {

+   return map<T,T>((x:T) => x ? x : value );

+ }