diff --git a/.changeset/dry-cats-wash.md b/.changeset/dry-cats-wash.md new file mode 100644 index 00000000..c4d7ed78 --- /dev/null +++ b/.changeset/dry-cats-wash.md @@ -0,0 +1,5 @@ +--- +'@withease/web-api': minor +--- + +Add `trackGeolocation` integration diff --git a/apps/web-api-demo/geolocation.html b/apps/web-api-demo/geolocation.html new file mode 100644 index 00000000..0c9e2869 --- /dev/null +++ b/apps/web-api-demo/geolocation.html @@ -0,0 +1,25 @@ + + + + + web-api demo + + + + + + +
+

Geolocation

+ +

latitude:

+

longitude:

+ + + + + +
+ + + diff --git a/apps/web-api-demo/index.html b/apps/web-api-demo/index.html index f08cf2f8..acb0ca2a 100644 --- a/apps/web-api-demo/index.html +++ b/apps/web-api-demo/index.html @@ -16,6 +16,7 @@

@withease/web-api

  • page-visibility
  • screen-orientation
  • preferred-languages
  • +
  • geolocation
  • diff --git a/apps/web-api-demo/src/geolocation.ts b/apps/web-api-demo/src/geolocation.ts new file mode 100644 index 00000000..87971560 --- /dev/null +++ b/apps/web-api-demo/src/geolocation.ts @@ -0,0 +1,22 @@ +import { trackGeolocation } from '@withease/web-api'; + +const latitudeElement = document.querySelector('#latitude')!; +const longitudeElement = document.querySelector('#longitude')!; +const getLocationButton = document.querySelector('#get-location')!; +const startWatchingButton = document.querySelector('#start-watching')!; +const stopWatchingButton = document.querySelector('#stop-watching')!; + +const { $latitude, $longitude, request, watching } = trackGeolocation({}); + +$latitude.watch((latitude) => { + console.log('latitude', latitude); + latitudeElement.textContent = JSON.stringify(latitude); +}); +$longitude.watch((longitude) => { + console.log('longitude', longitude); + longitudeElement.textContent = JSON.stringify(longitude); +}); + +getLocationButton.addEventListener('click', () => request()); +startWatchingButton.addEventListener('click', () => watching.start()); +stopWatchingButton.addEventListener('click', () => watching.stop()); diff --git a/apps/web-api-demo/test/geolocation.spec.ts b/apps/web-api-demo/test/geolocation.spec.ts new file mode 100644 index 00000000..01c666b9 --- /dev/null +++ b/apps/web-api-demo/test/geolocation.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test'; + +const GEOLOCATION_PAGE = '/geolocation.html'; + +test.use({ + geolocation: { longitude: 41.890221, latitude: 12.492348 }, + permissions: ['geolocation'], +}); + +test('request', async ({ page, context }) => { + await page.goto(GEOLOCATION_PAGE); + + const latitudeContainer = await page.$('#latitude'); + const longitudeContainer = await page.$('#longitude'); + const getLocationButton = await page.$('#get-location'); + + // By default it should be null + expect(await latitudeContainer!.textContent()).toBe('null'); + expect(await longitudeContainer!.textContent()).toBe('null'); + + // After requesting the location, it should be updated + await getLocationButton!.click(); + expect(await latitudeContainer!.textContent()).toBe('12.492348'); + expect(await longitudeContainer!.textContent()).toBe('41.890221'); + + // Change geolocation, values should NOT be updated + await context.setGeolocation({ longitude: 22.492348, latitude: 32.890221 }); + expect(await latitudeContainer!.textContent()).toBe('12.492348'); + expect(await longitudeContainer!.textContent()).toBe('41.890221'); + // Request the location again, values should be updated + await getLocationButton!.click(); + expect(await latitudeContainer!.textContent()).toBe('32.890221'); + expect(await longitudeContainer!.textContent()).toBe('22.492348'); +}); + +test('watch', async ({ page, context }) => { + await page.goto(GEOLOCATION_PAGE); + + const latitudeContainer = await page.$('#latitude'); + const longitudeContainer = await page.$('#longitude'); + const startWatchingButton = await page.$('#start-watching'); + const stopWatchingButton = await page.$('#stop-watching'); + + // By default it should be null + expect(await latitudeContainer!.textContent()).toBe('null'); + expect(await longitudeContainer!.textContent()).toBe('null'); + + // After watching the location, it should be updated immediately + await startWatchingButton!.click(); + expect(await latitudeContainer!.textContent()).toBe('12.492348'); + expect(await longitudeContainer!.textContent()).toBe('41.890221'); + + // Change geolocation, values should be updated immediately + await context.setGeolocation({ longitude: 22.492348, latitude: 32.890221 }); + expect(await latitudeContainer!.textContent()).toBe('32.890221'); + expect(await longitudeContainer!.textContent()).toBe('22.492348'); + + // Stop watching and change geolocation, values should NOT be updated + await stopWatchingButton!.click(); + await context.setGeolocation({ longitude: 42.492348, latitude: 52.890221 }); + expect(await latitudeContainer!.textContent()).toBe('32.890221'); + expect(await longitudeContainer!.textContent()).toBe('22.492348'); +}); diff --git a/apps/website/docs/.vitepress/config.mjs b/apps/website/docs/.vitepress/config.mjs index 6e161c3e..4a9ef11a 100644 --- a/apps/website/docs/.vitepress/config.mjs +++ b/apps/website/docs/.vitepress/config.mjs @@ -107,6 +107,10 @@ export default defineConfig({ text: 'Preferred languages', link: '/web-api/preferred_languages', }, + { + text: 'Geolocation', + link: '/web-api/geolocation', + }, ], }, ]), diff --git a/apps/website/docs/web-api/geolocation.live.vue b/apps/website/docs/web-api/geolocation.live.vue new file mode 100644 index 00000000..34a155c8 --- /dev/null +++ b/apps/website/docs/web-api/geolocation.live.vue @@ -0,0 +1,19 @@ + + + diff --git a/apps/website/docs/web-api/geolocation.md b/apps/website/docs/web-api/geolocation.md new file mode 100644 index 00000000..8d2f42ce --- /dev/null +++ b/apps/website/docs/web-api/geolocation.md @@ -0,0 +1,267 @@ +--- +title: Geolocation + +outline: [2, 3] +--- + +# Geolocation + +Allows tracking geolocation with [_Events_](https://effector.dev/en/api/effector/event/) and [_Stores_](https://effector.dev/docs/api/effector/store). + +::: info + +Uses [Geolocation API](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API) under the hood + +::: + +## Usage + +All you need to do is to create an integration by calling `trackGeolocation` with an integration options: + +- `maximumAge?`: a positive `number` representing the maximum age in milliseconds of a possible cached position that is acceptable to return. If set to `0`, it means that the device cannot use a cached position and must attempt to retrieve the real current position. If set to `Infinity` the device must return a cached position regardless of its age. +- `timeout?`: a positive `number` representing the maximum length of time (in milliseconds) the device is allowed to take in order to return a position. The maximum value for this attribute is `Infinity`. +- `enableHighAccuracy?`: a `boolean` that indicates the application would like to receive the best possible results. + +```ts +import { trackGeolocation } from '@withease/web-api'; + +const { $location, $latitude, $longitude, request, reporting, watching } = + trackGeolocation({ + maximumAge, + timeout, + enableHighAccuracy, + }); +``` + +Returns an object with: + +- `$location` - [_Store_](https://effector.dev/docs/api/effector/store) with the current location in the format `{ latitude, longitude }` +- `$latitude` - [_Store_](https://effector.dev/docs/api/effector/store) with the current latitude +- `$longitude` - [_Store_](https://effector.dev/docs/api/effector/store) with the current longitude +- `request` - [_EventCallable_](https://effector.dev/en/api/effector/event/#eventCallable) that has to be called to get the current location +- `watching` - an object with the following properties: + - `start` - [_EventCallable_](https://effector.dev/en/api/effector/event/#eventCallable) that has to be called to start watching the current location + - `stop` - [_EventCallable_](https://effector.dev/en/api/effector/event/#eventCallable) that has to be called to stop watching the current location + - `$active` - [_Store_](https://effector.dev/docs/api/effector/store) with `true` if watching is started and `false` if watching is stopped +- `reporting` - an object with the following properties: + - `failed` - [_Event_](https://effector.dev/en/api/effector/event) that fires when the location request fails + +## Live demo + +Let us show you a live demo of how it works. The following demo displays `$latitude` and `$longitude` values. _Click "Request geolocation" button to retrieve it._ + + + + + +## Additional capabilities + +While creating an integration, you can override the default geolocation provider with your custom one. It can be done by passing an array of providers to the `trackGeolocation` function. + +```ts +import { trackGeolocation } from '@withease/web-api'; + +const geo = trackGeolocation({ + /* ... */ + // by default providers field contains trackGeolocation.browserProvider + // which represents the browser built-in Geolocation API + providers: [trackGeolocation.browserProvider], +}); +``` + +The logic is quite straightforward: integration will call providers one by one until one of them returns the location. The first provider that returns the location will be used. + +### Regional restrictions + +In some countries and regions, the use of geolocation can be restricted. If you are aiming to provide a service in such locations, you use some local providers to get the location of the user. For example, in China, you can use [Baidu](https://lbsyun.baidu.com/index.php?title=jspopular/guide/geolocation), [Autonavi](https://lbsyun.baidu.com/index.php?title=jspopular/guide/geolocation), or [Tencent](https://lbs.qq.com/webApi/component/componentGuide/componentGeolocation). + +Geolocation integration of `@withease/web-api` allows to use any provider additionally to the default one provided by the browser. To do so, you need to pass an `providers` option to the `trackGeolocation` function. + +```ts +import { trackGeolocation } from '@withease/web-api'; + +const geo = trackGeolocation({ + /* ... */ + providers: [ + /* default browser Geolocation API */ + trackGeolocation.browserProvider, + /* your custom providers */ + ], +}); +``` + +Any provider should conform to the following contract: + +```ts +/* This type mimics https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPosition */ +type CustomGeolocationPosition = { + timestamp: number; + coords: { + latitude: number; + longitude: number; + accuracy?: number; + altitude?: number; + altitudeAccuracy?: number; + heading?: number; + speed?: number; + }; +}; + +/* This type mimics https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError */ +type CustomGeolocationError = { + /* + * You have to map your error codes to the Geolocation API error codes + * In case of unknown error, you are free to skip this field + */ + code?: 'PERMISSION_DENIED' | 'POSITION_UNAVAILABLE' | 'TIMEOUT'; + /* + * You can provide a custom message for the error + */ + message?: string; + /* + * You can provide a raw error object from your provider + */ + raw?: unknown; +}; + +type CustomProvider = ( + /* All options would be passed from trackGeolocation call */ { + maximumAge, + timeout, + enableHighAccuracy, + } +) => { + /* This function can throw CustomGeolocationError in case of error */ + getCurrentPosition: () => Promise; + /* + * This function should call successCallback with the position or errorCallback with the error. + * Function should return an Unsubscribe function, which should stop watching the position. + */ + watchPosition: ( + successCallback: (position: CustomGeolocationPosition) => void, + errorCallback: (error: CustomGeolocationError) => void + ) => Unsubscribe; +}; +``` + +::: details Baidu example + +For example, in case of Baidu, you can write something like this: + +```ts +function baiduProvider({ maximumAge, timeout, enableHighAccuracy }) { + // Create a Baidu geolocation instance outside of the getCurrentPosition function + // to avoid creating a new instance every time the function is called + const geolocation = new BMap.Geolocation(); + + const getCurrentPosition = ({ maximumAge, timeout, enableHighAccuracy }) => { + // getCurrentPosition function should return a Promise + return new Promise((resolve, reject) => { + geolocation.getCurrentPosition(function (r) { + if (this.getStatus() === BMAP_STATUS_SUCCESS) { + // in case of success, resolve with the result + resolve({ + timestamp: Date.now(), + coords: { latitude: r.point.lat, longitude: r.point.lng }, + }); + } else { + // map Baidu error codes to the Geolocation API error codes + let code; + switch (this.getStatus()) { + case BMAP_STATUS_PERMISSION_DENIED: + code = 'PERMISSION_DENIED'; + break; + case BMAP_STATUS_SERVICE_UNAVAILABLE: + code = 'POSITION_UNAVAILABLE'; + break; + case BMAP_STATUS_TIMEOUT: + code = 'TIMEOUT'; + break; + } + + // reject with the error object + reject({ code, raw: this.getStatus() }); + } + }); + }); + }; + + /* + * Bailu does not support watching the position + * so, we have to write an imitation of the watchPosition function + */ + const watchPosition = (successCallback, errorCallback) => { + const timerId = setInterval(async () => { + try { + const position = await getCurrentPosition(); + + successCallback(position); + } catch (error) { + errorCallback(error); + } + }, 1_000); + + return () => clearInterval(timerId); + }; + + return { + getCurrentPosition, + watchPosition, + }; +} + +const geo = trackGeolocation({ + /* ... */ + providers: [ + /* default browser Geolocation API */ + trackGeolocation.browserProvider, + /* Baidu provider */ + baiduProvider, + ], +}); +``` + +::: + +Array of `providers` would be used in the order they are passed to the `trackGeolocation` function. The first provider that returns the coordinates would be used. + +### React Native + +In case of React Native, it is recommended to use the [`@react-native-community/geolocation`](https://github.com/michalchudziak/react-native-geolocation) package and do not use `navigator.geolocation` directly. You can easily achieve this by excluding `trackGeolocation.browserProvider` from the list of providers. + +```ts +import ReactNativeGeolocation from '@react-native-community/geolocation'; + +const geo = trackGeolocation({ + /* ... */ + providers: [ + trackGeolocation.browserProvider, // [!code --] + ReactNativeGeolocation, // [!code ++] + ], +}); +``` + +### Testing + +You can pass a [_Store_](https://effector.dev/docs/api/effector/store) to `providers` option to get a way to mock the geolocation provider during testing via [Fork API](/magazine/fork_api_rules). + +```ts +import { createStore, fork } from 'effector'; +import { trackGeolocation } from '@withease/web-api'; + +// Create a store with the default provider +const $geolocationProviders = createStore([trackGeolocation.browserProvider]); + +// Create an integration with the store +const geo = trackGeolocation({ + /* ... */ + providers: $geolocationProviders, +}); + +// during testing, you can replace the provider with your mock +const scope = fork({ values: [[$geolocationProviders, myFakeProvider]] }); +``` + +That is it, any calculations on the created [_Scope_](https://effector.dev/docs/api/effector/scope) will use the `myFakeProvider` instead of the default one. diff --git a/packages/web-api/package.json b/packages/web-api/package.json index 4e97bba4..bc6a2466 100644 --- a/packages/web-api/package.json +++ b/packages/web-api/package.json @@ -7,6 +7,7 @@ }, "scripts": { "test:run": "vitest run --typecheck", + "test:watch": "vitest --typecheck", "build": "vite build", "size": "size-limit", "publint": "node ../../tools/publint.mjs", @@ -34,7 +35,7 @@ "size-limit": [ { "path": "./dist/web-api.js", - "limit": "1.58 kB" + "limit": "2.49 kB" } ] } diff --git a/packages/web-api/src/geolocation.test.ts b/packages/web-api/src/geolocation.test.ts new file mode 100644 index 00000000..7bc5ac4c --- /dev/null +++ b/packages/web-api/src/geolocation.test.ts @@ -0,0 +1,295 @@ +/* + * This file contains tests only for custom geolocation providers. + * e2e-tests for built-in browser navigator.geolocation in apps/web-api-demo/test/geolocation.spec.ts + */ + +import { allSettled, createStore, createWatch, fork } from 'effector'; +import { describe, expect, test, vi } from 'vitest'; + +import { trackGeolocation } from './geolocation'; + +describe('trackGeolocation', () => { + test('request', async () => { + let lat = 41.890221; + let lon = 12.492348; + const myCustomProvider = vi.fn(() => ({ + async getCurrentPosition() { + return { + coords: { latitude: lat, longitude: lon }, + timestamp: Date.now(), + }; + }, + watchPosition(success: any, error: any) { + return () => {}; + }, + })); + + const geo = trackGeolocation({ providers: [myCustomProvider] }); + + const scope = fork(); + + expect(scope.getState(geo.$location)).toMatchInlineSnapshot(`null`); + + await allSettled(geo.request, { scope }); + expect(scope.getState(geo.$location)).toMatchInlineSnapshot(` + { + "latitude": 41.890221, + "longitude": 12.492348, + } + `); + + lat = 42.890221; + lon = 13.492348; + + await allSettled(geo.request, { scope }); + expect(scope.getState(geo.$location)).toMatchInlineSnapshot(` + { + "latitude": 42.890221, + "longitude": 13.492348, + } + `); + + expect(myCustomProvider).toBeCalledTimes(1); + }); + + test('watching', async () => { + const $externalLocation: any = createStore({ + latitude: 41.890221, + longitude: 12.492348, + }); + + const myCustomProvider = vi.fn(() => ({ + async getCurrentPosition() { + return { + coords: $externalLocation.getState(), + timestamp: Date.now(), + }; + }, + watchPosition(success: any, error: any) { + return $externalLocation.watch((location: any) => + success({ + coords: location, + timestamp: Date.now(), + }) + ); + }, + })); + + const geo = trackGeolocation({ providers: [myCustomProvider] }); + + const scope = fork(); + + expect(scope.getState(geo.$location)).toMatchInlineSnapshot(`null`); + + await allSettled(geo.watching.start, { scope }); + expect(scope.getState(geo.$location)).toMatchInlineSnapshot(` + { + "latitude": 41.890221, + "longitude": 12.492348, + } + `); + + $externalLocation.setState({ latitude: 42.890221, longitude: 13.492348 }); + expect(scope.getState(geo.$location)).toMatchInlineSnapshot(` + { + "latitude": 42.890221, + "longitude": 13.492348, + } + `); + + await allSettled(geo.watching.stop, { scope }); + $externalLocation.setState({ latitude: 43.890221, longitude: 14.492348 }); + expect(scope.getState(geo.$location)).toMatchInlineSnapshot(` + { + "latitude": 42.890221, + "longitude": 13.492348, + } + `); + + expect(myCustomProvider).toBeCalledTimes(1); + }); + + test('reporting', async () => { + const myCustomProvider = () => ({ + async getCurrentPosition() { + throw { + code: 'PERMISSION_DENIED', + message: 'User denied the request for Geolocation.', + raw: '用户拒绝了地理位置请求。', + }; + }, + watchPosition(success: any, error: any) { + return () => {}; + }, + }); + + const failedListener = vi.fn(); + + const geo = trackGeolocation({ providers: [myCustomProvider] }); + + const scope = fork(); + + createWatch({ unit: geo.reporting.failed, fn: failedListener, scope }); + + await allSettled(geo.request, { scope }); + + expect(failedListener).toBeCalledWith({ + code: 'PERMISSION_DENIED', + message: 'User denied the request for Geolocation.', + raw: '用户拒绝了地理位置请求。', + }); + }); + + test('do not throw if default provider is not available', async () => { + expect(() => trackGeolocation()).not.toThrow(); + }); +}); + +describe('trackGeolocation, providers as a Store', () => { + const firstProvider = () => ({ + name: 'firstProvider', + async getCurrentPosition() { + return { + coords: { latitude: 1, longitude: 1 }, + timestamp: Date.now(), + }; + }, + watchPosition(success: any, error: any) { + return () => {}; + }, + }); + + const secondProvider = () => ({ + name: 'secondProvider', + async getCurrentPosition() { + return { + coords: { latitude: 2, longitude: 2 }, + timestamp: Date.now(), + }; + }, + watchPosition(success: any, error: any) { + return () => {}; + }, + }); + + const $providers = createStore([firstProvider]); + + const geo = trackGeolocation({ providers: $providers }); + + test('request', async () => { + const scopeWithOriginal = fork(); + const scopeWithReplace = fork({ values: [[$providers, [secondProvider]]] }); + + await allSettled(geo.request, { scope: scopeWithReplace }); + expect(scopeWithReplace.getState(geo.$location)).toMatchInlineSnapshot(` + { + "latitude": 2, + "longitude": 2, + } + `); + + await allSettled(geo.request, { scope: scopeWithOriginal }); + expect(scopeWithOriginal.getState(geo.$location)).toMatchInlineSnapshot(` + { + "latitude": 1, + "longitude": 1, + } + `); + }); +}); + +describe('trackGeolocation, failure of provider', () => { + const brokenProvider = () => ({ + async getCurrentPosition() { + throw new Error('Wow, government do not want you to use this provider!'); + }, + watchPosition(success: any, error: any) { + throw new Error('Wow, government do not want you to use this provider!'); + }, + }); + + const slightlyBrokenProvider = () => ({ + async getCurrentPosition() { + throw new Error('Not this time, buddy!'); + }, + watchPosition(success: any, error: any) { + error(new Error('Not this time, buddy!')); + + return () => {}; + }, + }); + + const okProvider = () => ({ + async getCurrentPosition() { + return { + coords: { latitude: 2, longitude: 2 }, + timestamp: Date.now(), + }; + }, + watchPosition(success: any, error: any) { + success({ + coords: { latitude: 2, longitude: 2 }, + timestamp: Date.now(), + }); + return () => {}; + }, + }); + + const geo = trackGeolocation({ + providers: [brokenProvider, slightlyBrokenProvider, okProvider], + }); + + test('request', async () => { + const scope = fork(); + + const failedWatcher = vi.fn(); + createWatch({ unit: geo.reporting.failed, fn: failedWatcher, scope }); + + await allSettled(geo.request, { scope }); + + expect(scope.getState(geo.$location)).toMatchInlineSnapshot(` + { + "latitude": 2, + "longitude": 2, + } + `); + + expect(failedWatcher.mock.calls).toMatchInlineSnapshot(` + [ + [ + [Error: Wow, government do not want you to use this provider!], + ], + [ + [Error: Not this time, buddy!], + ], + ] + `); + }); + + test('watching', async () => { + const scope = fork(); + + const failedWatcher = vi.fn(); + createWatch({ unit: geo.reporting.failed, fn: failedWatcher, scope }); + + await allSettled(geo.watching.start, { scope }); + + expect(scope.getState(geo.$location)).toMatchInlineSnapshot(` + { + "latitude": 2, + "longitude": 2, + } + `); + + expect(failedWatcher.mock.calls).toMatchInlineSnapshot(` + [ + [ + [Error: Wow, government do not want you to use this provider!], + ], + [ + [Error: Not this time, buddy!], + ], + ] + `); + }); +}); diff --git a/packages/web-api/src/geolocation.ts b/packages/web-api/src/geolocation.ts new file mode 100644 index 00000000..405dfc5f --- /dev/null +++ b/packages/web-api/src/geolocation.ts @@ -0,0 +1,337 @@ +import { + type Event, + type EventCallable, + type Store, + type Effect, + combine, + createEvent, + createStore, + sample, + attach, + scopeBind, + is, +} from 'effector'; + +import { readonly } from './shared'; + +type InitializedProvider = ReturnType | globalThis.Geolocation; + +type GeolocationParams = { + maximumAge?: number; + timeout?: number; + enableHighAccuracy?: boolean; +}; + +/** + * This type mimics GeolocationPostion + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPosition} + */ +type CustomGeolocationPosition = { + timestamp: number; + coords: { + latitude: number; + longitude: number; + accuracy?: number; + altitude?: number; + altitudeAccuracy?: number; + heading?: number; + speed?: number; + }; +}; + +/** + * This type mimics GeolocationPositionError + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/GeolocationPositionError} + */ +type CustomGeolocationError = { + code?: 'PERMISSION_DENIED' | 'POSITION_UNAVAILABLE' | 'TIMEOUT'; + message?: string; + raw?: unknown; +}; + +type Unsubscribe = () => void; + +type CustomProvider = (params: GeolocationParams) => { + getCurrentPosition: () => Promise; + watchPosition: ( + successCallback: (position: CustomGeolocationPosition) => void, + errorCallback: (error: CustomGeolocationError) => void + ) => Unsubscribe; +}; + +type Geolocation = { + $location: Store<{ latitude: number; longitude: number } | null>; + $latitude: Store; + $longitude: Store; + request: EventCallable; + watching: { + start: EventCallable; + stop: EventCallable; + $active: Store; + }; + reporting: { + failed: Event; + }; +}; + +const BrowserProvider = Symbol('BrowserProvider'); + +export function trackGeolocation( + params?: GeolocationParams & { + providers?: + | Array + | Store< + Array< + typeof BrowserProvider | CustomProvider | globalThis.Geolocation + > + >; + } +): Geolocation { + let $providers: Store< + Array + >; + const providersFromParams = params?.providers; + if (is.store(providersFromParams)) { + $providers = providersFromParams; + } else { + $providers = createStore(providersFromParams ?? [BrowserProvider]); + } + + const initializeAllProvidersFx = attach({ + source: $providers, + effect(providers) { + return providers + .map((provider) => initializeProvider(provider, params)) + .filter(Boolean) as Array< + ReturnType | globalThis.Geolocation + >; + }, + }); + + const $initializedProviders = createStore | null>( + null, + { serialize: 'ignore' } + ).on(initializeAllProvidersFx.doneData, (_, providers) => providers); + + // -- units + + const $location = createStore<{ + latitude: number; + longitude: number; + } | null>(null); + + const $longitude = combine( + $location, + (location) => location?.longitude ?? null + ); + + const $latitude = combine( + $location, + (location) => location?.latitude ?? null + ); + + const request = createEvent(); + + const startWatching = createEvent(); + const stopWatching = createEvent(); + const $watchingActive = createStore(false); + + const failed = createEvent< + CustomGeolocationError | globalThis.GeolocationPositionError + >(); + + // -- shared logic + + const newPosition = createEvent< + CustomGeolocationPosition | globalThis.GeolocationPosition + >(); + + sample({ + clock: newPosition, + fn: (r) => ({ latitude: r.coords.latitude, longitude: r.coords.longitude }), + target: $location, + }); + + // -- get current position + + const getCurrentPositionFx: Effect< + void, + CustomGeolocationPosition | globalThis.GeolocationPosition, + CustomGeolocationError | globalThis.GeolocationPositionError + > = attach({ + source: $initializedProviders, + async effect(initializedProviders) { + let geolocation: + | globalThis.GeolocationPosition + | CustomGeolocationPosition + | null = null; + + const boundFailed = scopeBind(failed, { safe: true }); + + let providers: InitializedProvider[]; + + if (initializedProviders) { + providers = initializedProviders; + } else { + providers = await initializeAllProvidersFx(); + } + + for (const provider of providers) { + try { + if (isDefaultProvider(provider)) { + geolocation = await new Promise( + (resolve, reject) => + provider.getCurrentPosition(resolve, reject, params) + ); + } else { + geolocation = await provider.getCurrentPosition(); + } + } catch (e: any) { + boundFailed(e); + } + } + + if (!geolocation) { + throw { + code: 'POSITION_UNAVAILABLE', + message: 'No available geolocation provider', + }; + } + + return geolocation; + }, + }); + + sample({ clock: request, target: getCurrentPositionFx }); + sample({ + clock: getCurrentPositionFx.doneData, + target: newPosition, + }); + sample({ clock: getCurrentPositionFx.failData, target: failed }); + + // -- watch position + + const $unsubscribe = createStore(null); + + const watchPositionFx = attach({ + source: $initializedProviders, + async effect(initializedProviders) { + const boundNewPosition = scopeBind(newPosition, { safe: true }); + const boundFailed = scopeBind(failed, { safe: true }); + + let providers: InitializedProvider[]; + + if (initializedProviders) { + providers = initializedProviders; + } else { + providers = await initializeAllProvidersFx(); + } + + const defaultUnwatchMap = new Map<(id: number) => void, number>(); + const customUnwatchSet = new Set(); + + for (const provider of providers) { + try { + if (isDefaultProvider(provider)) { + const watchId = provider.watchPosition( + boundNewPosition, + boundFailed, + params + ); + + defaultUnwatchMap.set( + (id: number) => provider.clearWatch(id), + watchId + ); + } else { + const unwatch = provider.watchPosition( + boundNewPosition, + boundFailed + ); + + customUnwatchSet.add(unwatch); + } + } catch (e: any) { + boundFailed(e); + } + } + + return () => { + for (const [unwatch, id] of defaultUnwatchMap) { + unwatch(id); + defaultUnwatchMap.delete(unwatch); + } + + for (const unwatch of customUnwatchSet) { + unwatch(); + customUnwatchSet.delete(unwatch); + } + }; + }, + }); + + const unwatchPositionFx = attach({ + source: $unsubscribe, + effect(unwatch) { + unwatch?.(); + }, + }); + + sample({ clock: startWatching, target: watchPositionFx }); + sample({ clock: watchPositionFx.doneData, target: $unsubscribe }); + sample({ clock: stopWatching, target: unwatchPositionFx }); + sample({ clock: unwatchPositionFx.finally, target: $unsubscribe.reinit }); + + $watchingActive.on(startWatching, () => true).on(stopWatching, () => false); + + // -- public API + + return { + $location: readonly($location), + $longitude, + $latitude, + request, + watching: { + start: startWatching, + stop: stopWatching, + $active: readonly($watchingActive), + }, + reporting: { + failed: readonly(failed), + }, + }; +} + +trackGeolocation.browserProvider = BrowserProvider; + +function initializeProvider( + provider: typeof BrowserProvider | CustomProvider | globalThis.Geolocation, + params?: GeolocationParams +) { + /* BrowserProvider symbol means usage of navigator.geolocation */ + if (provider === BrowserProvider) { + const browserGeolocationAvailable = + globalThis.navigator && 'geolocation' in globalThis.navigator; + if (!browserGeolocationAvailable) { + return null; + } + + return globalThis.navigator.geolocation; + } + + if (isDefaultProvider(provider)) { + return provider; + } + + return provider(params ?? {}); +} + +function isDefaultProvider(provider: any): provider is globalThis.Geolocation { + return ( + 'getCurrentPosition' in provider && + 'watchPosition' in provider && + 'clearWatch' in provider + ); +} diff --git a/packages/web-api/src/index.ts b/packages/web-api/src/index.ts index 9d6c3e4c..cd17cc34 100644 --- a/packages/web-api/src/index.ts +++ b/packages/web-api/src/index.ts @@ -3,3 +3,4 @@ export { trackPageVisibility } from './page_visibility'; export { trackNetworkStatus } from './network_status'; export { trackMediaQuery } from './media_query'; export { trackPreferredLanguages } from './preferred_languages'; +export { trackGeolocation } from './geolocation'; diff --git a/packages/web-api/src/shared.ts b/packages/web-api/src/shared.ts index e9eca7fa..f49213ca 100644 --- a/packages/web-api/src/shared.ts +++ b/packages/web-api/src/shared.ts @@ -1,5 +1,8 @@ import { - Event, + type Event, + type Store, + type EventCallable, + type StoreWritable, attach, createEffect, createEvent, @@ -104,3 +107,10 @@ export function setupListener( return event; } + +export function readonly(unit: StoreWritable): Store; +export function readonly(unit: EventCallable): Event; + +export function readonly(unit: any) { + return unit.map((v: any) => v); +}