From eb4bc00f223107660706b3711b47dfbf65b456a4 Mon Sep 17 00:00:00 2001 From: James Daniels Date: Wed, 13 Nov 2019 15:09:47 -0800 Subject: [PATCH] First pass on the new RC API and the start of the AngularFireLazy effort * Add all, numbers, strings, and booleans Observables to AngularFireRemoteConfig * Proxy all of firebase.remoteConfig() in AngularFireRemoteConfig dealing with lazy loading of the SDK * Same effort with AngularFireAnalytics --- src/analytics/analytics.ts | 34 +++++++--- src/core/angularfire2.ts | 33 ++++++++++ src/remote-config/remote-config.ts | 99 +++++++++++++++++++++--------- 3 files changed, 128 insertions(+), 38 deletions(-) diff --git a/src/analytics/analytics.ts b/src/analytics/analytics.ts index 112f96c6a..77d8c0431 100644 --- a/src/analytics/analytics.ts +++ b/src/analytics/analytics.ts @@ -1,9 +1,9 @@ import { Injectable, Inject, Optional, NgZone, InjectionToken } from '@angular/core'; import { Observable, from } from 'rxjs'; -import { map, tap, filter, withLatestFrom } from 'rxjs/operators'; -import { FirebaseAppConfig, FirebaseOptions, runOutsideAngular } from '@angular/fire'; +import { map, tap, filter, withLatestFrom, shareReplay } from 'rxjs/operators'; import { Router, NavigationEnd, ActivationEnd } from '@angular/router'; -import { FirebaseAnalytics, FIREBASE_OPTIONS, FIREBASE_APP_NAME, _firebaseAppFactory } from '@angular/fire'; +import { FirebaseAppConfig, FirebaseOptions, runOutsideAngular, _lazySDKProxy, FirebaseAnalytics, FIREBASE_OPTIONS, FIREBASE_APP_NAME, _firebaseAppFactory } from '@angular/fire'; +import { analytics, app } from 'firebase'; export const AUTOMATICALLY_SET_CURRENT_SCREEN = new InjectionToken('angularfire2.analytics.setCurrentScreen'); export const AUTOMATICALLY_LOG_SCREEN_VIEWS = new InjectionToken('angularfire2.analytics.logScreenViews'); @@ -15,13 +15,28 @@ export const APP_NAME = new InjectionToken('angularfire2.analytics.appNa export const DEFAULT_APP_VERSION = '?'; export const DEFAULT_APP_NAME = 'Angular App'; +// SEMVER: once we move to Typescript 3.6 use `PromiseProxy` +type AnalyticsProxy = { + // TODO can we pull the richer types from the Firebase SDK .d.ts? ReturnType is infering + // I could even do this in a manual build-step + logEvent(eventName: string, eventParams?: {[key: string]: any}, options?: analytics.AnalyticsCallOptions): Promise, + setCurrentScreen(screenName: string, options?: analytics.AnalyticsCallOptions): Promise, + setUserId(id: string, options?: analytics.AnalyticsCallOptions): Promise, + setUserProperties(properties: analytics.CustomParams, options?: analytics.AnalyticsCallOptions): Promise, + setAnalyticsCollectionEnabled(enabled: boolean): Promise, + app: Promise +}; + +export interface AngularFireAnalytics extends AnalyticsProxy {}; + @Injectable() export class AngularFireAnalytics { /** * Firebase Analytics instance */ - public readonly analytics: Observable; + private readonly analytics$: Observable; + private get analytics() { return this.analytics$.toPromise(); } constructor( @Inject(FIREBASE_OPTIONS) options:FirebaseOptions, @@ -39,12 +54,13 @@ export class AngularFireAnalytics { const requireAnalytics = from(import('firebase/analytics')); const app = _firebaseAppFactory(options, zone, nameOrConfig); - this.analytics = requireAnalytics.pipe( + this.analytics$ = requireAnalytics.pipe( map(() => app.analytics()), tap(analytics => { if (analyticsCollectionEnabled === false) { analytics.setAnalyticsCollectionEnabled(false) } }), - runOutsideAngular(zone) + runOutsideAngular(zone), + shareReplay(1) ); if (router && (automaticallySetCurrentScreen !== false || automaticallyLogScreenViews !== false)) { @@ -53,7 +69,7 @@ export class AngularFireAnalytics { const activationEndEvents = router.events.pipe(filter(e => e instanceof ActivationEnd)); const navigationEndEvents = router.events.pipe(filter(e => e instanceof NavigationEnd)); navigationEndEvents.pipe( - withLatestFrom(activationEndEvents, this.analytics), + withLatestFrom(activationEndEvents, this.analytics$), tap(([navigationEnd, activationEnd, analytics]) => { const url = navigationEnd.url; const screen_name = activationEnd.snapshot.routeConfig && activationEnd.snapshot.routeConfig.path || undefined; @@ -73,12 +89,14 @@ export class AngularFireAnalytics { // TODO do something other than just check auth presence, what if it's lazy loaded? if (app.auth && automaticallyTrackUserIdentifier !== false) { new Observable(app.auth().onAuthStateChanged.bind(app.auth())).pipe( - withLatestFrom(this.analytics), + withLatestFrom(this.analytics$), tap(([user, analytics]) => analytics.setUserId(user ? user.uid : null!, { global: true })), runOutsideAngular(zone) ).subscribe() } + return _lazySDKProxy(this, this.analytics, zone); + } } diff --git a/src/core/angularfire2.ts b/src/core/angularfire2.ts index e95292bfd..dab20fac5 100644 --- a/src/core/angularfire2.ts +++ b/src/core/angularfire2.ts @@ -66,3 +66,36 @@ export const runInZone = (zone: NgZone) => (obs$: Observable): Observable< ); }); } + +//SEMVER: once we move to TypeScript 3.6, we can use these to build lazy interfaces +/* + type FunctionPropertyNames = { [K in keyof T]: T[K] extends Function ? K : never }[keyof T]; + type PromiseReturningFunctionPropertyNames = { [K in FunctionPropertyNames]: ReturnType extends Promise ? K : never }[FunctionPropertyNames]; + type NonPromiseReturningFunctionPropertyNames = { [K in FunctionPropertyNames]: ReturnType extends Promise ? never : K }[FunctionPropertyNames]; + type NonFunctionPropertyNames = { [K in keyof T]: T[K] extends Function ? never : K }[keyof T]; + + export type PromiseProxy = { [K in NonFunctionPropertyNames]: Promise } & + { [K in NonPromiseReturningFunctionPropertyNames]: (...args: Parameters) => Promise> } & + { [K in PromiseReturningFunctionPropertyNames ]: (...args: Parameters) => ReturnType }; +*/ + +export const _lazySDKProxy = (klass: any, promise: Promise, zone: NgZone) => new Proxy(klass, { + get: (_, name) => zone.runOutsideAngular(() => + klass[name] || new Proxy(() => + promise.then(mod => { + const ret = mod[name]; + // TODO move to proper type guards + if (typeof ret == 'function') { + return ret.bind(mod); + } else if (ret && ret.then) { + return ret.then((res:any) => zone.run(() => res)); + } else { + return zone.run(() => ret); + } + }), { + get: (self, name) => self()[name], + // TODO handle callbacks + apply: (self, _, args) => self().then(it => it(...args)) + }) + ) +}); \ No newline at end of file diff --git a/src/remote-config/remote-config.ts b/src/remote-config/remote-config.ts index d3855ef14..ab92c33bf 100644 --- a/src/remote-config/remote-config.ts +++ b/src/remote-config/remote-config.ts @@ -1,7 +1,7 @@ import { Injectable, Inject, Optional, NgZone, InjectionToken } from '@angular/core'; import { Observable, from, concat } from 'rxjs'; -import { map, switchMap, tap, take } from 'rxjs/operators'; -import { FirebaseAppConfig, FirebaseOptions, FIREBASE_OPTIONS, FIREBASE_APP_NAME } from '@angular/fire'; +import { map, switchMap, tap, shareReplay, distinctUntilChanged } from 'rxjs/operators'; +import { FirebaseAppConfig, FirebaseOptions, _lazySDKProxy, FIREBASE_OPTIONS, FIREBASE_APP_NAME } from '@angular/fire'; import { remoteConfig } from 'firebase/app'; // @ts-ignore @@ -14,19 +14,38 @@ export const DEFAULT_CONFIG = new InjectionToken('angularfire2.re import { FirebaseRemoteConfig, _firebaseAppFactory, runOutsideAngular } from '@angular/fire'; -@Injectable() -export class AngularFireRemoteConfig { +// SEMVER: once we move to Typescript 3.6 use `PromiseProxy` rather than hardcoding +type RemoteConfigProxy = { + activate: () => Promise; + ensureInitialized: () => Promise; + fetch: () => Promise; + fetchAndActivate: () => Promise; + getAll: () => Promise<{[key:string]: remoteConfig.Value}>; + getBoolean: (key:string) => Promise; + getNumber: (key:string) => Promise; + getString: (key:string) => Promise; + getValue: (key:string) => Promise; + setLogLevel: (logLevel: remoteConfig.LogLevel) => Promise; + settings: Promise; + defaultConfig: Promise<{ + [key: string]: string | number | boolean; + }>; + fetchTimeMillis: Promise; + lastFetchStatus: Promise; +}; - /** - * Firebase RemoteConfig instance - */ - public readonly remoteConfig: Observable; +export interface AngularFireRemoteConfig extends RemoteConfigProxy {}; - public readonly freshConfiguration: Observable<{[key:string]: remoteConfig.Value}>; +@Injectable() +export class AngularFireRemoteConfig { - public readonly configuration: Observable<{[key:string]: remoteConfig.Value}>; + private readonly remoteConfig$: Observable; + private get remoteConfig() { return this.remoteConfig$.toPromise(); } - public readonly activate: Observable<{[key:string]: remoteConfig.Value}>; + readonly all: Observable<{[key:string]: remoteConfig.Value}>; + readonly numbers: Observable<{[key:string]: number}> & {[key:string]: Observable}; + readonly booleans: Observable<{[key:string]: boolean}> & {[key:string]: Observable}; + readonly strings: Observable<{[key:string]: string}> & {[key:string]: Observable}; constructor( @Inject(FIREBASE_OPTIONS) options:FirebaseOptions, @@ -41,7 +60,7 @@ export class AngularFireRemoteConfig { // @ts-ignore zapping in the UMD in the build script const requireRemoteConfig = from(import('@firebase/remote-config')); - this.remoteConfig = requireRemoteConfig.pipe( + this.remoteConfig$ = requireRemoteConfig.pipe( map(rc => rc.registerRemoteConfig(firebase)), map(() => _firebaseAppFactory(options, zone, nameOrConfig)), map(app => app.remoteConfig()), @@ -49,30 +68,50 @@ export class AngularFireRemoteConfig { if (settings) { rc.settings = settings } if (defaultConfig) { rc.defaultConfig = defaultConfig } }), - runOutsideAngular(zone) - ); - - this.activate = this.remoteConfig.pipe( - switchMap(rc => rc.activate().then(() => rc)), - tap(rc => rc.fetch()), - map(rc => rc.getAll()), - runOutsideAngular(zone), - take(1) - ) - - this.freshConfiguration = this.remoteConfig.pipe( - switchMap(rc => rc.fetchAndActivate().then(() => rc.getAll())), runOutsideAngular(zone), - take(1) - ) + shareReplay(1) + ); - this.configuration = this.remoteConfig.pipe( + this.all = this.remoteConfig$.pipe( switchMap(rc => concat( rc.activate().then(() => rc.getAll()), rc.fetchAndActivate().then(() => rc.getAll()) )), - runOutsideAngular(zone) - ) + runOutsideAngular(zone), + // TODO startWith(rehydrate(deafultConfig)), + shareReplay(1) + // TODO distinctUntilChanged(compareFn) + ); + + const allAs = (type: 'String'|'Boolean'|'Number') => this.all.pipe( + map(all => Object.keys(all).reduce((c, k) => { + c[k] = all[k][`as${type}`](); + return c; + }, {})) + ) as any; + + this.strings = new Proxy(allAs('String'), { + get: (self, name:string) => self[name] || this.all.pipe( + map(rc => rc[name].asString()), + distinctUntilChanged() + ) + }); + + this.booleans = new Proxy(allAs('Boolean'), { + get: (self, name:string) => self[name] || this.all.pipe( + map(rc => rc[name].asBoolean()), + distinctUntilChanged() + ) + }); + + this.numbers = new Proxy(allAs('Number'), { + get: (self, name:string) => self[name] || this.all.pipe( + map(rc => rc[name].asNumber()), + distinctUntilChanged() + ) + }); + + return _lazySDKProxy(this, this.remoteConfig, zone); } }