diff --git a/README.md b/README.md index 40f872a4f..6eefa101d 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,12 @@ export class MyApp { - [Installation & Setup](docs/install-and-setup.md) +### **NEW:** Monitor usage of your application in production + +> `AngularFireAnalytics` provides a convient method of interacting with Google Analytics in your Angular application. The provided `ScreenTrackingService` and `UserTrackingService` automatically log events when you're using the Angular Router or Firebase Authentication respectively. [Learn more about Google Analytics](https://firebase.google.com/docs/analytics). + +- [Getting started with Google Analytics](docs/analytics/getting-started.md) + ### Interacting with your database(s) Firebase offers two cloud-based, client-accessible database solutions that support realtime data syncing. [Learn about the differences between them in the Firebase Documentation](https://firebase.google.com/docs/firestore/rtdb-vs-firestore). @@ -94,11 +100,19 @@ Firebase offers two cloud-based, client-accessible database solutions that suppo - [Getting started with Cloud Storage](docs/storage/storage.md) -### Send push notifications +### Receive push notifications - [Getting started with Firebase Messaging](docs/messaging/messaging.md) -### Monitor your application performance in production +### **BETA:** Change behavior and appearance of your application without deploying + +> Firebase Remote Config is a cloud service that lets you change the behavior and appearance of your app without requiring users to download an app update. [Learn more about Remote Config](https://firebase.google.com/docs/remote-config). + +- [Getting started with Remote Config](docs/remote-config/getting-started.md) + +### **NEW:** Monitor your application performance in production + +> Firebase Performance Monitoring is a service that helps you to gain insight into the performance characteristics of your iOS, Android, and web apps. [Learn more about Performance Monitoring](https://firebase.google.com/docs/perf-mon). - [Getting started with Performance Monitoring](docs/performance/getting-started.md) diff --git a/docs/analytics/getting-started.md b/docs/analytics/getting-started.md new file mode 100644 index 000000000..8db7a2f9d --- /dev/null +++ b/docs/analytics/getting-started.md @@ -0,0 +1,135 @@ +# Getting started with Google Analytics + +`AngularFireAnalytics` dynamically imports the `firebase/analytics` library and provides a promisified version of the [Firebase Analytics SDK (`firebase.analytics.Analytics`)](https://firebase.google.com/docs/reference/js/firebase.analytics.Analytics.html). + +### API: + +```ts +class AngularFireAnalytics { + updateConfig(options: {[key:string]: any}): Promise; + + // from firebase.analytics() proxy: + 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; +} + +COLLECTION_ENABLED = InjectionToken; +APP_VERSION = InjectionToken; +APP_NAME = InjectionToken; +DEBUG_MODE = InjectionToken; +CONFIG = InjectionToken; +``` + +### Usage: + +```ts +import { AngularFireAnalyticsModule } from '@angular/fire/analytics'; + +@NgModule({ + imports: [ + AngularFireModule.initializeApp(environment.firebase), + AngularFireAnalyticsModule + ] +}) +export class AppModule { } +``` + +`AngularFireAnalyticsModule` will dyanamically import and configure `firebase/analytics`. A `page_view` event will automatically be logged (see `CONFIG` below if you wish to disable this behavior.) + +In your component you can then dependency inject `AngularFireAnalytics` and make calls against the SDK: + +```ts +import { AngularFireAnalytics } from '@angular/fire/analytics'; + +constructor(analytics: AngularFireAnalytics) { + analytics.logEvent('custom_event', { ... }); +} +``` + +## Tracking Screen Views + +You can log [`screen_view` events](https://firebase.google.com/docs/reference/js/firebase.analytics.Analytics.html#parameters_10) yourself of course, but AngularFire provides the `ScreenTrackingService` which automatically integrates with the Angular Router to provide Firebase with screen view tracking. You simply can integrate like so: + +```ts +import { AngularFireAnalyticsModule, ScreenTrackingService } from '@angular/fire/analytics'; + +@NgModule({ + imports: [ + AngularFireModule.initializeApp(environment.firebase), + AngularFireAnalyticsModule + ], + providers: [ + ScreenTrackingService + ] +}) +export class AppModule { } +``` + +`AngularFireAnalyticsModule` will initialize `ScreenTrackingService` if it is provided. + +## Tracking User Identifiers + +To enrich your Analytics data you can track the currently signed in user by setting [`setuserid`](https://firebase.google.com/docs/reference/js/firebase.analytics.Analytics.html#setuserid) and [`setUserProperties`](https://firebase.google.com/docs/reference/js/firebase.analytics.Analytics.html#set-user-properties). AngularFire provides a `UserTrackingService` which will dynamically import `firebase/auth`, monitor for changes in the logged in user, and call `setuserid` for you automatically. + + +```ts +import { AngularFireAnalyticsModule, UserTrackingService } from '@angular/fire/analytics'; + +@NgModule({ + imports: [ + AngularFireModule.initializeApp(environment.firebase), + AngularFireAnalyticsModule + ], + providers: [ + UserTrackingService + ] +}) +export class AppModule { } +``` + +`AngularFireAnalyticsModule` will initialize `UserTrackingService` if it is provided. + +## Configuration with Dependency Injection + +### Configure Google Analtyics with `CONFIG` + +Using the `CONFIG` DI Token (*default: {}*) will allow you to configure Google Analytics. E.g, you could skip sending the initial `page_view` event, anonymize IP addresses, and disallow ads personalization signals for all events like so: + +```ts +import { AngularFireAnalyticsModule, CONFIG } from '@angular/fire/analytics'; + +@NgModule({ + imports: [ + AngularFireModule.initializeApp(environment.firebase), + AngularFireAnalyticsModule + ], + providers: [ + { provide: CONFIG, useValue: { + send_page_view: false, + allow_ad_personalization_signals: false, + anonymize_ip: true + } } + ] +}) +export class AppModule { } +``` + +See the gtag.js documentation to learn of the different configuration options at your disposal. + +### Use DebugView `DEBUG_MODE` + +To use [DebugView in Analtyics](https://console.firebase.google.com/project/_/analytics/debugview) set `DEBUG_MODE` to `true` (*default: false*). + +### Track deployments with `APP_NAME` and `APP_VERSION` + +If you provide `APP_NAME` and `APP_VERSION` (*default: undefined*) you will be able to [track version adoption](https://console.firebase.google.com/project/_/analytics/latestrelease) of your PWA. + +### Disable analytics collection via `COLLECTION_ENABLED` + +If you set `COLLECTION_ENABLED` (*default: true*) to `false` then analytics collection will be disabled for this app on this device. To opt back in to analytics collection you could then call `setAnalyticsCollectionEnabled(true)`. + +Putting these APIs to use with cookies would allow you to create a flexible analytics collection scheme that would respect your user's desire for privacy. \ No newline at end of file diff --git a/docs/install-and-setup.md b/docs/install-and-setup.md index 256610f22..1fa54c8d4 100644 --- a/docs/install-and-setup.md +++ b/docs/install-and-setup.md @@ -96,10 +96,12 @@ export class AppModule {} After adding the AngularFireModule you also need to add modules for the individual @NgModules that your application needs. + - `AngularFireAnalytics` - `AngularFireAuthModule` - `AngularFireDatabaseModule` - `AngularFireFunctionsModule` - `AngularFirestoreModule` + - `AngularFireRemoteConfigModule` - `AngularFireStorageModule` - `AngularFireMessagingModule` - `AngularFirePerformanceModule` @@ -113,6 +115,7 @@ import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { AppComponent } from './app.component'; import { AngularFireModule } from '@angular/fire'; +import { AngularFireAnalyticsModule } from '@angular/fire/analytics'; import { AngularFirestoreModule } from '@angular/fire/firestore'; import { AngularFireStorageModule } from '@angular/fire/storage'; import { AngularFireAuthModule } from '@angular/fire/auth'; @@ -122,6 +125,7 @@ import { environment } from '../environments/environment'; imports: [ BrowserModule, AngularFireModule.initializeApp(environment.firebase, 'my-app-name'), // imports firebase/app needed for everything + AngularFireAnalyticsModule, // dynamically imports firebase/analytics AngularFirestoreModule, // imports firebase/firestore, only needed for database features AngularFireAuthModule, // imports firebase/auth, only needed for auth features, AngularFireStorageModule // imports firebase/storage only needed for storage features diff --git a/docs/remote-config/getting-started.md b/docs/remote-config/getting-started.md new file mode 100644 index 000000000..3d108db7a --- /dev/null +++ b/docs/remote-config/getting-started.md @@ -0,0 +1,128 @@ +

Getting started with Remote Config β

+ +`AngularFireRemoteConfig` dynamically imports the `firebase/remote-config` library on demand, provides convenience observables, pipes, and a promisified version of the [Firebase Remote Config SDK (`firebase.remoteConfig.RemoteConfig`)](https://firebase.google.com/docs/reference/js/firebase.remoteconfig.RemoteConfig). + +### API: + +```ts +class AngularFireRemoteConfigModule { } + +interface ConfigTemplate {[key:string]: string|number|boolean} + +type Parameter extends remoteConfig.Value { + key: string, + fetchTimeMillis: number +} + +class AngularFireRemoteConfig { + changes: Observable; + parameters: Observable; + numbers: Observable<{[key:string]: number|undefined}> & {[key:string]: Observable}; + booleans: Observable<{[key:string]: boolean|undefined}> & {[key:string]: Observable}; + strings: Observable<{[key:string]: string|undefined}> & {[key:string]: Observable}; + + // from firebase.remoteConfig() proxy: + 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; +} + +// Pipes for working with .changes and .parameters +filterRemote: () => MonoTypeOperatorFunction +filterFresh: (interval: number) => MonoTypeOperatorFunction +budget: (interval: number) => MonoTypeOperatorFunction + +// scanToObject is for use with .changes +scanToObject: () => OperatorFunction + +// mapToObject is the same behavior are scanToObject but for use with .parameters, +mapToObject: () => OperatorFunction + +SETTINGS = InjectionToken; +DEFAULTS = InjectionToken; +``` + +## Configuration with Dependency Injection + +### Configure Remote Config with `SETTINGS` + +Using the `SETTINGS` DI Token (*default: {}*) will allow you to [configure Firebase Remote Config](https://firebase.google.com/docs/reference/js/firebase.remoteconfig.Settings.html). + +### Configure default values with `DEFAULTS` + +Providing `DEFAULTS ({[key: string]: string | number | boolean})` has `AngularFireRemoteConfig` emit the provided defaults first, which allows you to count on Remote Config when the user is offline or in environments that the Remote Config service does not handle (i.e, Server Side Rendering). + +## Putting it all together: + +```ts +@NgModule({ + imports: [ + AngularFireModule.initializeApp(environment.firebase), + AngularFireRemoteConfigModule + ], + providers: [ + { provide: DEFAULT_CONFIG, useValue: { enableAwesome: true } }, + { + provide: REMOTE_CONFIG_SETTINGS, + useFactory: () => isDevMode() ? { minimumFetchIntervalMillis: 10_000 } : {} + } + ] +}) +export class AppModule { } + +... + +constructor(remoteConfig: AngularFireRemoteConfig) { + remoteConfig.changes.pipe( + filterFresh(172_800_000), // ensure we have values from at least 48 hours ago + first(), + // scanToObject when used this way is similar to defaults + // but most importantly smart-casts remote config values and adds type safety + scanToObject({ + enableAwesome: true, + titleBackgroundColor: 'blue', + titleFontSize: 12 + }) + ).subscribe(…); + + // all remote config values cast as strings + remoteConfig.strings.subscribe(...) + remoteConfig.booleans.subscribe(...); // as booleans + remoteConfig.numbers.subscribe(...); // as numbers + + // convenience for observing a single string + remoteConfig.strings.titleBackgroundColor.subscribe(...); + remoteConfig.booleans.enableAwesome.subscribe(...); // boolean + remoteConfig.numbers.titleBackgroundColor.subscribe(...); // number + + // however those may emit more than once as the remote config cache fires and gets fresh values from the server + // you can filter it out of .changes for more control: + remoteConfig.changes.pipe( + filter(param => param.key === 'titleBackgroundColor'), + map(param => param.asString()) + // budget at most 800ms and return the freshest value possible in that time + // our budget pipe is similar to timeout but won't error or abort the pending server fetch (it won't emit it, if the deadline is exceeded, but it will have been fetched so can use the freshest values on next subscription) + budget(800), + last() + ).subscribe(...) + + // just like .changes, but scanned as into an array + remoteConfig.parameters.subscribe(all => ...); + + // or make promisified firebase().remoteConfig() calls direct off AngularFireRemoteConfig + // using our proxy + remoteConfig.getAll().then(all => ...); + remoteConfig.lastFetchStatus.then(status => ...); +} +``` \ No newline at end of file diff --git a/karma.conf.js b/karma.conf.js index 5ab48f0da..1eb9c4980 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -34,10 +34,13 @@ module.exports = function(config) { 'node_modules/firebase/firebase-storage.js', 'dist/packages-dist/bundles/core.umd.{js,map}', 'dist/packages-dist/bundles/auth.umd.{js,map}', + 'dist/packages-dist/bundles/analytics.umd.{js,map}', 'dist/packages-dist/bundles/auth-guard.umd.{js,map}', 'dist/packages-dist/bundles/database.umd.{js,map}', 'dist/packages-dist/bundles/firestore.umd.{js,map}', 'dist/packages-dist/bundles/functions.umd.{js,map}', + 'dist/packages-dist/bundles/messaging.umd.{js,map}', + 'dist/packages-dist/bundles/remote-config.umd.{js,map}', 'dist/packages-dist/bundles/storage.umd.{js,map}', 'dist/packages-dist/bundles/performance.umd.{js,map}', 'dist/packages-dist/bundles/database-deprecated.umd.{js,map}', diff --git a/package.json b/package.json index dde0a3460..680d2d7bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@angular/fire", - "version": "5.2.3", + "version": "5.3.0", "description": "The official library of Firebase and Angular.", "private": true, "scripts": { diff --git a/src/analytics/analytics.module.ts b/src/analytics/analytics.module.ts new file mode 100644 index 000000000..a1e470a6f --- /dev/null +++ b/src/analytics/analytics.module.ts @@ -0,0 +1,17 @@ +import { NgModule, Optional } from '@angular/core'; +import { UserTrackingService, ScreenTrackingService } from './analytics.service'; +import { AngularFireAnalytics } from './analytics'; + +@NgModule({ + providers: [ AngularFireAnalytics ] +}) +export class AngularFireAnalyticsModule { + constructor( + analytics: AngularFireAnalytics, + @Optional() screenTracking: ScreenTrackingService, + @Optional() userTracking: UserTrackingService + ) { + // calling anything on analytics will eagerly load the SDK + analytics.app; + } +} diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts new file mode 100644 index 000000000..9f37da3fe --- /dev/null +++ b/src/analytics/analytics.service.ts @@ -0,0 +1,200 @@ +import { Injectable, Optional, NgZone, OnDestroy, ComponentFactoryResolver, Inject, PLATFORM_ID, Injector, NgModuleFactory } from '@angular/core'; +import { Subscription, from, Observable, of } from 'rxjs'; +import { filter, withLatestFrom, switchMap, map, tap, pairwise, startWith, groupBy, mergeMap } from 'rxjs/operators'; +import { Router, NavigationEnd, ActivationEnd, ROUTES } from '@angular/router'; +import { runOutsideAngular } from '@angular/fire'; +import { AngularFireAnalytics, DEBUG_MODE } from './analytics'; +import { User } from 'firebase/app'; +import { Title } from '@angular/platform-browser'; +import { isPlatformBrowser, isPlatformServer } from '@angular/common'; + +const FIREBASE_EVENT_ORIGIN_KEY = 'firebase_event_origin'; +const FIREBASE_PREVIOUS_SCREEN_CLASS_KEY = 'firebase_previous_class'; +const FIREBASE_PREVIOUS_SCREEN_INSTANCE_ID_KEY = 'firebase_previous_id'; +const FIREBASE_PREVIOUS_SCREEN_NAME_KEY = 'firebase_previous_screen'; +const FIREBASE_SCREEN_CLASS_KEY = 'firebase_screen_class'; +const FIREBASE_SCREEN_INSTANCE_ID_KEY = 'firebase_screen_id'; +const FIREBASE_SCREEN_NAME_KEY = 'firebase_screen'; +const OUTLET_KEY = 'outlet'; +const PAGE_PATH_KEY = 'page_path'; +const PAGE_TITLE_KEY = 'page_title'; +const SCREEN_CLASS_KEY = 'screen_class'; +const SCREEN_NAME_KEY = 'screen_name'; + +const SCREEN_VIEW_EVENT = 'screen_view'; +const EVENT_ORIGIN_AUTO = 'auto'; +const DEFAULT_SCREEN_CLASS = '???'; +const NG_PRIMARY_OUTLET = 'primary'; +const SCREEN_INSTANCE_DELIMITER = '#'; + +const ANNOTATIONS = '__annotations__'; + +@Injectable() +export class ScreenTrackingService implements OnDestroy { + + private disposable: Subscription|undefined; + + constructor( + analytics: AngularFireAnalytics, + @Optional() router:Router, + @Optional() title:Title, + componentFactoryResolver: ComponentFactoryResolver, + @Inject(PLATFORM_ID) platformId:Object, + @Optional() @Inject(DEBUG_MODE) debugModeEnabled:boolean|null, + zone: NgZone, + injector: Injector + ) { + if (!router || !isPlatformBrowser(platformId)) { return this } + zone.runOutsideAngular(() => { + const activationEndEvents = router.events.pipe(filter(e => e instanceof ActivationEnd)); + const navigationEndEvents = router.events.pipe(filter(e => e instanceof NavigationEnd)); + this.disposable = navigationEndEvents.pipe( + withLatestFrom(activationEndEvents), + switchMap(([navigationEnd, activationEnd]) => { + // SEMVER: start using optional chains and nullish coalescing once we support newer typescript + const page_path = navigationEnd.url; + const screen_name = activationEnd.snapshot.routeConfig && activationEnd.snapshot.routeConfig.path || page_path; + const params = { + [SCREEN_NAME_KEY]: screen_name, + [PAGE_PATH_KEY]: page_path, + [FIREBASE_EVENT_ORIGIN_KEY]: EVENT_ORIGIN_AUTO, + [FIREBASE_SCREEN_NAME_KEY]: screen_name, + [OUTLET_KEY]: activationEnd.snapshot.outlet + }; + if (title) { + params[PAGE_TITLE_KEY] = title.getTitle() + } + const component = activationEnd.snapshot.component; + const routeConfig = activationEnd.snapshot.routeConfig; + const loadChildren = routeConfig && routeConfig.loadChildren; + // TODO figure out how to handle minification + if (typeof loadChildren === "string") { + // SEMVER: this is the older lazy load style "./path#ClassName", drop this when we drop old ng + // TODO is it worth seeing if I can look up the component factory selector from the module name? + // it's lazy so it's not registered with componentFactoryResolver yet... seems a pain for a depreciated style + return of({...params, [SCREEN_CLASS_KEY]: loadChildren.split('#')[1]}); + } else if (typeof component === 'string') { + return of({...params, [SCREEN_CLASS_KEY]: component }); + } else if (component) { + const componentFactory = componentFactoryResolver.resolveComponentFactory(component); + return of({...params, [SCREEN_CLASS_KEY]: componentFactory.selector }); + } else if (loadChildren) { + const loadedChildren = loadChildren(); + var loadedChildren$: Observable = (loadedChildren instanceof Observable) ? loadedChildren : from(Promise.resolve(loadedChildren)); + return loadedChildren$.pipe( + map(lazyModule => { + if (lazyModule instanceof NgModuleFactory) { + // AOT create an injector + const moduleRef = lazyModule.create(injector); + // INVESTIGATE is this the right way to get at the matching route? + const routes = moduleRef.injector.get(ROUTES); + const component = routes[0][0].component; // should i just be grabbing 0-0 here? + try { + const componentFactory = moduleRef.componentFactoryResolver.resolveComponentFactory(component!); + return {...params, [SCREEN_CLASS_KEY]: componentFactory.selector}; + } catch(_) { + return {...params, [SCREEN_CLASS_KEY]: DEFAULT_SCREEN_CLASS}; + } + } else { + // JIT look at the annotations + // INVESTIGATE are there public APIs for this stuff? + const declarations = [].concat.apply([], (lazyModule[ANNOTATIONS] || []).map((f:any) => f.declarations)); + const selectors = [].concat.apply([], declarations.map((c:any) => (c[ANNOTATIONS] || []).map((f:any) => f.selector))); + // should I just be grabbing the selector like this or should i match against the route component? + // const routerModule = lazyModule.ngInjectorDef.imports.find(i => i.ngModule && ....); + // const route = routerModule.providers[0].find(p => p.provide == ROUTES).useValue[0]; + return {...params, [SCREEN_CLASS_KEY]: selectors[0] || DEFAULT_SCREEN_CLASS}; + } + }) + ); + } else { + return of({...params, [SCREEN_CLASS_KEY]: DEFAULT_SCREEN_CLASS}); + } + }), + map(params => ({ + [FIREBASE_SCREEN_CLASS_KEY]: params[SCREEN_CLASS_KEY], + [FIREBASE_SCREEN_INSTANCE_ID_KEY]: getScreenInstanceID(params), + ...params + })), + tap(params => { + // TODO perhaps I can be smarter about this, bubble events up to the nearest outlet? + if (params[OUTLET_KEY] == NG_PRIMARY_OUTLET) { + analytics.setCurrentScreen(params[SCREEN_NAME_KEY]); + analytics.updateConfig({ + [PAGE_PATH_KEY]: params[PAGE_PATH_KEY], + [SCREEN_CLASS_KEY]: params[SCREEN_CLASS_KEY] + }); + if (title) { + analytics.updateConfig({ [PAGE_TITLE_KEY]: params[PAGE_TITLE_KEY] }) + } + } + }), + groupBy(params => params[OUTLET_KEY]), + mergeMap(group => group.pipe(startWith(undefined), pairwise())), + map(([prior, current]) => prior ? { + [FIREBASE_PREVIOUS_SCREEN_CLASS_KEY]: prior[SCREEN_CLASS_KEY], + [FIREBASE_PREVIOUS_SCREEN_NAME_KEY]: prior[SCREEN_NAME_KEY], + [FIREBASE_PREVIOUS_SCREEN_INSTANCE_ID_KEY]: prior[FIREBASE_SCREEN_INSTANCE_ID_KEY], + ...current! + } : current!), + tap(params => debugModeEnabled && console.info(SCREEN_VIEW_EVENT, params)), + tap(params => zone.runOutsideAngular(() => analytics.logEvent(SCREEN_VIEW_EVENT, params))) + ).subscribe(); + }); + } + + ngOnDestroy() { + if (this.disposable) { this.disposable.unsubscribe(); } + } + +} + +@Injectable() +export class UserTrackingService implements OnDestroy { + + private disposable: Subscription|undefined; + + // TODO a user properties injector + constructor( + analytics: AngularFireAnalytics, + zone: NgZone, + @Inject(PLATFORM_ID) platformId:Object + ) { + if (!isPlatformServer(platformId)) { + zone.runOutsideAngular(() => { + // @ts-ignore zap the import in the UMD + this.disposable = from(import('firebase/auth')).pipe( + switchMap(() => analytics.app), + map(app => app.auth()), + switchMap(auth => new Observable(auth.onAuthStateChanged.bind(auth))), + switchMap(user => analytics.setUserId(user ? user.uid : null!)), + runOutsideAngular(zone) + ).subscribe(); + }); + } + } + + ngOnDestroy() { + if (this.disposable) { this.disposable.unsubscribe(); } + } +} + +// this is an INT64 in iOS/Android but use INT32 cause javascript +let nextScreenInstanceID = Math.floor(Math.random() * (2**32 - 1)) - 2**31; + +const knownScreenInstanceIDs: {[key:string]: number} = {}; + +const getScreenInstanceID = (params:{[key:string]: any}) => { + // unique the screen class against the outlet name + const screenInstanceKey = [ + params[SCREEN_CLASS_KEY], + params[OUTLET_KEY] + ].join(SCREEN_INSTANCE_DELIMITER); + if (knownScreenInstanceIDs.hasOwnProperty(screenInstanceKey)) { + return knownScreenInstanceIDs[screenInstanceKey]; + } else { + const ret = nextScreenInstanceID++; + knownScreenInstanceIDs[screenInstanceKey] = ret; + return ret; + } +} \ No newline at end of file diff --git a/src/analytics/analytics.spec.ts b/src/analytics/analytics.spec.ts new file mode 100644 index 000000000..d72115469 --- /dev/null +++ b/src/analytics/analytics.spec.ts @@ -0,0 +1,83 @@ +import { ReflectiveInjector, Provider } from '@angular/core'; +import { TestBed, inject } from '@angular/core/testing'; +import { FirebaseApp, FirebaseOptionsToken, AngularFireModule, FirebaseNameOrConfigToken } from '@angular/fire'; +import { AngularFireAnalytics, AngularFireAnalyticsModule, COLLECTION_ENABLED, APP_VERSION, APP_NAME } from '@angular/fire/analytics'; +import { COMMON_CONFIG } from './test-config'; + + +describe('AngularFireAnalytics', () => { + let app: FirebaseApp; + let analytics: AngularFireAnalytics; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + AngularFireModule.initializeApp(COMMON_CONFIG), + AngularFireAnalyticsModule + ] + }); + inject([FirebaseApp, AngularFireAnalytics], (app_: FirebaseApp, _analytics: AngularFireAnalytics) => { + app = app_; + analytics = _analytics; + })(); + }); + + afterEach(done => { + app.delete(); + done(); + }); + + it('should be exist', () => { + expect(analytics instanceof AngularFireAnalytics).toBe(true); + }); + + it('should have the Firebase Functions instance', () => { + expect(analytics.app).toBeDefined(); + }); + +}); + +const FIREBASE_APP_NAME_TOO = (Math.random() + 1).toString(36).substring(7); + +describe('AngularFireAnalytics with different app', () => { + let app: FirebaseApp; + let analytics: AngularFireAnalytics; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + AngularFireModule.initializeApp(COMMON_CONFIG), + AngularFireAnalyticsModule + ], + providers: [ + { provide: FirebaseNameOrConfigToken, useValue: FIREBASE_APP_NAME_TOO }, + { provide: FirebaseOptionsToken, useValue: COMMON_CONFIG }, + { provide: COLLECTION_ENABLED, useValue: true }, + { provide: APP_VERSION, useValue: '0.0' }, + { provide: APP_NAME, useValue: 'Test App!' } + ] + }); + inject([FirebaseApp, AngularFireAnalytics], (app_: FirebaseApp, _analytics: AngularFireAnalytics) => { + app = app_; + analytics = _analytics; + })(); + }); + + afterEach(done => { + app.delete(); + done(); + }); + + describe('', () => { + + it('should be an AngularFireAuth type', () => { + expect(analytics instanceof AngularFireAnalytics).toEqual(true); + }); + + it('should have the Firebase Functions instance', () => { + expect(analytics.app).toBeDefined(); + }); + + }); + +}); diff --git a/src/analytics/analytics.ts b/src/analytics/analytics.ts new file mode 100644 index 000000000..ac42be224 --- /dev/null +++ b/src/analytics/analytics.ts @@ -0,0 +1,103 @@ +import { Injectable, Inject, Optional, NgZone, InjectionToken, PLATFORM_ID } from '@angular/core'; +import { of } from 'rxjs'; +import { isPlatformBrowser } from '@angular/common'; +import { map, tap, shareReplay, switchMap } from 'rxjs/operators'; +import { FirebaseAppConfig, FirebaseOptions, runOutsideAngular, ɵlazySDKProxy, FirebaseAnalytics, FIREBASE_OPTIONS, FIREBASE_APP_NAME, _firebaseAppFactory } from '@angular/fire'; +import { analytics, app } from 'firebase'; + +export interface Config {[key:string]: any}; + +export const COLLECTION_ENABLED = new InjectionToken('angularfire2.analytics.analyticsCollectionEnabled'); +export const APP_VERSION = new InjectionToken('angularfire2.analytics.appVersion'); +export const APP_NAME = new InjectionToken('angularfire2.analytics.appName'); +export const DEBUG_MODE = new InjectionToken('angularfire2.analytics.debugMode'); +export const CONFIG = new InjectionToken('angularfire2.analytics.config'); + +const APP_NAME_KEY = 'app_name'; +const APP_VERSION_KEY = 'app_version'; +const DEBUG_MODE_KEY = 'debug_mode'; +const ANALYTICS_ID_FIELD = 'measurementId'; +const GTAG_CONFIG_COMMAND = 'config'; +const GTAG_FUNCTION_NAME = 'gtag'; +const DATA_LAYER_NAME = 'dataLayer'; + +// 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 { + + private gtag: (...args: any[]) => void; + private analyticsInitialized: Promise; + + async updateConfig(config: Config) { + await this.analyticsInitialized; + this.gtag(GTAG_CONFIG_COMMAND, this.options[ANALYTICS_ID_FIELD], { ...config, update: true }); + }; + + constructor( + @Inject(FIREBASE_OPTIONS) private options:FirebaseOptions, + @Optional() @Inject(FIREBASE_APP_NAME) nameOrConfig:string|FirebaseAppConfig|null|undefined, + @Optional() @Inject(COLLECTION_ENABLED) analyticsCollectionEnabled:boolean|null, + @Optional() @Inject(APP_VERSION) providedAppVersion:string|null, + @Optional() @Inject(APP_NAME) providedAppName:string|null, + @Optional() @Inject(DEBUG_MODE) debugModeEnabled:boolean|null, + @Optional() @Inject(CONFIG) providedConfig:Config|null, + @Inject(PLATFORM_ID) platformId:Object, + zone: NgZone + ) { + + if (isPlatformBrowser(platformId)) { + + window[DATA_LAYER_NAME] = window[DATA_LAYER_NAME] || []; + this.gtag = window[GTAG_FUNCTION_NAME] || function() { window[DATA_LAYER_NAME].push(arguments) } + this.analyticsInitialized = zone.runOutsideAngular(() => + new Promise(resolve => { + window[GTAG_FUNCTION_NAME] = (...args: any[]) => { + if (args[0] == 'js') { resolve() } + this.gtag(...args); + } + }) + ); + + } else { + + this.analyticsInitialized = Promise.resolve(); + this.gtag = () => {} + + } + + if (providedConfig) { this.updateConfig(providedConfig) } + if (providedAppName) { this.updateConfig({ [APP_NAME_KEY]: providedAppName }) } + if (providedAppVersion) { this.updateConfig({ [APP_VERSION_KEY]: providedAppVersion }) } + if (debugModeEnabled) { this.updateConfig({ [DEBUG_MODE_KEY]: 1 }) } + + const analytics = of(undefined).pipe( + // @ts-ignore zapping in the UMD in the build script + switchMap(() => zone.runOutsideAngular(() => import('firebase/analytics'))), + map(() => _firebaseAppFactory(options, zone, nameOrConfig)), + // SEMVER no need to cast once we drop older Firebase + map(app => app.analytics()), + tap(analytics => { + if (analyticsCollectionEnabled === false) { analytics.setAnalyticsCollectionEnabled(false) } + }), + runOutsideAngular(zone), + shareReplay({ bufferSize: 1, refCount: false }), + ); + + return ɵlazySDKProxy(this, analytics, zone); + + } + +} \ No newline at end of file diff --git a/src/analytics/index.spec.ts b/src/analytics/index.spec.ts new file mode 100644 index 000000000..f5d1cb076 --- /dev/null +++ b/src/analytics/index.spec.ts @@ -0,0 +1 @@ +import './analytics.spec'; diff --git a/src/analytics/index.ts b/src/analytics/index.ts new file mode 100644 index 000000000..4aaf8f92e --- /dev/null +++ b/src/analytics/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/src/analytics/package.json b/src/analytics/package.json new file mode 100644 index 000000000..8557582d6 --- /dev/null +++ b/src/analytics/package.json @@ -0,0 +1,8 @@ +{ + "name": "@angular/fire/analytics", + "main": "../bundles/analytics.umd.js", + "module": "index.js", + "es2015": "./es2015/index.js", + "typings": "index.d.ts", + "sideEffects": false +} diff --git a/src/analytics/public_api.ts b/src/analytics/public_api.ts new file mode 100644 index 000000000..8ce1376e6 --- /dev/null +++ b/src/analytics/public_api.ts @@ -0,0 +1,3 @@ +export * from './analytics'; +export * from './analytics.module'; +export * from './analytics.service'; diff --git a/src/analytics/test-config.ts b/src/analytics/test-config.ts new file mode 100644 index 000000000..4b69c98dd --- /dev/null +++ b/src/analytics/test-config.ts @@ -0,0 +1,7 @@ + +export const COMMON_CONFIG = { + apiKey: "AIzaSyBVSy3YpkVGiKXbbxeK0qBnu3-MNZ9UIjA", + authDomain: "angularfire2-test.firebaseapp.com", + databaseURL: "https://angularfire2-test.firebaseio.com", + storageBucket: "angularfire2-test.appspot.com", +}; diff --git a/src/analytics/tsconfig-build.json b/src/analytics/tsconfig-build.json new file mode 100644 index 000000000..6277bf530 --- /dev/null +++ b/src/analytics/tsconfig-build.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "module": "es2015", + "target": "es2015", + "noImplicitAny": false, + "outDir": "../../dist/packages-dist/analytics/es2015", + "rootDir": ".", + "sourceMap": true, + "inlineSources": true, + "declaration": false, + "removeComments": true, + "strictNullChecks": true, + "lib": ["es2015", "dom", "es2015.promise", "es2015.collection", "es2015.iterable"], + "skipLibCheck": true, + "moduleResolution": "node", + "paths": { + "@angular/fire": ["../../dist/packages-dist"] + } + }, + "files": [ + "index.ts", + "../../node_modules/zone.js/dist/zone.js.d.ts" + ], + "angularCompilerOptions": { + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "enableSummariesForJit": false + } +} + diff --git a/src/analytics/tsconfig-esm.json b/src/analytics/tsconfig-esm.json new file mode 100644 index 000000000..413beb9d0 --- /dev/null +++ b/src/analytics/tsconfig-esm.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig-build.json", + "compilerOptions": { + "target": "es5", + "outDir": "../../dist/packages-dist/analytics", + "declaration": true + }, + "files": [ + "public_api.ts", + "../../node_modules/zone.js/dist/zone.js.d.ts" + ], + "angularCompilerOptions": { + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "enableSummariesForJit": false, + "flatModuleOutFile": "index.js", + "flatModuleId": "@angular/fire/analytics" + } +} diff --git a/src/analytics/tsconfig-test.json b/src/analytics/tsconfig-test.json new file mode 100644 index 000000000..dfed897cc --- /dev/null +++ b/src/analytics/tsconfig-test.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig-esm.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@angular/fire": ["../../dist/packages-dist"], + "@angular/fire/analytics": ["../../dist/packages-dist/analytics"] + } + }, + "files": [ + "index.spec.ts", + "../../node_modules/zone.js/dist/zone.js.d.ts" + ] +} diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 535ac63e0..8a7fa689e 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -1,11 +1,9 @@ import { Injectable, Inject, Optional, NgZone, PLATFORM_ID } from '@angular/core'; import { Observable, of, from } from 'rxjs'; import { switchMap } from 'rxjs/operators'; -import { FirebaseAppConfig, FirebaseOptions } from '@angular/fire'; +import { FIREBASE_OPTIONS, FIREBASE_APP_NAME, FirebaseOptions, FirebaseAppConfig, FirebaseAuth, _firebaseAppFactory, FirebaseZoneScheduler } from '@angular/fire'; import { User, auth } from 'firebase/app'; -import { FirebaseAuth, FirebaseOptionsToken, FirebaseNameOrConfigToken, _firebaseAppFactory, FirebaseZoneScheduler } from '@angular/fire'; - @Injectable() export class AngularFireAuth { @@ -37,8 +35,8 @@ export class AngularFireAuth { public readonly idTokenResult: Observable; constructor( - @Inject(FirebaseOptionsToken) options:FirebaseOptions, - @Optional() @Inject(FirebaseNameOrConfigToken) nameOrConfig:string|FirebaseAppConfig|null|undefined, + @Inject(FIREBASE_OPTIONS) options:FirebaseOptions, + @Optional() @Inject(FIREBASE_APP_NAME) nameOrConfig:string|FirebaseAppConfig|null|undefined, @Inject(PLATFORM_ID) platformId: Object, private zone: NgZone ) { diff --git a/src/core/angularfire2.ts b/src/core/angularfire2.ts index 0da33106a..e3992375e 100644 --- a/src/core/angularfire2.ts +++ b/src/core/angularfire2.ts @@ -3,7 +3,9 @@ import { isPlatformServer } from '@angular/common'; import { Observable, Subscription, queueScheduler as queue } from 'rxjs'; // Put in database.ts when we drop database-depreciated +// SEMVER drop RealtimeDatabaseURL in favor of DATABASE_URL in next major export const RealtimeDatabaseURL = new InjectionToken('angularfire2.realtimeDatabaseURL'); +export const DATABASE_URL = RealtimeDatabaseURL; export class FirebaseZoneScheduler { constructor(public zone: NgZone, private platformId: Object) {} @@ -65,3 +67,54 @@ 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 }; +*/ + +// DEBUG quick debugger function for inline logging that typescript doesn't complain about +// wrote it for debugging the ɵlazySDKProxy, commenting out for now; should consider exposing a +// verbose mode for AngularFire in a future release that uses something like this in multiple places +// usage: () => log('something') || returnValue +// const log = (...args: any[]): false => { console.log(...args); return false } + +// The problem here are things like ngOnDestroy are missing, then triggering the service +// rather than dig too far; I'm capturing these as I go. +const noopFunctions = ['ngOnDestroy']; + +// INVESTIGATE should we make the Proxy revokable and do some cleanup? +// right now it's fairly simple but I'm sure this will grow in complexity +export const ɵlazySDKProxy = (klass: any, observable: Observable, zone: NgZone) => { + return new Proxy(klass, { + get: (_, name:string) => zone.runOutsideAngular(() => { + if (klass[name]) { return klass[name] } + if (noopFunctions.includes(name)) { return () => {} } + let promise = observable.toPromise().then(mod => { + const ret = mod && 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); + } + }); + // recurse the proxy + return new Proxy(() => undefined, { + get: (_, name) => promise[name], + // TODO handle callbacks as transparently as I can + apply: (self, _, args) => promise.then(it => it && it(...args)) + } + ) + }) + }) +}; \ No newline at end of file diff --git a/src/core/firebase.app.module.ts b/src/core/firebase.app.module.ts index d1a227396..c10e2c7e9 100644 --- a/src/core/firebase.app.module.ts +++ b/src/core/firebase.app.module.ts @@ -1,5 +1,5 @@ import { InjectionToken, NgModule, Optional, NgZone } from '@angular/core'; -import { auth, database, firestore, functions, messaging, storage } from 'firebase/app'; +import { auth, database, messaging, storage, firestore, functions } from 'firebase/app'; // @ts-ignore (https://github.com/firebase/firebase-js-sdk/pull/1206) import firebase from 'firebase/app'; // once fixed can pull in as "default as firebase" above @@ -7,29 +7,41 @@ import firebase from 'firebase/app'; // once fixed can pull in as "default as fi export type FirebaseOptions = {[key:string]: any}; export type FirebaseAppConfig = {[key:string]: any}; +// SEMVER drop FirebaseOptionsToken and FirebaseNameOrConfigToken in favor of FIREBASE_OPTIONS and FIREBASE_APP_NAME in next major export const FirebaseOptionsToken = new InjectionToken('angularfire2.app.options'); -export const FirebaseNameOrConfigToken = new InjectionToken('angularfire2.app.nameOrConfig') +export const FirebaseNameOrConfigToken = new InjectionToken('angularfire2.app.nameOrConfig'); + +export const FIREBASE_OPTIONS = FirebaseOptionsToken; +export const FIREBASE_APP_NAME = FirebaseNameOrConfigToken; export type FirebaseDatabase = database.Database; export type FirebaseAuth = auth.Auth; +// SEMVER analytics.Analytics; +export type FirebaseAnalytics = any; export type FirebaseMessaging = messaging.Messaging; +// SEMVER performance.Performance +export type FirebasePerformance = any; export type FirebaseStorage = storage.Storage; export type FirebaseFirestore = firestore.Firestore; export type FirebaseFunctions = functions.Functions; +// SEMVER remoteConfig.RemoteConfig; +export type FirebaseRemoteConfig = any; // Have to implement as we need to return a class from the provider, we should consider exporting // this in the firebase/app types as this is our highest risk of breaks export class FirebaseApp { name: string; options: {}; + analytics: () => FirebaseAnalytics; auth: () => FirebaseAuth; database: (databaseURL?: string) => FirebaseDatabase; messaging: () => FirebaseMessaging; - performance: () => any; // SEMVER: once >= 6 import performance.Performance + performance: () => FirebasePerformance; storage: (storageBucket?: string) => FirebaseStorage; delete: () => Promise; firestore: () => FirebaseFirestore; functions: (region?: string) => FirebaseFunctions; + remoteConfig: () => FirebaseRemoteConfig; } export function _firebaseAppFactory(options: FirebaseOptions, zone: NgZone, nameOrConfig?: string|FirebaseAppConfig|null) { @@ -47,9 +59,9 @@ const FirebaseAppProvider = { provide: FirebaseApp, useFactory: _firebaseAppFactory, deps: [ - FirebaseOptionsToken, + FIREBASE_OPTIONS, NgZone, - [new Optional(), FirebaseNameOrConfigToken] + [new Optional(), FIREBASE_APP_NAME] ] }; @@ -61,8 +73,8 @@ export class AngularFireModule { return { ngModule: AngularFireModule, providers: [ - { provide: FirebaseOptionsToken, useValue: options }, - { provide: FirebaseNameOrConfigToken, useValue: nameOrConfig } + { provide: FIREBASE_OPTIONS, useValue: options }, + { provide: FIREBASE_APP_NAME, useValue: nameOrConfig } ] } } diff --git a/src/database-deprecated/database.ts b/src/database-deprecated/database.ts index e23fa376f..291784e15 100644 --- a/src/database-deprecated/database.ts +++ b/src/database-deprecated/database.ts @@ -5,7 +5,7 @@ import { FirebaseListFactoryOpts, FirebaseObjectFactoryOpts, PathReference } fro import { FirebaseObjectFactory } from './firebase_object_factory'; import { FirebaseObjectObservable } from './firebase_object_observable'; import * as utils from './utils'; -import { FirebaseDatabase, FirebaseOptions, FirebaseAppConfig, FirebaseOptionsToken, FirebaseNameOrConfigToken, RealtimeDatabaseURL, _firebaseAppFactory } from '@angular/fire'; +import { FirebaseDatabase, FirebaseOptions, FirebaseAppConfig, RealtimeDatabaseURL, FIREBASE_OPTIONS, FIREBASE_APP_NAME, DATABASE_URL, _firebaseAppFactory } from '@angular/fire'; @Injectable() export class AngularFireDatabase { @@ -16,9 +16,9 @@ export class AngularFireDatabase { database: FirebaseDatabase; constructor( - @Inject(FirebaseOptionsToken) options:FirebaseOptions, - @Optional() @Inject(FirebaseNameOrConfigToken) nameOrConfig:string|FirebaseAppConfig|null|undefined, - @Optional() @Inject(RealtimeDatabaseURL) databaseURL:string|null, + @Inject(FIREBASE_OPTIONS) options:FirebaseOptions, + @Optional() @Inject(FIREBASE_APP_NAME) nameOrConfig:string|FirebaseAppConfig|null|undefined, + @Optional() @Inject(DATABASE_URL) databaseURL:string|null, zone: NgZone ) { this.database = zone.runOutsideAngular(() => { @@ -39,4 +39,4 @@ export class AngularFireDatabase { } -export { RealtimeDatabaseURL }; \ No newline at end of file +export { DATABASE_URL, DATABASE_URL as URL }; \ No newline at end of file diff --git a/src/database/database.ts b/src/database/database.ts index 1ed67b56f..e9afb569f 100644 --- a/src/database/database.ts +++ b/src/database/database.ts @@ -4,7 +4,7 @@ import { getRef } from './utils'; import { InjectionToken } from '@angular/core'; import { createListReference } from './list/create-reference'; import { createObjectReference } from './object/create-reference'; -import { FirebaseDatabase, FirebaseOptions, FirebaseAppConfig, FirebaseOptionsToken, FirebaseNameOrConfigToken, RealtimeDatabaseURL, _firebaseAppFactory, FirebaseZoneScheduler } from '@angular/fire'; +import { FirebaseDatabase, FirebaseOptions, FirebaseAppConfig, RealtimeDatabaseURL, FIREBASE_OPTIONS, FIREBASE_APP_NAME, DATABASE_URL, _firebaseAppFactory, FirebaseZoneScheduler } from '@angular/fire'; @Injectable() export class AngularFireDatabase { @@ -12,9 +12,9 @@ export class AngularFireDatabase { public readonly scheduler: FirebaseZoneScheduler; constructor( - @Inject(FirebaseOptionsToken) options:FirebaseOptions, - @Optional() @Inject(FirebaseNameOrConfigToken) nameOrConfig:string|FirebaseAppConfig|null|undefined, - @Optional() @Inject(RealtimeDatabaseURL) databaseURL:string|null, + @Inject(FIREBASE_OPTIONS) options:FirebaseOptions, + @Optional() @Inject(FIREBASE_APP_NAME) nameOrConfig:string|FirebaseAppConfig|null|undefined, + @Optional() @Inject(DATABASE_URL) databaseURL:string|null, @Inject(PLATFORM_ID) platformId: Object, zone: NgZone ) { @@ -58,4 +58,4 @@ export { SnapshotAction } from './interfaces'; -export { RealtimeDatabaseURL }; \ No newline at end of file +export { RealtimeDatabaseURL, DATABASE_URL, DATABASE_URL as URL }; \ No newline at end of file diff --git a/src/firestore/firestore.ts b/src/firestore/firestore.ts index 4893d46ff..d6d67f47c 100644 --- a/src/firestore/firestore.ts +++ b/src/firestore/firestore.ts @@ -7,7 +7,7 @@ import { AngularFirestoreDocument } from './document/document'; import { AngularFirestoreCollection } from './collection/collection'; import { AngularFirestoreCollectionGroup } from './collection-group/collection-group'; -import { FirebaseFirestore, FirebaseOptions, FirebaseAppConfig, FirebaseOptionsToken, FirebaseNameOrConfigToken, _firebaseAppFactory, FirebaseZoneScheduler } from '@angular/fire'; +import { FirebaseFirestore, FirebaseOptions, FirebaseAppConfig, FIREBASE_OPTIONS, FIREBASE_APP_NAME, _firebaseAppFactory, FirebaseZoneScheduler } from '@angular/fire'; import { isPlatformServer } from '@angular/common'; // Workaround for Nodejs build @@ -17,6 +17,7 @@ import firebase from 'firebase/app'; // SEMVER: have to import here while we target ng 6, as the version of typescript doesn't allow dynamic import of types import { firestore } from 'firebase/app'; +// SEMVER drop EnablePersistenceToken, PersistenceSettingsToken, and FirestoreSettingsToken in favor of new export names /** * The value of this token determines whether or not the firestore will have persistance enabled */ @@ -24,6 +25,11 @@ export const EnablePersistenceToken = new InjectionToken('angularfire2. export const PersistenceSettingsToken = new InjectionToken('angularfire2.firestore.persistenceSettings'); export const FirestoreSettingsToken = new InjectionToken('angularfire2.firestore.settings'); +export const ENABLE_PERSISTENCE = EnablePersistenceToken; +export const PERSISTENCE_SETTINGS = PersistenceSettingsToken +export const SETTINGS = FirestoreSettingsToken; + +// SEMVER kill this in the next major // timestampsInSnapshots was depreciated in 5.8.0 const major = parseInt(firebase.SDK_VERSION.split('.')[0]); const minor = parseInt(firebase.SDK_VERSION.split('.')[1]); @@ -115,13 +121,13 @@ export class AngularFirestore { * @param app */ constructor( - @Inject(FirebaseOptionsToken) options:FirebaseOptions, - @Optional() @Inject(FirebaseNameOrConfigToken) nameOrConfig:string|FirebaseAppConfig|null|undefined, - @Optional() @Inject(EnablePersistenceToken) shouldEnablePersistence: boolean|null, - @Optional() @Inject(FirestoreSettingsToken) settings: Settings|null, + @Inject(FIREBASE_OPTIONS) options:FirebaseOptions, + @Optional() @Inject(FIREBASE_APP_NAME) nameOrConfig:string|FirebaseAppConfig|null|undefined, + @Optional() @Inject(ENABLE_PERSISTENCE) shouldEnablePersistence: boolean|null, + @Optional() @Inject(SETTINGS) settings: Settings|null, @Inject(PLATFORM_ID) platformId: Object, zone: NgZone, - @Optional() @Inject(PersistenceSettingsToken) persistenceSettings: PersistenceSettings|null, + @Optional() @Inject(PERSISTENCE_SETTINGS) persistenceSettings: PersistenceSettings|null, ) { this.scheduler = new FirebaseZoneScheduler(zone, platformId); this.firestore = zone.runOutsideAngular(() => { diff --git a/src/functions/functions.ts b/src/functions/functions.ts index 922b65d4c..a619f71d8 100644 --- a/src/functions/functions.ts +++ b/src/functions/functions.ts @@ -1,13 +1,17 @@ import { Injectable, Inject, Optional, NgZone, PLATFORM_ID, InjectionToken } from '@angular/core'; import { Observable, from } from 'rxjs'; import { map } from 'rxjs/operators'; -import { FirebaseOptions, FirebaseAppConfig } from '@angular/fire'; -import { FirebaseFunctions, FirebaseOptionsToken, FirebaseNameOrConfigToken, _firebaseAppFactory, FirebaseZoneScheduler } from '@angular/fire'; +import { FirebaseOptions, FirebaseAppConfig, FIREBASE_APP_NAME } from '@angular/fire'; +import { FirebaseFunctions, FIREBASE_OPTIONS, _firebaseAppFactory, FirebaseZoneScheduler } from '@angular/fire'; -// SEMVER: @ v6 remove FunctionsRegionToken in favor of FUNCTIONS_REGION +// SEMVER: @ v6 remove FunctionsRegionToken and FUNCTIONS_REGION in favor of REGION export const FunctionsRegionToken = new InjectionToken('angularfire2.functions.region'); -export const FUNCTIONS_ORIGIN = new InjectionToken('angularfire2.functions.origin'); export const FUNCTIONS_REGION = FunctionsRegionToken; +// SEMVER: @ v6 remove FUNCTIONS_ORIGIN in favor of ORIGIN +export const FUNCTIONS_ORIGIN = new InjectionToken('angularfire2.functions.origin'); + +export const ORIGIN = FUNCTIONS_ORIGIN; +export const REGION = FunctionsRegionToken; @Injectable() export class AngularFireFunctions { @@ -20,12 +24,12 @@ export class AngularFireFunctions { public readonly scheduler: FirebaseZoneScheduler; constructor( - @Inject(FirebaseOptionsToken) options:FirebaseOptions, - @Optional() @Inject(FirebaseNameOrConfigToken) nameOrConfig:string|FirebaseAppConfig|null|undefined, + @Inject(FIREBASE_OPTIONS) options:FirebaseOptions, + @Optional() @Inject(FIREBASE_APP_NAME) nameOrConfig:string|FirebaseAppConfig|null|undefined, @Inject(PLATFORM_ID) platformId: Object, zone: NgZone, - @Optional() @Inject(FUNCTIONS_REGION) region:string|null, - @Optional() @Inject(FUNCTIONS_ORIGIN) origin:string|null + @Optional() @Inject(REGION) region:string|null, + @Optional() @Inject(ORIGIN) origin:string|null ) { this.scheduler = new FirebaseZoneScheduler(zone, platformId); diff --git a/src/messaging/messaging.spec.ts b/src/messaging/messaging.spec.ts new file mode 100644 index 000000000..d99434a18 --- /dev/null +++ b/src/messaging/messaging.spec.ts @@ -0,0 +1,79 @@ +import { ReflectiveInjector, Provider } from '@angular/core'; +import { TestBed, inject } from '@angular/core/testing'; +import { FirebaseApp, FirebaseOptionsToken, AngularFireModule, FirebaseNameOrConfigToken } from '@angular/fire'; +import { AngularFireMessaging, AngularFireMessagingModule } from '@angular/fire/messaging'; +import { COMMON_CONFIG } from './test-config'; + +describe('AngularFireMessaging', () => { + let app: FirebaseApp; + let afm: AngularFireMessaging; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + AngularFireModule.initializeApp(COMMON_CONFIG), + AngularFireMessagingModule + ] + }); + inject([FirebaseApp, AngularFireMessaging], (app_: FirebaseApp, _afm: AngularFireMessaging) => { + app = app_; + afm = _afm; + })(); + }); + + afterEach(done => { + app.delete(); + done(); + }); + + it('should be exist', () => { + expect(afm instanceof AngularFireMessaging).toBe(true); + }); + + it('should have the FCM instance', () => { + expect(afm.messaging).toBeDefined(); + }); + +}); + +const FIREBASE_APP_NAME_TOO = (Math.random() + 1).toString(36).substring(7); + +describe('AngularFireMessaging with different app', () => { + let app: FirebaseApp; + let afm: AngularFireMessaging; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + AngularFireModule.initializeApp(COMMON_CONFIG), + AngularFireMessagingModule + ], + providers: [ + { provide: FirebaseNameOrConfigToken, useValue: FIREBASE_APP_NAME_TOO }, + { provide: FirebaseOptionsToken, useValue: COMMON_CONFIG } + ] + }); + inject([FirebaseApp, AngularFireMessaging], (app_: FirebaseApp, _afm: AngularFireMessaging) => { + app = app_; + afm = _afm; + })(); + }); + + afterEach(done => { + app.delete(); + done(); + }); + + describe('', () => { + + it('should be an AngularFireMessaging type', () => { + expect(afm instanceof AngularFireMessaging).toEqual(true); + }); + + it('should have the FCM instance', () => { + expect(afm.messaging).toBeDefined(); + }); + + }); + +}); diff --git a/src/messaging/messaging.ts b/src/messaging/messaging.ts index 6c410af9c..f5c65e1d8 100644 --- a/src/messaging/messaging.ts +++ b/src/messaging/messaging.ts @@ -3,8 +3,8 @@ import { isPlatformServer } from '@angular/common'; import { messaging } from 'firebase/app'; import { Observable, empty, from, of, throwError } from 'rxjs'; import { mergeMap, catchError, map, switchMap, concat, defaultIfEmpty } from 'rxjs/operators'; -import { FirebaseOptions, FirebaseAppConfig, runOutsideAngular } from '@angular/fire'; -import { FirebaseOptionsToken, FirebaseNameOrConfigToken, _firebaseAppFactory, FirebaseZoneScheduler } from '@angular/fire'; +import { FirebaseOptions, FirebaseAppConfig, runOutsideAngular, FIREBASE_APP_NAME, FIREBASE_OPTIONS } from '@angular/fire'; +import { _firebaseAppFactory, FirebaseZoneScheduler } from '@angular/fire'; @Injectable() export class AngularFireMessaging { @@ -17,8 +17,8 @@ export class AngularFireMessaging { deleteToken: (token: string) => Observable; constructor( - @Inject(FirebaseOptionsToken) options:FirebaseOptions, - @Optional() @Inject(FirebaseNameOrConfigToken) nameOrConfig:string|FirebaseAppConfig|null|undefined, + @Inject(FIREBASE_OPTIONS) options:FirebaseOptions, + @Optional() @Inject(FIREBASE_APP_NAME) nameOrConfig:string|FirebaseAppConfig|null|undefined, @Inject(PLATFORM_ID) platformId: Object, zone: NgZone ) { diff --git a/src/messaging/tsconfig-test.json b/src/messaging/tsconfig-test.json index f6ca2b3c2..3b5da1a2b 100644 --- a/src/messaging/tsconfig-test.json +++ b/src/messaging/tsconfig-test.json @@ -3,7 +3,8 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@angular/fire": ["../../dist/packages-dist"] + "@angular/fire": ["../../dist/packages-dist"], + "@angular/fire/messaging": ["../../dist/packages-dist/messaging"] } }, "files": [ diff --git a/src/performance/performance.ts b/src/performance/performance.ts index 1794600c6..c21f31d79 100644 --- a/src/performance/performance.ts +++ b/src/performance/performance.ts @@ -4,6 +4,7 @@ import { first, tap, map, shareReplay, switchMap } from 'rxjs/operators'; import { performance } from 'firebase/app'; import { FirebaseApp } from '@angular/fire'; +// SEMVER @ v6, drop and move core ng metrics to a service export const AUTOMATICALLY_TRACE_CORE_NG_METRICS = new InjectionToken('angularfire2.performance.auto_trace'); export const INSTRUMENTATION_ENABLED = new InjectionToken('angularfire2.performance.instrumentationEnabled'); export const DATA_COLLECTION_ENABLED = new InjectionToken('angularfire2.performance.dataCollectionEnabled'); @@ -46,6 +47,7 @@ export class AngularFirePerformance { if (automaticallyTraceCoreNgMetrics != false) { // TODO determine more built in metrics + // this leaks... appRef.isStable.pipe( first(it => it), this.traceUntilComplete('isStable') diff --git a/src/remote-config/index.spec.ts b/src/remote-config/index.spec.ts new file mode 100644 index 000000000..e79a978ab --- /dev/null +++ b/src/remote-config/index.spec.ts @@ -0,0 +1 @@ +import './remote-config.spec'; diff --git a/src/remote-config/index.ts b/src/remote-config/index.ts new file mode 100644 index 000000000..4aaf8f92e --- /dev/null +++ b/src/remote-config/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/src/remote-config/package.json b/src/remote-config/package.json new file mode 100644 index 000000000..d1a1f1a66 --- /dev/null +++ b/src/remote-config/package.json @@ -0,0 +1,8 @@ +{ + "name": "@angular/fire/remote-config", + "main": "../bundles/remote-config.umd.js", + "module": "index.js", + "es2015": "./es2015/index.js", + "typings": "index.d.ts", + "sideEffects": false +} diff --git a/src/remote-config/public_api.ts b/src/remote-config/public_api.ts new file mode 100644 index 000000000..af5b96822 --- /dev/null +++ b/src/remote-config/public_api.ts @@ -0,0 +1,2 @@ +export * from './remote-config'; +export * from './remote-config.module'; \ No newline at end of file diff --git a/src/remote-config/remote-config.module.ts b/src/remote-config/remote-config.module.ts new file mode 100644 index 000000000..0c8392d72 --- /dev/null +++ b/src/remote-config/remote-config.module.ts @@ -0,0 +1,7 @@ +import { NgModule } from '@angular/core'; +import { AngularFireRemoteConfig } from './remote-config'; + +@NgModule({ + providers: [AngularFireRemoteConfig] +}) +export class AngularFireRemoteConfigModule { } diff --git a/src/remote-config/remote-config.spec.ts b/src/remote-config/remote-config.spec.ts new file mode 100644 index 000000000..dd59d7a98 --- /dev/null +++ b/src/remote-config/remote-config.spec.ts @@ -0,0 +1,81 @@ +import { ReflectiveInjector, Provider } from '@angular/core'; +import { TestBed, inject } from '@angular/core/testing'; +import { FirebaseApp, FirebaseOptionsToken, AngularFireModule, FirebaseNameOrConfigToken } from '@angular/fire'; +import { AngularFireRemoteConfig, AngularFireRemoteConfigModule, REMOTE_CONFIG_SETTINGS, DEFAULT_CONFIG } from '@angular/fire/remote-config'; +import { COMMON_CONFIG } from './test-config'; + +describe('AngularFireRemoteConfig', () => { + let app: FirebaseApp; + let rc: AngularFireRemoteConfig; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + AngularFireModule.initializeApp(COMMON_CONFIG), + AngularFireRemoteConfigModule + ] + }); + inject([FirebaseApp, AngularFireRemoteConfig], (app_: FirebaseApp, _rc: AngularFireRemoteConfig) => { + app = app_; + rc = _rc; + })(); + }); + + afterEach(done => { + app.delete(); + done(); + }); + + it('should be exist', () => { + expect(rc instanceof AngularFireRemoteConfig).toBe(true); + }); + + it('should have the Firebase Functions instance', () => { + expect(rc.getValue).toBeDefined(); + }); + +}); + +const FIREBASE_APP_NAME_TOO = (Math.random() + 1).toString(36).substring(7); + +describe('AngularFireRemoteConfig with different app', () => { + let app: FirebaseApp; + let rc: AngularFireRemoteConfig; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + AngularFireModule.initializeApp(COMMON_CONFIG), + AngularFireRemoteConfigModule + ], + providers: [ + { provide: FirebaseNameOrConfigToken, useValue: FIREBASE_APP_NAME_TOO }, + { provide: FirebaseOptionsToken, useValue: COMMON_CONFIG }, + { provide: REMOTE_CONFIG_SETTINGS, useValue: {} }, + { provide: DEFAULT_CONFIG, useValue: {} } + ] + }); + inject([FirebaseApp, AngularFireRemoteConfig], (app_: FirebaseApp, _rc: AngularFireRemoteConfig) => { + app = app_; + rc = _rc; + })(); + }); + + afterEach(done => { + app.delete(); + done(); + }); + + describe('', () => { + + it('should be an AngularFireAuth type', () => { + expect(rc instanceof AngularFireRemoteConfig).toEqual(true); + }); + + it('should have the Firebase Functions instance', () => { + expect(rc.getValue).toBeDefined(); + }); + + }); + +}); diff --git a/src/remote-config/remote-config.ts b/src/remote-config/remote-config.ts new file mode 100644 index 000000000..648e19c28 --- /dev/null +++ b/src/remote-config/remote-config.ts @@ -0,0 +1,237 @@ +import { Injectable, Inject, Optional, NgZone, InjectionToken } from '@angular/core'; +import { Observable, concat, of, pipe, OperatorFunction, MonoTypeOperatorFunction } from 'rxjs'; +import { map, switchMap, tap, shareReplay, distinctUntilChanged, filter, groupBy, mergeMap, scan, withLatestFrom, startWith, debounceTime } from 'rxjs/operators'; +import { FirebaseAppConfig, FirebaseOptions, ɵlazySDKProxy, FIREBASE_OPTIONS, FIREBASE_APP_NAME } from '@angular/fire'; +import { remoteConfig } from 'firebase/app'; + +export interface ConfigTemplate {[key:string]: string|number|boolean}; + +export const SETTINGS = new InjectionToken('angularfire2.remoteConfig.settings'); +export const DEFAULTS = new InjectionToken('angularfire2.remoteConfig.defaultConfig'); + +import { FirebaseRemoteConfig, _firebaseAppFactory, runOutsideAngular } from '@angular/fire'; + +// 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; +}; + +export interface AngularFireRemoteConfig extends RemoteConfigProxy {}; + +// TODO export as implements Partial<...> so minor doesn't break us +export class Value implements remoteConfig.Value { + asBoolean() { return ['1', 'true', 't', 'y', 'yes', 'on'].indexOf(this._value.toLowerCase()) > -1 } + asString() { return this._value } + asNumber() { return Number(this._value) || 0 } + getSource() { return this._source; } + constructor(public _source: remoteConfig.ValueSource, public _value: string) { } +} + +// SEMVER use ConstructorParameters when we can support Typescript 3.6 +export class Parameter extends Value { + constructor(public key: string, public fetchTimeMillis: number, source: remoteConfig.ValueSource, value: string) { + super(source, value); + } +} + +// If it's a Parameter array, test any, else test the individual Parameter +const filterTest = (fn: (param:Parameter) => boolean) => filter(it => Array.isArray(it) ? it.some(fn) : fn(it)) + +// Allow the user to bypass the default values and wait till they get something from the server, even if it's a cached copy; +// if used in conjuntion with first() it will only fetch RC values from the server if they aren't cached locally +export const filterRemote = () => filterTest(p => p.getSource() === 'remote'); + +// filterFresh allows the developer to effectively set up a maximum cache time +export const filterFresh = (howRecentInMillis: number) => filterTest(p => p.fetchTimeMillis + howRecentInMillis >= new Date().getTime()); + +@Injectable() +export class AngularFireRemoteConfig { + + readonly changes: Observable; + readonly parameters: Observable; + readonly numbers: Observable<{[key:string]: number|undefined}> & {[key:string]: Observable}; + readonly booleans: Observable<{[key:string]: boolean|undefined}> & {[key:string]: Observable}; + readonly strings: Observable<{[key:string]: string|undefined}> & {[key:string]: Observable}; + + constructor( + @Inject(FIREBASE_OPTIONS) options:FirebaseOptions, + @Optional() @Inject(FIREBASE_APP_NAME) nameOrConfig:string|FirebaseAppConfig|null|undefined, + @Optional() @Inject(SETTINGS) settings:remoteConfig.Settings|null, + @Optional() @Inject(DEFAULTS) defaultConfig:ConfigTemplate|null, + private zone: NgZone + ) { + + const remoteConfig$ = of(undefined).pipe( + // @ts-ignore zapping in the UMD in the build script + switchMap(() => zone.runOutsideAngular(() => import('firebase/remote-config'))), + map(() => _firebaseAppFactory(options, zone, nameOrConfig)), + // SEMVER no need to cast once we drop older Firebase + map(app => app.remoteConfig()), + tap(rc => { + if (settings) { rc.settings = settings } + if (defaultConfig) { rc.defaultConfig = defaultConfig } + }), + startWith(undefined), + runOutsideAngular(zone), + shareReplay({ bufferSize: 1, refCount: false }) + ); + + const loadedRemoteConfig$ = remoteConfig$.pipe( + filter(rc => !!rc) + ); + + let default$: Observable<{[key:string]: remoteConfig.Value}> = of(Object.keys(defaultConfig || {}).reduce( + (c, k) => ({...c, [k]: new Value("default", defaultConfig![k].toString()) }), {} + )); + + // we should filter out the defaults we provided to RC, since we have our own implementation + // that gives us a -1 for fetchTimeMillis (so filterFresh can filter them out) + const filterOutDefaults = map<{[key: string]: remoteConfig.Value}, {[key: string]: remoteConfig.Value}>(all => + Object.keys(all) + .filter(key => all[key].getSource() != 'default') + .reduce((acc, key) => ({...acc, [key]: all[key]}), {}) + ); + + const existing$ = loadedRemoteConfig$.pipe( + switchMap(rc => rc.activate().then(() => rc.getAll())), + filterOutDefaults + ); + + const fresh$ = loadedRemoteConfig$.pipe( + switchMap(rc => zone.runOutsideAngular(() => rc.fetchAndActivate().then(() => rc.getAll()))), + filterOutDefaults + ); + + this.parameters = concat(default$, existing$, fresh$).pipe( + scanToParametersArray(remoteConfig$), + shareReplay({ bufferSize: 1, refCount: true }) + ); + + this.changes = this.parameters.pipe( + switchMap(params => of(...params)), + groupBy(param => param.key), + mergeMap(group => group.pipe( + distinctUntilChanged() + )) + ); + + this.strings = proxyAll(this.parameters, 'strings'); + this.booleans = proxyAll(this.parameters, 'booleans'); + this.numbers = proxyAll(this.parameters, 'numbers'); + + return ɵlazySDKProxy(this, loadedRemoteConfig$, zone); + } + +} + +// I ditched loading the defaults into RC and a simple map for scan since we already have our own defaults implementation. +// The idea here being that if they have a default that never loads from the server, they will be able to tell via fetchTimeMillis on the Parameter. +// Also if it doesn't come from the server it won't emit again in .changes, due to the distinctUntilChanged, which we can simplify to === rather than deep comparison +const scanToParametersArray = (remoteConfig: Observable): OperatorFunction<{[key:string]: remoteConfig.Value}, Parameter[]> => pipe( + withLatestFrom(remoteConfig), + scan((existing, [all, rc]) => { + // SEMVER use "new Set" to unique once we're only targeting es6 + // at the scale we expect remote config to be at, we probably won't see a performance hit from this unoptimized uniqueness implementation + // const allKeys = [...new Set([...existing.map(p => p.key), ...Object.keys(all)])]; + const allKeys = [...existing.map(p => p.key), ...Object.keys(all)].filter((v, i, a) => a.indexOf(v) === i); + return allKeys.map(key => { + const updatedValue = all[key]; + return updatedValue ? new Parameter(key, rc ? rc.fetchTimeMillis : -1, updatedValue.getSource(), updatedValue.asString()) + : existing.find(p => p.key === key)! + }); + }, [] as Array) +); + +const AS_TO_FN = { 'strings': 'asString', 'numbers': 'asNumber', 'booleans': 'asBoolean' }; +const STATIC_VALUES = { 'numbers': 0, 'booleans': false, 'strings': undefined }; + +export const budget = (interval: number): MonoTypeOperatorFunction => (source: Observable) => new Observable(observer => { + let timedOut = false; + // TODO use scheduler task rather than settimeout + const timeout = setTimeout(() => { + observer.complete(); + timedOut = true; + }, interval); + return source.subscribe({ + next(val) { if (!timedOut) { observer.next(val); } }, + error(err) { if (!timedOut) { clearTimeout(timeout); observer.error(err); } }, + complete() { if (!timedOut) { clearTimeout(timeout); observer.complete(); } } + }) + }); + +const typedMethod = (it:any) => { + switch(typeof it) { + case 'string': return 'asString'; + case 'boolean': return 'asBoolean'; + case 'number': return 'asNumber'; + default: return 'asString'; + } +}; + +export function scanToObject(): OperatorFunction; +export function scanToObject(to: 'numbers'): OperatorFunction; +export function scanToObject(to: 'booleans'): OperatorFunction; +export function scanToObject(to: 'strings'): OperatorFunction; +export function scanToObject(template: T): OperatorFunction; +export function scanToObject(to: 'numbers'|'booleans'|'strings'|T = 'strings') { + return pipe( + // TODO cleanup + scan( + (c, p: Parameter) => ({...c, [p.key]: typeof to === 'object' ? + p[typedMethod(to[p.key])]() : + p[AS_TO_FN[to]]() }), + typeof to === 'object' ? + to as T & {[key:string]: string|undefined}: + {} as {[key:string]: number|boolean|string} + ), + debounceTime(1), + budget(10), + distinctUntilChanged((a,b) => JSON.stringify(a) === JSON.stringify(b)) + ); +}; + +export function mapToObject(): OperatorFunction; +export function mapToObject(to: 'numbers'): OperatorFunction; +export function mapToObject(to: 'booleans'): OperatorFunction; +export function mapToObject(to: 'strings'): OperatorFunction; +export function mapToObject(template: T): OperatorFunction; +export function mapToObject(to: 'numbers'|'booleans'|'strings'|T = 'strings') { + return pipe( + // TODO this is getting a little long, cleanup + map((params: Parameter[]) => params.reduce( + (c, p) => ({...c, [p.key]: typeof to === 'object' ? + p[typedMethod(to[p.key])]() : + p[AS_TO_FN[to]]() }), + typeof to === 'object' ? + to as T & {[key:string]: string|undefined} : + {} as {[key:string]: number|boolean|string} + )), + distinctUntilChanged((a,b) => JSON.stringify(a) === JSON.stringify(b)) + ); +}; + +// TODO look into the types here, I don't like the anys +const proxyAll = (observable: Observable, as: 'numbers'|'booleans'|'strings') => new Proxy( + observable.pipe(mapToObject(as as any)), { + get: (self, name:string) => self[name] || observable.pipe( + map(all => all.find(p => p.key === name)), + map(param => param ? param[AS_TO_FN[as]]() : STATIC_VALUES[as]), + distinctUntilChanged() + ) + } +) as any; \ No newline at end of file diff --git a/src/remote-config/test-config.ts b/src/remote-config/test-config.ts new file mode 100644 index 000000000..4b69c98dd --- /dev/null +++ b/src/remote-config/test-config.ts @@ -0,0 +1,7 @@ + +export const COMMON_CONFIG = { + apiKey: "AIzaSyBVSy3YpkVGiKXbbxeK0qBnu3-MNZ9UIjA", + authDomain: "angularfire2-test.firebaseapp.com", + databaseURL: "https://angularfire2-test.firebaseio.com", + storageBucket: "angularfire2-test.appspot.com", +}; diff --git a/src/remote-config/tsconfig-build.json b/src/remote-config/tsconfig-build.json new file mode 100644 index 000000000..4a9c81f39 --- /dev/null +++ b/src/remote-config/tsconfig-build.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "module": "es2015", + "target": "es2015", + "noImplicitAny": false, + "outDir": "../../dist/packages-dist/remote-config/es2015", + "rootDir": ".", + "sourceMap": true, + "inlineSources": true, + "declaration": false, + "removeComments": true, + "strictNullChecks": true, + "lib": ["es2015", "dom", "es2015.promise", "es2015.collection", "es2015.iterable"], + "skipLibCheck": true, + "moduleResolution": "node", + "paths": { + "@angular/fire": ["../../dist/packages-dist"] + } + }, + "files": [ + "index.ts", + "../../node_modules/zone.js/dist/zone.js.d.ts" + ], + "angularCompilerOptions": { + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "enableSummariesForJit": false + } +} + diff --git a/src/remote-config/tsconfig-esm.json b/src/remote-config/tsconfig-esm.json new file mode 100644 index 000000000..88dbfecf0 --- /dev/null +++ b/src/remote-config/tsconfig-esm.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig-build.json", + "compilerOptions": { + "target": "es5", + "outDir": "../../dist/packages-dist/remote-config", + "declaration": true + }, + "files": [ + "public_api.ts", + "../../node_modules/zone.js/dist/zone.js.d.ts" + ], + "angularCompilerOptions": { + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "enableSummariesForJit": false, + "flatModuleOutFile": "index.js", + "flatModuleId": "@angular/fire/remote-config" + } +} diff --git a/src/remote-config/tsconfig-test.json b/src/remote-config/tsconfig-test.json new file mode 100644 index 000000000..8516466dd --- /dev/null +++ b/src/remote-config/tsconfig-test.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig-esm.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@angular/fire": ["../../dist/packages-dist"], + "@angular/fire/remote-config": ["../../dist/packages-dist/remote-config"] + } + }, + "files": [ + "index.spec.ts", + "../../node_modules/zone.js/dist/zone.js.d.ts" + ] +} diff --git a/src/root.spec.js b/src/root.spec.js index 2819c3a0e..3ec5f5649 100644 --- a/src/root.spec.js +++ b/src/root.spec.js @@ -2,6 +2,7 @@ export * from './packages-dist/angularfire2.spec'; export * from './packages-dist/auth/auth.spec'; export * from './packages-dist/auth-guard/auth-guard.spec'; +export * from './packages-dist/analytics/analytics.spec'; export * from './packages-dist/firestore/firestore.spec'; export * from './packages-dist/firestore/document/document.spec'; export * from './packages-dist/firestore/collection/collection.spec'; @@ -16,7 +17,8 @@ export * from './packages-dist/database/list/state-changes.spec'; export * from './packages-dist/database/list/audit-trail.spec'; export * from './packages-dist/storage/storage.spec'; export * from './packages-dist/performance/performance.spec'; -//export * from './packages-dist/messaging/messaging.spec'; +export * from './packages-dist/remote-config/remote-config.spec'; +export * from './packages-dist/messaging/messaging.spec'; // // Since this a deprecated API, we run on it on manual tests only // // It needs a network connection to run which makes it flaky on Travis diff --git a/src/storage/storage.ts b/src/storage/storage.ts index 201a07154..10191a5ae 100644 --- a/src/storage/storage.ts +++ b/src/storage/storage.ts @@ -2,11 +2,13 @@ import { Injectable, Inject, Optional, InjectionToken, NgZone, PLATFORM_ID } fro import { createStorageRef, AngularFireStorageReference } from './ref'; import { createUploadTask, AngularFireUploadTask } from './task'; import { Observable } from 'rxjs'; -import { FirebaseStorage, FirebaseOptions, FirebaseAppConfig, FirebaseOptionsToken, FirebaseNameOrConfigToken, FirebaseZoneScheduler, _firebaseAppFactory } from '@angular/fire'; +import { FirebaseStorage, FirebaseOptions, FirebaseAppConfig, FirebaseZoneScheduler, _firebaseAppFactory, FIREBASE_OPTIONS, FIREBASE_APP_NAME } from '@angular/fire'; import { UploadMetadata } from './interfaces'; +// SEMVER drop StorageBucket in favor of BUCKET export const StorageBucket = new InjectionToken('angularfire2.storageBucket'); +export const BUCKET = StorageBucket; /** * AngularFireStorage Service @@ -21,9 +23,9 @@ export class AngularFireStorage { public readonly scheduler: FirebaseZoneScheduler; constructor( - @Inject(FirebaseOptionsToken) options:FirebaseOptions, - @Optional() @Inject(FirebaseNameOrConfigToken) nameOrConfig:string|FirebaseAppConfig|null|undefined, - @Optional() @Inject(StorageBucket) storageBucket:string|null, + @Inject(FIREBASE_OPTIONS) options:FirebaseOptions, + @Optional() @Inject(FIREBASE_APP_NAME) nameOrConfig:string|FirebaseAppConfig|null|undefined, + @Optional() @Inject(BUCKET) storageBucket:string|null, @Inject(PLATFORM_ID) platformId: Object, zone: NgZone ) { diff --git a/src/tsconfig.json b/src/tsconfig.json index 6978e6669..9ec8d1ee1 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -11,6 +11,7 @@ "noFallthroughCasesInSwitch": true, "paths": { "@angular/fire": ["./core"], + "@angular/fire/analytics": ["./analytics"], "@angular/fire/auth": ["./auth"], "@angular/fire/auth-guard": ["./auth-guard"], "@angular/fire/database": ["./database"], @@ -19,6 +20,7 @@ "@angular/fire/storage": ["./storage"], "@angular/fire/messaging": ["./messaging"], "@angular/fire/performance": ["./performance"], + "@angular/fire/remote-config": ["./remote-config"], "@angular/fire/database-deprecated": ["./database-deprecated"] }, "rootDir": ".", diff --git a/tools/build.js b/tools/build.js index a8ed7f167..9a43e3b32 100644 --- a/tools/build.js +++ b/tools/build.js @@ -22,6 +22,7 @@ const GLOBALS = { '@angular-devkit/core/node': 'ng-devkit.core.node', '@angular-devkit/architect': 'ng-devkit.architect', 'firebase': 'firebase', + 'firebase/analytics': 'firebase', 'firebase/app': 'firebase', 'firebase/auth': 'firebase', 'firebase/database': 'firebase', @@ -29,9 +30,12 @@ const GLOBALS = { 'firebase/firestore': 'firebase', 'firebase/functions': 'firebase', 'firebase/performance': 'firebase', + 'firebase/remote-config': 'firebase', + '@firebase/remote-config': 'firebase', 'firebase/storage': 'firebase', '@angular/fire': 'angularfire2', '@angular/fire/auth': 'angularfire2.auth', + '@angular/fire/analytics': 'angularfire2.analytics', '@angular/fire/auth-guard': 'angularfire2.auth_guard', '@angular/fire/database': 'angularfire2.database', '@angular/fire/database-deprecated': 'angularfire2.database_deprecated', @@ -40,6 +44,7 @@ const GLOBALS = { '@angular/fire/storage': 'angularfire2.storage', '@angular/fire/messaging': 'angularfire2.messaging', '@angular/fire/performance': 'angularfire2.performance', + '@angular/fire/remote-config': 'angularfire2.remote_config', 'fs': 'node.fs', 'path': 'node.path', 'inquirer': 'inquirer' @@ -66,6 +71,7 @@ const VERSIONS = { const MODULE_NAMES = { core: 'angularfire2', + analytics: 'angularfire2.analytics', auth: 'angularfire2.auth', "auth-guard": 'angularfire2.auth_guard', database: 'angularfire2.database', @@ -75,11 +81,13 @@ const MODULE_NAMES = { schematics: 'angularfire2.schematics', storage: 'angularfire2.storage', messaging: 'angularfire2.messaging', - performance: 'angularfire2.performance' + performance: 'angularfire2.performance', + "remote-config": 'angularfire2.remote_config' }; const ENTRIES = { core: `${process.cwd()}/dist/packages-dist/index.js`, + analytics: `${process.cwd()}/dist/packages-dist/analytics/index.js`, auth: `${process.cwd()}/dist/packages-dist/auth/index.js`, "auth-guard": `${process.cwd()}/dist/packages-dist/auth-guard/index.js`, database: `${process.cwd()}/dist/packages-dist/database/index.js`, @@ -89,11 +97,13 @@ const ENTRIES = { schematics: `${process.cwd()}/dist/packages-dist/schematics/index.js`, storage: `${process.cwd()}/dist/packages-dist/storage/index.js`, messaging: `${process.cwd()}/dist/packages-dist/messaging/index.js`, - performance: `${process.cwd()}/dist/packages-dist/performance/index.js` + performance: `${process.cwd()}/dist/packages-dist/performance/index.js`, + "remote-config": `${process.cwd()}/dist/packages-dist/remote-config/index.js` }; const SRC_PKG_PATHS = { core: `${process.cwd()}/src/core/package.json`, + analytics: `${process.cwd()}/src/analytics/package.json`, auth: `${process.cwd()}/src/auth/package.json`, "auth-guard": `${process.cwd()}/src/auth-guard/package.json`, database: `${process.cwd()}/src/database/package.json`, @@ -104,12 +114,14 @@ const SRC_PKG_PATHS = { storage: `${process.cwd()}/src/storage/package.json`, messaging: `${process.cwd()}/src/messaging/package.json`, performance: `${process.cwd()}/src/performance/package.json`, + "remote-config": `${process.cwd()}/src/remote-config/package.json`, schematics: `${process.cwd()}/dist/packages-dist/schematics/versions.js`, "schematics-es2015": `${process.cwd()}/dist/packages-dist/schematics/es2015/versions.js` }; const DEST_PKG_PATHS = { core: `${process.cwd()}/dist/packages-dist/package.json`, + analytics: `${process.cwd()}/dist/packages-dist/analytics/package.json`, auth: `${process.cwd()}/dist/packages-dist/auth/package.json`, "auth-guard": `${process.cwd()}/dist/packages-dist/auth-guard/package.json`, database: `${process.cwd()}/dist/packages-dist/database/package.json`, @@ -120,6 +132,7 @@ const DEST_PKG_PATHS = { storage: `${process.cwd()}/dist/packages-dist/storage/package.json`, messaging: `${process.cwd()}/dist/packages-dist/messaging/package.json`, performance: `${process.cwd()}/dist/packages-dist/performance/package.json`, + "remote-config": `${process.cwd()}/dist/packages-dist/remote-config/package.json`, schematics: `${process.cwd()}/dist/packages-dist/schematics/versions.js`, "schematics-es2015": `${process.cwd()}/dist/packages-dist/schematics/es2015/versions.js` }; @@ -286,8 +299,12 @@ function copySchematicFiles() { } function replaceDynamicImportsForUMD() { - writeFileSync('./dist/packages-dist/bundles/performance.umd.js', readFileSync('./dist/packages-dist/bundles/performance.umd.js', 'utf8').replace("rxjs.from(import('firebase/performance'))", "rxjs.empty()")); + writeFileSync('./dist/packages-dist/bundles/performance.umd.js', readFileSync('./dist/packages-dist/bundles/performance.umd.js', 'utf8').replace("return import('firebase/performance');", "return rxjs.empty();")); writeFileSync('./dist/packages-dist/bundles/messaging.umd.js', readFileSync('./dist/packages-dist/bundles/messaging.umd.js', 'utf8').replace("rxjs.from(import('firebase/messaging'))", "rxjs.empty()")); + writeFileSync('./dist/packages-dist/bundles/remote-config.umd.js', readFileSync('./dist/packages-dist/bundles/remote-config.umd.js', 'utf8').replace("return import('firebase/remote-config');", "return rxjs.empty();")); + writeFileSync('./dist/packages-dist/bundles/analytics.umd.js', readFileSync('./dist/packages-dist/bundles/analytics.umd.js', 'utf8').replace("return import('firebase/analytics');", "return rxjs.empty();")); + // TODO move into own step + writeFileSync('./dist/packages-dist/remote-config/remote-config.d.ts', readFileSync('./dist/packages-dist/remote-config/remote-config.d.ts', 'utf8').replace("implements remoteConfig.Value", "implements Partial")); } function measure(module) { @@ -305,6 +322,7 @@ function measure(module) { function getVersions() { const paths = [ getDestPackageFile('core'), + getDestPackageFile('analytics'), getDestPackageFile('auth'), getDestPackageFile('auth-guard'), getDestPackageFile('database'), @@ -314,6 +332,7 @@ function getVersions() { getDestPackageFile('storage'), getDestPackageFile('messaging'), getDestPackageFile('performance'), + getDestPackageFile('remote-config'), getDestPackageFile('database-deprecated') ]; return paths @@ -347,6 +366,7 @@ function buildModule(name, globals) { */ function buildModules(globals) { const core$ = buildModule('core', globals); + const analytics$ = buildModule('analytics', globals); const auth$ = buildModule('auth', globals); const authGuard$ = buildModule('auth-guard', globals); const db$ = buildModule('database', globals); @@ -356,9 +376,11 @@ function buildModules(globals) { const storage$ = buildModule('storage', globals); const messaging$ = buildModule('messaging', globals); const performance$ = buildModule('performance', globals); + const remoteConfig$ = buildModule('remote-config', globals); const dbdep$ = buildModule('database-deprecated', globals); return forkJoin(core$, from(copyRootTest())).pipe( switchMapTo(schematics$), + switchMapTo(analytics$), switchMapTo(auth$), switchMapTo(authGuard$), switchMapTo(db$), @@ -367,6 +389,7 @@ function buildModules(globals) { switchMapTo(storage$), switchMapTo(messaging$), switchMapTo(performance$), + switchMapTo(remoteConfig$), switchMapTo(dbdep$) ); } @@ -386,6 +409,7 @@ function buildLibrary(globals) { tap(() => { replaceDynamicImportsForUMD(); const coreStats = measure('core'); + const analyticsStats = measure('analytics'); const authStats = measure('auth'); const authGuardStats = measure('auth-guard'); const dbStats = measure('database'); @@ -394,9 +418,11 @@ function buildLibrary(globals) { const storageStats = measure('storage'); const messagingStats = measure('messaging'); const performanceStats = measure('performance'); + const remoteConfigStats = measure('remote-config'); const dbdepStats = measure('database-deprecated'); console.log(` core.umd.js - ${coreStats.size}, ${coreStats.gzip} +analytics.umd.js - ${analyticsStats.size}, ${analyticsStats.gzip} auth.umd.js - ${authStats.size}, ${authStats.gzip} auth-guard.umd.js - ${authGuardStats.size}, ${authGuardStats.gzip} database.umd.js - ${dbStats.size}, ${dbStats.gzip} @@ -405,6 +431,7 @@ functions.umd.js - ${functionsStats.size}, ${functionsStats.gzip} storage.umd.js - ${storageStats.size}, ${storageStats.gzip} messaging.umd.js - ${messagingStats.size}, ${messagingStats.gzip} performance.umd.js - ${performanceStats.size}, ${performanceStats.gzip} +remote-config.umd.js - ${remoteConfigStats.size}, ${remoteConfigStats.gzip} database-deprecated.umd.js - ${dbdepStats.size}, ${dbdepStats.gzip} `); verifyVersions();